From f05904821cfb0e992476ab4183831d6c0c3ea3b1 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Sun, 21 Dec 2025 12:04:44 +0800 Subject: [PATCH] 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 --- src-tauri/src/commands/failover.rs | 25 +++++ src-tauri/src/database/dao/failover.rs | 24 ++++- src-tauri/src/lib.rs | 2 + src-tauri/src/proxy/provider_router.rs | 75 ++++++++------- src/App.tsx | 4 +- src/components/mcp/UnifiedMcpPanel.tsx | 4 +- src/components/prompts/PromptListItem.tsx | 4 +- src/components/prompts/PromptPanel.tsx | 5 +- .../providers/forms/BasicFormFields.tsx | 5 +- .../forms/ProviderPresetSelector.tsx | 3 +- src/components/proxy/ProxyPanel.tsx | 8 +- src/components/proxy/ProxySettingsDialog.tsx | 6 +- src/components/proxy/ProxyToggle.tsx | 5 +- src/components/settings/SettingsPage.tsx | 51 +++++++--- src/components/usage/ModelTestConfigPanel.tsx | 24 ++--- src/components/usage/PricingConfigPanel.tsx | 11 +-- src/components/usage/RequestLogTable.tsx | 11 +-- src/components/usage/UsageDashboard.tsx | 4 +- src/components/usage/UsageSummaryCards.tsx | 30 +++--- src/hooks/useProxyStatus.ts | 12 ++- src/lib/api/failover.ts | 10 ++ src/lib/query/failover.ts | 96 ++++++++++++++++++- 22 files changed, 292 insertions(+), 127 deletions(-) diff --git a/src-tauri/src/commands/failover.rs b/src-tauri/src/commands/failover.rs index ddceeef13..bc8058ce2 100644 --- a/src-tauri/src/commands/failover.rs +++ b/src-tauri/src/commands/failover.rs @@ -82,3 +82,28 @@ pub async fn set_failover_item_enabled( .set_failover_item_enabled(&app_type, &provider_id, enabled) .map_err(|e| e.to_string()) } + +/// 获取自动故障转移总开关状态 +#[tauri::command] +pub async fn get_auto_failover_enabled(state: tauri::State<'_, AppState>) -> Result { + state + .db + .get_setting("auto_failover_enabled") + .map(|v| v.map(|s| s == "true").unwrap_or(false)) // 默认关闭 + .map_err(|e| e.to_string()) +} + +/// 设置自动故障转移总开关状态 +#[tauri::command] +pub async fn set_auto_failover_enabled( + state: tauri::State<'_, AppState>, + enabled: bool, +) -> Result<(), String> { + state + .db + .set_setting( + "auto_failover_enabled", + if enabled { "true" } else { "false" }, + ) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/database/dao/failover.rs b/src-tauri/src/database/dao/failover.rs index 41cff95ff..35009ea4c 100644 --- a/src-tauri/src/database/dao/failover.rs +++ b/src-tauri/src/database/dao/failover.rs @@ -182,11 +182,25 @@ impl Database { ) -> Result<(), AppError> { let conn = lock_conn!(self.conn); - conn.execute( - "UPDATE failover_queue SET enabled = ?3 WHERE app_type = ?1 AND provider_id = ?2", - rusqlite::params![app_type, provider_id, enabled], - ) - .map_err(|e| AppError::Database(e.to_string()))?; + let rows_affected = conn + .execute( + "UPDATE failover_queue SET enabled = ?3 WHERE app_type = ?1 AND provider_id = ?2", + rusqlite::params![app_type, provider_id, enabled], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + if rows_affected == 0 { + log::warn!( + "set_failover_item_enabled: 未找到匹配记录 app_type={app_type}, provider_id={provider_id}" + ); + return Err(AppError::Database(format!( + "未找到故障转移队列项: app_type={app_type}, provider_id={provider_id}" + ))); + } + + log::info!( + "set_failover_item_enabled: 已更新 app_type={app_type}, provider_id={provider_id}, enabled={enabled}" + ); Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3143eb2c8..f95547f86 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -678,6 +678,8 @@ pub fn run() { commands::remove_from_failover_queue, commands::reorder_failover_queue, commands::set_failover_item_enabled, + commands::get_auto_failover_enabled, + commands::set_auto_failover_enabled, // Usage statistics commands::get_usage_summary, commands::get_usage_trends, diff --git a/src-tauri/src/proxy/provider_router.rs b/src-tauri/src/proxy/provider_router.rs index d6f302268..3a5466f36 100644 --- a/src-tauri/src/proxy/provider_router.rs +++ b/src-tauri/src/proxy/provider_router.rs @@ -31,12 +31,19 @@ impl ProviderRouter { /// /// 返回按优先级排序的可用供应商列表: /// 1. 当前供应商(is_current=true)始终第一位 - /// 2. 故障转移队列中的其他供应商(按 queue_order 排序) + /// 2. 故障转移队列中的其他供应商(按 queue_order 排序)- 仅当自动故障转移开关开启时 /// 3. 只返回熔断器未打开的供应商 pub async fn select_providers(&self, app_type: &str) -> Result, AppError> { let mut result = Vec::new(); let all_providers = self.db.get_all_providers(app_type)?; + // 检查自动故障转移总开关是否开启 + let auto_failover_enabled = self + .db + .get_setting("auto_failover_enabled") + .map(|v| v.map(|s| s == "true").unwrap_or(false)) // 默认关闭 + .unwrap_or(false); + // 1. 当前供应商始终第一位 if let Some(current_id) = self.db.get_current_provider(app_type)? { if let Some(current) = all_providers.get(¤t_id) { @@ -61,43 +68,47 @@ impl ProviderRouter { } } - // 2. 获取故障转移队列中的供应商 - let queue = self.db.get_failover_queue(app_type)?; + // 2. 获取故障转移队列中的供应商(仅当自动故障转移开关开启时) + if auto_failover_enabled { + let queue = self.db.get_failover_queue(app_type)?; - for item in queue { - // 跳过已添加的当前供应商 - if result.iter().any(|p| p.id == item.provider_id) { - continue; - } + for item in queue { + // 跳过已添加的当前供应商 + if result.iter().any(|p| p.id == item.provider_id) { + continue; + } - // 跳过禁用的队列项 - if !item.enabled { - continue; - } + // 跳过禁用的队列项 + if !item.enabled { + continue; + } - // 获取供应商信息 - if let Some(provider) = all_providers.get(&item.provider_id) { - // 检查熔断器状态 - let circuit_key = format!("{}:{}", app_type, provider.id); - let breaker = self.get_or_create_circuit_breaker(&circuit_key).await; + // 获取供应商信息 + if let Some(provider) = all_providers.get(&item.provider_id) { + // 检查熔断器状态 + let circuit_key = format!("{}:{}", app_type, provider.id); + let breaker = self.get_or_create_circuit_breaker(&circuit_key).await; - if breaker.is_available().await { - log::info!( - "[{}] Failover provider available: {} ({}) at queue position {}", - app_type, - provider.name, - provider.id, - item.queue_order - ); - result.push(provider.clone()); - } else { - log::debug!( - "[{}] Failover provider {} circuit breaker open, skipping", - app_type, - provider.name - ); + if breaker.is_available().await { + log::info!( + "[{}] Failover provider available: {} ({}) at queue position {}", + app_type, + provider.name, + provider.id, + item.queue_order + ); + result.push(provider.clone()); + } else { + log::debug!( + "[{}] Failover provider {} circuit breaker open, skipping", + app_type, + provider.name + ); + } } } + } else { + log::info!("[{app_type}] Auto-failover disabled, using only current provider"); } if result.is_empty() { diff --git a/src/App.tsx b/src/App.tsx index a73fa88ff..d9b730c0f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -350,7 +350,9 @@ function App() { appId={activeApp} isLoading={isLoading} isProxyRunning={isProxyRunning} - isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive} + isProxyTakeover={ + isProxyRunning && isCurrentAppTakeoverActive + } onSwitch={switchProvider} onEdit={setEditingProvider} onDelete={setConfirmDelete} diff --git a/src/components/mcp/UnifiedMcpPanel.tsx b/src/components/mcp/UnifiedMcpPanel.tsx index 9f765bbae..f004b1c7d 100644 --- a/src/components/mcp/UnifiedMcpPanel.tsx +++ b/src/components/mcp/UnifiedMcpPanel.tsx @@ -237,9 +237,7 @@ const UnifiedMcpListItem: React.FC = ({ {/* 左侧:服务器信息 */}
-

- {name} -

+

{name}

{docsUrl && (
-

- {prompt.name} -

+

{prompt.name}

{prompt.description && (

{prompt.description} diff --git a/src/components/prompts/PromptPanel.tsx b/src/components/prompts/PromptPanel.tsx index 88822b3ac..af73626aa 100644 --- a/src/components/prompts/PromptPanel.tsx +++ b/src/components/prompts/PromptPanel.tsx @@ -114,10 +114,7 @@ const PromptPanel = React.forwardRef( ) : promptEntries.length === 0 ? (

- +

{t("prompts.empty")} diff --git a/src/components/providers/forms/BasicFormFields.tsx b/src/components/providers/forms/BasicFormFields.tsx index 82477c2a3..514343474 100644 --- a/src/components/providers/forms/BasicFormFields.tsx +++ b/src/components/providers/forms/BasicFormFields.tsx @@ -153,7 +153,10 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) { {t("provider.websiteUrl")} - + diff --git a/src/components/providers/forms/ProviderPresetSelector.tsx b/src/components/providers/forms/ProviderPresetSelector.tsx index 56523af00..80d8220f6 100644 --- a/src/components/providers/forms/ProviderPresetSelector.tsx +++ b/src/components/providers/forms/ProviderPresetSelector.tsx @@ -149,8 +149,7 @@ export function ProviderPresetSelector({ className={`${getPresetButtonClass(isSelected, entry.preset)} relative`} style={getPresetButtonStyle(isSelected, entry.preset)} title={ - presetCategoryLabels[category] ?? - t("providerPreset.other") + presetCategoryLabels[category] ?? t("providerPreset.other") } > {renderPresetIcon(entry.preset)} diff --git a/src/components/proxy/ProxyPanel.tsx b/src/components/proxy/ProxyPanel.tsx index 6d0141de6..08df765b2 100644 --- a/src/components/proxy/ProxyPanel.tsx +++ b/src/components/proxy/ProxyPanel.tsx @@ -51,7 +51,9 @@ export function ProxyPanel() {

- {t("proxy.panel.serviceAddress", { defaultValue: "服务地址" })} + {t("proxy.panel.serviceAddress", { + defaultValue: "服务地址", + })}

- {t("proxy.panel.stoppedTitle", { defaultValue: "代理服务已停止" })} + {t("proxy.panel.stoppedTitle", { + defaultValue: "代理服务已停止", + })}

{t("proxy.panel.stoppedDescription", { diff --git a/src/components/proxy/ProxySettingsDialog.tsx b/src/components/proxy/ProxySettingsDialog.tsx index 0a5b8afdb..3ae452f89 100644 --- a/src/components/proxy/ProxySettingsDialog.tsx +++ b/src/components/proxy/ProxySettingsDialog.tsx @@ -29,7 +29,11 @@ import type { ProxyConfig } from "@/types/proxy"; // 表单数据类型(仅包含可编辑字段) type ProxyConfigForm = Pick< ProxyConfig, - "listen_address" | "listen_port" | "max_retries" | "request_timeout" | "enable_logging" + | "listen_address" + | "listen_port" + | "max_retries" + | "request_timeout" + | "enable_logging" >; const createProxyConfigSchema = (t: TFunction) => { diff --git a/src/components/proxy/ProxyToggle.tsx b/src/components/proxy/ProxyToggle.tsx index 631ce7c71..7b007c63c 100644 --- a/src/components/proxy/ProxyToggle.tsx +++ b/src/components/proxy/ProxyToggle.tsx @@ -49,10 +49,7 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) { return (

diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index ddffc0dd5..9695e9741 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -47,6 +47,10 @@ import type { SettingsFormState } from "@/hooks/useSettings"; import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"; import { useProxyStatus } from "@/hooks/useProxyStatus"; +import { + useAutoFailoverEnabled, + useSetAutoFailoverEnabled, +} from "@/lib/query/failover"; interface SettingsDialogProps { open: boolean; @@ -182,11 +186,18 @@ export function SettingsPage({ stopWithRestore, isPending: isProxyPending, } = useProxyStatus(); - const [failoverEnabled, setFailoverEnabled] = useState(true); + + // 使用持久化的自动故障转移开关状态 + const { data: failoverEnabled = false } = useAutoFailoverEnabled(); + const setAutoFailoverEnabled = useSetAutoFailoverEnabled(); const handleToggleProxy = async (checked: boolean) => { try { if (!checked) { + // 关闭代理时,同时关闭故障转移 + if (failoverEnabled) { + setAutoFailoverEnabled.mutate(false); + } await stopWithRestore(); } else { await startProxyServer(); @@ -196,6 +207,19 @@ export function SettingsPage({ } }; + // 处理故障转移开关:开启时自动启动代理 + const handleToggleFailover = async (checked: boolean) => { + try { + if (checked && !isRunning) { + // 开启故障转移时,先启动代理 + await startProxyServer(); + } + setAutoFailoverEnabled.mutate(checked); + } catch (error) { + console.error("Toggle failover failed:", error); + } + }; + return (
{isBusy ? ( @@ -215,9 +239,7 @@ export function SettingsPage({ {t("settings.tabAdvanced")} - - {t("usage.title")} - + {t("usage.title")} {t("common.about")} @@ -318,7 +340,9 @@ export function SettingsPage({ - {isRunning ? t("settings.advanced.proxy.running") : t("settings.advanced.proxy.stopped")} + {isRunning + ? t("settings.advanced.proxy.running") + : t("settings.advanced.proxy.stopped")}
@@ -402,19 +429,19 @@ export function SettingsPage({ @@ -423,8 +450,8 @@ export function SettingsPage({ {/* 熔断器配置 */}
diff --git a/src/components/usage/ModelTestConfigPanel.tsx b/src/components/usage/ModelTestConfigPanel.tsx index 998b954ad..213cf6152 100644 --- a/src/components/usage/ModelTestConfigPanel.tsx +++ b/src/components/usage/ModelTestConfigPanel.tsx @@ -51,9 +51,7 @@ export function ModelTestConfigPanel() { closeButton: true, }); } catch (e) { - toast.error( - t("streamCheck.configSaveFailed") + ": " + String(e), - ); + toast.error(t("streamCheck.configSaveFailed") + ": " + String(e)); } finally { setIsSaving(false); } @@ -82,9 +80,7 @@ export function ModelTestConfigPanel() {

- +
- +
- +
- +
- + - {t("usage.loadPricingError")}:{" "} - {String(error)} + {t("usage.loadPricingError")}: {String(error)} @@ -124,9 +123,7 @@ export function PricingConfigPanel() {
{!pricing || pricing.length === 0 ? ( - - {t("usage.noPricingData")} - + {t("usage.noPricingData")} ) : (
@@ -220,9 +217,7 @@ export function PricingConfigPanel() { > - - {t("usage.deleteConfirmTitle")} - + {t("usage.deleteConfirmTitle")} {t("usage.deleteConfirmDesc")} diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index 39d8d7169..6f6436833 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -87,9 +87,7 @@ export function RequestLogTable() { - - {t("usage.allApps")} - + {t("usage.allApps")} Claude Codex Gemini @@ -149,9 +147,7 @@ export function RequestLogTable() {
- - {t("usage.timeRange")}: - + {t("usage.timeRange")}: - {log.providerName || - t("usage.unknownProvider")} + {log.providerName || t("usage.unknownProvider")}

{t("usage.title")}

-

- {t("usage.subtitle")} -

+

{t("usage.subtitle")}

+
{t("usage.input")} @@ -78,21 +78,21 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) { icon: Database, color: "text-orange-500", bg: "bg-orange-500/10", - subValue: ( -
-
- {t("usage.cacheWrite")} - - {(cacheWriteTokens / 1000).toFixed(1)}k - -
-
- {t("usage.cacheRead")} - - {(cacheReadTokens / 1000).toFixed(1)}k - -
+ subValue: ( +
+
+ {t("usage.cacheWrite")} + + {(cacheWriteTokens / 1000).toFixed(1)}k +
+
+ {t("usage.cacheRead")} + + {(cacheReadTokens / 1000).toFixed(1)}k + +
+
), }, ]; diff --git a/src/hooks/useProxyStatus.ts b/src/hooks/useProxyStatus.ts index e679513a1..e43a159eb 100644 --- a/src/hooks/useProxyStatus.ts +++ b/src/hooks/useProxyStatus.ts @@ -51,7 +51,8 @@ export function useProxyStatus() { }, onError: (error: Error) => { const detail = - extractErrorMessage(error) || t("common.unknown", { defaultValue: "未知错误" }); + extractErrorMessage(error) || + t("common.unknown", { defaultValue: "未知错误" }); toast.error( t("proxy.server.startFailed", { defaultValue: `启动代理服务失败: ${detail}`, @@ -77,7 +78,8 @@ export function useProxyStatus() { }, onError: (error: Error) => { const detail = - extractErrorMessage(error) || t("common.unknown", { defaultValue: "未知错误" }); + extractErrorMessage(error) || + t("common.unknown", { defaultValue: "未知错误" }); toast.error( t("proxy.stopWithRestoreFailed", { defaultValue: `停止失败: ${detail}`, @@ -114,7 +116,8 @@ export function useProxyStatus() { }, onError: (error: Error) => { const detail = - extractErrorMessage(error) || t("common.unknown", { defaultValue: "未知错误" }); + extractErrorMessage(error) || + t("common.unknown", { defaultValue: "未知错误" }); toast.error( t("proxy.takeover.failed", { defaultValue: `操作失败: ${detail}`, @@ -137,7 +140,8 @@ export function useProxyStatus() { }, onError: (error: Error) => { const detail = - extractErrorMessage(error) || t("common.unknown", { defaultValue: "未知错误" }); + extractErrorMessage(error) || + t("common.unknown", { defaultValue: "未知错误" }); toast.error( t("proxy.switchFailed", { error: detail, diff --git a/src/lib/api/failover.ts b/src/lib/api/failover.ts index 58fc26e21..85c00c852 100644 --- a/src/lib/api/failover.ts +++ b/src/lib/api/failover.ts @@ -104,4 +104,14 @@ export const failoverApi = { enabled, }); }, + + // 获取自动故障转移总开关状态 + async getAutoFailoverEnabled(): Promise { + return invoke("get_auto_failover_enabled"); + }, + + // 设置自动故障转移总开关状态 + async setAutoFailoverEnabled(enabled: boolean): Promise { + return invoke("set_auto_failover_enabled", { enabled }); + }, }; diff --git a/src/lib/query/failover.ts b/src/lib/query/failover.ts index 9f0c7d9fb..bfba5b52a 100644 --- a/src/lib/query/failover.ts +++ b/src/lib/query/failover.ts @@ -173,6 +173,7 @@ export function useReorderFailoverQueue() { /** * 设置故障转移队列项的启用状态 + * 使用乐观更新(Optimistic Update)以提供即时反馈 */ export function useSetFailoverItemEnabled() { const queryClient = useQueryClient(); @@ -187,10 +188,103 @@ export function useSetFailoverItemEnabled() { providerId: string; enabled: boolean; }) => failoverApi.setFailoverItemEnabled(appType, providerId, enabled), - onSuccess: (_, variables) => { + + // 乐观更新:立即更新缓存中的数据 + onMutate: async (variables) => { + // 取消正在进行的查询,防止覆盖乐观更新 + await queryClient.cancelQueries({ + queryKey: ["failoverQueue", variables.appType], + }); + + // 保存之前的数据以便回滚 + const previousQueue = queryClient.getQueryData< + import("@/types/proxy").FailoverQueueItem[] + >(["failoverQueue", variables.appType]); + + // 乐观地更新缓存 + if (previousQueue) { + queryClient.setQueryData( + ["failoverQueue", variables.appType], + previousQueue.map((item) => + item.providerId === variables.providerId + ? { ...item, enabled: variables.enabled } + : item, + ), + ); + } + + // 返回上下文供 onError 使用 + return { previousQueue }; + }, + + // 错误时回滚 + onError: (_error, variables, context) => { + if (context?.previousQueue) { + queryClient.setQueryData( + ["failoverQueue", variables.appType], + context.previousQueue, + ); + } + }, + + // 无论成功失败,都重新获取最新数据以确保一致性 + onSettled: (_, __, variables) => { queryClient.invalidateQueries({ queryKey: ["failoverQueue", variables.appType], }); }, }); } + +// ========== 自动故障转移总开关 Hooks ========== + +/** + * 获取自动故障转移总开关状态 + */ +export function useAutoFailoverEnabled() { + return useQuery({ + queryKey: ["autoFailoverEnabled"], + queryFn: () => failoverApi.getAutoFailoverEnabled(), + // 默认值为 false(与后端保持一致) + placeholderData: false, + }); +} + +/** + * 设置自动故障转移总开关状态 + */ +export function useSetAutoFailoverEnabled() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (enabled: boolean) => + failoverApi.setAutoFailoverEnabled(enabled), + + // 乐观更新 + onMutate: async (enabled) => { + await queryClient.cancelQueries({ queryKey: ["autoFailoverEnabled"] }); + const previousValue = queryClient.getQueryData([ + "autoFailoverEnabled", + ]); + + queryClient.setQueryData(["autoFailoverEnabled"], enabled); + + return { previousValue }; + }, + + // 错误时回滚 + onError: (_error, _enabled, context) => { + if (context?.previousValue !== undefined) { + queryClient.setQueryData( + ["autoFailoverEnabled"], + context.previousValue, + ); + } + }, + + // 无论成功失败,都重新获取 + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["autoFailoverEnabled"] }); + }, + }); +}