mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 16:26:16 +08:00
fix(failover): patch P1-P3 reliability gaps surfaced by team review
- Forwarder buffers non-streaming bodies and primes streaming first chunk before signaling success, so body timeouts and SSE first-chunk failures route through the circuit breaker instead of being recorded as success on response-header arrival - Atomic enable-failover: switch to P1 before persisting the flag, and roll back auto-added queue entries when the switch is rejected (e.g. official providers) - Hot-reload circuit breaker config on per-app proxy config change instead of waiting for a proxy restart - FailoverToggle / FailoverQueueManager / AutoFailoverConfigPanel require proxy takeover for the active app; the backend command also rejects enabling when takeover is off - ProviderHealthBadge consumes the backend is_healthy flag instead of hardcoding the 5-failure threshold Cleanup: - impl From<&AppProxyConfig> for CircuitBreakerConfig and use it from the command layer - Collapse three identical TabsContent blocks into a single map
This commit is contained in:
@@ -364,6 +364,7 @@ export function ProviderCard({
|
||||
{isProxyRunning && isInFailoverQueue && health && (
|
||||
<ProviderHealthBadge
|
||||
consecutiveFailures={health.consecutive_failures}
|
||||
isHealthy={health.is_healthy}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ProviderHealthBadgeProps {
|
||||
consecutiveFailures: number;
|
||||
isHealthy?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -13,6 +14,7 @@ interface ProviderHealthBadgeProps {
|
||||
*/
|
||||
export function ProviderHealthBadge({
|
||||
consecutiveFailures,
|
||||
isHealthy,
|
||||
className,
|
||||
}: ProviderHealthBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -29,7 +31,7 @@ export function ProviderHealthBadge({
|
||||
bgColor: "bg-green-500/10",
|
||||
textColor: "text-green-600 dark:text-green-400",
|
||||
};
|
||||
} else if (consecutiveFailures < 5) {
|
||||
} else if (isHealthy !== false) {
|
||||
return {
|
||||
labelKey: "health.degraded",
|
||||
labelFallback: "降级",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useAutoFailoverEnabled,
|
||||
useSetAutoFailoverEnabled,
|
||||
} from "@/lib/query/failover";
|
||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AppId } from "@/lib/api";
|
||||
@@ -24,8 +25,11 @@ export function FailoverToggle({ className, activeApp }: FailoverToggleProps) {
|
||||
const { data: isEnabled = false, isLoading } =
|
||||
useAutoFailoverEnabled(activeApp);
|
||||
const setEnabled = useSetAutoFailoverEnabled();
|
||||
const { takeoverStatus } = useProxyStatus();
|
||||
const takeoverEnabled = takeoverStatus?.[activeApp] ?? false;
|
||||
|
||||
const handleToggle = (checked: boolean) => {
|
||||
if (checked && !takeoverEnabled) return;
|
||||
setEnabled.mutate({ appType: activeApp, enabled: checked });
|
||||
};
|
||||
|
||||
@@ -36,15 +40,20 @@ export function FailoverToggle({ className, activeApp }: FailoverToggleProps) {
|
||||
? "Codex"
|
||||
: "Gemini";
|
||||
|
||||
const tooltipText = isEnabled
|
||||
? t("failover.tooltip.enabled", {
|
||||
const tooltipText = !takeoverEnabled
|
||||
? t("failover.tooltip.takeoverRequired", {
|
||||
app: appLabel,
|
||||
defaultValue: `${appLabel} 故障转移已启用\n按队列优先级(P1→P2→...)选择供应商`,
|
||||
defaultValue: `请先接管 ${appLabel},再启用故障转移`,
|
||||
})
|
||||
: t("failover.tooltip.disabled", {
|
||||
app: appLabel,
|
||||
defaultValue: `启用 ${appLabel} 故障转移\n将立即切换到队列 P1,并在失败时自动切换到下一个`,
|
||||
});
|
||||
: isEnabled
|
||||
? t("failover.tooltip.enabled", {
|
||||
app: appLabel,
|
||||
defaultValue: `${appLabel} 故障转移已启用\n按队列优先级(P1→P2→...)选择供应商`,
|
||||
})
|
||||
: t("failover.tooltip.disabled", {
|
||||
app: appLabel,
|
||||
defaultValue: `启用 ${appLabel} 故障转移\n将立即切换到队列 P1,并在失败时自动切换到下一个`,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -69,7 +78,7 @@ export function FailoverToggle({ className, activeApp }: FailoverToggleProps) {
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={setEnabled.isPending || isLoading}
|
||||
disabled={setEnabled.isPending || isLoading || !takeoverEnabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -725,6 +725,7 @@ function ProviderQueueItem({
|
||||
{/* 健康徽章 */}
|
||||
<ProviderHealthBadge
|
||||
consecutiveFailures={health?.consecutive_failures ?? 0}
|
||||
isHealthy={health?.is_healthy}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -35,6 +35,7 @@ export function ProxyTabContent({
|
||||
|
||||
const {
|
||||
isRunning,
|
||||
takeoverStatus,
|
||||
startProxyServer,
|
||||
stopWithRestore,
|
||||
isPending: isProxyPending,
|
||||
@@ -176,72 +177,38 @@ export function ProxyTabContent({
|
||||
<TabsTrigger value="codex">Codex</TabsTrigger>
|
||||
<TabsTrigger value="gemini">Gemini</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="claude" className="mt-4 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>
|
||||
<FailoverQueueManager
|
||||
appType="claude"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-border/50 pt-6">
|
||||
<AutoFailoverConfigPanel
|
||||
appType="claude"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="codex" className="mt-4 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>
|
||||
<FailoverQueueManager
|
||||
appType="codex"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-border/50 pt-6">
|
||||
<AutoFailoverConfigPanel
|
||||
appType="codex"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="gemini" className="mt-4 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>
|
||||
<FailoverQueueManager
|
||||
appType="gemini"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-border/50 pt-6">
|
||||
<AutoFailoverConfigPanel
|
||||
appType="gemini"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
{(["claude", "codex", "gemini"] as const).map((appType) => {
|
||||
const failoverDisabled =
|
||||
!isRunning || !(takeoverStatus?.[appType] ?? false);
|
||||
return (
|
||||
<TabsContent
|
||||
key={appType}
|
||||
value={appType}
|
||||
className="mt-4 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>
|
||||
<FailoverQueueManager
|
||||
appType={appType}
|
||||
disabled={failoverDisabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-border/50 pt-6">
|
||||
<AutoFailoverConfigPanel
|
||||
appType={appType}
|
||||
disabled={failoverDisabled}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
|
||||
@@ -227,8 +227,12 @@ export function useUpdateAppProxyConfig() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["appProxyConfig", variables.appType],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["autoFailoverEnabled", variables.appType],
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["proxyConfig"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["circuitBreakerConfig"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(
|
||||
|
||||
Reference in New Issue
Block a user