feat(failover): add auto-failover master switch with proxy integration (#427)

* 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
This commit is contained in:
YoVinchen
2025-12-21 22:39:50 +08:00
committed by GitHub
parent f047960a33
commit a1537807eb
25 changed files with 479 additions and 141 deletions
+3 -1
View File
@@ -350,7 +350,9 @@ function App() {
appId={activeApp}
isLoading={isLoading}
isProxyRunning={isProxyRunning}
isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}
isProxyTakeover={
isProxyRunning && isCurrentAppTakeoverActive
}
onSwitch={switchProvider}
onEdit={setEditingProvider}
onDelete={setConfirmDelete}
+1 -3
View File
@@ -237,9 +237,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
{/* 左侧:服务器信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-foreground">
{name}
</h3>
<h3 className="font-medium text-foreground">{name}</h3>
{docsUrl && (
<Button
type="button"
+1 -3
View File
@@ -36,9 +36,7 @@ const PromptListItem: React.FC<PromptListItemProps> = ({
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground mb-1">
{prompt.name}
</h3>
<h3 className="font-medium text-foreground mb-1">{prompt.name}</h3>
{prompt.description && (
<p className="text-sm text-muted-foreground truncate">
{prompt.description}
+1 -4
View File
@@ -114,10 +114,7 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
) : promptEntries.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center">
<FileText
size={24}
className="text-muted-foreground"
/>
<FileText size={24} className="text-muted-foreground" />
</div>
<h3 className="text-lg font-medium text-foreground mb-2">
{t("prompts.empty")}
@@ -153,7 +153,10 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
<FormItem>
<FormLabel>{t("provider.websiteUrl")}</FormLabel>
<FormControl>
<Input {...field} placeholder={t("providerForm.websiteUrlPlaceholder")} />
<Input
{...field}
placeholder={t("providerForm.websiteUrlPlaceholder")}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -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)}
+6 -2
View File
@@ -51,7 +51,9 @@ export function ProxyPanel() {
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-muted-foreground">
{t("proxy.panel.serviceAddress", { defaultValue: "服务地址" })}
{t("proxy.panel.serviceAddress", {
defaultValue: "服务地址",
})}
</p>
<Button
size="sm"
@@ -229,7 +231,9 @@ export function ProxyPanel() {
<Server className="h-8 w-8" />
</div>
<p className="text-base font-medium text-foreground mb-1">
{t("proxy.panel.stoppedTitle", { defaultValue: "代理服务已停止" })}
{t("proxy.panel.stoppedTitle", {
defaultValue: "代理服务已停止",
})}
</p>
<p className="text-sm text-muted-foreground mb-4">
{t("proxy.panel.stoppedDescription", {
+5 -1
View File
@@ -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) => {
+1 -4
View File
@@ -49,10 +49,7 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
return (
<div
className={cn(
"p-1 rounded-xl transition-all",
className,
)}
className={cn("p-1 rounded-xl transition-all", className)}
title={tooltipText}
>
<div className="flex items-center gap-2 px-2 h-8 rounded-md cursor-default">
+39 -12
View File
@@ -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 (
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] overflow-hidden px-6">
{isBusy ? (
@@ -215,9 +239,7 @@ export function SettingsPage({
<TabsTrigger value="advanced">
{t("settings.tabAdvanced")}
</TabsTrigger>
<TabsTrigger value="usage">
{t("usage.title")}
</TabsTrigger>
<TabsTrigger value="usage">{t("usage.title")}</TabsTrigger>
<TabsTrigger value="about">{t("common.about")}</TabsTrigger>
</TabsList>
@@ -318,7 +340,9 @@ export function SettingsPage({
<Activity
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
/>
{isRunning ? t("settings.advanced.proxy.running") : t("settings.advanced.proxy.stopped")}
{isRunning
? t("settings.advanced.proxy.running")
: t("settings.advanced.proxy.stopped")}
</Badge>
<Switch
checked={isRunning}
@@ -376,8 +400,11 @@ export function SettingsPage({
<div className="flex items-center gap-2 pl-4">
<Switch
checked={failoverEnabled}
onCheckedChange={setFailoverEnabled}
checked={failoverEnabled && isRunning}
onCheckedChange={handleToggleFailover}
disabled={
setAutoFailoverEnabled.isPending || isProxyPending
}
/>
</div>
</AccordionPrimitive.Header>
@@ -402,19 +429,19 @@ export function SettingsPage({
<TabsContent value="claude" className="mt-4">
<FailoverQueueManager
appType="claude"
disabled={!failoverEnabled}
disabled={!failoverEnabled || !isRunning}
/>
</TabsContent>
<TabsContent value="codex" className="mt-4">
<FailoverQueueManager
appType="codex"
disabled={!failoverEnabled}
disabled={!failoverEnabled || !isRunning}
/>
</TabsContent>
<TabsContent value="gemini" className="mt-4">
<FailoverQueueManager
appType="gemini"
disabled={!failoverEnabled}
disabled={!failoverEnabled || !isRunning}
/>
</TabsContent>
</Tabs>
@@ -423,8 +450,8 @@ export function SettingsPage({
{/* 熔断器配置 */}
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
enabled={failoverEnabled}
onEnabledChange={setFailoverEnabled}
enabled={failoverEnabled && isRunning}
onEnabledChange={handleToggleFailover}
/>
</div>
</div>
+6 -18
View File
@@ -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() {
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="claudeModel">
{t("streamCheck.claudeModel")}
</Label>
<Label htmlFor="claudeModel">{t("streamCheck.claudeModel")}</Label>
<Input
id="claudeModel"
value={config.claudeModel}
@@ -96,9 +92,7 @@ export function ModelTestConfigPanel() {
</div>
<div className="space-y-2">
<Label htmlFor="codexModel">
{t("streamCheck.codexModel")}
</Label>
<Label htmlFor="codexModel">{t("streamCheck.codexModel")}</Label>
<Input
id="codexModel"
value={config.codexModel}
@@ -110,9 +104,7 @@ export function ModelTestConfigPanel() {
</div>
<div className="space-y-2">
<Label htmlFor="geminiModel">
{t("streamCheck.geminiModel")}
</Label>
<Label htmlFor="geminiModel">{t("streamCheck.geminiModel")}</Label>
<Input
id="geminiModel"
value={config.geminiModel}
@@ -132,9 +124,7 @@ export function ModelTestConfigPanel() {
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="timeoutSecs">
{t("streamCheck.timeout")}
</Label>
<Label htmlFor="timeoutSecs">{t("streamCheck.timeout")}</Label>
<Input
id="timeoutSecs"
type="number"
@@ -151,9 +141,7 @@ export function ModelTestConfigPanel() {
</div>
<div className="space-y-2">
<Label htmlFor="maxRetries">
{t("streamCheck.maxRetries")}
</Label>
<Label htmlFor="maxRetries">{t("streamCheck.maxRetries")}</Label>
<Input
id="maxRetries"
type="number"
+3 -8
View File
@@ -93,8 +93,7 @@ export function PricingConfigPanel() {
<CardContent>
<Alert variant="destructive">
<AlertDescription>
{t("usage.loadPricingError")}:{" "}
{String(error)}
{t("usage.loadPricingError")}: {String(error)}
</AlertDescription>
</Alert>
</CardContent>
@@ -124,9 +123,7 @@ export function PricingConfigPanel() {
<div className="space-y-4">
{!pricing || pricing.length === 0 ? (
<Alert>
<AlertDescription>
{t("usage.noPricingData")}
</AlertDescription>
<AlertDescription>{t("usage.noPricingData")}</AlertDescription>
</Alert>
) : (
<div className="rounded-md bg-card/60 shadow-sm">
@@ -220,9 +217,7 @@ export function PricingConfigPanel() {
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("usage.deleteConfirmTitle")}
</DialogTitle>
<DialogTitle>{t("usage.deleteConfirmTitle")}</DialogTitle>
<DialogDescription>
{t("usage.deleteConfirmDesc")}
</DialogDescription>
+3 -8
View File
@@ -87,9 +87,7 @@ export function RequestLogTable() {
<SelectValue placeholder={t("usage.appType")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("usage.allApps")}
</SelectItem>
<SelectItem value="all">{t("usage.allApps")}</SelectItem>
<SelectItem value="claude">Claude</SelectItem>
<SelectItem value="codex">Codex</SelectItem>
<SelectItem value="gemini">Gemini</SelectItem>
@@ -149,9 +147,7 @@ export function RequestLogTable() {
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="whitespace-nowrap">
{t("usage.timeRange")}:
</span>
<span className="whitespace-nowrap">{t("usage.timeRange")}:</span>
<Input
type="datetime-local"
className="h-8 w-[200px] bg-background"
@@ -283,8 +279,7 @@ export function RequestLogTable() {
)}
</TableCell>
<TableCell>
{log.providerName ||
t("usage.unknownProvider")}
{log.providerName || t("usage.unknownProvider")}
</TableCell>
<TableCell
className="font-mono text-sm max-w-[280px] truncate"
+1 -3
View File
@@ -26,9 +26,7 @@ export function UsageDashboard() {
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex flex-col gap-1">
<h2 className="text-2xl font-bold">{t("usage.title")}</h2>
<p className="text-sm text-muted-foreground">
{t("usage.subtitle")}
</p>
<p className="text-sm text-muted-foreground">{t("usage.subtitle")}</p>
</div>
<Tabs
+15 -15
View File
@@ -56,7 +56,7 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
color: "text-purple-500",
bg: "bg-purple-500/10",
subValue: (
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
<div className="flex justify-between items-center">
<span>{t("usage.input")}</span>
<span className="text-foreground/80">
@@ -78,21 +78,21 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
icon: Database,
color: "text-orange-500",
bg: "bg-orange-500/10",
subValue: (
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
<div className="flex justify-between items-center">
<span>{t("usage.cacheWrite")}</span>
<span className="text-foreground/80">
{(cacheWriteTokens / 1000).toFixed(1)}k
</span>
</div>
<div className="flex justify-between items-center">
<span>{t("usage.cacheRead")}</span>
<span className="text-foreground/80">
{(cacheReadTokens / 1000).toFixed(1)}k
</span>
</div>
subValue: (
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
<div className="flex justify-between items-center">
<span>{t("usage.cacheWrite")}</span>
<span className="text-foreground/80">
{(cacheWriteTokens / 1000).toFixed(1)}k
</span>
</div>
<div className="flex justify-between items-center">
<span>{t("usage.cacheRead")}</span>
<span className="text-foreground/80">
{(cacheReadTokens / 1000).toFixed(1)}k
</span>
</div>
</div>
),
},
];
+8 -4
View File
@@ -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,
+10
View File
@@ -104,4 +104,14 @@ export const failoverApi = {
enabled,
});
},
// 获取自动故障转移总开关状态
async getAutoFailoverEnabled(): Promise<boolean> {
return invoke("get_auto_failover_enabled");
},
// 设置自动故障转移总开关状态
async setAutoFailoverEnabled(enabled: boolean): Promise<void> {
return invoke("set_auto_failover_enabled", { enabled });
},
};
+95 -1
View File
@@ -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<import("@/types/proxy").FailoverQueueItem[]>(
["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<boolean>([
"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"] });
},
});
}