feat(proxy): implement independent failover queue management

Add a new failover queue system that operates independently from provider
sortIndex, allowing users to configure failover order per app type.

Backend changes:
- Add failover_queue table to schema.rs for persistent storage
- Create dao/failover.rs with CRUD operations for queue management
- Add Tauri commands for queue operations (get, add, remove, reorder, toggle)
- Refactor provider_router.rs select_providers() to use failover queue:
  - Current provider always takes first priority
  - Queue providers ordered by queue_order as fallback
  - Only providers with open circuit breakers are included

Frontend changes:
- Add FailoverQueueItem type to proxy.ts
- Extend failover.ts API with queue management methods
- Add React Query hooks for queue data fetching and mutations
- Create FailoverQueueManager component with drag-and-drop reordering
- Integrate queue management into SettingsPage under "Auto Failover"
- Add i18n translations for zh and en locales
This commit is contained in:
Jason
2025-12-12 16:13:07 +08:00
parent c42a0dccaf
commit 5d424b1383
15 changed files with 1198 additions and 47 deletions
@@ -0,0 +1,413 @@
/**
* 故障转移队列管理组件
*
* 允许用户管理代理模式下的故障转移队列,支持:
* - 拖拽排序
* - 添加/移除供应商
* - 启用/禁用队列项
*/
import { useState, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { CSS } from "@dnd-kit/utilities";
import { DndContext, closestCenter } from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import {
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { toast } from "sonner";
import {
GripVertical,
Plus,
Trash2,
Loader2,
Info,
AlertTriangle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import type { FailoverQueueItem } from "@/types/proxy";
import type { AppId } from "@/lib/api";
import {
useFailoverQueue,
useAvailableProvidersForFailover,
useAddToFailoverQueue,
useRemoveFromFailoverQueue,
useReorderFailoverQueue,
useSetFailoverItemEnabled,
} from "@/lib/query/failover";
interface FailoverQueueManagerProps {
appType: AppId;
disabled?: boolean;
}
export function FailoverQueueManager({
appType,
disabled = false,
}: FailoverQueueManagerProps) {
const { t } = useTranslation();
const [selectedProviderId, setSelectedProviderId] = useState<string>("");
// 查询数据
const {
data: queue,
isLoading: isQueueLoading,
error: queueError,
} = useFailoverQueue(appType);
const {
data: availableProviders,
isLoading: isProvidersLoading,
} = useAvailableProvidersForFailover(appType);
// Mutations
const addToQueue = useAddToFailoverQueue();
const removeFromQueue = useRemoveFromFailoverQueue();
const reorderQueue = useReorderFailoverQueue();
const setItemEnabled = useSetFailoverItemEnabled();
// 拖拽配置
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
// 排序后的队列
const sortedQueue = useMemo(() => {
if (!queue) return [];
return [...queue].sort((a, b) => a.queueOrder - b.queueOrder);
}, [queue]);
// 处理拖拽结束
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id || !sortedQueue) return;
const oldIndex = sortedQueue.findIndex(
(item) => item.providerId === active.id,
);
const newIndex = sortedQueue.findIndex(
(item) => item.providerId === over.id,
);
if (oldIndex === -1 || newIndex === -1) return;
const reordered = arrayMove(sortedQueue, oldIndex, newIndex);
const providerIds = reordered.map((item) => item.providerId);
try {
await reorderQueue.mutateAsync({ appType, providerIds });
toast.success(
t("proxy.failoverQueue.reorderSuccess", "队列顺序已更新"),
);
} catch (error) {
toast.error(
t("proxy.failoverQueue.reorderFailed", "更新顺序失败") +
": " +
String(error),
);
}
},
[sortedQueue, appType, reorderQueue, t],
);
// 添加供应商到队列
const handleAddProvider = async () => {
if (!selectedProviderId) return;
try {
await addToQueue.mutateAsync({
appType,
providerId: selectedProviderId,
});
setSelectedProviderId("");
toast.success(t("proxy.failoverQueue.addSuccess", "已添加到故障转移队列"));
} catch (error) {
toast.error(
t("proxy.failoverQueue.addFailed", "添加失败") + ": " + String(error),
);
}
};
// 从队列移除供应商
const handleRemoveProvider = async (providerId: string) => {
try {
await removeFromQueue.mutateAsync({ appType, providerId });
toast.success(
t("proxy.failoverQueue.removeSuccess", "已从故障转移队列移除"),
);
} catch (error) {
toast.error(
t("proxy.failoverQueue.removeFailed", "移除失败") + ": " + String(error),
);
}
};
// 切换启用状态
const handleToggleEnabled = async (providerId: string, enabled: boolean) => {
try {
await setItemEnabled.mutateAsync({ appType, providerId, enabled });
} catch (error) {
toast.error(
t("proxy.failoverQueue.toggleFailed", "状态更新失败") +
": " +
String(error),
);
}
};
if (isQueueLoading) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (queueError) {
return (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{String(queueError)}</AlertDescription>
</Alert>
);
}
return (
<div className="space-y-4">
{/* 说明信息 */}
<Alert className="border-blue-500/40 bg-blue-500/10">
<Info className="h-4 w-4" />
<AlertDescription className="text-sm">
{t(
"proxy.failoverQueue.info",
"当前激活的供应商始终优先。当请求失败时,系统会按队列顺序依次尝试其他供应商。",
)}
</AlertDescription>
</Alert>
{/* 添加供应商 */}
<div className="flex items-center gap-2">
<Select
value={selectedProviderId}
onValueChange={setSelectedProviderId}
disabled={disabled || isProvidersLoading}
>
<SelectTrigger className="flex-1">
<SelectValue
placeholder={t(
"proxy.failoverQueue.selectProvider",
"选择供应商添加到队列",
)}
/>
</SelectTrigger>
<SelectContent>
{availableProviders?.map((provider) => (
<SelectItem key={provider.id} value={provider.id}>
{provider.name}
</SelectItem>
))}
{(!availableProviders || availableProviders.length === 0) && (
<div className="px-2 py-4 text-center text-sm text-muted-foreground">
{t(
"proxy.failoverQueue.noAvailableProviders",
"没有可添加的供应商",
)}
</div>
)}
</SelectContent>
</Select>
<Button
onClick={handleAddProvider}
disabled={
disabled || !selectedProviderId || addToQueue.isPending
}
size="icon"
variant="outline"
>
{addToQueue.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
</Button>
</div>
{/* 队列列表 */}
{sortedQueue.length === 0 ? (
<div className="rounded-lg border border-dashed border-muted-foreground/40 p-8 text-center">
<p className="text-sm text-muted-foreground">
{t(
"proxy.failoverQueue.empty",
"故障转移队列为空。添加供应商以启用自动故障转移。",
)}
</p>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortedQueue.map((item) => item.providerId)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{sortedQueue.map((item, index) => (
<SortableQueueItem
key={item.providerId}
item={item}
index={index}
disabled={disabled}
onToggleEnabled={handleToggleEnabled}
onRemove={handleRemoveProvider}
isRemoving={removeFromQueue.isPending}
isToggling={setItemEnabled.isPending}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
{/* 队列说明 */}
{sortedQueue.length > 0 && (
<p className="text-xs text-muted-foreground">
{t(
"proxy.failoverQueue.dragHint",
"拖拽供应商可调整故障转移顺序,序号越小优先级越高。",
)}
</p>
)}
</div>
);
}
interface SortableQueueItemProps {
item: FailoverQueueItem;
index: number;
disabled: boolean;
onToggleEnabled: (providerId: string, enabled: boolean) => void;
onRemove: (providerId: string) => void;
isRemoving: boolean;
isToggling: boolean;
}
function SortableQueueItem({
item,
index,
disabled,
onToggleEnabled,
onRemove,
isRemoving,
isToggling,
}: SortableQueueItemProps) {
const { t } = useTranslation();
const {
setNodeRef,
attributes,
listeners,
transform,
transition,
isDragging,
} = useSortable({ id: item.providerId, disabled });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"flex items-center gap-3 rounded-lg border bg-card p-3 transition-colors",
isDragging && "opacity-50 shadow-lg",
!item.enabled && "opacity-60",
)}
>
{/* 拖拽手柄 */}
<button
type="button"
className={cn(
"cursor-grab touch-none text-muted-foreground hover:text-foreground",
disabled && "cursor-not-allowed opacity-50",
)}
{...attributes}
{...listeners}
disabled={disabled}
aria-label={t("provider.dragHandle", "拖拽排序")}
>
<GripVertical className="h-5 w-5" />
</button>
{/* 序号 */}
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-medium">
{index + 1}
</div>
{/* 供应商名称 */}
<div className="flex-1 min-w-0">
<span
className={cn(
"text-sm font-medium truncate block",
!item.enabled && "text-muted-foreground line-through",
)}
>
{item.providerName}
</span>
</div>
{/* 启用开关 */}
<Switch
checked={item.enabled}
onCheckedChange={(checked) =>
onToggleEnabled(item.providerId, checked)
}
disabled={disabled || isToggling}
aria-label={t("proxy.failoverQueue.toggleEnabled", "启用/禁用")}
/>
{/* 删除按钮 */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => onRemove(item.providerId)}
disabled={disabled || isRemoving}
aria-label={t("common.delete", "删除")}
>
{isRemoving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</div>
);
}
+51 -5
View File
@@ -35,6 +35,7 @@ import { ProxyPanel } from "@/components/proxy";
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager";
import { UsageDashboard } from "@/components/usage/UsageDashboard";
import { useSettings } from "@/hooks/useSettings";
import { useImportExport } from "@/hooks/useImportExport";
@@ -354,7 +355,7 @@ export function SettingsPage({
</h3>
<p className="text-sm text-muted-foreground font-normal">
</p>
</div>
</div>
@@ -373,10 +374,55 @@ export function SettingsPage({
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<AutoFailoverConfigPanel
enabled={failoverEnabled}
onEnabledChange={setFailoverEnabled}
/>
<div className="space-y-6">
{/* 故障转移队列管理 */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title", "故障转移队列")}
</h4>
<p className="text-xs text-muted-foreground">
{t(
"proxy.failoverQueue.description",
"管理各应用的供应商故障转移顺序",
)}
</p>
</div>
<Tabs defaultValue="claude" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="claude">Claude</TabsTrigger>
<TabsTrigger value="codex">Codex</TabsTrigger>
<TabsTrigger value="gemini">Gemini</TabsTrigger>
</TabsList>
<TabsContent value="claude" className="mt-4">
<FailoverQueueManager
appType="claude"
disabled={!failoverEnabled}
/>
</TabsContent>
<TabsContent value="codex" className="mt-4">
<FailoverQueueManager
appType="codex"
disabled={!failoverEnabled}
/>
</TabsContent>
<TabsContent value="gemini" className="mt-4">
<FailoverQueueManager
appType="gemini"
disabled={!failoverEnabled}
/>
</TabsContent>
</Tabs>
</div>
{/* 熔断器配置 */}
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
enabled={failoverEnabled}
onEnabledChange={setFailoverEnabled}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
+45
View File
@@ -840,5 +840,50 @@
},
"agents": {
"title": "Agents"
},
"proxy": {
"failoverQueue": {
"title": "Failover Queue",
"description": "Manage failover order for each app's providers",
"info": "The current active provider always takes priority. When requests fail, the system will try other providers in queue order.",
"selectProvider": "Select a provider to add to queue",
"noAvailableProviders": "No providers available to add",
"empty": "Failover queue is empty. Add providers to enable automatic failover.",
"dragHint": "Drag providers to adjust failover order. Lower numbers have higher priority.",
"toggleEnabled": "Enable/Disable",
"addSuccess": "Added to failover queue",
"addFailed": "Failed to add",
"removeSuccess": "Removed from failover queue",
"removeFailed": "Failed to remove",
"reorderSuccess": "Queue order updated",
"reorderFailed": "Failed to update order",
"toggleFailed": "Failed to update status"
},
"autoFailover": {
"info": "When multiple proxy targets are enabled, the system will try them in priority order. When a provider fails consecutively reaching the threshold, the circuit breaker will open and skip that provider.",
"configSaved": "Auto failover config saved",
"configSaveFailed": "Failed to save",
"retrySettings": "Retry & Timeout Settings",
"failureThreshold": "Failure Threshold",
"failureThresholdHint": "Open circuit breaker after this many consecutive failures (recommended: 3-10)",
"timeout": "Recovery Wait Time (seconds)",
"timeoutHint": "Wait this long before trying to recover after circuit opens (recommended: 30-120)",
"circuitBreakerSettings": "Circuit Breaker Advanced Settings",
"successThreshold": "Recovery Success Threshold",
"successThresholdHint": "Close circuit breaker after this many successes in half-open state",
"errorRate": "Error Rate Threshold (%)",
"errorRateHint": "Open circuit breaker when error rate exceeds this value",
"minRequests": "Minimum Requests",
"minRequestsHint": "Minimum requests before calculating error rate",
"explanationTitle": "How It Works",
"failureThresholdLabel": "Failure Threshold",
"failureThresholdExplain": "Circuit breaker opens after this many consecutive failures, making the provider temporarily unavailable",
"timeoutLabel": "Recovery Wait Time",
"timeoutExplain": "After circuit opens, wait this long before trying half-open state",
"successThresholdLabel": "Recovery Success Threshold",
"successThresholdExplain": "In half-open state, close circuit breaker after this many successes, making provider available again",
"errorRateLabel": "Error Rate Threshold",
"errorRateExplain": "Open circuit breaker when error rate exceeds this value, even if failure threshold not reached"
}
}
}
+45
View File
@@ -840,5 +840,50 @@
},
"agents": {
"title": "智能体"
},
"proxy": {
"failoverQueue": {
"title": "故障转移队列",
"description": "管理各应用的供应商故障转移顺序",
"info": "当前激活的供应商始终优先。当请求失败时,系统会按队列顺序依次尝试其他供应商。",
"selectProvider": "选择供应商添加到队列",
"noAvailableProviders": "没有可添加的供应商",
"empty": "故障转移队列为空。添加供应商以启用自动故障转移。",
"dragHint": "拖拽供应商可调整故障转移顺序,序号越小优先级越高。",
"toggleEnabled": "启用/禁用",
"addSuccess": "已添加到故障转移队列",
"addFailed": "添加失败",
"removeSuccess": "已从故障转移队列移除",
"removeFailed": "移除失败",
"reorderSuccess": "队列顺序已更新",
"reorderFailed": "更新顺序失败",
"toggleFailed": "状态更新失败"
},
"autoFailover": {
"info": "当启用多个代理目标时,系统会按优先级顺序依次尝试。当某个供应商连续失败达到阈值时,熔断器会自动打开,跳过该供应商。",
"configSaved": "自动故障转移配置已保存",
"configSaveFailed": "保存失败",
"retrySettings": "重试与超时设置",
"failureThreshold": "失败阈值",
"failureThresholdHint": "连续失败多少次后打开熔断器(建议: 3-10)",
"timeout": "恢复等待时间(秒)",
"timeoutHint": "熔断器打开后,等待多久后尝试恢复(建议: 30-120)",
"circuitBreakerSettings": "熔断器高级设置",
"successThreshold": "恢复成功阈值",
"successThresholdHint": "半开状态下成功多少次后关闭熔断器",
"errorRate": "错误率阈值 (%)",
"errorRateHint": "错误率超过此值时打开熔断器",
"minRequests": "最小请求数",
"minRequestsHint": "计算错误率前的最小请求数",
"explanationTitle": "工作原理",
"failureThresholdLabel": "失败阈值",
"failureThresholdExplain": "连续失败达到此次数时,熔断器打开,该供应商暂时不可用",
"timeoutLabel": "恢复等待时间",
"timeoutExplain": "熔断器打开后,等待此时间后尝试半开状态",
"successThresholdLabel": "恢复成功阈值",
"successThresholdExplain": "半开状态下,成功达到此次数时关闭熔断器,供应商恢复可用",
"errorRateLabel": "错误率阈值",
"errorRateExplain": "错误率超过此值时,即使未达到失败阈值也会打开熔断器"
}
}
}
+50
View File
@@ -3,6 +3,7 @@ import type {
ProviderHealth,
CircuitBreakerConfig,
CircuitBreakerStats,
FailoverQueueItem,
} from "@/types/proxy";
export interface Provider {
@@ -21,6 +22,8 @@ export interface Provider {
}
export const failoverApi = {
// ========== 旧版代理目标 API(保留向后兼容)==========
// 获取代理目标列表
async getProxyTargets(appType: string): Promise<Provider[]> {
return invoke("get_proxy_targets", { appType });
@@ -35,6 +38,8 @@ export const failoverApi = {
return invoke("set_proxy_target", { providerId, appType, enabled });
},
// ========== 熔断器 API ==========
// 获取供应商健康状态
async getProviderHealth(
providerId: string,
@@ -70,4 +75,49 @@ export const failoverApi = {
): Promise<CircuitBreakerStats | null> {
return invoke("get_circuit_breaker_stats", { providerId, appType });
},
// ========== 故障转移队列 API(新) ==========
// 获取故障转移队列
async getFailoverQueue(appType: string): Promise<FailoverQueueItem[]> {
return invoke("get_failover_queue", { appType });
},
// 获取可添加到队列的供应商(不在队列中的)
async getAvailableProvidersForFailover(appType: string): Promise<Provider[]> {
return invoke("get_available_providers_for_failover", { appType });
},
// 添加供应商到故障转移队列
async addToFailoverQueue(
appType: string,
providerId: string,
): Promise<void> {
return invoke("add_to_failover_queue", { appType, providerId });
},
// 从故障转移队列移除供应商
async removeFromFailoverQueue(
appType: string,
providerId: string,
): Promise<void> {
return invoke("remove_from_failover_queue", { appType, providerId });
},
// 重新排序故障转移队列
async reorderFailoverQueue(
appType: string,
providerIds: string[],
): Promise<void> {
return invoke("reorder_failover_queue", { appType, providerIds });
},
// 设置故障转移队列项的启用状态
async setFailoverItemEnabled(
appType: string,
providerId: string,
enabled: boolean,
): Promise<void> {
return invoke("set_failover_item_enabled", { appType, providerId, enabled });
},
};
+137 -13
View File
@@ -1,6 +1,8 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { failoverApi } from "@/lib/api/failover";
// ========== 旧版代理目标 Hooks(保留向后兼容)==========
/**
* 获取代理目标列表
*/
@@ -12,19 +14,6 @@ export function useProxyTargets(appType: string) {
});
}
/**
* 获取供应商健康状态
*/
export function useProviderHealth(providerId: string, appType: string) {
return useQuery({
queryKey: ["providerHealth", providerId, appType],
queryFn: () => failoverApi.getProviderHealth(providerId, appType),
enabled: !!providerId && !!appType,
refetchInterval: 5000, // 每 5 秒刷新一次
retry: false,
});
}
/**
* 设置代理目标
*/
@@ -56,6 +45,21 @@ export function useSetProxyTarget() {
});
}
// ========== 熔断器 Hooks ==========
/**
* 获取供应商健康状态
*/
export function useProviderHealth(providerId: string, appType: string) {
return useQuery({
queryKey: ["providerHealth", providerId, appType],
queryFn: () => failoverApi.getProviderHealth(providerId, appType),
enabled: !!providerId && !!appType,
refetchInterval: 5000, // 每 5 秒刷新一次
retry: false,
});
}
/**
* 重置熔断器
*/
@@ -114,3 +118,123 @@ export function useCircuitBreakerStats(providerId: string, appType: string) {
refetchInterval: 5000, // 每 5 秒刷新一次
});
}
// ========== 故障转移队列 Hooks(新) ==========
/**
* 获取故障转移队列
*/
export function useFailoverQueue(appType: string) {
return useQuery({
queryKey: ["failoverQueue", appType],
queryFn: () => failoverApi.getFailoverQueue(appType),
enabled: !!appType,
});
}
/**
* 获取可添加到队列的供应商
*/
export function useAvailableProvidersForFailover(appType: string) {
return useQuery({
queryKey: ["availableProvidersForFailover", appType],
queryFn: () => failoverApi.getAvailableProvidersForFailover(appType),
enabled: !!appType,
});
}
/**
* 添加供应商到故障转移队列
*/
export function useAddToFailoverQueue() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
appType,
providerId,
}: {
appType: string;
providerId: string;
}) => failoverApi.addToFailoverQueue(appType, providerId),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["failoverQueue", variables.appType],
});
queryClient.invalidateQueries({
queryKey: ["availableProvidersForFailover", variables.appType],
});
},
});
}
/**
* 从故障转移队列移除供应商
*/
export function useRemoveFromFailoverQueue() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
appType,
providerId,
}: {
appType: string;
providerId: string;
}) => failoverApi.removeFromFailoverQueue(appType, providerId),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["failoverQueue", variables.appType],
});
queryClient.invalidateQueries({
queryKey: ["availableProvidersForFailover", variables.appType],
});
},
});
}
/**
* 重新排序故障转移队列
*/
export function useReorderFailoverQueue() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
appType,
providerIds,
}: {
appType: string;
providerIds: string[];
}) => failoverApi.reorderFailoverQueue(appType, providerIds),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["failoverQueue", variables.appType],
});
},
});
}
/**
* 设置故障转移队列项的启用状态
*/
export function useSetFailoverItemEnabled() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
appType,
providerId,
enabled,
}: {
appType: string;
providerId: string;
enabled: boolean;
}) => failoverApi.setFailoverItemEnabled(appType, providerId, enabled),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["failoverQueue", variables.appType],
});
},
});
}
+9
View File
@@ -93,3 +93,12 @@ export interface ProxyUsageRecord {
error: string | null;
timestamp: string;
}
// 故障转移队列条目
export interface FailoverQueueItem {
providerId: string;
providerName: string;
queueOrder: number;
enabled: boolean;
createdAt: number;
}