diff --git a/src-tauri/src/commands/failover.rs b/src-tauri/src/commands/failover.rs new file mode 100644 index 000000000..ddceeef13 --- /dev/null +++ b/src-tauri/src/commands/failover.rs @@ -0,0 +1,84 @@ +//! 故障转移队列命令 +//! +//! 管理代理模式下的故障转移队列 + +use crate::database::FailoverQueueItem; +use crate::provider::Provider; +use crate::store::AppState; + +/// 获取故障转移队列 +#[tauri::command] +pub async fn get_failover_queue( + state: tauri::State<'_, AppState>, + app_type: String, +) -> Result, String> { + state + .db + .get_failover_queue(&app_type) + .map_err(|e| e.to_string()) +} + +/// 获取可添加到故障转移队列的供应商(不在队列中的) +#[tauri::command] +pub async fn get_available_providers_for_failover( + state: tauri::State<'_, AppState>, + app_type: String, +) -> Result, String> { + state + .db + .get_available_providers_for_failover(&app_type) + .map_err(|e| e.to_string()) +} + +/// 添加供应商到故障转移队列 +#[tauri::command] +pub async fn add_to_failover_queue( + state: tauri::State<'_, AppState>, + app_type: String, + provider_id: String, +) -> Result<(), String> { + state + .db + .add_to_failover_queue(&app_type, &provider_id) + .map_err(|e| e.to_string()) +} + +/// 从故障转移队列移除供应商 +#[tauri::command] +pub async fn remove_from_failover_queue( + state: tauri::State<'_, AppState>, + app_type: String, + provider_id: String, +) -> Result<(), String> { + state + .db + .remove_from_failover_queue(&app_type, &provider_id) + .map_err(|e| e.to_string()) +} + +/// 重新排序故障转移队列 +#[tauri::command] +pub async fn reorder_failover_queue( + state: tauri::State<'_, AppState>, + app_type: String, + provider_ids: Vec, +) -> Result<(), String> { + state + .db + .reorder_failover_queue(&app_type, &provider_ids) + .map_err(|e| e.to_string()) +} + +/// 设置故障转移队列项的启用状态 +#[tauri::command] +pub async fn set_failover_item_enabled( + state: tauri::State<'_, AppState>, + app_type: String, + provider_id: String, + enabled: bool, +) -> Result<(), String> { + state + .db + .set_failover_item_enabled(&app_type, &provider_id, enabled) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 9b7e87b98..85dfb3adc 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -3,6 +3,7 @@ mod config; mod deeplink; mod env; +mod failover; mod import_export; mod mcp; mod misc; @@ -18,6 +19,7 @@ mod usage; pub use config::*; pub use deeplink::*; pub use env::*; +pub use failover::*; pub use import_export::*; pub use mcp::*; pub use misc::*; diff --git a/src-tauri/src/database/dao/failover.rs b/src-tauri/src/database/dao/failover.rs new file mode 100644 index 000000000..66e24f92d --- /dev/null +++ b/src-tauri/src/database/dao/failover.rs @@ -0,0 +1,248 @@ +//! 故障转移队列 DAO +//! +//! 管理代理模式下的故障转移队列 + +use crate::database::{lock_conn, Database}; +use crate::error::AppError; +use crate::provider::Provider; +use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// 故障转移队列条目 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FailoverQueueItem { + pub provider_id: String, + pub provider_name: String, + pub queue_order: i32, + pub enabled: bool, + pub created_at: i64, +} + +impl Database { + /// 获取故障转移队列(按 queue_order 排序) + pub fn get_failover_queue(&self, app_type: &str) -> Result, AppError> { + let conn = lock_conn!(self.conn); + + let mut stmt = conn + .prepare( + "SELECT fq.provider_id, p.name, fq.queue_order, fq.enabled, fq.created_at + FROM failover_queue fq + JOIN providers p ON fq.provider_id = p.id AND fq.app_type = p.app_type + WHERE fq.app_type = ?1 + ORDER BY fq.queue_order ASC", + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + let items = stmt + .query_map([app_type], |row| { + Ok(FailoverQueueItem { + provider_id: row.get(0)?, + provider_name: row.get(1)?, + queue_order: row.get(2)?, + enabled: row.get(3)?, + created_at: row.get(4)?, + }) + }) + .map_err(|e| AppError::Database(e.to_string()))? + .collect::, _>>() + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(items) + } + + /// 获取故障转移队列中的供应商(完整 Provider 信息,按顺序) + pub fn get_failover_providers(&self, app_type: &str) -> Result, AppError> { + let queue = self.get_failover_queue(app_type)?; + let all_providers = self.get_all_providers(app_type)?; + + let mut result = Vec::new(); + for item in queue { + if item.enabled { + if let Some(provider) = all_providers.get(&item.provider_id) { + result.push(provider.clone()); + } + } + } + + Ok(result) + } + + /// 添加供应商到故障转移队列末尾 + pub fn add_to_failover_queue( + &self, + app_type: &str, + provider_id: &str, + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + + // 获取当前最大 queue_order + let max_order: i32 = conn + .query_row( + "SELECT COALESCE(MAX(queue_order), 0) FROM failover_queue WHERE app_type = ?1", + [app_type], + |row| row.get(0), + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + conn.execute( + "INSERT OR IGNORE INTO failover_queue (app_type, provider_id, queue_order, enabled, created_at) + VALUES (?1, ?2, ?3, 1, ?4)", + rusqlite::params![app_type, provider_id, max_order + 1, now], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(()) + } + + /// 从故障转移队列中移除供应商 + pub fn remove_from_failover_queue( + &self, + app_type: &str, + provider_id: &str, + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + + // 获取被删除项的 queue_order + let removed_order: Option = conn + .query_row( + "SELECT queue_order FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2", + [app_type, provider_id], + |row| row.get(0), + ) + .ok(); + + // 删除该项 + conn.execute( + "DELETE FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2", + [app_type, provider_id], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 重新排序后面的项(填补空隙) + if let Some(order) = removed_order { + conn.execute( + "UPDATE failover_queue + SET queue_order = queue_order - 1 + WHERE app_type = ?1 AND queue_order > ?2", + rusqlite::params![app_type, order], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + } + + Ok(()) + } + + /// 重新排序故障转移队列 + /// provider_ids: 按新顺序排列的 provider_id 列表 + pub fn reorder_failover_queue( + &self, + app_type: &str, + provider_ids: &[String], + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + + // 使用事务确保原子性 + conn.execute("BEGIN TRANSACTION", []) + .map_err(|e| AppError::Database(e.to_string()))?; + + let result = (|| { + for (index, provider_id) in provider_ids.iter().enumerate() { + conn.execute( + "UPDATE failover_queue + SET queue_order = ?3 + WHERE app_type = ?1 AND provider_id = ?2", + rusqlite::params![app_type, provider_id, (index + 1) as i32], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + } + Ok(()) + })(); + + match result { + Ok(_) => { + conn.execute("COMMIT", []) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + Err(e) => { + conn.execute("ROLLBACK", []).ok(); + Err(e) + } + } + } + + /// 设置故障转移队列中供应商的启用状态 + pub fn set_failover_item_enabled( + &self, + app_type: &str, + provider_id: &str, + enabled: bool, + ) -> 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()))?; + + Ok(()) + } + + /// 清空故障转移队列 + pub fn clear_failover_queue(&self, app_type: &str) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + + conn.execute( + "DELETE FROM failover_queue WHERE app_type = ?1", + [app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(()) + } + + /// 检查供应商是否在故障转移队列中 + pub fn is_in_failover_queue( + &self, + app_type: &str, + provider_id: &str, + ) -> Result { + let conn = lock_conn!(self.conn); + + let count: i32 = conn + .query_row( + "SELECT COUNT(*) FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2", + [app_type, provider_id], + |row| row.get(0), + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(count > 0) + } + + /// 获取可添加到故障转移队列的供应商(不在队列中的) + pub fn get_available_providers_for_failover( + &self, + app_type: &str, + ) -> Result, AppError> { + let all_providers = self.get_all_providers(app_type)?; + let queue = self.get_failover_queue(app_type)?; + + let queue_ids: std::collections::HashSet<_> = + queue.iter().map(|item| &item.provider_id).collect(); + + let available: Vec = all_providers + .into_values() + .filter(|p| !queue_ids.contains(&p.id)) + .collect(); + + Ok(available) + } +} diff --git a/src-tauri/src/database/dao/mod.rs b/src-tauri/src/database/dao/mod.rs index a3b6ca471..c759c2a86 100644 --- a/src-tauri/src/database/dao/mod.rs +++ b/src-tauri/src/database/dao/mod.rs @@ -2,6 +2,7 @@ //! //! Database access operations for each domain +pub mod failover; pub mod mcp; pub mod prompts; pub mod providers; @@ -11,3 +12,5 @@ pub mod skills; pub mod stream_check; // 所有 DAO 方法都通过 Database impl 提供,无需单独导出 +// 导出 FailoverQueueItem 供外部使用 +pub use failover::FailoverQueueItem; diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 2786a20b9..73a4877a4 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -31,6 +31,9 @@ mod schema; #[cfg(test)] mod tests; +// DAO 类型导出供外部使用 +pub use dao::FailoverQueueItem; + use crate::config::get_app_config_dir; use crate::error::AppError; use rusqlite::Connection; diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 3dbdca557..deab11aae 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -319,6 +319,30 @@ impl Database { [], ); + // 14. Failover Queue 表 (故障转移队列) + conn.execute( + "CREATE TABLE IF NOT EXISTS failover_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + app_type TEXT NOT NULL, + provider_id TEXT NOT NULL, + queue_order INTEGER NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + UNIQUE (app_type, provider_id), + FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 为故障转移队列创建索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_failover_queue_order + ON failover_queue(app_type, queue_order)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f04dbfe14..6a5874f7f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -686,6 +686,13 @@ pub fn run() { commands::get_circuit_breaker_config, commands::update_circuit_breaker_config, commands::get_circuit_breaker_stats, + // Failover queue management + commands::get_failover_queue, + commands::get_available_providers_for_failover, + commands::add_to_failover_queue, + commands::remove_from_failover_queue, + commands::reorder_failover_queue, + commands::set_failover_item_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 8e4483414..6866fc1c5 100644 --- a/src-tauri/src/proxy/provider_router.rs +++ b/src-tauri/src/proxy/provider_router.rs @@ -28,44 +28,92 @@ impl ProviderRouter { } /// 选择可用的供应商(支持故障转移) - /// 返回按优先级排序的可用供应商列表 + /// + /// 返回按优先级排序的可用供应商列表: + /// 1. 当前供应商(is_current=true)始终第一位 + /// 2. 故障转移队列中的其他供应商(按 queue_order 排序) + /// 3. 只返回熔断器未打开的供应商 pub async fn select_providers(&self, app_type: &str) -> Result, AppError> { - // 直接获取当前选中的供应商(基于 is_current 字段) - let current_id = self - .db - .get_current_provider(app_type)? - .ok_or_else(|| AppError::Config(format!("No current provider for {}", app_type)))?; + let mut result = Vec::new(); + let all_providers = self.db.get_all_providers(app_type)?; - let providers = self.db.get_all_providers(app_type)?; - let provider = providers - .get(¤t_id) - .ok_or_else(|| AppError::Config(format!("Current provider {} not found", current_id)))? - .clone(); + // 1. 当前供应商始终第一位 + if let Some(current_id) = self.db.get_current_provider(app_type)? { + if let Some(current) = all_providers.get(¤t_id) { + let circuit_key = format!("{}:{}", app_type, current.id); + let breaker = self.get_or_create_circuit_breaker(&circuit_key).await; - log::info!( - "[{}] Selected current provider: {} ({})", - app_type, - provider.name, - provider.id - ); + if breaker.allow_request().await { + log::info!( + "[{}] Current provider available: {} ({})", + app_type, + current.name, + current.id + ); + result.push(current.clone()); + } else { + log::warn!( + "[{}] Current provider {} circuit breaker open, checking failover queue", + app_type, + current.name + ); + } + } + } - // 检查熔断器状态 - let circuit_key = format!("{}:{}", app_type, provider.id); - let breaker = self.get_or_create_circuit_breaker(&circuit_key).await; + // 2. 获取故障转移队列中的供应商 + let queue = self.db.get_failover_queue(app_type)?; - if !breaker.allow_request().await { - log::warn!( - "Provider {} is unavailable (circuit breaker open)", - provider.id - ); + for item in queue { + // 跳过已添加的当前供应商 + if result.iter().any(|p| p.id == item.provider_id) { + 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 breaker.allow_request().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 result.is_empty() { return Err(AppError::Config(format!( - "Current provider {} is unavailable (circuit breaker open)", - provider.name + "No available provider for {} (all circuit breakers open or no providers configured)", + app_type ))); } - // 返回单个供应商(保留 Vec 接口以兼容现有代码) - Ok(vec![provider]) + log::info!( + "[{}] Failover chain: {} provider(s) available", + app_type, + result.len() + ); + + Ok(result) } /// 记录供应商请求结果 diff --git a/src/components/proxy/FailoverQueueManager.tsx b/src/components/proxy/FailoverQueueManager.tsx new file mode 100644 index 000000000..dc9a36d10 --- /dev/null +++ b/src/components/proxy/FailoverQueueManager.tsx @@ -0,0 +1,413 @@ +/** + * 故障转移队列管理组件 + * + * 允许用户管理代理模式下的故障转移队列,支持: + * - 拖拽排序 + * - 添加/移除供应商 + * - 启用/禁用队列项 + */ + +import { useState, useCallback, useMemo } 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 { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import type { FailoverQueueItem } from "@/types/proxy"; +import type { AppId } from "@/lib/api"; +import { + useFailoverQueue, + useAvailableProvidersForFailover, + useAddToFailoverQueue, + useRemoveFromFailoverQueue, + useReorderFailoverQueue, + useSetFailoverItemEnabled, +} from "@/lib/query/failover"; + +interface FailoverQueueManagerProps { + appType: AppId; + disabled?: boolean; +} + +export function FailoverQueueManager({ + appType, + disabled = false, +}: FailoverQueueManagerProps) { + const { t } = useTranslation(); + const [selectedProviderId, setSelectedProviderId] = useState(""); + + // 查询数据 + const { + data: queue, + isLoading: isQueueLoading, + error: queueError, + } = useFailoverQueue(appType); + const { + data: availableProviders, + isLoading: isProvidersLoading, + } = useAvailableProvidersForFailover(appType); + + // 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", "队列顺序已更新"), + ); + } catch (error) { + toast.error( + t("proxy.failoverQueue.reorderFailed", "更新顺序失败") + + ": " + + String(error), + ); + } + }, + [sortedQueue, appType, reorderQueue, t], + ); + + // 添加供应商到队列 + const handleAddProvider = async () => { + if (!selectedProviderId) return; + + try { + await addToQueue.mutateAsync({ + appType, + providerId: selectedProviderId, + }); + setSelectedProviderId(""); + toast.success(t("proxy.failoverQueue.addSuccess", "已添加到故障转移队列")); + } catch (error) { + toast.error( + t("proxy.failoverQueue.addFailed", "添加失败") + ": " + String(error), + ); + } + }; + + // 从队列移除供应商 + const handleRemoveProvider = async (providerId: string) => { + try { + await removeFromQueue.mutateAsync({ appType, providerId }); + toast.success( + t("proxy.failoverQueue.removeSuccess", "已从故障转移队列移除"), + ); + } catch (error) { + toast.error( + t("proxy.failoverQueue.removeFailed", "移除失败") + ": " + String(error), + ); + } + }; + + // 切换启用状态 + 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 ( +
+ +
+ ); + } + + if (queueError) { + return ( + + + {String(queueError)} + + ); + } + + return ( +
+ {/* 说明信息 */} + + + + {t( + "proxy.failoverQueue.info", + "当前激活的供应商始终优先。当请求失败时,系统会按队列顺序依次尝试其他供应商。", + )} + + + + {/* 添加供应商 */} +
+ + +
+ + {/* 队列列表 */} + {sortedQueue.length === 0 ? ( +
+

+ {t( + "proxy.failoverQueue.empty", + "故障转移队列为空。添加供应商以启用自动故障转移。", + )} +

+
+ ) : ( + + item.providerId)} + strategy={verticalListSortingStrategy} + > +
+ {sortedQueue.map((item, index) => ( + + ))} +
+
+
+ )} + + {/* 队列说明 */} + {sortedQueue.length > 0 && ( +

+ {t( + "proxy.failoverQueue.dragHint", + "拖拽供应商可调整故障转移顺序,序号越小优先级越高。", + )} +

+ )} +
+ ); +} + +interface SortableQueueItemProps { + item: FailoverQueueItem; + index: number; + disabled: boolean; + onToggleEnabled: (providerId: string, enabled: boolean) => void; + onRemove: (providerId: string) => void; + isRemoving: boolean; + isToggling: boolean; +} + +function SortableQueueItem({ + item, + index, + disabled, + onToggleEnabled, + onRemove, + isRemoving, + isToggling, +}: SortableQueueItemProps) { + const { t } = useTranslation(); + const { + setNodeRef, + attributes, + listeners, + transform, + transition, + isDragging, + } = useSortable({ id: item.providerId, disabled }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ {/* 拖拽手柄 */} + + + {/* 序号 */} +
+ {index + 1} +
+ + {/* 供应商名称 */} +
+ + {item.providerName} + +
+ + {/* 启用开关 */} + + onToggleEnabled(item.providerId, checked) + } + disabled={disabled || isToggling} + aria-label={t("proxy.failoverQueue.toggleEnabled", "启用/禁用")} + /> + + {/* 删除按钮 */} + +
+ ); +} diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 42ece5a2e..dc2016a21 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -35,6 +35,7 @@ import { ProxyPanel } from "@/components/proxy"; import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel"; import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel"; import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel"; +import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager"; import { UsageDashboard } from "@/components/usage/UsageDashboard"; import { useSettings } from "@/hooks/useSettings"; import { useImportExport } from "@/hooks/useImportExport"; @@ -354,7 +355,7 @@ export function SettingsPage({ 自动故障转移

- 配置自动故障转移和熔断策略 + 配置故障转移队列和熔断策略

@@ -373,10 +374,55 @@ export function SettingsPage({ - +
+ {/* 故障转移队列管理 */} +
+
+

+ {t("proxy.failoverQueue.title", "故障转移队列")} +

+

+ {t( + "proxy.failoverQueue.description", + "管理各应用的供应商故障转移顺序", + )} +

+
+ + + Claude + Codex + Gemini + + + + + + + + + + + +
+ + {/* 熔断器配置 */} +
+ +
+
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 92820c5ab..24b785791 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -840,5 +840,50 @@ }, "agents": { "title": "Agents" + }, + "proxy": { + "failoverQueue": { + "title": "Failover Queue", + "description": "Manage failover order for each app's providers", + "info": "The current active provider always takes priority. When requests fail, the system will try other providers in queue order.", + "selectProvider": "Select a provider to add to queue", + "noAvailableProviders": "No providers available to add", + "empty": "Failover queue is empty. Add providers to enable automatic failover.", + "dragHint": "Drag providers to adjust failover order. Lower numbers have higher priority.", + "toggleEnabled": "Enable/Disable", + "addSuccess": "Added to failover queue", + "addFailed": "Failed to add", + "removeSuccess": "Removed from failover queue", + "removeFailed": "Failed to remove", + "reorderSuccess": "Queue order updated", + "reorderFailed": "Failed to update order", + "toggleFailed": "Failed to update status" + }, + "autoFailover": { + "info": "When multiple proxy targets are enabled, the system will try them in priority order. When a provider fails consecutively reaching the threshold, the circuit breaker will open and skip that provider.", + "configSaved": "Auto failover config saved", + "configSaveFailed": "Failed to save", + "retrySettings": "Retry & Timeout Settings", + "failureThreshold": "Failure Threshold", + "failureThresholdHint": "Open circuit breaker after this many consecutive failures (recommended: 3-10)", + "timeout": "Recovery Wait Time (seconds)", + "timeoutHint": "Wait this long before trying to recover after circuit opens (recommended: 30-120)", + "circuitBreakerSettings": "Circuit Breaker Advanced Settings", + "successThreshold": "Recovery Success Threshold", + "successThresholdHint": "Close circuit breaker after this many successes in half-open state", + "errorRate": "Error Rate Threshold (%)", + "errorRateHint": "Open circuit breaker when error rate exceeds this value", + "minRequests": "Minimum Requests", + "minRequestsHint": "Minimum requests before calculating error rate", + "explanationTitle": "How It Works", + "failureThresholdLabel": "Failure Threshold", + "failureThresholdExplain": "Circuit breaker opens after this many consecutive failures, making the provider temporarily unavailable", + "timeoutLabel": "Recovery Wait Time", + "timeoutExplain": "After circuit opens, wait this long before trying half-open state", + "successThresholdLabel": "Recovery Success Threshold", + "successThresholdExplain": "In half-open state, close circuit breaker after this many successes, making provider available again", + "errorRateLabel": "Error Rate Threshold", + "errorRateExplain": "Open circuit breaker when error rate exceeds this value, even if failure threshold not reached" + } } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 6eacfbfde..8809e4744 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -840,5 +840,50 @@ }, "agents": { "title": "智能体" + }, + "proxy": { + "failoverQueue": { + "title": "故障转移队列", + "description": "管理各应用的供应商故障转移顺序", + "info": "当前激活的供应商始终优先。当请求失败时,系统会按队列顺序依次尝试其他供应商。", + "selectProvider": "选择供应商添加到队列", + "noAvailableProviders": "没有可添加的供应商", + "empty": "故障转移队列为空。添加供应商以启用自动故障转移。", + "dragHint": "拖拽供应商可调整故障转移顺序,序号越小优先级越高。", + "toggleEnabled": "启用/禁用", + "addSuccess": "已添加到故障转移队列", + "addFailed": "添加失败", + "removeSuccess": "已从故障转移队列移除", + "removeFailed": "移除失败", + "reorderSuccess": "队列顺序已更新", + "reorderFailed": "更新顺序失败", + "toggleFailed": "状态更新失败" + }, + "autoFailover": { + "info": "当启用多个代理目标时,系统会按优先级顺序依次尝试。当某个供应商连续失败达到阈值时,熔断器会自动打开,跳过该供应商。", + "configSaved": "自动故障转移配置已保存", + "configSaveFailed": "保存失败", + "retrySettings": "重试与超时设置", + "failureThreshold": "失败阈值", + "failureThresholdHint": "连续失败多少次后打开熔断器(建议: 3-10)", + "timeout": "恢复等待时间(秒)", + "timeoutHint": "熔断器打开后,等待多久后尝试恢复(建议: 30-120)", + "circuitBreakerSettings": "熔断器高级设置", + "successThreshold": "恢复成功阈值", + "successThresholdHint": "半开状态下成功多少次后关闭熔断器", + "errorRate": "错误率阈值 (%)", + "errorRateHint": "错误率超过此值时打开熔断器", + "minRequests": "最小请求数", + "minRequestsHint": "计算错误率前的最小请求数", + "explanationTitle": "工作原理", + "failureThresholdLabel": "失败阈值", + "failureThresholdExplain": "连续失败达到此次数时,熔断器打开,该供应商暂时不可用", + "timeoutLabel": "恢复等待时间", + "timeoutExplain": "熔断器打开后,等待此时间后尝试半开状态", + "successThresholdLabel": "恢复成功阈值", + "successThresholdExplain": "半开状态下,成功达到此次数时关闭熔断器,供应商恢复可用", + "errorRateLabel": "错误率阈值", + "errorRateExplain": "错误率超过此值时,即使未达到失败阈值也会打开熔断器" + } } } diff --git a/src/lib/api/failover.ts b/src/lib/api/failover.ts index aed49451a..d6c03d0a5 100644 --- a/src/lib/api/failover.ts +++ b/src/lib/api/failover.ts @@ -3,6 +3,7 @@ import type { ProviderHealth, CircuitBreakerConfig, CircuitBreakerStats, + FailoverQueueItem, } from "@/types/proxy"; export interface Provider { @@ -21,6 +22,8 @@ export interface Provider { } export const failoverApi = { + // ========== 旧版代理目标 API(保留向后兼容)========== + // 获取代理目标列表 async getProxyTargets(appType: string): Promise { return invoke("get_proxy_targets", { appType }); @@ -35,6 +38,8 @@ export const failoverApi = { return invoke("set_proxy_target", { providerId, appType, enabled }); }, + // ========== 熔断器 API ========== + // 获取供应商健康状态 async getProviderHealth( providerId: string, @@ -70,4 +75,49 @@ export const failoverApi = { ): Promise { return invoke("get_circuit_breaker_stats", { providerId, appType }); }, + + // ========== 故障转移队列 API(新) ========== + + // 获取故障转移队列 + async getFailoverQueue(appType: string): Promise { + return invoke("get_failover_queue", { appType }); + }, + + // 获取可添加到队列的供应商(不在队列中的) + async getAvailableProvidersForFailover(appType: string): Promise { + return invoke("get_available_providers_for_failover", { appType }); + }, + + // 添加供应商到故障转移队列 + async addToFailoverQueue( + appType: string, + providerId: string, + ): Promise { + return invoke("add_to_failover_queue", { appType, providerId }); + }, + + // 从故障转移队列移除供应商 + async removeFromFailoverQueue( + appType: string, + providerId: string, + ): Promise { + return invoke("remove_from_failover_queue", { appType, providerId }); + }, + + // 重新排序故障转移队列 + async reorderFailoverQueue( + appType: string, + providerIds: string[], + ): Promise { + return invoke("reorder_failover_queue", { appType, providerIds }); + }, + + // 设置故障转移队列项的启用状态 + async setFailoverItemEnabled( + appType: string, + providerId: string, + enabled: boolean, + ): Promise { + return invoke("set_failover_item_enabled", { appType, providerId, enabled }); + }, }; diff --git a/src/lib/query/failover.ts b/src/lib/query/failover.ts index 7c9aea6c3..acfaef56a 100644 --- a/src/lib/query/failover.ts +++ b/src/lib/query/failover.ts @@ -1,6 +1,8 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { failoverApi } from "@/lib/api/failover"; +// ========== 旧版代理目标 Hooks(保留向后兼容)========== + /** * 获取代理目标列表 */ @@ -12,19 +14,6 @@ export function useProxyTargets(appType: string) { }); } -/** - * 获取供应商健康状态 - */ -export function useProviderHealth(providerId: string, appType: string) { - return useQuery({ - queryKey: ["providerHealth", providerId, appType], - queryFn: () => failoverApi.getProviderHealth(providerId, appType), - enabled: !!providerId && !!appType, - refetchInterval: 5000, // 每 5 秒刷新一次 - retry: false, - }); -} - /** * 设置代理目标 */ @@ -56,6 +45,21 @@ export function useSetProxyTarget() { }); } +// ========== 熔断器 Hooks ========== + +/** + * 获取供应商健康状态 + */ +export function useProviderHealth(providerId: string, appType: string) { + return useQuery({ + queryKey: ["providerHealth", providerId, appType], + queryFn: () => failoverApi.getProviderHealth(providerId, appType), + enabled: !!providerId && !!appType, + refetchInterval: 5000, // 每 5 秒刷新一次 + retry: false, + }); +} + /** * 重置熔断器 */ @@ -114,3 +118,123 @@ export function useCircuitBreakerStats(providerId: string, appType: string) { refetchInterval: 5000, // 每 5 秒刷新一次 }); } + +// ========== 故障转移队列 Hooks(新) ========== + +/** + * 获取故障转移队列 + */ +export function useFailoverQueue(appType: string) { + return useQuery({ + queryKey: ["failoverQueue", appType], + queryFn: () => failoverApi.getFailoverQueue(appType), + enabled: !!appType, + }); +} + +/** + * 获取可添加到队列的供应商 + */ +export function useAvailableProvidersForFailover(appType: string) { + return useQuery({ + queryKey: ["availableProvidersForFailover", appType], + queryFn: () => failoverApi.getAvailableProvidersForFailover(appType), + enabled: !!appType, + }); +} + +/** + * 添加供应商到故障转移队列 + */ +export function useAddToFailoverQueue() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + appType, + providerId, + }: { + appType: string; + providerId: string; + }) => failoverApi.addToFailoverQueue(appType, providerId), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: ["failoverQueue", variables.appType], + }); + queryClient.invalidateQueries({ + queryKey: ["availableProvidersForFailover", variables.appType], + }); + }, + }); +} + +/** + * 从故障转移队列移除供应商 + */ +export function useRemoveFromFailoverQueue() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + appType, + providerId, + }: { + appType: string; + providerId: string; + }) => failoverApi.removeFromFailoverQueue(appType, providerId), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: ["failoverQueue", variables.appType], + }); + queryClient.invalidateQueries({ + queryKey: ["availableProvidersForFailover", variables.appType], + }); + }, + }); +} + +/** + * 重新排序故障转移队列 + */ +export function useReorderFailoverQueue() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + appType, + providerIds, + }: { + appType: string; + providerIds: string[]; + }) => failoverApi.reorderFailoverQueue(appType, providerIds), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: ["failoverQueue", variables.appType], + }); + }, + }); +} + +/** + * 设置故障转移队列项的启用状态 + */ +export function useSetFailoverItemEnabled() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + appType, + providerId, + enabled, + }: { + appType: string; + providerId: string; + enabled: boolean; + }) => failoverApi.setFailoverItemEnabled(appType, providerId, enabled), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: ["failoverQueue", variables.appType], + }); + }, + }); +} diff --git a/src/types/proxy.ts b/src/types/proxy.ts index 133295c2e..393e0e662 100644 --- a/src/types/proxy.ts +++ b/src/types/proxy.ts @@ -93,3 +93,12 @@ export interface ProxyUsageRecord { error: string | null; timestamp: string; } + +// 故障转移队列条目 +export interface FailoverQueueItem { + providerId: string; + providerName: string; + queueOrder: number; + enabled: boolean; + createdAt: number; +}