mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 00:35:32 +08:00
Feat/auto failover switch (#440)
* feat(failover): add auto-failover master switch with proxy integration
- Add persistent auto_failover_enabled setting in database
- Add get/set_auto_failover_enabled commands
- Provider router respects master switch state
- Proxy shutdown automatically disables failover
- Enabling failover auto-starts proxy server
- Optimistic updates for failover queue toggle
* feat(proxy): persist proxy takeover state across app restarts
- Add proxy_takeover_{app_type} settings for per-app state tracking
- Restore proxy takeover state automatically on app startup
- Preserve state on normal exit, clear on manual stop
- Add stop_with_restore_keep_state method for graceful shutdown
* fix(proxy): set takeover state for all apps in start_with_takeover
* fix(windows): hide console window when checking CLI versions
Add CREATE_NO_WINDOW flag to prevent command prompt from flashing
when detecting claude/codex/gemini CLI versions on Windows.
* refactor(failover): make auto-failover toggle per-app independent
- Change setting key from 'auto_failover_enabled' to 'auto_failover_enabled_{app_type}'
- Update provider_router to check per-app failover setting
- When failover disabled, use current provider only; when enabled, use queue order
- Add unit tests for failover enabled/disabled behavior
* feat(failover): auto-switch to higher priority provider on recovery
- After circuit breaker reset, check if recovered provider has higher priority
- Automatically switch back if queue_order is lower (higher priority)
- Stream health check now resets circuit breaker on success/degraded
* chore: remove unused start_proxy_with_takeover command
- Remove command registration from lib.rs
- Add comment clarifying failover queue is preserved on proxy stop
* feat(ui): integrate failover controls into provider cards
- Add failover toggle button to provider card actions
- Show priority badge (P1, P2, ...) for queued providers
- Highlight active provider with green border in failover mode
- Sync drag-drop order with failover queue
- Move per-app failover toggle to FailoverQueueManager
- Simplify SettingsPage failover section
* test(providers): add mocks for failover hooks in ProviderList tests
* refactor(failover): merge failover_queue table into providers
- Add in_failover_queue field to providers table
- Remove standalone failover_queue table and related indexes
- Simplify queue ordering by reusing sort_index field
- Remove reorder_failover_queue and set_failover_item_enabled commands
- Update frontend to use simplified FailoverQueueItem type
* fix(database): ensure in_failover_queue column exists for v2 databases
Add column check in create_tables to handle existing v2 databases
that were created before the failover queue refactor.
* fix(ui): differentiate active provider border color by proxy mode
- Use green border/gradient when proxy takeover is active
- Use blue border/gradient in normal mode (no proxy)
- Improves visual distinction between proxy and non-proxy states
* fix(database): clear provider health record when removing from failover queue
When a provider is removed from the failover queue, its health monitoring
is no longer needed. This change ensures the health record is also deleted
from the database to prevent stale data.
* fix(failover): improve cache cleanup for provider health and circuit breaker
- Use removeQueries instead of invalidateQueries when stopping proxy to
completely clear health and circuit breaker caches
- Clear provider health and circuit breaker caches when removing from
failover queue
- Refresh failover queue after drag-sort since queue order depends on
sort_index
- Only show health badge when provider is in failover queue
* style: apply prettier formatting to App.tsx and ProviderList.tsx
* fix(proxy): handle missing health records and clear health on proxy stop
- Return default healthy state when provider health record not found
- Add clear_provider_health_for_app to clear health for specific app
- Clear app health records when stopping proxy takeover
* fix(proxy): track actual provider used in forwarding for accurate logging
Introduce ForwardResult and ForwardError structs to return the actual
provider that handled the request. This ensures usage statistics and
error logs reflect the correct provider after failover.
This commit is contained in:
@@ -12,14 +12,14 @@ import {
|
||||
} from "@/lib/query/failover";
|
||||
|
||||
export interface AutoFailoverConfigPanelProps {
|
||||
enabled: boolean;
|
||||
onEnabledChange: (enabled: boolean) => void;
|
||||
enabled?: boolean;
|
||||
onEnabledChange?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function AutoFailoverConfigPanel({
|
||||
enabled,
|
||||
enabled = true,
|
||||
onEnabledChange: _onEnabledChange,
|
||||
}: AutoFailoverConfigPanelProps) {
|
||||
}: AutoFailoverConfigPanelProps = {}) {
|
||||
// Note: onEnabledChange is currently unused but kept in the interface
|
||||
// for potential future use by parent components
|
||||
void _onEnabledChange;
|
||||
|
||||
@@ -2,37 +2,14 @@
|
||||
* 故障转移队列管理组件
|
||||
*
|
||||
* 允许用户管理代理模式下的故障转移队列,支持:
|
||||
* - 拖拽排序
|
||||
* - 添加/移除供应商
|
||||
* - 启用/禁用队列项
|
||||
* - 队列顺序基于首页供应商列表的 sort_index
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useState } 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 { 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";
|
||||
@@ -51,8 +28,8 @@ import {
|
||||
useAvailableProvidersForFailover,
|
||||
useAddToFailoverQueue,
|
||||
useRemoveFromFailoverQueue,
|
||||
useReorderFailoverQueue,
|
||||
useSetFailoverItemEnabled,
|
||||
useAutoFailoverEnabled,
|
||||
useSetAutoFailoverEnabled,
|
||||
} from "@/lib/query/failover";
|
||||
|
||||
interface FailoverQueueManagerProps {
|
||||
@@ -67,6 +44,10 @@ export function FailoverQueueManager({
|
||||
const { t } = useTranslation();
|
||||
const [selectedProviderId, setSelectedProviderId] = useState<string>("");
|
||||
|
||||
// 故障转移开关状态(每个应用独立)
|
||||
const { data: isFailoverEnabled = false } = useAutoFailoverEnabled(appType);
|
||||
const setFailoverEnabled = useSetAutoFailoverEnabled();
|
||||
|
||||
// 查询数据
|
||||
const {
|
||||
data: queue,
|
||||
@@ -79,59 +60,11 @@ export function FailoverQueueManager({
|
||||
// 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", "队列顺序已更新"),
|
||||
{ closeButton: true },
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("proxy.failoverQueue.reorderFailed", "更新顺序失败") +
|
||||
": " +
|
||||
String(error),
|
||||
);
|
||||
}
|
||||
},
|
||||
[sortedQueue, appType, reorderQueue, t],
|
||||
);
|
||||
// 切换故障转移开关
|
||||
const handleToggleFailover = (enabled: boolean) => {
|
||||
setFailoverEnabled.mutate({ appType, enabled });
|
||||
};
|
||||
|
||||
// 添加供应商到队列
|
||||
const handleAddProvider = async () => {
|
||||
@@ -171,19 +104,6 @@ export function FailoverQueueManager({
|
||||
}
|
||||
};
|
||||
|
||||
// 切换启用状态
|
||||
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">
|
||||
@@ -203,13 +123,41 @@ export function FailoverQueueManager({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 自动故障转移开关 */}
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50 border border-border/50">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{t("proxy.failover.autoSwitch", {
|
||||
defaultValue: "自动故障转移",
|
||||
})}
|
||||
</span>
|
||||
{isFailoverEnabled && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-emerald-500/20 text-emerald-600 dark:text-emerald-400">
|
||||
{t("common.enabled", { defaultValue: "已开启" })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.failover.autoSwitchDescription", {
|
||||
defaultValue: "开启后,请求失败时自动切换到队列中的下一个供应商",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isFailoverEnabled}
|
||||
onCheckedChange={handleToggleFailover}
|
||||
disabled={disabled || setFailoverEnabled.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 说明信息 */}
|
||||
<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>
|
||||
@@ -260,7 +208,7 @@ export function FailoverQueueManager({
|
||||
</div>
|
||||
|
||||
{/* 队列列表 */}
|
||||
{sortedQueue.length === 0 ? (
|
||||
{!queue || queue.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(
|
||||
@@ -270,39 +218,26 @@ export function FailoverQueueManager({
|
||||
</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>
|
||||
<div className="space-y-2">
|
||||
{queue.map((item, index) => (
|
||||
<QueueItem
|
||||
key={item.providerId}
|
||||
item={item}
|
||||
index={index}
|
||||
disabled={disabled}
|
||||
onRemove={handleRemoveProvider}
|
||||
isRemoving={removeFromQueue.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 队列说明 */}
|
||||
{sortedQueue.length > 0 && (
|
||||
{queue && queue.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"proxy.failoverQueue.dragHint",
|
||||
"拖拽供应商可调整故障转移顺序,序号越小优先级越高。",
|
||||
"proxy.failoverQueue.orderHint",
|
||||
"队列顺序与首页供应商列表顺序一致,可在首页拖拽调整顺序。",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
@@ -310,65 +245,29 @@ export function FailoverQueueManager({
|
||||
);
|
||||
}
|
||||
|
||||
interface SortableQueueItemProps {
|
||||
interface QueueItemProps {
|
||||
item: FailoverQueueItem;
|
||||
index: number;
|
||||
disabled: boolean;
|
||||
onToggleEnabled: (providerId: string, enabled: boolean) => void;
|
||||
onRemove: (providerId: string) => void;
|
||||
isRemoving: boolean;
|
||||
isToggling: boolean;
|
||||
}
|
||||
|
||||
function SortableQueueItem({
|
||||
function QueueItem({
|
||||
item,
|
||||
index,
|
||||
disabled,
|
||||
onToggleEnabled,
|
||||
onRemove,
|
||||
isRemoving,
|
||||
isToggling,
|
||||
}: SortableQueueItemProps) {
|
||||
}: QueueItemProps) {
|
||||
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}
|
||||
@@ -376,24 +275,11 @@ function SortableQueueItem({
|
||||
|
||||
{/* 供应商名称 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium truncate block",
|
||||
!item.enabled && "text-muted-foreground line-through",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-medium truncate block">
|
||||
{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"
|
||||
|
||||
@@ -147,13 +147,10 @@ export function ProxyPanel() {
|
||||
<ProviderQueueGroup
|
||||
appType="claude"
|
||||
appLabel="Claude"
|
||||
targets={claudeQueue
|
||||
.filter((item) => item.enabled)
|
||||
.sort((a, b) => a.queueOrder - b.queueOrder)
|
||||
.map((item) => ({
|
||||
id: item.providerId,
|
||||
name: item.providerName,
|
||||
}))}
|
||||
targets={claudeQueue.map((item) => ({
|
||||
id: item.providerId,
|
||||
name: item.providerName,
|
||||
}))}
|
||||
status={status}
|
||||
/>
|
||||
)}
|
||||
@@ -163,13 +160,10 @@ export function ProxyPanel() {
|
||||
<ProviderQueueGroup
|
||||
appType="codex"
|
||||
appLabel="Codex"
|
||||
targets={codexQueue
|
||||
.filter((item) => item.enabled)
|
||||
.sort((a, b) => a.queueOrder - b.queueOrder)
|
||||
.map((item) => ({
|
||||
id: item.providerId,
|
||||
name: item.providerName,
|
||||
}))}
|
||||
targets={codexQueue.map((item) => ({
|
||||
id: item.providerId,
|
||||
name: item.providerName,
|
||||
}))}
|
||||
status={status}
|
||||
/>
|
||||
)}
|
||||
@@ -179,13 +173,10 @@ export function ProxyPanel() {
|
||||
<ProviderQueueGroup
|
||||
appType="gemini"
|
||||
appLabel="Gemini"
|
||||
targets={geminiQueue
|
||||
.filter((item) => item.enabled)
|
||||
.sort((a, b) => a.queueOrder - b.queueOrder)
|
||||
.map((item) => ({
|
||||
id: item.providerId,
|
||||
name: item.providerName,
|
||||
}))}
|
||||
targets={geminiQueue.map((item) => ({
|
||||
id: item.providerId,
|
||||
name: item.providerName,
|
||||
}))}
|
||||
status={status}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user