feat(ui): add auto-failover configuration UI and provider health display

Add comprehensive UI for failover management:

Components:
- ProviderHealthBadge: Display provider health status with color coding
- CircuitBreakerConfigPanel: Configure failure/success thresholds,
  timeout duration, and error rate limits
- AutoFailoverConfigPanel: Manage proxy targets with drag-and-drop
  priority ordering and individual enable/disable controls
- ProxyPanel: Integrate failover tabs for unified proxy management

Provider enhancements:
- ProviderCard: Show health badge and proxy target indicator
- ProviderActions: Add "Set as Proxy Target" action
- EditProviderDialog: Add is_proxy_target toggle
- ProviderList: Support proxy target filtering mode

Other:
- Update App.tsx routing for settings integration
- Update useProviderActions hook with proxy target mutation
- Fix ProviderList tests for updated component API
This commit is contained in:
YoVinchen
2025-12-08 11:27:35 +08:00
parent e093164b8d
commit 94da8ca89d
12 changed files with 1103 additions and 53 deletions
+9 -3
View File
@@ -23,6 +23,7 @@ import {
} from "@/lib/api";
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
import { useProviderActions } from "@/hooks/useProviderActions";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { extractErrorMessage } from "@/utils/errorUtils";
import { cn } from "@/lib/utils";
import { AppSwitcher } from "@/components/AppSwitcher";
@@ -62,7 +63,13 @@ function App() {
const addActionButtonClass =
"bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8";
const { data, isLoading, refetch } = useProvidersQuery(activeApp);
// 获取代理服务状态
const { isRunning: isProxyRunning } = useProxyStatus();
// 获取供应商列表,当代理服务运行时自动刷新
const { data, isLoading, refetch } = useProvidersQuery(activeApp, {
isProxyRunning,
});
const providers = useMemo(() => data?.providers ?? {}, [data]);
const currentProviderId = data?.currentProviderId ?? "";
const isClaudeApp = activeApp === "claude";
@@ -74,7 +81,6 @@ function App() {
switchProvider,
deleteProvider,
saveUsageScript,
setProxyTarget,
} = useProviderActions(activeApp);
// 监听来自托盘菜单的切换事件
@@ -313,8 +319,8 @@ function App() {
currentProviderId={currentProviderId}
appId={activeApp}
isLoading={isLoading}
isProxyRunning={isProxyRunning}
onSwitch={switchProvider}
onSetProxyTarget={setProxyTarget}
onEdit={setEditingProvider}
onDelete={setConfirmDelete}
onDuplicate={handleDuplicateProvider}
+42 -16
View File
@@ -33,13 +33,23 @@ export function EditProviderDialog({
unknown
> | null>(null);
// 使用 ref 标记是否已经加载过,防止重复读取覆盖用户编辑
const [hasLoadedLive, setHasLoadedLive] = useState(false);
useEffect(() => {
let cancelled = false;
const load = async () => {
if (!open || !provider) {
setLiveSettings(null);
setHasLoadedLive(false);
return;
}
// 关键修复:只在首次打开时加载一次
if (hasLoadedLive) {
return;
}
try {
const currentId = await providersApi.getCurrent(appId);
if (currentId && provider.id === currentId) {
@@ -49,13 +59,20 @@ export function EditProviderDialog({
)) as Record<string, unknown>;
if (!cancelled && live && typeof live === "object") {
setLiveSettings(live);
setHasLoadedLive(true);
}
} catch {
// 读取实时配置失败则回退到 SSOT(不打断编辑流程)
if (!cancelled) setLiveSettings(null);
if (!cancelled) {
setLiveSettings(null);
setHasLoadedLive(true);
}
}
} else {
if (!cancelled) setLiveSettings(null);
if (!cancelled) {
setLiveSettings(null);
setHasLoadedLive(true);
}
}
} finally {
// no-op
@@ -65,14 +82,33 @@ export function EditProviderDialog({
return () => {
cancelled = true;
};
}, [open, provider, appId]);
}, [open, provider?.id, appId, hasLoadedLive]); // 只依赖 provider.id,不依赖整个 provider 对象
const initialSettingsConfig = useMemo(() => {
return (liveSettings ?? provider?.settingsConfig ?? {}) as Record<
string,
unknown
>;
}, [liveSettings, provider]);
}, [liveSettings, provider?.settingsConfig]); // 只依赖 settingsConfig,不依赖整个 provider
// 固定 initialData,防止 provider 对象更新时重置表单
const initialData = useMemo(() => {
if (!provider) return null;
return {
name: provider.name,
notes: provider.notes,
websiteUrl: provider.websiteUrl,
settingsConfig: initialSettingsConfig,
category: provider.category,
meta: provider.meta,
icon: provider.icon,
iconColor: provider.iconColor,
};
}, [
provider?.id, // 只依赖 ID,provider 对象更新不会触发重新计算
initialSettingsConfig,
// 注意:不依赖 provider 的其他字段,防止表单重置
]);
const handleSubmit = useCallback(
async (values: ProviderFormValues) => {
@@ -104,7 +140,7 @@ export function EditProviderDialog({
[onSubmit, onOpenChange, provider],
);
if (!provider) {
if (!provider || !initialData) {
return null;
}
@@ -130,17 +166,7 @@ export function EditProviderDialog({
submitLabel={t("common.save")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
initialData={{
name: provider.name,
notes: provider.notes,
websiteUrl: provider.websiteUrl,
// 若读取到实时配置则优先使用
settingsConfig: initialSettingsConfig,
category: provider.category,
meta: provider.meta,
icon: provider.icon,
iconColor: provider.iconColor,
}}
initialData={initialData}
showButtons={false}
/>
</FullScreenPanel>
@@ -7,6 +7,7 @@ import {
Play,
TestTube2,
Trash2,
RotateCcw,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -21,6 +22,9 @@ interface ProviderActionsProps {
onTest?: () => void;
onConfigureUsage: () => void;
onDelete: () => void;
onResetCircuitBreaker?: () => void;
isProxyTarget?: boolean;
consecutiveFailures?: number;
}
export function ProviderActions({
@@ -32,6 +36,9 @@ export function ProviderActions({
onTest,
onConfigureUsage,
onDelete,
onResetCircuitBreaker,
isProxyTarget,
consecutiveFailures = 0,
}: ProviderActionsProps) {
const { t } = useTranslation();
const iconButtonClass = "h-8 w-8 p-1";
@@ -110,6 +117,32 @@ export function ProviderActions({
<BarChart3 className="h-4 w-4" />
</Button>
{/* 重置熔断器按钮 - 代理目标启用时显示 */}
{onResetCircuitBreaker && isProxyTarget && (
<Button
size="icon"
variant="ghost"
onClick={onResetCircuitBreaker}
disabled={consecutiveFailures === 0}
title={
consecutiveFailures > 0
? t("provider.resetCircuitBreaker", {
defaultValue: "重置熔断器",
})
: t("provider.noFailures", {
defaultValue: "当前无失败记录",
})
}
className={cn(
iconButtonClass,
consecutiveFailures > 0 &&
"hover:text-orange-500 dark:hover:text-orange-400",
)}
>
<RotateCcw className="h-4 w-4" />
</Button>
)}
<Button
size="icon"
variant="ghost"
+139 -6
View File
@@ -13,6 +13,13 @@ import { ProviderIcon } from "@/components/ProviderIcon";
import UsageFooter from "@/components/UsageFooter";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
import {
useProviderHealth,
useResetCircuitBreaker,
useSetProxyTarget,
} from "@/lib/query/failover";
import { toast } from "sonner";
interface DragHandleProps {
attributes: DraggableAttributes;
@@ -32,8 +39,9 @@ interface ProviderCardProps {
onDuplicate: (provider: Provider) => void;
onTest?: (provider: Provider) => void;
isTesting?: boolean;
onSetProxyTarget: (provider: Provider) => void;
isProxyRunning: boolean;
proxyPriority?: number; // 代理目标的实际优先级 (1, 2, 3...)
allProviders?: Provider[]; // 所有供应商列表,用于计算开启后的优先级
dragHandleProps?: DragHandleProps;
}
@@ -84,12 +92,109 @@ export function ProviderCard({
onDuplicate,
onTest,
isTesting,
onSetProxyTarget,
isProxyRunning,
proxyPriority,
allProviders,
dragHandleProps,
}: ProviderCardProps) {
const { t } = useTranslation();
// 获取供应商健康状态
const { data: health } = useProviderHealth(provider.id, appId);
// 设置代理目标
const setProxyTargetMutation = useSetProxyTarget();
// 重置熔断器
const resetCircuitBreaker = useResetCircuitBreaker();
const handleSetProxyTarget = async (enabled: boolean) => {
try {
await setProxyTargetMutation.mutateAsync({
providerId: provider.id,
appType: appId,
enabled,
});
// 计算实际优先级(开启时)
let actualPriority: number | undefined;
if (enabled && allProviders) {
// 模拟开启后的状态:获取所有将要启用代理的 providers
const futureProxyTargets = allProviders.filter((p) => {
// 包括:已经是代理目标的 或 当前要开启的这个
if (p.id === provider.id) return true;
return p.isProxyTarget;
});
// 按 sortIndex 排序
const sortedTargets = futureProxyTargets.sort((a, b) => {
const indexA = a.sortIndex ?? Number.MAX_SAFE_INTEGER;
const indexB = b.sortIndex ?? Number.MAX_SAFE_INTEGER;
return indexA - indexB;
});
// 找到当前 provider 的位置
const position = sortedTargets.findIndex((p) => p.id === provider.id);
actualPriority = position >= 0 ? position + 1 : undefined;
}
const message = enabled
? actualPriority
? t("provider.proxyTargetEnabled", {
defaultValue: `已启用代理目标(优先级:P${actualPriority}`,
})
: t("provider.proxyTargetEnabled", {
defaultValue: "已启用代理目标",
})
: t("provider.proxyTargetDisabled", {
defaultValue: "已禁用代理目标",
});
const description = enabled
? t("provider.proxyTargetEnabledDesc", {
defaultValue: "下次请求将按优先级自动选择此供应商",
})
: t("provider.proxyTargetDisabledDesc", {
defaultValue: "后续请求将使用其他可用供应商",
});
toast.success(message, {
description,
duration: 4000,
});
} catch (error) {
toast.error(
t("provider.setProxyTargetFailed", {
defaultValue: "操作失败",
}) +
": " +
String(error),
);
}
};
const handleResetCircuitBreaker = async () => {
try {
await resetCircuitBreaker.mutateAsync({
providerId: provider.id,
appType: appId,
});
toast.success(
t("provider.circuitBreakerReset", {
defaultValue: "熔断器已重置",
}),
);
} catch (error) {
toast.error(
t("provider.circuitBreakerResetFailed", {
defaultValue: "重置失败",
}) +
": " +
String(error),
);
}
};
const fallbackUrlText = t("provider.notConfigured", {
defaultValue: "未配置接口地址",
});
@@ -163,6 +268,29 @@ export function ProviderCard({
<h3 className="text-base font-semibold leading-none">
{provider.name}
</h3>
{/* 健康状态徽章和优先级 */}
{isProxyRunning && (
<div className="flex items-center gap-1.5">
{/* 健康徽章:代理目标启用时始终显示,没有健康数据时默认为正常(0失败) */}
{(provider.isProxyTarget || health) && (
<ProviderHealthBadge
consecutiveFailures={health?.consecutive_failures ?? 0}
isProxyTarget={provider.isProxyTarget ?? false}
/>
)}
{/* 优先级:仅在代理目标启用时显示 */}
{provider.isProxyTarget && proxyPriority && (
<span
className="text-xs text-muted-foreground"
title={`代理队列优先级:第${proxyPriority}`}
>
P{proxyPriority}
</span>
)}
</div>
)}
{provider.category === "third_party" &&
provider.meta?.isPartner && (
<span
@@ -185,11 +313,9 @@ export function ProviderCard({
id={`proxy-target-switch-${provider.id}`}
checked={provider.isProxyTarget || false}
onCheckedChange={(checked) => {
if (checked && !provider.isProxyTarget) {
onSetProxyTarget(provider);
}
handleSetProxyTarget(checked);
}}
disabled={provider.isProxyTarget}
disabled={setProxyTargetMutation.isPending}
className="scale-75 data-[state=checked]:bg-purple-500"
/>
{provider.isProxyTarget && (
@@ -255,6 +381,13 @@ export function ProviderCard({
onTest={onTest ? () => onTest(provider) : undefined}
onConfigureUsage={() => onConfigureUsage(provider)}
onDelete={() => onDelete(provider)}
onResetCircuitBreaker={
isProxyRunning && provider.isProxyTarget
? handleResetCircuitBreaker
: undefined
}
isProxyTarget={provider.isProxyTarget}
consecutiveFailures={health?.consecutive_failures ?? 0}
/>
</div>
</div>
@@ -0,0 +1,69 @@
import { cn } from "@/lib/utils";
import { ProviderHealthStatus } from "@/types/proxy";
interface ProviderHealthBadgeProps {
consecutiveFailures: number;
isProxyTarget?: boolean;
className?: string;
}
/**
* 供应商健康状态徽章
* 根据连续失败次数显示不同颜色的状态指示器
*/
export function ProviderHealthBadge({
consecutiveFailures,
isProxyTarget,
className,
}: ProviderHealthBadgeProps) {
// 如果代理目标已关闭但有失败记录,仍然显示(自动熔断场景)
// 如果代理目标启用,始终显示
// 如果代理目标关闭且无失败记录,隐藏
if (!isProxyTarget && consecutiveFailures === 0) return null;
// 根据失败次数计算状态
const getStatus = () => {
if (consecutiveFailures === 0) {
return {
label: "正常",
status: ProviderHealthStatus.Healthy,
color: "bg-green-500",
bgColor: "bg-green-50 dark:bg-green-950",
textColor: "text-green-700 dark:text-green-300",
};
} else if (consecutiveFailures < 5) {
return {
label: "降级",
status: ProviderHealthStatus.Degraded,
color: "bg-yellow-500",
bgColor: "bg-yellow-50 dark:bg-yellow-950",
textColor: "text-yellow-700 dark:text-yellow-300",
};
} else {
return {
label: "熔断",
status: ProviderHealthStatus.Failed,
color: "bg-red-500",
bgColor: "bg-red-50 dark:bg-red-950",
textColor: "text-red-700 dark:text-red-300",
};
}
};
const statusConfig = getStatus();
return (
<div
className={cn(
"inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium",
statusConfig.bgColor,
statusConfig.textColor,
className,
)}
title={`连续失败 ${consecutiveFailures}`}
>
<div className={cn("w-2 h-2 rounded-full", statusConfig.color)} />
<span>{statusConfig.label}</span>
</div>
);
}
+32 -10
View File
@@ -5,11 +5,11 @@ import {
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { useMemo } from "react";
import type { CSSProperties } from "react";
import type { Provider } from "@/types";
import type { AppId } from "@/lib/api";
import { useDragSort } from "@/hooks/useDragSort";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { useModelTest } from "@/hooks/useModelTest";
import { ProviderCard } from "@/components/providers/ProviderCard";
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
@@ -26,7 +26,7 @@ interface ProviderListProps {
onOpenWebsite: (url: string) => void;
onCreate?: () => void;
isLoading?: boolean;
onSetProxyTarget: (provider: Provider) => void;
isProxyRunning?: boolean; // 代理服务运行状态
}
export function ProviderList({
@@ -41,19 +41,37 @@ export function ProviderList({
onOpenWebsite,
onCreate,
isLoading = false,
onSetProxyTarget,
isProxyRunning = false, // 默认值为 false
}: ProviderListProps) {
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
providers,
appId,
);
// 获取代理服务运行状态
const { isRunning: isProxyRunning } = useProxyStatus();
// 模型测试
const { testProvider, isTesting } = useModelTest(appId);
// 计算代理目标的实际优先级映射 (P1, P2, P3...)
const proxyPriorityMap = useMemo(() => {
// 获取所有启用代理目标的供应商
const proxyTargets = sortedProviders.filter((p) => p.isProxyTarget);
// 按 sortIndex 排序
const sortedTargets = proxyTargets.sort((a, b) => {
const indexA = a.sortIndex ?? Number.MAX_SAFE_INTEGER;
const indexB = b.sortIndex ?? Number.MAX_SAFE_INTEGER;
return indexA - indexB;
});
// 创建优先级映射
const map = new Map<string, number>();
sortedTargets.forEach((provider, index) => {
map.set(provider.id, index + 1); // P1, P2, P3...
});
return map;
}, [sortedProviders]);
const handleTest = (provider: Provider) => {
testProvider(provider.id, provider.name);
};
@@ -103,8 +121,9 @@ export function ProviderList({
onOpenWebsite={onOpenWebsite}
onTest={handleTest}
isTesting={isTesting(provider.id)}
onSetProxyTarget={onSetProxyTarget}
isProxyRunning={isProxyRunning}
proxyPriority={proxyPriorityMap.get(provider.id)}
allProviders={sortedProviders}
/>
))}
</div>
@@ -125,8 +144,9 @@ interface SortableProviderCardProps {
onOpenWebsite: (url: string) => void;
onTest: (provider: Provider) => void;
isTesting: boolean;
onSetProxyTarget: (provider: Provider) => void;
isProxyRunning: boolean;
proxyPriority?: number; // 代理目标的实际优先级 (1, 2, 3...)
allProviders?: Provider[]; // 所有供应商列表
}
function SortableProviderCard({
@@ -141,8 +161,9 @@ function SortableProviderCard({
onOpenWebsite,
onTest,
isTesting,
onSetProxyTarget,
isProxyRunning,
proxyPriority,
allProviders,
}: SortableProviderCardProps) {
const {
setNodeRef,
@@ -174,8 +195,9 @@ function SortableProviderCard({
onOpenWebsite={onOpenWebsite}
onTest={onTest}
isTesting={isTesting}
onSetProxyTarget={onSetProxyTarget}
isProxyRunning={isProxyRunning}
proxyPriority={proxyPriority}
allProviders={allProviders}
dragHandleProps={{
attributes,
listeners,
@@ -0,0 +1,403 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ChevronDown, ChevronRight, Save, Loader2, Info } from "lucide-react";
import { toast } from "sonner";
import {
useCircuitBreakerConfig,
useUpdateCircuitBreakerConfig,
} from "@/lib/query/failover";
/**
* 自动故障转移配置面板
* 放置在高级设置页面,模型测试配置下方
*/
export function AutoFailoverConfigPanel() {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(false);
const { data: config, isLoading, error } = useCircuitBreakerConfig();
const updateConfig = useUpdateCircuitBreakerConfig();
const [formData, setFormData] = useState({
enabled: true, // 自动故障转移总开关
failureThreshold: 5,
successThreshold: 2,
timeoutSeconds: 60,
errorRateThreshold: 0.5,
minRequests: 10,
});
useEffect(() => {
if (config) {
setFormData({
enabled: true, // 默认开启,后续可以从数据库读取
...config,
});
}
}, [config]);
const handleSave = async () => {
try {
await updateConfig.mutateAsync({
failureThreshold: formData.failureThreshold,
successThreshold: formData.successThreshold,
timeoutSeconds: formData.timeoutSeconds,
errorRateThreshold: formData.errorRateThreshold,
minRequests: formData.minRequests,
});
toast.success(
t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"),
);
} catch (e) {
toast.error(
t("proxy.autoFailover.configSaveFailed", "保存失败") + ": " + String(e),
);
}
};
const handleReset = () => {
if (config) {
setFormData({
enabled: formData.enabled,
...config,
});
}
};
if (isLoading) {
return (
<Card className="border rounded-lg">
<CardHeader
className="cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<ChevronRight className="h-4 w-4" />
<CardTitle className="text-base">
{t("proxy.autoFailover.title", "自动故障转移配置")}
</CardTitle>
</div>
</CardHeader>
</Card>
);
}
return (
<Card className="border rounded-lg">
<CardHeader
className="cursor-pointer select-none"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<div className="flex-1">
<div className="flex items-center gap-3">
<CardTitle className="text-base">
{t("proxy.autoFailover.title", "自动故障转移配置")}
</CardTitle>
{/* 总开关 - 点击时阻止折叠/展开 */}
<div
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-2"
>
<Switch
id="autoFailoverEnabled"
checked={formData.enabled}
onCheckedChange={(checked) =>
setFormData({ ...formData, enabled: checked })
}
className="scale-90"
/>
<Label
htmlFor="autoFailoverEnabled"
className="text-xs font-medium cursor-pointer"
>
{formData.enabled
? t("proxy.autoFailover.enabled", "已启用")
: t("proxy.autoFailover.disabled", "已禁用")}
</Label>
</div>
</div>
{!isExpanded && (
<CardDescription className="mt-1">
{t(
"proxy.autoFailover.description",
"配置多个代理目标之间的自动切换规则和熔断策略",
)}
</CardDescription>
)}
</div>
</div>
</CardHeader>
{isExpanded && (
<CardContent className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{String(error)}</AlertDescription>
</Alert>
)}
<Alert className="border-blue-500/40 bg-blue-500/10">
<Info className="h-4 w-4" />
<AlertDescription className="text-sm">
{t(
"proxy.autoFailover.info",
"当启用多个代理目标时,系统会按优先级顺序依次尝试。当某个供应商连续失败达到阈值时,熔断器会自动打开,跳过该供应商。",
)}
</AlertDescription>
</Alert>
{/* 重试与超时配置 */}
<div className="space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4">
<h4 className="text-sm font-semibold">
{t("proxy.autoFailover.retrySettings", "重试与超时设置")}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="failureThreshold">
{t("proxy.autoFailover.failureThreshold", "失败阈值")}
</Label>
<Input
id="failureThreshold"
type="number"
min="1"
max="20"
value={formData.failureThreshold}
onChange={(e) =>
setFormData({
...formData,
failureThreshold: parseInt(e.target.value) || 5,
})
}
disabled={!formData.enabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.failureThresholdHint",
"连续失败多少次后打开熔断器(建议: 3-10)",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="timeoutSeconds">
{t("proxy.autoFailover.timeout", "恢复等待时间(秒)")}
</Label>
<Input
id="timeoutSeconds"
type="number"
min="10"
max="300"
value={formData.timeoutSeconds}
onChange={(e) =>
setFormData({
...formData,
timeoutSeconds: parseInt(e.target.value) || 60,
})
}
disabled={!formData.enabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.timeoutHint",
"熔断器打开后,等待多久后尝试恢复(建议: 30-120)",
)}
</p>
</div>
</div>
</div>
{/* 熔断器高级配置 */}
<div className="space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4">
<h4 className="text-sm font-semibold">
{t("proxy.autoFailover.circuitBreakerSettings", "熔断器高级设置")}
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="successThreshold">
{t("proxy.autoFailover.successThreshold", "恢复成功阈值")}
</Label>
<Input
id="successThreshold"
type="number"
min="1"
max="10"
value={formData.successThreshold}
onChange={(e) =>
setFormData({
...formData,
successThreshold: parseInt(e.target.value) || 2,
})
}
disabled={!formData.enabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.successThresholdHint",
"半开状态下成功多少次后关闭熔断器",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="errorRateThreshold">
{t("proxy.autoFailover.errorRate", "错误率阈值 (%)")}
</Label>
<Input
id="errorRateThreshold"
type="number"
min="0"
max="100"
step="5"
value={Math.round(formData.errorRateThreshold * 100)}
onChange={(e) =>
setFormData({
...formData,
errorRateThreshold:
(parseInt(e.target.value) || 50) / 100,
})
}
disabled={!formData.enabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.errorRateHint",
"错误率超过此值时打开熔断器",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="minRequests">
{t("proxy.autoFailover.minRequests", "最小请求数")}
</Label>
<Input
id="minRequests"
type="number"
min="5"
max="100"
value={formData.minRequests}
onChange={(e) =>
setFormData({
...formData,
minRequests: parseInt(e.target.value) || 10,
})
}
disabled={!formData.enabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.minRequestsHint",
"计算错误率前的最小请求数",
)}
</p>
</div>
</div>
</div>
{/* 操作按钮 */}
<div className="flex justify-end gap-3 pt-2">
<Button
variant="outline"
onClick={handleReset}
disabled={updateConfig.isPending || !formData.enabled}
>
{t("common.reset", "重置")}
</Button>
<Button
onClick={handleSave}
disabled={updateConfig.isPending || !formData.enabled}
>
{updateConfig.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("common.saving", "保存中...")}
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{t("common.save", "保存")}
</>
)}
</Button>
</div>
{/* 说明信息 */}
<div className="p-4 bg-muted/50 rounded-lg space-y-2 text-sm">
<h4 className="font-medium">
{t("proxy.autoFailover.explanationTitle", "工作原理")}
</h4>
<ul className="space-y-1 text-muted-foreground">
<li>
{" "}
<strong>
{t("proxy.autoFailover.failureThresholdLabel", "失败阈值")}
</strong>
{t(
"proxy.autoFailover.failureThresholdExplain",
"连续失败达到此次数时,熔断器打开,该供应商暂时不可用",
)}
</li>
<li>
{" "}
<strong>
{t("proxy.autoFailover.timeoutLabel", "恢复等待时间")}
</strong>
{t(
"proxy.autoFailover.timeoutExplain",
"熔断器打开后,等待此时间后尝试半开状态",
)}
</li>
<li>
{" "}
<strong>
{t(
"proxy.autoFailover.successThresholdLabel",
"恢复成功阈值",
)}
</strong>
{t(
"proxy.autoFailover.successThresholdExplain",
"半开状态下,成功达到此次数时关闭熔断器,供应商恢复可用",
)}
</li>
<li>
{" "}
<strong>
{t("proxy.autoFailover.errorRateLabel", "错误率阈值")}
</strong>
{t(
"proxy.autoFailover.errorRateExplain",
"错误率超过此值时,即使未达到失败阈值也会打开熔断器",
)}
</li>
</ul>
</div>
</CardContent>
)}
</Card>
);
}
@@ -0,0 +1,208 @@
import {
useCircuitBreakerConfig,
useUpdateCircuitBreakerConfig,
} from "@/lib/query/failover";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { useState, useEffect } from "react";
import { toast } from "sonner";
/**
* 熔断器配置面板
* 允许用户调整熔断器参数
*/
export function CircuitBreakerConfigPanel() {
const { data: config, isLoading } = useCircuitBreakerConfig();
const updateConfig = useUpdateCircuitBreakerConfig();
const [formData, setFormData] = useState({
failureThreshold: 5,
successThreshold: 2,
timeoutSeconds: 60,
errorRateThreshold: 0.5,
minRequests: 10,
});
// 当配置加载完成时更新表单数据
useEffect(() => {
if (config) {
setFormData(config);
}
}, [config]);
const handleSave = async () => {
try {
await updateConfig.mutateAsync(formData);
toast.success("熔断器配置已保存");
} catch (error) {
toast.error("保存失败: " + String(error));
}
};
const handleReset = () => {
if (config) {
setFormData(config);
}
};
if (isLoading) {
return <div className="text-sm text-muted-foreground">...</div>;
}
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold"></h3>
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
<div className="h-px bg-border my-4" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 失败阈值 */}
<div className="space-y-2">
<Label htmlFor="failureThreshold"></Label>
<Input
id="failureThreshold"
type="number"
min="1"
max="20"
value={formData.failureThreshold}
onChange={(e) =>
setFormData({
...formData,
failureThreshold: parseInt(e.target.value) || 5,
})
}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 超时时间 */}
<div className="space-y-2">
<Label htmlFor="timeoutSeconds"></Label>
<Input
id="timeoutSeconds"
type="number"
min="10"
max="300"
value={formData.timeoutSeconds}
onChange={(e) =>
setFormData({
...formData,
timeoutSeconds: parseInt(e.target.value) || 60,
})
}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 成功阈值 */}
<div className="space-y-2">
<Label htmlFor="successThreshold"></Label>
<Input
id="successThreshold"
type="number"
min="1"
max="10"
value={formData.successThreshold}
onChange={(e) =>
setFormData({
...formData,
successThreshold: parseInt(e.target.value) || 2,
})
}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 错误率阈值 */}
<div className="space-y-2">
<Label htmlFor="errorRateThreshold"> (%)</Label>
<Input
id="errorRateThreshold"
type="number"
min="0"
max="100"
step="5"
value={Math.round(formData.errorRateThreshold * 100)}
onChange={(e) =>
setFormData({
...formData,
errorRateThreshold: (parseInt(e.target.value) || 50) / 100,
})
}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 最小请求数 */}
<div className="space-y-2">
<Label htmlFor="minRequests"></Label>
<Input
id="minRequests"
type="number"
min="5"
max="100"
value={formData.minRequests}
onChange={(e) =>
setFormData({
...formData,
minRequests: parseInt(e.target.value) || 10,
})
}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
<div className="flex gap-3">
<Button onClick={handleSave} disabled={updateConfig.isPending}>
{updateConfig.isPending ? "保存中..." : "保存配置"}
</Button>
<Button
variant="outline"
onClick={handleReset}
disabled={updateConfig.isPending}
>
</Button>
</div>
{/* 说明信息 */}
<div className="p-4 bg-muted/50 rounded-lg space-y-2 text-sm">
<h4 className="font-medium"></h4>
<ul className="space-y-1 text-muted-foreground">
<li>
<strong></strong>
</li>
<li>
<strong></strong>
</li>
<li>
<strong></strong>
</li>
<li>
<strong></strong>
</li>
<li>
<strong></strong>
</li>
</ul>
</div>
</div>
);
}
+163 -2
View File
@@ -3,14 +3,30 @@ import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { Settings, Activity, Clock, TrendingUp, Server } from "lucide-react";
import {
Settings,
Activity,
Clock,
TrendingUp,
Server,
ListOrdered,
} from "lucide-react";
import { ProxySettingsDialog } from "./ProxySettingsDialog";
import { toast } from "sonner";
import { useProxyTargets } from "@/lib/query/failover";
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
import { useProviderHealth } from "@/lib/query/failover";
import type { ProxyStatus } from "@/types/proxy";
export function ProxyPanel() {
const { status, isRunning, start, stop, isPending } = useProxyStatus();
const [showSettings, setShowSettings] = useState(false);
// 获取所有三个应用类型的代理目标列表
const { data: claudeTargets = [] } = useProxyTargets("claude");
const { data: codexTargets = [] } = useProxyTargets("codex");
const { data: geminiTargets = [] } = useProxyTargets("gemini");
const handleToggle = async () => {
try {
if (isRunning) {
@@ -112,7 +128,7 @@ export function ProxyPanel() {
<div className="pt-3 border-t border-white/10 space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
使
</p>
{status.active_targets && status.active_targets.length > 0 ? (
<div className="grid gap-2 sm:grid-cols-2">
@@ -146,6 +162,50 @@ export function ProxyPanel() {
</p>
)}
</div>
{/* 供应商队列 - 按应用类型分组展示 */}
{(claudeTargets.length > 0 ||
codexTargets.length > 0 ||
geminiTargets.length > 0) && (
<div className="pt-3 border-t border-white/10 space-y-3">
<div className="flex items-center gap-2">
<ListOrdered className="h-3.5 w-3.5 text-muted-foreground" />
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
</p>
</div>
{/* Claude 队列 */}
{claudeTargets.length > 0 && (
<ProviderQueueGroup
appType="claude"
appLabel="Claude"
targets={claudeTargets}
status={status}
/>
)}
{/* Codex 队列 */}
{codexTargets.length > 0 && (
<ProviderQueueGroup
appType="codex"
appLabel="Codex"
targets={codexTargets}
status={status}
/>
)}
{/* Gemini 队列 */}
{geminiTargets.length > 0 && (
<ProviderQueueGroup
appType="gemini"
appLabel="Gemini"
targets={geminiTargets}
status={status}
/>
)}
</div>
)}
</div>
<div className="grid gap-3 md:grid-cols-4">
@@ -220,3 +280,104 @@ function StatCard({ icon, label, value, variant = "default" }: StatCardProps) {
</div>
);
}
interface ProviderQueueGroupProps {
appType: string;
appLabel: string;
targets: Array<{
id: string;
name: string;
}>;
status: ProxyStatus;
}
function ProviderQueueGroup({
appType,
appLabel,
targets,
status,
}: ProviderQueueGroupProps) {
// 查找该应用类型的当前活跃目标
const activeTarget = status.active_targets?.find(
(t) => t.app_type === appType,
);
return (
<div className="space-y-2">
{/* 应用类型标题 */}
<div className="flex items-center gap-2 px-2">
<span className="text-xs font-semibold text-foreground/80">
{appLabel}
</span>
<div className="flex-1 h-px bg-border/50" />
</div>
{/* 供应商列表 */}
<div className="space-y-1.5">
{targets.map((target, index) => (
<ProviderQueueItem
key={target.id}
provider={target}
priority={index + 1}
appType={appType}
isCurrent={activeTarget?.provider_id === target.id}
/>
))}
</div>
</div>
);
}
interface ProviderQueueItemProps {
provider: {
id: string;
name: string;
};
priority: number;
appType: string;
isCurrent: boolean;
}
function ProviderQueueItem({
provider,
priority,
appType,
isCurrent,
}: ProviderQueueItemProps) {
const { data: health } = useProviderHealth(provider.id, appType);
return (
<div
className={`flex items-center justify-between rounded-md border px-3 py-2 text-sm transition-colors ${
isCurrent
? "border-primary/40 bg-primary/10 text-primary font-medium"
: "border-white/10 bg-background/60"
}`}
>
<div className="flex items-center gap-2">
<span
className={`flex-shrink-0 flex items-center justify-center w-5 h-5 rounded-full text-xs font-bold ${
isCurrent
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
{priority}
</span>
<span className={isCurrent ? "" : "text-foreground"}>
{provider.name}
</span>
{isCurrent && (
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/20 text-primary">
使
</span>
)}
</div>
{/* 健康徽章:队列中的代理目标始终显示,没有健康数据时默认为正常 */}
<ProviderHealthBadge
consecutiveFailures={health?.consecutive_failures ?? 0}
isProxyTarget={true}
/>
</div>
);
}
+4
View File
@@ -20,6 +20,7 @@ import { AboutSection } from "@/components/settings/AboutSection";
import { ProxyPanel } from "@/components/proxy";
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
import { UsageDashboard } from "@/components/usage/UsageDashboard";
import { useSettings } from "@/hooks/useSettings";
import { useImportExport } from "@/hooks/useImportExport";
@@ -222,6 +223,9 @@ export function SettingsPage({
{/* 模型测试配置 */}
<ModelTestConfigPanel />
{/* 自动故障转移配置 */}
<AutoFailoverConfigPanel />
<ImportExportSection
status={importStatus}
selectedFile={selectedFile}
+1 -13
View File
@@ -9,7 +9,6 @@ import {
useUpdateProviderMutation,
useDeleteProviderMutation,
useSwitchProviderMutation,
useSetProxyTargetMutation,
} from "@/lib/query";
import { extractErrorMessage } from "@/utils/errorUtils";
@@ -25,7 +24,6 @@ export function useProviderActions(activeApp: AppId) {
const updateProviderMutation = useUpdateProviderMutation(activeApp);
const deleteProviderMutation = useDeleteProviderMutation(activeApp);
const switchProviderMutation = useSwitchProviderMutation(activeApp);
const setProxyTargetMutation = useSetProxyTargetMutation(activeApp);
// Claude 插件同步逻辑
const syncClaudePlugin = useCallback(
@@ -93,14 +91,6 @@ export function useProviderActions(activeApp: AppId) {
[switchProviderMutation, syncClaudePlugin],
);
// 设置代理目标
const setProxyTarget = useCallback(
async (provider: Provider) => {
await setProxyTargetMutation.mutateAsync(provider.id);
},
[setProxyTargetMutation],
);
// 删除供应商
const deleteProvider = useCallback(
async (id: string) => {
@@ -146,14 +136,12 @@ export function useProviderActions(activeApp: AppId) {
addProvider,
updateProvider,
switchProvider,
setProxyTarget,
deleteProvider,
saveUsageScript,
isLoading:
addProviderMutation.isPending ||
updateProviderMutation.isPending ||
deleteProviderMutation.isPending ||
switchProviderMutation.isPending ||
setProxyTargetMutation.isPending,
switchProviderMutation.isPending,
};
}
-3
View File
@@ -125,7 +125,6 @@ describe("ProviderList Component", () => {
onDelete={vi.fn()}
onDuplicate={vi.fn()}
onOpenWebsite={vi.fn()}
onSetProxyTarget={vi.fn()}
isLoading
/>,
);
@@ -154,7 +153,6 @@ describe("ProviderList Component", () => {
onDelete={vi.fn()}
onDuplicate={vi.fn()}
onOpenWebsite={vi.fn()}
onSetProxyTarget={vi.fn()}
onCreate={handleCreate}
/>,
);
@@ -195,7 +193,6 @@ describe("ProviderList Component", () => {
onDuplicate={handleDuplicate}
onConfigureUsage={handleUsage}
onOpenWebsite={handleOpenWebsite}
onSetProxyTarget={vi.fn()}
/>,
);