mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(proxy): implement independent failover queue management
Add a new failover queue system that operates independently from provider sortIndex, allowing users to configure failover order per app type. Backend changes: - Add failover_queue table to schema.rs for persistent storage - Create dao/failover.rs with CRUD operations for queue management - Add Tauri commands for queue operations (get, add, remove, reorder, toggle) - Refactor provider_router.rs select_providers() to use failover queue: - Current provider always takes first priority - Queue providers ordered by queue_order as fallback - Only providers with open circuit breakers are included Frontend changes: - Add FailoverQueueItem type to proxy.ts - Extend failover.ts API with queue management methods - Add React Query hooks for queue data fetching and mutations - Create FailoverQueueManager component with drag-and-drop reordering - Integrate queue management into SettingsPage under "Auto Failover" - Add i18n translations for zh and en locales
This commit is contained in:
@@ -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<Vec<FailoverQueueItem>, 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<Vec<Provider>, 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<String>,
|
||||
) -> 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())
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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<Vec<FailoverQueueItem>, 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::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// 获取故障转移队列中的供应商(完整 Provider 信息,按顺序)
|
||||
pub fn get_failover_providers(&self, app_type: &str) -> Result<Vec<Provider>, 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<i32> = 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<bool, AppError> {
|
||||
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<Vec<Provider>, 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<Provider> = all_providers
|
||||
.into_values()
|
||||
.filter(|p| !queue_ids.contains(&p.id))
|
||||
.collect();
|
||||
|
||||
Ok(available)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -28,44 +28,92 @@ impl ProviderRouter {
|
||||
}
|
||||
|
||||
/// 选择可用的供应商(支持故障转移)
|
||||
/// 返回按优先级排序的可用供应商列表
|
||||
///
|
||||
/// 返回按优先级排序的可用供应商列表:
|
||||
/// 1. 当前供应商(is_current=true)始终第一位
|
||||
/// 2. 故障转移队列中的其他供应商(按 queue_order 排序)
|
||||
/// 3. 只返回熔断器未打开的供应商
|
||||
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, 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)
|
||||
}
|
||||
|
||||
/// 记录供应商请求结果
|
||||
|
||||
@@ -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<string>("");
|
||||
|
||||
// 查询数据
|
||||
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 (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (queueError) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>{String(queueError)}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 说明信息 */}
|
||||
<Alert className="border-blue-500/40 bg-blue-500/10">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
{t(
|
||||
"proxy.failoverQueue.info",
|
||||
"当前激活的供应商始终优先。当请求失败时,系统会按队列顺序依次尝试其他供应商。",
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* 添加供应商 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={selectedProviderId}
|
||||
onValueChange={setSelectedProviderId}
|
||||
disabled={disabled || isProvidersLoading}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"proxy.failoverQueue.selectProvider",
|
||||
"选择供应商添加到队列",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableProviders?.map((provider) => (
|
||||
<SelectItem key={provider.id} value={provider.id}>
|
||||
{provider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{(!availableProviders || availableProviders.length === 0) && (
|
||||
<div className="px-2 py-4 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"proxy.failoverQueue.noAvailableProviders",
|
||||
"没有可添加的供应商",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
onClick={handleAddProvider}
|
||||
disabled={
|
||||
disabled || !selectedProviderId || addToQueue.isPending
|
||||
}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
>
|
||||
{addToQueue.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 队列列表 */}
|
||||
{sortedQueue.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-muted-foreground/40 p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"proxy.failoverQueue.empty",
|
||||
"故障转移队列为空。添加供应商以启用自动故障转移。",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={sortedQueue.map((item) => item.providerId)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{sortedQueue.map((item, index) => (
|
||||
<SortableQueueItem
|
||||
key={item.providerId}
|
||||
item={item}
|
||||
index={index}
|
||||
disabled={disabled}
|
||||
onToggleEnabled={handleToggleEnabled}
|
||||
onRemove={handleRemoveProvider}
|
||||
isRemoving={removeFromQueue.isPending}
|
||||
isToggling={setItemEnabled.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{/* 队列说明 */}
|
||||
{sortedQueue.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"proxy.failoverQueue.dragHint",
|
||||
"拖拽供应商可调整故障转移顺序,序号越小优先级越高。",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg border bg-card p-3 transition-colors",
|
||||
isDragging && "opacity-50 shadow-lg",
|
||||
!item.enabled && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{/* 拖拽手柄 */}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"cursor-grab touch-none text-muted-foreground hover:text-foreground",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
disabled={disabled}
|
||||
aria-label={t("provider.dragHandle", "拖拽排序")}
|
||||
>
|
||||
<GripVertical className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* 序号 */}
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-medium">
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* 供应商名称 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium truncate block",
|
||||
!item.enabled && "text-muted-foreground line-through",
|
||||
)}
|
||||
>
|
||||
{item.providerName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 启用开关 */}
|
||||
<Switch
|
||||
checked={item.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleEnabled(item.providerId, checked)
|
||||
}
|
||||
disabled={disabled || isToggling}
|
||||
aria-label={t("proxy.failoverQueue.toggleEnabled", "启用/禁用")}
|
||||
/>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onRemove(item.providerId)}
|
||||
disabled={disabled || isRemoving}
|
||||
aria-label={t("common.delete", "删除")}
|
||||
>
|
||||
{isRemoving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
自动故障转移
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
配置自动故障转移和熔断策略
|
||||
配置故障转移队列和熔断策略
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -373,10 +374,55 @@ export function SettingsPage({
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<AutoFailoverConfigPanel
|
||||
enabled={failoverEnabled}
|
||||
onEnabledChange={setFailoverEnabled}
|
||||
/>
|
||||
<div className="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>
|
||||
<Tabs defaultValue="claude" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="claude">Claude</TabsTrigger>
|
||||
<TabsTrigger value="codex">Codex</TabsTrigger>
|
||||
<TabsTrigger value="gemini">Gemini</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="claude" className="mt-4">
|
||||
<FailoverQueueManager
|
||||
appType="claude"
|
||||
disabled={!failoverEnabled}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="codex" className="mt-4">
|
||||
<FailoverQueueManager
|
||||
appType="codex"
|
||||
disabled={!failoverEnabled}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="gemini" className="mt-4">
|
||||
<FailoverQueueManager
|
||||
appType="gemini"
|
||||
disabled={!failoverEnabled}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* 熔断器配置 */}
|
||||
<div className="border-t border-border/50 pt-6">
|
||||
<AutoFailoverConfigPanel
|
||||
enabled={failoverEnabled}
|
||||
onEnabledChange={setFailoverEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "错误率超过此值时,即使未达到失败阈值也会打开熔断器"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Provider[]> {
|
||||
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<CircuitBreakerStats | null> {
|
||||
return invoke("get_circuit_breaker_stats", { providerId, appType });
|
||||
},
|
||||
|
||||
// ========== 故障转移队列 API(新) ==========
|
||||
|
||||
// 获取故障转移队列
|
||||
async getFailoverQueue(appType: string): Promise<FailoverQueueItem[]> {
|
||||
return invoke("get_failover_queue", { appType });
|
||||
},
|
||||
|
||||
// 获取可添加到队列的供应商(不在队列中的)
|
||||
async getAvailableProvidersForFailover(appType: string): Promise<Provider[]> {
|
||||
return invoke("get_available_providers_for_failover", { appType });
|
||||
},
|
||||
|
||||
// 添加供应商到故障转移队列
|
||||
async addToFailoverQueue(
|
||||
appType: string,
|
||||
providerId: string,
|
||||
): Promise<void> {
|
||||
return invoke("add_to_failover_queue", { appType, providerId });
|
||||
},
|
||||
|
||||
// 从故障转移队列移除供应商
|
||||
async removeFromFailoverQueue(
|
||||
appType: string,
|
||||
providerId: string,
|
||||
): Promise<void> {
|
||||
return invoke("remove_from_failover_queue", { appType, providerId });
|
||||
},
|
||||
|
||||
// 重新排序故障转移队列
|
||||
async reorderFailoverQueue(
|
||||
appType: string,
|
||||
providerIds: string[],
|
||||
): Promise<void> {
|
||||
return invoke("reorder_failover_queue", { appType, providerIds });
|
||||
},
|
||||
|
||||
// 设置故障转移队列项的启用状态
|
||||
async setFailoverItemEnabled(
|
||||
appType: string,
|
||||
providerId: string,
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
return invoke("set_failover_item_enabled", { appType, providerId, enabled });
|
||||
},
|
||||
};
|
||||
|
||||
+137
-13
@@ -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],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user