mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e670763f65 | |||
| f079ade731 | |||
| 0398d288ae | |||
| f5f7ab7ce2 | |||
| 2c18e125dc | |||
| 74a257b565 | |||
| d894162bb4 | |||
| fec93aa6d5 | |||
| e92a167a78 | |||
| f6e8d656ce | |||
| cabe1944ad | |||
| 64047fb3dd | |||
| 79adfc7fe8 | |||
| c9b851c5e6 | |||
| deea8455a9 | |||
| 59096d9c15 | |||
| c231c840a7 | |||
| 95c323c50b | |||
| 44f2722329 | |||
| f05904821c |
@@ -1,6 +1,6 @@
|
|||||||
//! 故障转移队列命令
|
//! 故障转移队列命令
|
||||||
//!
|
//!
|
||||||
//! 管理代理模式下的故障转移队列
|
//! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段)
|
||||||
|
|
||||||
use crate::database::FailoverQueueItem;
|
use crate::database::FailoverQueueItem;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
@@ -56,54 +56,35 @@ pub async fn remove_from_failover_queue(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 重新排序故障转移队列
|
/// 获取指定应用的自动故障转移开关状态
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn reorder_failover_queue(
|
pub async fn get_auto_failover_enabled(
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
app_type: String,
|
app_type: String,
|
||||||
provider_ids: Vec<String>,
|
) -> Result<bool, String> {
|
||||||
) -> Result<(), String> {
|
let key = format!("auto_failover_enabled_{app_type}");
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.reorder_failover_queue(&app_type, &provider_ids)
|
.get_setting(&key)
|
||||||
.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())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取自动故障转移总开关状态
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_auto_failover_enabled(state: tauri::State<'_, AppState>) -> Result<bool, String> {
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.get_setting("auto_failover_enabled")
|
|
||||||
.map(|v| v.map(|s| s == "true").unwrap_or(false)) // 默认关闭
|
.map(|v| v.map(|s| s == "true").unwrap_or(false)) // 默认关闭
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置自动故障转移总开关状态
|
/// 设置指定应用的自动故障转移开关状态
|
||||||
|
///
|
||||||
|
/// 注意:关闭故障转移时不会清除队列,队列内容会保留供下次开启时使用
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_auto_failover_enabled(
|
pub async fn set_auto_failover_enabled(
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
|
app_type: String,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
state
|
let key = format!("auto_failover_enabled_{app_type}");
|
||||||
.db
|
let value = if enabled { "true" } else { "false" };
|
||||||
.set_setting(
|
|
||||||
"auto_failover_enabled",
|
log::info!(
|
||||||
if enabled { "true" } else { "false" },
|
"[Failover] Setting auto_failover_enabled: key='{key}', value='{value}', app_type='{app_type}'"
|
||||||
)
|
);
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
|
state.db.set_setting(&key, value).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ use crate::init_status::InitErrorPayload;
|
|||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
/// 打开外部链接
|
/// 打开外部链接
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String> {
|
pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String> {
|
||||||
@@ -142,11 +148,16 @@ fn extract_version(raw: &str) -> String {
|
|||||||
fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
let output = if cfg!(target_os = "windows") {
|
#[cfg(target_os = "windows")]
|
||||||
|
let output = {
|
||||||
Command::new("cmd")
|
Command::new("cmd")
|
||||||
.args(["/C", &format!("{tool} --version")])
|
.args(["/C", &format!("{tool} --version")])
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
.output()
|
.output()
|
||||||
} else {
|
};
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let output = {
|
||||||
Command::new("sh")
|
Command::new("sh")
|
||||||
.arg("-c")
|
.arg("-c")
|
||||||
.arg(format!("{tool} --version"))
|
.arg(format!("{tool} --version"))
|
||||||
@@ -239,10 +250,22 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
|||||||
let current_path = std::env::var("PATH").unwrap_or_default();
|
let current_path = std::env::var("PATH").unwrap_or_default();
|
||||||
let new_path = format!("{}:{}", path.display(), current_path);
|
let new_path = format!("{}:{}", path.display(), current_path);
|
||||||
|
|
||||||
let output = Command::new(&tool_path)
|
#[cfg(target_os = "windows")]
|
||||||
.arg("--version")
|
let output = {
|
||||||
.env("PATH", &new_path)
|
Command::new(&tool_path)
|
||||||
.output();
|
.arg("--version")
|
||||||
|
.env("PATH", &new_path)
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.output()
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let output = {
|
||||||
|
Command::new(&tool_path)
|
||||||
|
.arg("--version")
|
||||||
|
.env("PATH", &new_path)
|
||||||
|
.output()
|
||||||
|
};
|
||||||
|
|
||||||
if let Ok(out) = output {
|
if let Ok(out) = output {
|
||||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
|||||||
@@ -14,14 +14,6 @@ pub async fn start_proxy_server(
|
|||||||
state.proxy_service.start().await
|
state.proxy_service.start().await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 启动代理服务器(带 Live 配置接管)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn start_proxy_with_takeover(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
) -> Result<ProxyServerInfo, String> {
|
|
||||||
state.proxy_service.start_with_takeover().await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 停止代理服务器(恢复 Live 配置)
|
/// 停止代理服务器(恢复 Live 配置)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn stop_proxy_with_restore(state: tauri::State<'_, AppState>) -> Result<(), String> {
|
pub async fn stop_proxy_with_restore(state: tauri::State<'_, AppState>) -> Result<(), String> {
|
||||||
@@ -111,8 +103,13 @@ pub async fn get_provider_health(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 重置熔断器
|
/// 重置熔断器
|
||||||
|
///
|
||||||
|
/// 重置后会检查是否应该切回队列中优先级更高的供应商:
|
||||||
|
/// 1. 检查自动故障转移是否开启
|
||||||
|
/// 2. 如果恢复的供应商在队列中优先级更高(queue_order 更小),则自动切换
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn reset_circuit_breaker(
|
pub async fn reset_circuit_breaker(
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
provider_id: String,
|
provider_id: String,
|
||||||
app_type: String,
|
app_type: String,
|
||||||
@@ -129,6 +126,75 @@ pub async fn reset_circuit_breaker(
|
|||||||
.reset_provider_circuit_breaker(&provider_id, &app_type)
|
.reset_provider_circuit_breaker(&provider_id, &app_type)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// 3. 检查是否应该切回优先级更高的供应商
|
||||||
|
let failover_key = format!("auto_failover_enabled_{app_type}");
|
||||||
|
let auto_failover_enabled = match db.get_setting(&failover_key) {
|
||||||
|
Ok(Some(value)) => value == "true",
|
||||||
|
Ok(None) => {
|
||||||
|
log::debug!(
|
||||||
|
"[{app_type}] Failover setting '{failover_key}' not found, defaulting to disabled"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!(
|
||||||
|
"[{app_type}] Failed to read failover setting '{failover_key}': {e}, defaulting to disabled"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if auto_failover_enabled && state.proxy_service.is_running().await {
|
||||||
|
// 获取当前供应商 ID
|
||||||
|
let current_id = db
|
||||||
|
.get_current_provider(&app_type)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if let Some(current_id) = current_id {
|
||||||
|
// 获取故障转移队列
|
||||||
|
let queue = db
|
||||||
|
.get_failover_queue(&app_type)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 找到恢复的供应商和当前供应商在队列中的位置(使用 sort_index)
|
||||||
|
let restored_order = queue
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.provider_id == provider_id)
|
||||||
|
.and_then(|item| item.sort_index);
|
||||||
|
|
||||||
|
let current_order = queue
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.provider_id == current_id)
|
||||||
|
.and_then(|item| item.sort_index);
|
||||||
|
|
||||||
|
// 如果恢复的供应商优先级更高(sort_index 更小),则切换
|
||||||
|
if let (Some(restored), Some(current)) = (restored_order, current_order) {
|
||||||
|
if restored < current {
|
||||||
|
log::info!(
|
||||||
|
"[Recovery] 供应商 {provider_id} 已恢复且优先级更高 (P{restored} vs P{current}),自动切换"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取供应商名称用于日志和事件
|
||||||
|
let provider_name = db
|
||||||
|
.get_all_providers(&app_type)
|
||||||
|
.ok()
|
||||||
|
.and_then(|providers| providers.get(&provider_id).map(|p| p.name.clone()))
|
||||||
|
.unwrap_or_else(|| provider_id.clone());
|
||||||
|
|
||||||
|
// 创建故障转移切换管理器并执行切换
|
||||||
|
let switch_manager =
|
||||||
|
crate::proxy::failover_switch::FailoverSwitchManager::new(db.clone());
|
||||||
|
if let Err(e) = switch_manager
|
||||||
|
.try_switch(Some(&app_handle), &app_type, &provider_id, &provider_name)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::error!("[Recovery] 自动切换失败: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,9 +52,7 @@ pub async fn stream_check_all_providers(
|
|||||||
}
|
}
|
||||||
if let Ok(queue) = state.db.get_failover_queue(app_type.as_str()) {
|
if let Ok(queue) = state.db.get_failover_queue(app_type.as_str()) {
|
||||||
for item in queue {
|
for item in queue {
|
||||||
if item.enabled {
|
ids.insert(item.provider_id);
|
||||||
ids.insert(item.provider_id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(ids)
|
Some(ids)
|
||||||
|
|||||||
@@ -1,36 +1,32 @@
|
|||||||
//! 故障转移队列 DAO
|
//! 故障转移队列 DAO
|
||||||
//!
|
//!
|
||||||
//! 管理代理模式下的故障转移队列
|
//! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段)
|
||||||
|
|
||||||
use crate::database::{lock_conn, Database};
|
use crate::database::{lock_conn, Database};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
/// 故障转移队列条目
|
/// 故障转移队列条目(简化版,用于前端展示)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct FailoverQueueItem {
|
pub struct FailoverQueueItem {
|
||||||
pub provider_id: String,
|
pub provider_id: String,
|
||||||
pub provider_name: String,
|
pub provider_name: String,
|
||||||
pub queue_order: i32,
|
pub sort_index: Option<usize>,
|
||||||
pub enabled: bool,
|
|
||||||
pub created_at: i64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Database {
|
impl Database {
|
||||||
/// 获取故障转移队列(按 queue_order 排序)
|
/// 获取故障转移队列(按 sort_index 排序)
|
||||||
pub fn get_failover_queue(&self, app_type: &str) -> Result<Vec<FailoverQueueItem>, AppError> {
|
pub fn get_failover_queue(&self, app_type: &str) -> Result<Vec<FailoverQueueItem>, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT fq.provider_id, p.name, fq.queue_order, fq.enabled, fq.created_at
|
"SELECT id, name, sort_index
|
||||||
FROM failover_queue fq
|
FROM providers
|
||||||
JOIN providers p ON fq.provider_id = p.id AND fq.app_type = p.app_type
|
WHERE app_type = ?1 AND in_failover_queue = 1
|
||||||
WHERE fq.app_type = ?1
|
ORDER BY COALESCE(sort_index, 999999), id ASC",
|
||||||
ORDER BY fq.queue_order ASC",
|
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
@@ -39,9 +35,7 @@ impl Database {
|
|||||||
Ok(FailoverQueueItem {
|
Ok(FailoverQueueItem {
|
||||||
provider_id: row.get(0)?,
|
provider_id: row.get(0)?,
|
||||||
provider_name: row.get(1)?,
|
provider_name: row.get(1)?,
|
||||||
queue_order: row.get(2)?,
|
sort_index: row.get(2)?,
|
||||||
enabled: row.get(3)?,
|
|
||||||
created_at: row.get(4)?,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?
|
.map_err(|e| AppError::Database(e.to_string()))?
|
||||||
@@ -53,43 +47,23 @@ impl Database {
|
|||||||
|
|
||||||
/// 获取故障转移队列中的供应商(完整 Provider 信息,按顺序)
|
/// 获取故障转移队列中的供应商(完整 Provider 信息,按顺序)
|
||||||
pub fn get_failover_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
|
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 all_providers = self.get_all_providers(app_type)?;
|
||||||
|
|
||||||
let mut result = Vec::new();
|
let result: Vec<Provider> = all_providers
|
||||||
for item in queue {
|
.into_values()
|
||||||
if item.enabled {
|
.filter(|p| p.in_failover_queue)
|
||||||
if let Some(provider) = all_providers.get(&item.provider_id) {
|
.collect();
|
||||||
result.push(provider.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加供应商到故障转移队列末尾
|
/// 添加供应商到故障转移队列
|
||||||
pub fn add_to_failover_queue(&self, app_type: &str, provider_id: &str) -> Result<(), AppError> {
|
pub fn add_to_failover_queue(&self, app_type: &str, provider_id: &str) -> Result<(), AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
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(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO failover_queue (app_type, provider_id, queue_order, enabled, created_at)
|
"UPDATE providers SET in_failover_queue = 1 WHERE id = ?1 AND app_type = ?2",
|
||||||
VALUES (?1, ?2, ?3, 1, ?4)",
|
rusqlite::params![provider_id, app_type],
|
||||||
rusqlite::params![app_type, provider_id, max_order + 1, now],
|
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
@@ -104,103 +78,21 @@ impl Database {
|
|||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
// 获取被删除项的 queue_order
|
// 1. 从队列中移除
|
||||||
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(
|
conn.execute(
|
||||||
"DELETE FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
|
"UPDATE providers SET in_failover_queue = 0 WHERE id = ?1 AND app_type = ?2",
|
||||||
[app_type, provider_id],
|
rusqlite::params![provider_id, app_type],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 重新排序后面的项(填补空隙)
|
// 2. 清除该供应商的健康状态(退出队列后不再需要健康监控)
|
||||||
if let Some(order) = removed_order {
|
conn.execute(
|
||||||
conn.execute(
|
"DELETE FROM provider_health WHERE provider_id = ?1 AND app_type = ?2",
|
||||||
"UPDATE failover_queue
|
rusqlite::params![provider_id, app_type],
|
||||||
SET queue_order = queue_order - 1
|
)
|
||||||
WHERE app_type = ?1 AND queue_order > ?2",
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
rusqlite::params![app_type, order],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
log::info!("已从故障转移队列移除供应商 {provider_id} ({app_type}), 并清除其健康状态");
|
||||||
}
|
|
||||||
|
|
||||||
/// 重新排序故障转移队列
|
|
||||||
/// 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);
|
|
||||||
|
|
||||||
let rows_affected = 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()))?;
|
|
||||||
|
|
||||||
if rows_affected == 0 {
|
|
||||||
log::warn!(
|
|
||||||
"set_failover_item_enabled: 未找到匹配记录 app_type={app_type}, provider_id={provider_id}"
|
|
||||||
);
|
|
||||||
return Err(AppError::Database(format!(
|
|
||||||
"未找到故障转移队列项: app_type={app_type}, provider_id={provider_id}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"set_failover_item_enabled: 已更新 app_type={app_type}, provider_id={provider_id}, enabled={enabled}"
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -209,8 +101,11 @@ impl Database {
|
|||||||
pub fn clear_failover_queue(&self, app_type: &str) -> Result<(), AppError> {
|
pub fn clear_failover_queue(&self, app_type: &str) -> Result<(), AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
conn.execute("DELETE FROM failover_queue WHERE app_type = ?1", [app_type])
|
conn.execute(
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
"UPDATE providers SET in_failover_queue = 0 WHERE app_type = ?1",
|
||||||
|
[app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -223,15 +118,15 @@ impl Database {
|
|||||||
) -> Result<bool, AppError> {
|
) -> Result<bool, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
let count: i32 = conn
|
let in_queue: bool = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT COUNT(*) FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
|
"SELECT in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||||
[app_type, provider_id],
|
rusqlite::params![provider_id, app_type],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.unwrap_or(false);
|
||||||
|
|
||||||
Ok(count > 0)
|
Ok(in_queue)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取可添加到故障转移队列的供应商(不在队列中的)
|
/// 获取可添加到故障转移队列的供应商(不在队列中的)
|
||||||
@@ -240,14 +135,10 @@ impl Database {
|
|||||||
app_type: &str,
|
app_type: &str,
|
||||||
) -> Result<Vec<Provider>, AppError> {
|
) -> Result<Vec<Provider>, AppError> {
|
||||||
let all_providers = self.get_all_providers(app_type)?;
|
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
|
let available: Vec<Provider> = all_providers
|
||||||
.into_values()
|
.into_values()
|
||||||
.filter(|p| !queue_ids.contains(&p.id))
|
.filter(|p| !p.in_failover_queue)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(available)
|
Ok(available)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ impl Database {
|
|||||||
) -> Result<IndexMap<String, Provider>, AppError> {
|
) -> Result<IndexMap<String, Provider>, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta
|
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue
|
||||||
FROM providers WHERE app_type = ?1
|
FROM providers WHERE app_type = ?1
|
||||||
ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC"
|
ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC"
|
||||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -35,6 +35,7 @@ impl Database {
|
|||||||
let icon: Option<String> = row.get(8)?;
|
let icon: Option<String> = row.get(8)?;
|
||||||
let icon_color: Option<String> = row.get(9)?;
|
let icon_color: Option<String> = row.get(9)?;
|
||||||
let meta_str: String = row.get(10)?;
|
let meta_str: String = row.get(10)?;
|
||||||
|
let in_failover_queue: bool = row.get(11)?;
|
||||||
|
|
||||||
let settings_config =
|
let settings_config =
|
||||||
serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
|
serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
|
||||||
@@ -54,6 +55,7 @@ impl Database {
|
|||||||
meta: Some(meta),
|
meta: Some(meta),
|
||||||
icon,
|
icon,
|
||||||
icon_color,
|
icon_color,
|
||||||
|
in_failover_queue,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
@@ -129,7 +131,7 @@ impl Database {
|
|||||||
) -> Result<Option<Provider>, AppError> {
|
) -> Result<Option<Provider>, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
let result = conn.query_row(
|
let result = conn.query_row(
|
||||||
"SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta
|
"SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue
|
||||||
FROM providers WHERE id = ?1 AND app_type = ?2",
|
FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||||
params![id, app_type],
|
params![id, app_type],
|
||||||
|row| {
|
|row| {
|
||||||
@@ -143,6 +145,7 @@ impl Database {
|
|||||||
let icon: Option<String> = row.get(7)?;
|
let icon: Option<String> = row.get(7)?;
|
||||||
let icon_color: Option<String> = row.get(8)?;
|
let icon_color: Option<String> = row.get(8)?;
|
||||||
let meta_str: String = row.get(9)?;
|
let meta_str: String = row.get(9)?;
|
||||||
|
let in_failover_queue: bool = row.get(10)?;
|
||||||
|
|
||||||
let settings_config = serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
|
let settings_config = serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
|
||||||
let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default();
|
let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default();
|
||||||
@@ -159,6 +162,7 @@ impl Database {
|
|||||||
meta: Some(meta),
|
meta: Some(meta),
|
||||||
icon,
|
icon,
|
||||||
icon_color,
|
icon_color,
|
||||||
|
in_failover_queue,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -184,17 +188,18 @@ impl Database {
|
|||||||
let mut meta_clone = provider.meta.clone().unwrap_or_default();
|
let mut meta_clone = provider.meta.clone().unwrap_or_default();
|
||||||
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
|
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
|
||||||
|
|
||||||
// 检查是否存在(用于判断新增/更新,以及保留 is_current)
|
// 检查是否存在(用于判断新增/更新,以及保留 is_current 和 in_failover_queue)
|
||||||
let existing: Option<bool> = tx
|
let existing: Option<(bool, bool)> = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT is_current FROM providers WHERE id = ?1 AND app_type = ?2",
|
"SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||||
params![provider.id, app_type],
|
params![provider.id, app_type],
|
||||||
|row| row.get(0),
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
)
|
)
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
let is_update = existing.is_some();
|
let is_update = existing.is_some();
|
||||||
let is_current = existing.unwrap_or(false);
|
let (is_current, in_failover_queue) =
|
||||||
|
existing.unwrap_or((false, provider.in_failover_queue));
|
||||||
|
|
||||||
if is_update {
|
if is_update {
|
||||||
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
|
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
|
||||||
@@ -210,8 +215,9 @@ impl Database {
|
|||||||
icon = ?8,
|
icon = ?8,
|
||||||
icon_color = ?9,
|
icon_color = ?9,
|
||||||
meta = ?10,
|
meta = ?10,
|
||||||
is_current = ?11
|
is_current = ?11,
|
||||||
WHERE id = ?12 AND app_type = ?13",
|
in_failover_queue = ?12
|
||||||
|
WHERE id = ?13 AND app_type = ?14",
|
||||||
params![
|
params![
|
||||||
provider.name,
|
provider.name,
|
||||||
serde_json::to_string(&provider.settings_config).unwrap(),
|
serde_json::to_string(&provider.settings_config).unwrap(),
|
||||||
@@ -224,6 +230,7 @@ impl Database {
|
|||||||
provider.icon_color,
|
provider.icon_color,
|
||||||
serde_json::to_string(&meta_clone).unwrap(),
|
serde_json::to_string(&meta_clone).unwrap(),
|
||||||
is_current,
|
is_current,
|
||||||
|
in_failover_queue,
|
||||||
provider.id,
|
provider.id,
|
||||||
app_type,
|
app_type,
|
||||||
],
|
],
|
||||||
@@ -234,8 +241,8 @@ impl Database {
|
|||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO providers (
|
"INSERT INTO providers (
|
||||||
id, app_type, name, settings_config, website_url, category,
|
id, app_type, name, settings_config, website_url, category,
|
||||||
created_at, sort_index, notes, icon, icon_color, meta, is_current
|
created_at, sort_index, notes, icon, icon_color, meta, is_current, in_failover_queue
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||||
params![
|
params![
|
||||||
provider.id,
|
provider.id,
|
||||||
app_type,
|
app_type,
|
||||||
@@ -250,6 +257,7 @@ impl Database {
|
|||||||
provider.icon_color,
|
provider.icon_color,
|
||||||
serde_json::to_string(&meta_clone).unwrap(),
|
serde_json::to_string(&meta_clone).unwrap(),
|
||||||
is_current,
|
is_current,
|
||||||
|
in_failover_queue,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|||||||
@@ -102,28 +102,45 @@ impl Database {
|
|||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
) -> Result<ProviderHealth, AppError> {
|
) -> Result<ProviderHealth, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let result = {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT provider_id, app_type, is_healthy, consecutive_failures,
|
"SELECT provider_id, app_type, is_healthy, consecutive_failures,
|
||||||
last_success_at, last_failure_at, last_error, updated_at
|
last_success_at, last_failure_at, last_error, updated_at
|
||||||
FROM provider_health
|
FROM provider_health
|
||||||
WHERE provider_id = ?1 AND app_type = ?2",
|
WHERE provider_id = ?1 AND app_type = ?2",
|
||||||
rusqlite::params![provider_id, app_type],
|
rusqlite::params![provider_id, app_type],
|
||||||
|row| {
|
|row| {
|
||||||
Ok(ProviderHealth {
|
Ok(ProviderHealth {
|
||||||
provider_id: row.get(0)?,
|
provider_id: row.get(0)?,
|
||||||
app_type: row.get(1)?,
|
app_type: row.get(1)?,
|
||||||
is_healthy: row.get::<_, i64>(2)? != 0,
|
is_healthy: row.get::<_, i64>(2)? != 0,
|
||||||
consecutive_failures: row.get::<_, i64>(3)? as u32,
|
consecutive_failures: row.get::<_, i64>(3)? as u32,
|
||||||
last_success_at: row.get(4)?,
|
last_success_at: row.get(4)?,
|
||||||
last_failure_at: row.get(5)?,
|
last_failure_at: row.get(5)?,
|
||||||
last_error: row.get(6)?,
|
last_error: row.get(6)?,
|
||||||
updated_at: row.get(7)?,
|
updated_at: row.get(7)?,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(health) => Ok(health),
|
||||||
|
// 缺少记录时视为健康(关闭后清空状态,再次打开时默认正常)
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(ProviderHealth {
|
||||||
|
provider_id: provider_id.to_string(),
|
||||||
|
app_type: app_type.to_string(),
|
||||||
|
is_healthy: true,
|
||||||
|
consecutive_failures: 0,
|
||||||
|
last_success_at: None,
|
||||||
|
last_failure_at: None,
|
||||||
|
last_error: None,
|
||||||
|
updated_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
}),
|
||||||
|
Err(e) => Err(AppError::Database(e.to_string())),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新Provider健康状态
|
/// 更新Provider健康状态
|
||||||
@@ -228,6 +245,20 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 清空指定应用的健康状态(关闭单个代理时使用)
|
||||||
|
pub async fn clear_provider_health_for_app(&self, app_type: &str) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM provider_health WHERE app_type = ?1",
|
||||||
|
[app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
log::debug!("Cleared provider health records for app {app_type}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 清空所有Provider健康状态(代理停止时调用)
|
/// 清空所有Provider健康状态(代理停止时调用)
|
||||||
pub async fn clear_all_provider_health(&self) -> Result<(), AppError> {
|
pub async fn clear_all_provider_health(&self) -> Result<(), AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ impl Database {
|
|||||||
icon_color TEXT,
|
icon_color TEXT,
|
||||||
meta TEXT NOT NULL DEFAULT '{}',
|
meta TEXT NOT NULL DEFAULT '{}',
|
||||||
is_current BOOLEAN NOT NULL DEFAULT 0,
|
is_current BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
in_failover_queue BOOLEAN NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY (id, app_type)
|
PRIMARY KEY (id, app_type)
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
@@ -312,29 +313,24 @@ impl Database {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 14. Failover Queue 表 (故障转移队列)
|
// 确保 in_failover_queue 列存在(对于已存在的 v2 数据库)
|
||||||
conn.execute(
|
Self::add_column_if_missing(
|
||||||
"CREATE TABLE IF NOT EXISTS failover_queue (
|
conn,
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
"providers",
|
||||||
app_type TEXT NOT NULL,
|
"in_failover_queue",
|
||||||
provider_id TEXT NOT NULL,
|
"BOOLEAN NOT NULL DEFAULT 0",
|
||||||
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()))?;
|
|
||||||
|
|
||||||
// 为故障转移队列创建索引
|
// 删除旧的 failover_queue 表(如果存在)
|
||||||
conn.execute(
|
let _ = conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", []);
|
||||||
"CREATE INDEX IF NOT EXISTS idx_failover_queue_order
|
let _ = conn.execute("DROP TABLE IF EXISTS failover_queue", []);
|
||||||
ON failover_queue(app_type, queue_order)",
|
|
||||||
|
// 为故障转移队列创建索引(基于 providers 表)
|
||||||
|
let _ = conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_providers_failover
|
||||||
|
ON providers(app_type, in_failover_queue, sort_index)",
|
||||||
[],
|
[],
|
||||||
)
|
);
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -472,6 +468,26 @@ impl Database {
|
|||||||
Self::add_column_if_missing(conn, "providers", "limit_daily_usd", "TEXT")?;
|
Self::add_column_if_missing(conn, "providers", "limit_daily_usd", "TEXT")?;
|
||||||
Self::add_column_if_missing(conn, "providers", "limit_monthly_usd", "TEXT")?;
|
Self::add_column_if_missing(conn, "providers", "limit_monthly_usd", "TEXT")?;
|
||||||
Self::add_column_if_missing(conn, "providers", "provider_type", "TEXT")?;
|
Self::add_column_if_missing(conn, "providers", "provider_type", "TEXT")?;
|
||||||
|
Self::add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"providers",
|
||||||
|
"in_failover_queue",
|
||||||
|
"BOOLEAN NOT NULL DEFAULT 0",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// 删除旧的 failover_queue 表(如果存在)
|
||||||
|
conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", [])
|
||||||
|
.map_err(|e| AppError::Database(format!("删除 failover_queue 索引失败: {e}")))?;
|
||||||
|
conn.execute("DROP TABLE IF EXISTS failover_queue", [])
|
||||||
|
.map_err(|e| AppError::Database(format!("删除 failover_queue 表失败: {e}")))?;
|
||||||
|
|
||||||
|
// 创建 failover 索引
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_providers_failover
|
||||||
|
ON providers(app_type, in_failover_queue, sort_index)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(format!("创建 failover 索引失败: {e}")))?;
|
||||||
|
|
||||||
// proxy_request_logs 表(包含所有字段)
|
// proxy_request_logs 表(包含所有字段)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ fn dry_run_validates_schema_compatibility() {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ pub(crate) fn build_provider_from_request(
|
|||||||
meta,
|
meta,
|
||||||
icon: request.icon.clone(),
|
icon: request.icon.clone(),
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(provider)
|
Ok(provider)
|
||||||
|
|||||||
@@ -650,7 +650,6 @@ pub fn run() {
|
|||||||
commands::get_auto_launch_status,
|
commands::get_auto_launch_status,
|
||||||
// Proxy server management
|
// Proxy server management
|
||||||
commands::start_proxy_server,
|
commands::start_proxy_server,
|
||||||
commands::start_proxy_with_takeover,
|
|
||||||
commands::stop_proxy_with_restore,
|
commands::stop_proxy_with_restore,
|
||||||
commands::get_proxy_takeover_status,
|
commands::get_proxy_takeover_status,
|
||||||
commands::set_proxy_takeover_for_app,
|
commands::set_proxy_takeover_for_app,
|
||||||
@@ -671,8 +670,6 @@ pub fn run() {
|
|||||||
commands::get_available_providers_for_failover,
|
commands::get_available_providers_for_failover,
|
||||||
commands::add_to_failover_queue,
|
commands::add_to_failover_queue,
|
||||||
commands::remove_from_failover_queue,
|
commands::remove_from_failover_queue,
|
||||||
commands::reorder_failover_queue,
|
|
||||||
commands::set_failover_item_enabled,
|
|
||||||
commands::get_auto_failover_enabled,
|
commands::get_auto_failover_enabled,
|
||||||
commands::set_auto_failover_enabled,
|
commands::set_auto_failover_enabled,
|
||||||
// Usage statistics
|
// Usage statistics
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ pub struct Provider {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[serde(rename = "iconColor")]
|
#[serde(rename = "iconColor")]
|
||||||
pub icon_color: Option<String>,
|
pub icon_color: Option<String>,
|
||||||
|
/// 是否加入故障转移队列
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde(rename = "inFailoverQueue")]
|
||||||
|
pub in_failover_queue: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Provider {
|
impl Provider {
|
||||||
@@ -58,6 +62,7 @@ impl Provider {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ use std::sync::Arc;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
pub struct ForwardResult {
|
||||||
|
pub response: Response,
|
||||||
|
pub provider: Provider,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ForwardError {
|
||||||
|
pub error: ProxyError,
|
||||||
|
pub provider: Option<Provider>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct RequestForwarder {
|
pub struct RequestForwarder {
|
||||||
client: Client,
|
client: Client,
|
||||||
/// 共享的 ProviderRouter(持有熔断器状态)
|
/// 共享的 ProviderRouter(持有熔断器状态)
|
||||||
@@ -134,13 +144,16 @@ impl RequestForwarder {
|
|||||||
body: Value,
|
body: Value,
|
||||||
headers: axum::http::HeaderMap,
|
headers: axum::http::HeaderMap,
|
||||||
providers: Vec<Provider>,
|
providers: Vec<Provider>,
|
||||||
) -> Result<Response, ProxyError> {
|
) -> Result<ForwardResult, ForwardError> {
|
||||||
// 获取适配器
|
// 获取适配器
|
||||||
let adapter = get_adapter(app_type);
|
let adapter = get_adapter(app_type);
|
||||||
let app_type_str = app_type.as_str();
|
let app_type_str = app_type.as_str();
|
||||||
|
|
||||||
if providers.is_empty() {
|
if providers.is_empty() {
|
||||||
return Err(ProxyError::NoAvailableProvider);
|
return Err(ForwardError {
|
||||||
|
error: ProxyError::NoAvailableProvider,
|
||||||
|
provider: None,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -150,6 +163,7 @@ impl RequestForwarder {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut last_error = None;
|
let mut last_error = None;
|
||||||
|
let mut last_provider = None;
|
||||||
let mut attempted_providers = 0usize;
|
let mut attempted_providers = 0usize;
|
||||||
|
|
||||||
// 依次尝试每个供应商
|
// 依次尝试每个供应商
|
||||||
@@ -269,7 +283,10 @@ impl RequestForwarder {
|
|||||||
latency
|
latency
|
||||||
);
|
);
|
||||||
|
|
||||||
return Ok(response);
|
return Ok(ForwardResult {
|
||||||
|
response,
|
||||||
|
provider: provider.clone(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let latency = start.elapsed().as_millis() as u64;
|
let latency = start.elapsed().as_millis() as u64;
|
||||||
@@ -310,6 +327,7 @@ impl RequestForwarder {
|
|||||||
);
|
);
|
||||||
|
|
||||||
last_error = Some(e);
|
last_error = Some(e);
|
||||||
|
last_provider = Some(provider.clone());
|
||||||
// 继续尝试下一个供应商
|
// 继续尝试下一个供应商
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -331,7 +349,10 @@ impl RequestForwarder {
|
|||||||
provider.name,
|
provider.name,
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
return Err(e);
|
return Err(ForwardError {
|
||||||
|
error: e,
|
||||||
|
provider: Some(provider.clone()),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,7 +370,10 @@ impl RequestForwarder {
|
|||||||
(status.success_requests as f32 / status.total_requests as f32) * 100.0;
|
(status.success_requests as f32 / status.total_requests as f32) * 100.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Err(ProxyError::NoAvailableProvider);
|
return Err(ForwardError {
|
||||||
|
error: ProxyError::NoAvailableProvider,
|
||||||
|
provider: None,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 所有供应商都失败了
|
// 所有供应商都失败了
|
||||||
@@ -369,7 +393,10 @@ impl RequestForwarder {
|
|||||||
providers.len()
|
providers.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
Err(last_error.unwrap_or(ProxyError::MaxRetriesExceeded))
|
Err(ForwardError {
|
||||||
|
error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded),
|
||||||
|
provider: last_provider,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 转发单个请求(使用适配器)
|
/// 转发单个请求(使用适配器)
|
||||||
|
|||||||
@@ -61,27 +61,16 @@ pub async fn handle_messages(
|
|||||||
headers: axum::http::HeaderMap,
|
headers: axum::http::HeaderMap,
|
||||||
Json(body): Json<Value>,
|
Json(body): Json<Value>,
|
||||||
) -> Result<axum::response::Response, ProxyError> {
|
) -> Result<axum::response::Response, ProxyError> {
|
||||||
let ctx = RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?;
|
let mut ctx = RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?;
|
||||||
|
|
||||||
// 检查是否需要格式转换(OpenRouter 等中转服务)
|
|
||||||
let adapter = get_adapter(&AppType::Claude);
|
|
||||||
let needs_transform = adapter.needs_transform(&ctx.provider);
|
|
||||||
|
|
||||||
let is_stream = body
|
let is_stream = body
|
||||||
.get("stream")
|
.get("stream")
|
||||||
.and_then(|s| s.as_bool())
|
.and_then(|s| s.as_bool())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"[Claude] Provider: {}, needs_transform: {}, is_stream: {}",
|
|
||||||
ctx.provider.name,
|
|
||||||
needs_transform,
|
|
||||||
is_stream
|
|
||||||
);
|
|
||||||
|
|
||||||
// 转发请求
|
// 转发请求
|
||||||
let forwarder = ctx.create_forwarder(&state);
|
let forwarder = ctx.create_forwarder(&state);
|
||||||
let response = match forwarder
|
let result = match forwarder
|
||||||
.forward_with_retry(
|
.forward_with_retry(
|
||||||
&AppType::Claude,
|
&AppType::Claude,
|
||||||
"/v1/messages",
|
"/v1/messages",
|
||||||
@@ -91,13 +80,30 @@ pub async fn handle_messages(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(resp) => resp,
|
Ok(result) => result,
|
||||||
Err(e) => {
|
Err(mut err) => {
|
||||||
log_forward_error(&state, &ctx, is_stream, &e);
|
if let Some(provider) = err.provider.take() {
|
||||||
return Err(e);
|
ctx.provider = provider;
|
||||||
|
}
|
||||||
|
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||||||
|
return Err(err.error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ctx.provider = result.provider;
|
||||||
|
let response = result.response;
|
||||||
|
|
||||||
|
// 检查是否需要格式转换(OpenRouter 等中转服务)
|
||||||
|
let adapter = get_adapter(&AppType::Claude);
|
||||||
|
let needs_transform = adapter.needs_transform(&ctx.provider);
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[Claude] Provider: {}, needs_transform: {}, is_stream: {}",
|
||||||
|
ctx.provider.name,
|
||||||
|
needs_transform,
|
||||||
|
is_stream
|
||||||
|
);
|
||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
log::info!("[Claude] 上游响应状态: {status}");
|
log::info!("[Claude] 上游响应状态: {status}");
|
||||||
|
|
||||||
@@ -295,7 +301,7 @@ pub async fn handle_chat_completions(
|
|||||||
) -> Result<axum::response::Response, ProxyError> {
|
) -> Result<axum::response::Response, ProxyError> {
|
||||||
log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======");
|
log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======");
|
||||||
|
|
||||||
let ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
|
let mut ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
|
||||||
|
|
||||||
let is_stream = body
|
let is_stream = body
|
||||||
.get("stream")
|
.get("stream")
|
||||||
@@ -309,7 +315,7 @@ pub async fn handle_chat_completions(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let forwarder = ctx.create_forwarder(&state);
|
let forwarder = ctx.create_forwarder(&state);
|
||||||
let response = match forwarder
|
let result = match forwarder
|
||||||
.forward_with_retry(
|
.forward_with_retry(
|
||||||
&AppType::Codex,
|
&AppType::Codex,
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
@@ -319,13 +325,19 @@ pub async fn handle_chat_completions(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(resp) => resp,
|
Ok(result) => result,
|
||||||
Err(e) => {
|
Err(mut err) => {
|
||||||
log_forward_error(&state, &ctx, is_stream, &e);
|
if let Some(provider) = err.provider.take() {
|
||||||
return Err(e);
|
ctx.provider = provider;
|
||||||
|
}
|
||||||
|
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||||||
|
return Err(err.error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ctx.provider = result.provider;
|
||||||
|
let response = result.response;
|
||||||
|
|
||||||
log::info!("[Codex] 上游响应状态: {}", response.status());
|
log::info!("[Codex] 上游响应状态: {}", response.status());
|
||||||
|
|
||||||
process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await
|
process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await
|
||||||
@@ -337,7 +349,7 @@ pub async fn handle_responses(
|
|||||||
headers: axum::http::HeaderMap,
|
headers: axum::http::HeaderMap,
|
||||||
Json(body): Json<Value>,
|
Json(body): Json<Value>,
|
||||||
) -> Result<axum::response::Response, ProxyError> {
|
) -> Result<axum::response::Response, ProxyError> {
|
||||||
let ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
|
let mut ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
|
||||||
|
|
||||||
let is_stream = body
|
let is_stream = body
|
||||||
.get("stream")
|
.get("stream")
|
||||||
@@ -345,7 +357,7 @@ pub async fn handle_responses(
|
|||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
let forwarder = ctx.create_forwarder(&state);
|
let forwarder = ctx.create_forwarder(&state);
|
||||||
let response = match forwarder
|
let result = match forwarder
|
||||||
.forward_with_retry(
|
.forward_with_retry(
|
||||||
&AppType::Codex,
|
&AppType::Codex,
|
||||||
"/v1/responses",
|
"/v1/responses",
|
||||||
@@ -355,13 +367,19 @@ pub async fn handle_responses(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(resp) => resp,
|
Ok(result) => result,
|
||||||
Err(e) => {
|
Err(mut err) => {
|
||||||
log_forward_error(&state, &ctx, is_stream, &e);
|
if let Some(provider) = err.provider.take() {
|
||||||
return Err(e);
|
ctx.provider = provider;
|
||||||
|
}
|
||||||
|
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||||||
|
return Err(err.error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ctx.provider = result.provider;
|
||||||
|
let response = result.response;
|
||||||
|
|
||||||
log::info!("[Codex] 上游响应状态: {}", response.status());
|
log::info!("[Codex] 上游响应状态: {}", response.status());
|
||||||
|
|
||||||
process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await
|
process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await
|
||||||
@@ -379,7 +397,7 @@ pub async fn handle_gemini(
|
|||||||
Json(body): Json<Value>,
|
Json(body): Json<Value>,
|
||||||
) -> Result<axum::response::Response, ProxyError> {
|
) -> Result<axum::response::Response, ProxyError> {
|
||||||
// Gemini 的模型名称在 URI 中
|
// Gemini 的模型名称在 URI 中
|
||||||
let ctx = RequestContext::new(&state, &body, AppType::Gemini, "Gemini", "gemini")
|
let mut ctx = RequestContext::new(&state, &body, AppType::Gemini, "Gemini", "gemini")
|
||||||
.await?
|
.await?
|
||||||
.with_model_from_uri(&uri);
|
.with_model_from_uri(&uri);
|
||||||
|
|
||||||
@@ -397,7 +415,7 @@ pub async fn handle_gemini(
|
|||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
let forwarder = ctx.create_forwarder(&state);
|
let forwarder = ctx.create_forwarder(&state);
|
||||||
let response = match forwarder
|
let result = match forwarder
|
||||||
.forward_with_retry(
|
.forward_with_retry(
|
||||||
&AppType::Gemini,
|
&AppType::Gemini,
|
||||||
endpoint,
|
endpoint,
|
||||||
@@ -407,13 +425,19 @@ pub async fn handle_gemini(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(resp) => resp,
|
Ok(result) => result,
|
||||||
Err(e) => {
|
Err(mut err) => {
|
||||||
log_forward_error(&state, &ctx, is_stream, &e);
|
if let Some(provider) = err.provider.take() {
|
||||||
return Err(e);
|
ctx.provider = provider;
|
||||||
|
}
|
||||||
|
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||||||
|
return Err(err.error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ctx.provider = result.provider;
|
||||||
|
let response = result.response;
|
||||||
|
|
||||||
log::info!("[Gemini] 上游响应状态: {}", response.status());
|
log::info!("[Gemini] 上游响应状态: {}", response.status());
|
||||||
|
|
||||||
process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await
|
process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await
|
||||||
|
|||||||
@@ -30,85 +30,92 @@ impl ProviderRouter {
|
|||||||
/// 选择可用的供应商(支持故障转移)
|
/// 选择可用的供应商(支持故障转移)
|
||||||
///
|
///
|
||||||
/// 返回按优先级排序的可用供应商列表:
|
/// 返回按优先级排序的可用供应商列表:
|
||||||
/// 1. 当前供应商(is_current=true)始终第一位
|
/// - 故障转移关闭时:仅返回当前供应商
|
||||||
/// 2. 故障转移队列中的其他供应商(按 queue_order 排序)- 仅当自动故障转移开关开启时
|
/// - 故障转移开启时:完全按照故障转移队列顺序返回,忽略当前供应商设置
|
||||||
/// 3. 只返回熔断器未打开的供应商
|
|
||||||
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
|
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
let all_providers = self.db.get_all_providers(app_type)?;
|
|
||||||
|
|
||||||
// 检查自动故障转移总开关是否开启
|
// 检查该应用的自动故障转移开关是否开启
|
||||||
let auto_failover_enabled = self
|
let failover_key = format!("auto_failover_enabled_{app_type}");
|
||||||
.db
|
let auto_failover_enabled = match self.db.get_setting(&failover_key) {
|
||||||
.get_setting("auto_failover_enabled")
|
Ok(Some(value)) => {
|
||||||
.map(|v| v.map(|s| s == "true").unwrap_or(false)) // 默认关闭
|
let enabled = value == "true";
|
||||||
.unwrap_or(false);
|
log::info!(
|
||||||
|
"[{app_type}] Failover setting '{failover_key}' = '{value}', enabled: {enabled}"
|
||||||
|
);
|
||||||
|
enabled
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
log::warn!(
|
||||||
|
"[{app_type}] Failover setting '{failover_key}' not found in database, defaulting to disabled"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!(
|
||||||
|
"[{app_type}] Failed to read failover setting '{failover_key}': {e}, defaulting to disabled"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 1. 当前供应商始终第一位
|
if auto_failover_enabled {
|
||||||
if let Some(current_id) = self.db.get_current_provider(app_type)? {
|
// 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序
|
||||||
if let Some(current) = all_providers.get(¤t_id) {
|
let failover_providers = self.db.get_failover_providers(app_type)?;
|
||||||
let circuit_key = format!("{}:{}", app_type, current.id);
|
log::info!(
|
||||||
|
"[{}] Failover enabled, using queue order ({} items)",
|
||||||
|
app_type,
|
||||||
|
failover_providers.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
for provider in failover_providers {
|
||||||
|
// 检查熔断器状态
|
||||||
|
let circuit_key = format!("{}:{}", app_type, provider.id);
|
||||||
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
||||||
|
|
||||||
if breaker.is_available().await {
|
if breaker.is_available().await {
|
||||||
log::info!(
|
log::info!(
|
||||||
"[{}] Current provider available: {} ({})",
|
"[{}] Queue provider available: {} ({}) at sort_index {:?}",
|
||||||
app_type,
|
app_type,
|
||||||
current.name,
|
provider.name,
|
||||||
current.id
|
provider.id,
|
||||||
|
provider.sort_index
|
||||||
);
|
);
|
||||||
result.push(current.clone());
|
result.push(provider);
|
||||||
} else {
|
} else {
|
||||||
log::warn!(
|
log::debug!(
|
||||||
"[{}] Current provider {} circuit breaker open, checking failover queue",
|
"[{}] Queue provider {} circuit breaker open, skipping",
|
||||||
app_type,
|
app_type,
|
||||||
current.name
|
provider.name
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
// 故障转移关闭:仅使用当前供应商
|
||||||
|
log::info!("[{app_type}] Failover disabled, using current provider only");
|
||||||
|
|
||||||
// 2. 获取故障转移队列中的供应商(仅当自动故障转移开关开启时)
|
if let Some(current_id) = self.db.get_current_provider(app_type)? {
|
||||||
if auto_failover_enabled {
|
if let Some(current) = self.db.get_provider_by_id(¤t_id, app_type)? {
|
||||||
let queue = self.db.get_failover_queue(app_type)?;
|
let circuit_key = format!("{}:{}", app_type, current.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;
|
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
||||||
|
|
||||||
if breaker.is_available().await {
|
if breaker.is_available().await {
|
||||||
log::info!(
|
log::info!(
|
||||||
"[{}] Failover provider available: {} ({}) at queue position {}",
|
"[{}] Current provider available: {} ({})",
|
||||||
app_type,
|
app_type,
|
||||||
provider.name,
|
current.name,
|
||||||
provider.id,
|
current.id
|
||||||
item.queue_order
|
|
||||||
);
|
);
|
||||||
result.push(provider.clone());
|
result.push(current);
|
||||||
} else {
|
} else {
|
||||||
log::debug!(
|
log::warn!(
|
||||||
"[{}] Failover provider {} circuit breaker open, skipping",
|
"[{}] Current provider {} circuit breaker open",
|
||||||
app_type,
|
app_type,
|
||||||
provider.name
|
current.name
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
log::info!("[{app_type}] Auto-failover disabled, using only current provider");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.is_empty() {
|
if result.is_empty() {
|
||||||
@@ -118,7 +125,7 @@ impl ProviderRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"[{}] Failover chain: {} provider(s) available",
|
"[{}] Provider chain: {} provider(s) available",
|
||||||
app_type,
|
app_type,
|
||||||
result.len()
|
result.len()
|
||||||
);
|
);
|
||||||
@@ -275,25 +282,14 @@ mod tests {
|
|||||||
let db = Arc::new(Database::memory().unwrap());
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
let router = ProviderRouter::new(db);
|
let router = ProviderRouter::new(db);
|
||||||
|
|
||||||
// 测试创建熔断器
|
|
||||||
let breaker = router.get_or_create_circuit_breaker("claude:test").await;
|
let breaker = router.get_or_create_circuit_breaker("claude:test").await;
|
||||||
assert!(breaker.allow_request().await.allowed);
|
assert!(breaker.allow_request().await.allowed);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn select_providers_does_not_consume_half_open_permit() {
|
async fn test_failover_disabled_uses_current_provider() {
|
||||||
let db = Arc::new(Database::memory().unwrap());
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
|
|
||||||
// 配置:让熔断器 Open 后立刻进入 HalfOpen(timeout_seconds=0),并用 1 次失败就打开熔断器
|
|
||||||
db.update_circuit_breaker_config(&CircuitBreakerConfig {
|
|
||||||
failure_threshold: 1,
|
|
||||||
timeout_seconds: 0,
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// 准备 2 个 Provider:A(当前)+ B(队列)
|
|
||||||
let provider_a =
|
let provider_a =
|
||||||
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
||||||
let provider_b =
|
let provider_b =
|
||||||
@@ -304,19 +300,78 @@ mod tests {
|
|||||||
db.set_current_provider("claude", "a").unwrap();
|
db.set_current_provider("claude", "a").unwrap();
|
||||||
db.add_to_failover_queue("claude", "b").unwrap();
|
db.add_to_failover_queue("claude", "b").unwrap();
|
||||||
|
|
||||||
|
let router = ProviderRouter::new(db.clone());
|
||||||
|
let providers = router.select_providers("claude").await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(providers.len(), 1);
|
||||||
|
assert_eq!(providers[0].id, "a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_failover_enabled_uses_queue_order() {
|
||||||
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
|
|
||||||
|
// 设置 sort_index 来控制顺序:b=1, a=2
|
||||||
|
let mut provider_a =
|
||||||
|
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
||||||
|
provider_a.sort_index = Some(2);
|
||||||
|
let mut provider_b =
|
||||||
|
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
||||||
|
provider_b.sort_index = Some(1);
|
||||||
|
|
||||||
|
db.save_provider("claude", &provider_a).unwrap();
|
||||||
|
db.save_provider("claude", &provider_b).unwrap();
|
||||||
|
db.set_current_provider("claude", "a").unwrap();
|
||||||
|
|
||||||
|
db.add_to_failover_queue("claude", "b").unwrap();
|
||||||
|
db.add_to_failover_queue("claude", "a").unwrap();
|
||||||
|
db.set_setting("auto_failover_enabled_claude", "true")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let router = ProviderRouter::new(db.clone());
|
||||||
|
let providers = router.select_providers("claude").await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(providers.len(), 2);
|
||||||
|
// 按 sort_index 排序:b(1) 在前,a(2) 在后
|
||||||
|
assert_eq!(providers[0].id, "b");
|
||||||
|
assert_eq!(providers[1].id, "a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_select_providers_does_not_consume_half_open_permit() {
|
||||||
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
|
|
||||||
|
db.update_circuit_breaker_config(&CircuitBreakerConfig {
|
||||||
|
failure_threshold: 1,
|
||||||
|
timeout_seconds: 0,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let provider_a =
|
||||||
|
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
||||||
|
let provider_b =
|
||||||
|
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
||||||
|
|
||||||
|
db.save_provider("claude", &provider_a).unwrap();
|
||||||
|
db.save_provider("claude", &provider_b).unwrap();
|
||||||
|
|
||||||
|
db.add_to_failover_queue("claude", "a").unwrap();
|
||||||
|
db.add_to_failover_queue("claude", "b").unwrap();
|
||||||
|
db.set_setting("auto_failover_enabled_claude", "true")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let router = ProviderRouter::new(db.clone());
|
let router = ProviderRouter::new(db.clone());
|
||||||
|
|
||||||
// 让 B 进入 Open 状态(failure_threshold=1)
|
|
||||||
router
|
router
|
||||||
.record_result("b", "claude", false, false, Some("fail".to_string()))
|
.record_result("b", "claude", false, false, Some("fail".to_string()))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// select_providers 只做“可用性判断”,不应占用 HalfOpen 探测名额
|
|
||||||
let providers = router.select_providers("claude").await.unwrap();
|
let providers = router.select_providers("claude").await.unwrap();
|
||||||
assert_eq!(providers.len(), 2);
|
assert_eq!(providers.len(), 2);
|
||||||
|
|
||||||
// 如果 select_providers 错误地消耗了 HalfOpen 名额,这里会返回 false(被限流拒绝)
|
|
||||||
assert!(router.allow_provider_request("b", "claude").await.allowed);
|
assert!(router.allow_provider_request("b", "claude").await.allowed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -270,6 +270,7 @@ mod tests {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ mod tests {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ mod tests {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ mod tests {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -394,6 +394,7 @@ mod tests {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -258,7 +258,13 @@ impl ProxyService {
|
|||||||
.set_proxy_takeover_enabled(app_type_str, false)
|
.set_proxy_takeover_enabled(app_type_str, false)
|
||||||
.map_err(|e| format!("清除 {app_type_str} 接管状态失败: {e}"))?;
|
.map_err(|e| format!("清除 {app_type_str} 接管状态失败: {e}"))?;
|
||||||
|
|
||||||
// 4) 若无其它接管,更新旧标志,并停止代理服务
|
// 4) 清除该应用的健康状态(关闭代理时重置队列状态)
|
||||||
|
self.db
|
||||||
|
.clear_provider_health_for_app(app_type_str)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("清除 {app_type_str} 健康状态失败: {e}"))?;
|
||||||
|
|
||||||
|
// 5) 若无其它接管,更新旧标志,并停止代理服务
|
||||||
let has_any_backup = self
|
let has_any_backup = self
|
||||||
.db
|
.db
|
||||||
.has_any_live_backup()
|
.has_any_live_backup()
|
||||||
@@ -538,6 +544,7 @@ impl ProxyService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("重置健康状态失败: {e}"))?;
|
.map_err(|e| format!("重置健康状态失败: {e}"))?;
|
||||||
|
|
||||||
|
// 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用
|
||||||
log::info!("代理已停止,Live 配置已恢复");
|
log::info!("代理已停止,Live 配置已恢复");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-14
@@ -72,9 +72,20 @@ function App() {
|
|||||||
"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";
|
"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 { isRunning: isProxyRunning, takeoverStatus } = useProxyStatus();
|
const {
|
||||||
|
isRunning: isProxyRunning,
|
||||||
|
takeoverStatus,
|
||||||
|
status: proxyStatus,
|
||||||
|
} = useProxyStatus();
|
||||||
// 当前应用的代理是否开启
|
// 当前应用的代理是否开启
|
||||||
const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false;
|
const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false;
|
||||||
|
// 当前应用代理实际使用的供应商 ID(从 active_targets 中获取)
|
||||||
|
const activeProviderId = useMemo(() => {
|
||||||
|
const target = proxyStatus?.active_targets?.find(
|
||||||
|
(t) => t.app_type === activeApp,
|
||||||
|
);
|
||||||
|
return target?.provider_id;
|
||||||
|
}, [proxyStatus?.active_targets, activeApp]);
|
||||||
|
|
||||||
// 获取供应商列表,当代理服务运行时自动刷新
|
// 获取供应商列表,当代理服务运行时自动刷新
|
||||||
const { data, isLoading, refetch } = useProvidersQuery(activeApp, {
|
const { data, isLoading, refetch } = useProvidersQuery(activeApp, {
|
||||||
@@ -105,7 +116,7 @@ function App() {
|
|||||||
if (event.appType === activeApp) {
|
if (event.appType === activeApp) {
|
||||||
await refetch();
|
await refetch();
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[App] Failed to subscribe provider switch event", error);
|
console.error("[App] Failed to subscribe provider switch event", error);
|
||||||
@@ -135,7 +146,7 @@ function App() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
"[App] Failed to check environment conflicts on startup:",
|
"[App] Failed to check environment conflicts on startup:",
|
||||||
error
|
error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -151,7 +162,7 @@ function App() {
|
|||||||
if (migrated) {
|
if (migrated) {
|
||||||
toast.success(
|
toast.success(
|
||||||
t("migration.success", { defaultValue: "配置迁移成功" }),
|
t("migration.success", { defaultValue: "配置迁移成功" }),
|
||||||
{ closeButton: true }
|
{ closeButton: true },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -172,10 +183,10 @@ function App() {
|
|||||||
// 合并新检测到的冲突
|
// 合并新检测到的冲突
|
||||||
setEnvConflicts((prev) => {
|
setEnvConflicts((prev) => {
|
||||||
const existingKeys = new Set(
|
const existingKeys = new Set(
|
||||||
prev.map((c) => `${c.varName}:${c.sourcePath}`)
|
prev.map((c) => `${c.varName}:${c.sourcePath}`),
|
||||||
);
|
);
|
||||||
const newConflicts = conflicts.filter(
|
const newConflicts = conflicts.filter(
|
||||||
(c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`)
|
(c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`),
|
||||||
);
|
);
|
||||||
return [...prev, ...newConflicts];
|
return [...prev, ...newConflicts];
|
||||||
});
|
});
|
||||||
@@ -187,7 +198,7 @@ function App() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
"[App] Failed to check environment conflicts on app switch:",
|
"[App] Failed to check environment conflicts on app switch:",
|
||||||
error
|
error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -248,7 +259,7 @@ function App() {
|
|||||||
(p) =>
|
(p) =>
|
||||||
p.sortIndex !== undefined &&
|
p.sortIndex !== undefined &&
|
||||||
p.sortIndex >= newSortIndex! &&
|
p.sortIndex >= newSortIndex! &&
|
||||||
p.id !== provider.id
|
p.id !== provider.id,
|
||||||
)
|
)
|
||||||
.map((p) => ({
|
.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
@@ -264,7 +275,7 @@ function App() {
|
|||||||
toast.error(
|
toast.error(
|
||||||
t("provider.sortUpdateFailed", {
|
t("provider.sortUpdateFailed", {
|
||||||
defaultValue: "排序更新失败",
|
defaultValue: "排序更新失败",
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
return; // 如果排序更新失败,不继续添加
|
return; // 如果排序更新失败,不继续添加
|
||||||
}
|
}
|
||||||
@@ -334,7 +345,9 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "agents":
|
case "agents":
|
||||||
return <AgentsPanel onOpenChange={() => setCurrentView("providers")} />;
|
return (
|
||||||
|
<AgentsPanel onOpenChange={() => setCurrentView("providers")} />
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||||
@@ -355,7 +368,10 @@ function App() {
|
|||||||
appId={activeApp}
|
appId={activeApp}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isProxyRunning={isProxyRunning}
|
isProxyRunning={isProxyRunning}
|
||||||
isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}
|
isProxyTakeover={
|
||||||
|
isProxyRunning && isCurrentAppTakeoverActive
|
||||||
|
}
|
||||||
|
activeProviderId={activeProviderId}
|
||||||
onSwitch={switchProvider}
|
onSwitch={switchProvider}
|
||||||
onEdit={setEditingProvider}
|
onEdit={setEditingProvider}
|
||||||
onDelete={setConfirmDelete}
|
onDelete={setConfirmDelete}
|
||||||
@@ -418,7 +434,7 @@ function App() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
"[App] Failed to re-check conflicts after deletion:",
|
"[App] Failed to re-check conflicts after deletion:",
|
||||||
error
|
error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -475,7 +491,7 @@ function App() {
|
|||||||
"text-xl font-semibold transition-colors",
|
"text-xl font-semibold transition-colors",
|
||||||
isProxyRunning && isCurrentAppTakeoverActive
|
isProxyRunning && isCurrentAppTakeoverActive
|
||||||
? "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300"
|
? "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300"
|
||||||
: "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
: "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
CC Switch
|
CC Switch
|
||||||
@@ -557,7 +573,7 @@ function App() {
|
|||||||
"transition-all duration-200 ease-in-out overflow-hidden",
|
"transition-all duration-200 ease-in-out overflow-hidden",
|
||||||
hasSkillsSupport
|
hasSkillsSupport
|
||||||
? "opacity-100 w-8 scale-100 px-2"
|
? "opacity-100 w-8 scale-100 px-2"
|
||||||
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1"
|
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
|
||||||
)}
|
)}
|
||||||
title={t("skills.manage")}
|
title={t("skills.manage")}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface FailoverPriorityBadgeProps {
|
||||||
|
priority: number; // 1, 2, 3, ...
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 故障转移优先级徽章
|
||||||
|
* 显示供应商在故障转移队列中的优先级顺序
|
||||||
|
*/
|
||||||
|
export function FailoverPriorityBadge({
|
||||||
|
priority,
|
||||||
|
className,
|
||||||
|
}: FailoverPriorityBadgeProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center px-1.5 py-0.5 rounded text-xs font-semibold",
|
||||||
|
"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
title={t("failover.priority.tooltip", {
|
||||||
|
priority,
|
||||||
|
defaultValue: `故障转移优先级 ${priority}`,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
P{priority}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Edit,
|
Edit,
|
||||||
Loader2,
|
Loader2,
|
||||||
Play,
|
Play,
|
||||||
|
Plus,
|
||||||
TestTube2,
|
TestTube2,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -22,6 +23,10 @@ interface ProviderActionsProps {
|
|||||||
onTest?: () => void;
|
onTest?: () => void;
|
||||||
onConfigureUsage: () => void;
|
onConfigureUsage: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
// 故障转移相关
|
||||||
|
isAutoFailoverEnabled?: boolean;
|
||||||
|
isInFailoverQueue?: boolean;
|
||||||
|
onToggleFailover?: (enabled: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderActions({
|
export function ProviderActions({
|
||||||
@@ -34,38 +39,88 @@ export function ProviderActions({
|
|||||||
onTest,
|
onTest,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
// 故障转移相关
|
||||||
|
isAutoFailoverEnabled = false,
|
||||||
|
isInFailoverQueue = false,
|
||||||
|
onToggleFailover,
|
||||||
}: ProviderActionsProps) {
|
}: ProviderActionsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const iconButtonClass = "h-8 w-8 p-1";
|
const iconButtonClass = "h-8 w-8 p-1";
|
||||||
|
|
||||||
|
// 故障转移模式下的按钮逻辑
|
||||||
|
const isFailoverMode = isAutoFailoverEnabled && onToggleFailover;
|
||||||
|
|
||||||
|
// 处理主按钮点击
|
||||||
|
const handleMainButtonClick = () => {
|
||||||
|
if (isFailoverMode) {
|
||||||
|
// 故障转移模式:切换队列状态
|
||||||
|
onToggleFailover(!isInFailoverQueue);
|
||||||
|
} else {
|
||||||
|
// 普通模式:切换供应商
|
||||||
|
onSwitch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 主按钮的状态和样式
|
||||||
|
const getMainButtonState = () => {
|
||||||
|
if (isFailoverMode) {
|
||||||
|
// 故障转移模式
|
||||||
|
if (isInFailoverQueue) {
|
||||||
|
return {
|
||||||
|
disabled: false,
|
||||||
|
variant: "secondary" as const,
|
||||||
|
className:
|
||||||
|
"bg-blue-100 text-blue-600 hover:bg-blue-200 dark:bg-blue-900/50 dark:text-blue-400 dark:hover:bg-blue-900/70",
|
||||||
|
icon: <Check className="h-4 w-4" />,
|
||||||
|
text: t("failover.inQueue", { defaultValue: "已加入" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
disabled: false,
|
||||||
|
variant: "default" as const,
|
||||||
|
className:
|
||||||
|
"bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
|
||||||
|
icon: <Plus className="h-4 w-4" />,
|
||||||
|
text: t("failover.addQueue", { defaultValue: "加入" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通模式
|
||||||
|
if (isCurrent) {
|
||||||
|
return {
|
||||||
|
disabled: true,
|
||||||
|
variant: "secondary" as const,
|
||||||
|
className:
|
||||||
|
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
|
||||||
|
icon: <Check className="h-4 w-4" />,
|
||||||
|
text: t("provider.inUse"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
disabled: false,
|
||||||
|
variant: "default" as const,
|
||||||
|
className: isProxyTakeover
|
||||||
|
? "bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700"
|
||||||
|
: "",
|
||||||
|
icon: <Play className="h-4 w-4" />,
|
||||||
|
text: t("provider.enable"),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonState = getMainButtonState();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={isCurrent ? "secondary" : "default"}
|
variant={buttonState.variant}
|
||||||
onClick={onSwitch}
|
onClick={handleMainButtonClick}
|
||||||
disabled={isCurrent}
|
disabled={buttonState.disabled}
|
||||||
className={cn(
|
className={cn("w-[4.5rem] px-2.5", buttonState.className)}
|
||||||
"w-[4.5rem] px-2.5",
|
|
||||||
isCurrent &&
|
|
||||||
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
|
|
||||||
// 代理接管模式下启用按钮使用绿色
|
|
||||||
!isCurrent &&
|
|
||||||
isProxyTakeover &&
|
|
||||||
"bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{isCurrent ? (
|
{buttonState.icon}
|
||||||
<>
|
{buttonState.text}
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
{t("provider.inUse")}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Play className="h-4 w-4" />
|
|
||||||
{t("provider.enable")}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { ProviderActions } from "@/components/providers/ProviderActions";
|
|||||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||||
import UsageFooter from "@/components/UsageFooter";
|
import UsageFooter from "@/components/UsageFooter";
|
||||||
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
|
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
|
||||||
|
import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge";
|
||||||
import { useProviderHealth } from "@/lib/query/failover";
|
import { useProviderHealth } from "@/lib/query/failover";
|
||||||
import { useUsageQuery } from "@/lib/query/queries";
|
import { useUsageQuery } from "@/lib/query/queries";
|
||||||
|
|
||||||
@@ -36,6 +37,12 @@ interface ProviderCardProps {
|
|||||||
isProxyRunning: boolean;
|
isProxyRunning: boolean;
|
||||||
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
|
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
|
||||||
dragHandleProps?: DragHandleProps;
|
dragHandleProps?: DragHandleProps;
|
||||||
|
// 故障转移相关
|
||||||
|
isAutoFailoverEnabled?: boolean; // 是否开启自动故障转移
|
||||||
|
failoverPriority?: number; // 故障转移优先级(1 = P1, 2 = P2, ...)
|
||||||
|
isInFailoverQueue?: boolean; // 是否在故障转移队列中
|
||||||
|
onToggleFailover?: (enabled: boolean) => void; // 切换故障转移队列
|
||||||
|
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
|
||||||
}
|
}
|
||||||
|
|
||||||
const extractApiUrl = (provider: Provider, fallbackText: string) => {
|
const extractApiUrl = (provider: Provider, fallbackText: string) => {
|
||||||
@@ -88,6 +95,12 @@ export function ProviderCard({
|
|||||||
isProxyRunning,
|
isProxyRunning,
|
||||||
isProxyTakeover = false,
|
isProxyTakeover = false,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
|
// 故障转移相关
|
||||||
|
isAutoFailoverEnabled = false,
|
||||||
|
failoverPriority,
|
||||||
|
isInFailoverQueue = false,
|
||||||
|
onToggleFailover,
|
||||||
|
activeProviderId,
|
||||||
}: ProviderCardProps) {
|
}: ProviderCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -148,21 +161,32 @@ export function ProviderCard({
|
|||||||
onOpenWebsite(displayUrl);
|
onOpenWebsite(displayUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 判断是否是"当前使用中"的供应商
|
||||||
|
// - 故障转移模式:代理实际使用的供应商(activeProviderId)
|
||||||
|
// - 代理接管模式(非故障转移):isCurrent
|
||||||
|
// - 普通模式:isCurrent
|
||||||
|
const isActiveProvider = isAutoFailoverEnabled
|
||||||
|
? activeProviderId === provider.id
|
||||||
|
: isCurrent;
|
||||||
|
|
||||||
|
// 判断是否使用绿色(代理接管模式)还是蓝色(普通模式)
|
||||||
|
const shouldUseGreen = isProxyTakeover && isActiveProvider;
|
||||||
|
const shouldUseBlue = !isProxyTakeover && isActiveProvider;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300",
|
"relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300",
|
||||||
"bg-card text-card-foreground group",
|
"bg-card text-card-foreground group",
|
||||||
// 代理接管模式下 hover 使用绿色边框,否则使用蓝色
|
// hover 时的边框效果
|
||||||
isProxyTakeover
|
isAutoFailoverEnabled || isProxyTakeover
|
||||||
? "hover:border-emerald-500/50"
|
? "hover:border-emerald-500/50"
|
||||||
: "hover:border-border-active",
|
: "hover:border-border-active",
|
||||||
// 代理接管模式下当前供应商使用绿色边框
|
// 当前激活的供应商边框样式
|
||||||
isProxyTakeover && isCurrent
|
shouldUseGreen &&
|
||||||
? "border-emerald-500/60 shadow-sm shadow-emerald-500/10"
|
"border-emerald-500/60 shadow-sm shadow-emerald-500/10",
|
||||||
: isCurrent
|
shouldUseBlue && "border-blue-500/60 shadow-sm shadow-blue-500/10",
|
||||||
? "border-primary/50 shadow-sm"
|
!isActiveProvider && "hover:shadow-sm",
|
||||||
: "hover:shadow-sm",
|
|
||||||
dragHandleProps?.isDragging &&
|
dragHandleProps?.isDragging &&
|
||||||
"cursor-grabbing border-primary shadow-lg scale-105 z-10",
|
"cursor-grabbing border-primary shadow-lg scale-105 z-10",
|
||||||
)}
|
)}
|
||||||
@@ -170,11 +194,11 @@ export function ProviderCard({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none",
|
"absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none",
|
||||||
// 代理接管模式下使用绿色渐变,否则使用蓝色主色调
|
// 代理接管模式使用绿色渐变,普通模式使用蓝色渐变
|
||||||
isProxyTakeover && isCurrent
|
shouldUseGreen && "from-emerald-500/10",
|
||||||
? "from-emerald-500/10"
|
shouldUseBlue && "from-blue-500/10",
|
||||||
: "from-primary/10",
|
!isActiveProvider && "from-primary/10",
|
||||||
isCurrent ? "opacity-100" : "opacity-0",
|
isActiveProvider ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
@@ -209,13 +233,20 @@ export function ProviderCard({
|
|||||||
{provider.name}
|
{provider.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* 健康状态徽章和优先级 */}
|
{/* 健康状态徽章 */}
|
||||||
{isProxyRunning && health && (
|
{isProxyRunning && isInFailoverQueue && health && (
|
||||||
<ProviderHealthBadge
|
<ProviderHealthBadge
|
||||||
consecutiveFailures={health.consecutive_failures}
|
consecutiveFailures={health.consecutive_failures}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 故障转移优先级徽章 */}
|
||||||
|
{isAutoFailoverEnabled &&
|
||||||
|
isInFailoverQueue &&
|
||||||
|
failoverPriority && (
|
||||||
|
<FailoverPriorityBadge priority={failoverPriority} />
|
||||||
|
)}
|
||||||
|
|
||||||
{provider.category === "third_party" &&
|
{provider.category === "third_party" &&
|
||||||
provider.meta?.isPartner && (
|
provider.meta?.isPartner && (
|
||||||
<span
|
<span
|
||||||
@@ -308,6 +339,10 @@ export function ProviderCard({
|
|||||||
onTest={onTest ? () => onTest(provider) : undefined}
|
onTest={onTest ? () => onTest(provider) : undefined}
|
||||||
onConfigureUsage={() => onConfigureUsage(provider)}
|
onConfigureUsage={() => onConfigureUsage(provider)}
|
||||||
onDelete={() => onDelete(provider)}
|
onDelete={() => onDelete(provider)}
|
||||||
|
// 故障转移相关
|
||||||
|
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
||||||
|
isInFailoverQueue={isInFailoverQueue}
|
||||||
|
onToggleFailover={onToggleFailover}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ import { useDragSort } from "@/hooks/useDragSort";
|
|||||||
import { useStreamCheck } from "@/hooks/useStreamCheck";
|
import { useStreamCheck } from "@/hooks/useStreamCheck";
|
||||||
import { ProviderCard } from "@/components/providers/ProviderCard";
|
import { ProviderCard } from "@/components/providers/ProviderCard";
|
||||||
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
|
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
|
||||||
|
import {
|
||||||
|
useAutoFailoverEnabled,
|
||||||
|
useFailoverQueue,
|
||||||
|
useAddToFailoverQueue,
|
||||||
|
useRemoveFromFailoverQueue,
|
||||||
|
} from "@/lib/query/failover";
|
||||||
|
import { useCallback } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
@@ -38,6 +45,7 @@ interface ProviderListProps {
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isProxyRunning?: boolean; // 代理服务运行状态
|
isProxyRunning?: boolean; // 代理服务运行状态
|
||||||
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管)
|
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管)
|
||||||
|
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderList({
|
export function ProviderList({
|
||||||
@@ -52,18 +60,62 @@ export function ProviderList({
|
|||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
onCreate,
|
onCreate,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isProxyRunning = false, // 默认值为 false
|
isProxyRunning = false,
|
||||||
isProxyTakeover = false, // 默认值为 false
|
isProxyTakeover = false,
|
||||||
|
activeProviderId,
|
||||||
}: ProviderListProps) {
|
}: ProviderListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
|
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
|
||||||
providers,
|
providers,
|
||||||
appId
|
appId,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 流式健康检查
|
// 流式健康检查
|
||||||
const { checkProvider, isChecking } = useStreamCheck(appId);
|
const { checkProvider, isChecking } = useStreamCheck(appId);
|
||||||
|
|
||||||
|
// 故障转移相关
|
||||||
|
const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId);
|
||||||
|
const { data: failoverQueue } = useFailoverQueue(appId);
|
||||||
|
const addToQueue = useAddToFailoverQueue();
|
||||||
|
const removeFromQueue = useRemoveFromFailoverQueue();
|
||||||
|
|
||||||
|
// 联动状态:只有当前应用开启代理接管且故障转移开启时才启用故障转移模式
|
||||||
|
const isFailoverModeActive =
|
||||||
|
isProxyTakeover === true && isAutoFailoverEnabled === true;
|
||||||
|
|
||||||
|
// 计算供应商在故障转移队列中的优先级(基于 sortIndex 排序)
|
||||||
|
const getFailoverPriority = useCallback(
|
||||||
|
(providerId: string): number | undefined => {
|
||||||
|
if (!isFailoverModeActive || !failoverQueue) return undefined;
|
||||||
|
const index = failoverQueue.findIndex(
|
||||||
|
(item) => item.providerId === providerId,
|
||||||
|
);
|
||||||
|
return index >= 0 ? index + 1 : undefined;
|
||||||
|
},
|
||||||
|
[isFailoverModeActive, failoverQueue],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 判断供应商是否在故障转移队列中
|
||||||
|
const isInFailoverQueue = useCallback(
|
||||||
|
(providerId: string): boolean => {
|
||||||
|
if (!isFailoverModeActive || !failoverQueue) return false;
|
||||||
|
return failoverQueue.some((item) => item.providerId === providerId);
|
||||||
|
},
|
||||||
|
[isFailoverModeActive, failoverQueue],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 切换供应商的故障转移队列状态
|
||||||
|
const handleToggleFailover = useCallback(
|
||||||
|
(providerId: string, enabled: boolean) => {
|
||||||
|
if (enabled) {
|
||||||
|
addToQueue.mutate({ appType: appId, providerId });
|
||||||
|
} else {
|
||||||
|
removeFromQueue.mutate({ appType: appId, providerId });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[appId, addToQueue, removeFromQueue],
|
||||||
|
);
|
||||||
|
|
||||||
const handleTest = (provider: Provider) => {
|
const handleTest = (provider: Provider) => {
|
||||||
checkProvider(provider.id, provider.name);
|
checkProvider(provider.id, provider.name);
|
||||||
};
|
};
|
||||||
@@ -106,7 +158,7 @@ export function ProviderList({
|
|||||||
return sortedProviders.filter((provider) => {
|
return sortedProviders.filter((provider) => {
|
||||||
const fields = [provider.name, provider.notes, provider.websiteUrl];
|
const fields = [provider.name, provider.notes, provider.websiteUrl];
|
||||||
return fields.some((field) =>
|
return fields.some((field) =>
|
||||||
field?.toString().toLowerCase().includes(keyword)
|
field?.toString().toLowerCase().includes(keyword),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [searchTerm, sortedProviders]);
|
}, [searchTerm, sortedProviders]);
|
||||||
@@ -155,6 +207,14 @@ export function ProviderList({
|
|||||||
isTesting={isChecking(provider.id)}
|
isTesting={isChecking(provider.id)}
|
||||||
isProxyRunning={isProxyRunning}
|
isProxyRunning={isProxyRunning}
|
||||||
isProxyTakeover={isProxyTakeover}
|
isProxyTakeover={isProxyTakeover}
|
||||||
|
// 故障转移相关:联动状态
|
||||||
|
isAutoFailoverEnabled={isFailoverModeActive}
|
||||||
|
failoverPriority={getFailoverPriority(provider.id)}
|
||||||
|
isInFailoverQueue={isInFailoverQueue(provider.id)}
|
||||||
|
onToggleFailover={(enabled) =>
|
||||||
|
handleToggleFailover(provider.id, enabled)
|
||||||
|
}
|
||||||
|
activeProviderId={activeProviderId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -255,6 +315,12 @@ interface SortableProviderCardProps {
|
|||||||
isTesting: boolean;
|
isTesting: boolean;
|
||||||
isProxyRunning: boolean;
|
isProxyRunning: boolean;
|
||||||
isProxyTakeover: boolean;
|
isProxyTakeover: boolean;
|
||||||
|
// 故障转移相关
|
||||||
|
isAutoFailoverEnabled: boolean;
|
||||||
|
failoverPriority?: number;
|
||||||
|
isInFailoverQueue: boolean;
|
||||||
|
onToggleFailover: (enabled: boolean) => void;
|
||||||
|
activeProviderId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableProviderCard({
|
function SortableProviderCard({
|
||||||
@@ -271,6 +337,11 @@ function SortableProviderCard({
|
|||||||
isTesting,
|
isTesting,
|
||||||
isProxyRunning,
|
isProxyRunning,
|
||||||
isProxyTakeover,
|
isProxyTakeover,
|
||||||
|
isAutoFailoverEnabled,
|
||||||
|
failoverPriority,
|
||||||
|
isInFailoverQueue,
|
||||||
|
onToggleFailover,
|
||||||
|
activeProviderId,
|
||||||
}: SortableProviderCardProps) {
|
}: SortableProviderCardProps) {
|
||||||
const {
|
const {
|
||||||
setNodeRef,
|
setNodeRef,
|
||||||
@@ -309,6 +380,12 @@ function SortableProviderCard({
|
|||||||
listeners,
|
listeners,
|
||||||
isDragging,
|
isDragging,
|
||||||
}}
|
}}
|
||||||
|
// 故障转移相关
|
||||||
|
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
||||||
|
failoverPriority={failoverPriority}
|
||||||
|
isInFailoverQueue={isInFailoverQueue}
|
||||||
|
onToggleFailover={onToggleFailover}
|
||||||
|
activeProviderId={activeProviderId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ import {
|
|||||||
} from "@/lib/query/failover";
|
} from "@/lib/query/failover";
|
||||||
|
|
||||||
export interface AutoFailoverConfigPanelProps {
|
export interface AutoFailoverConfigPanelProps {
|
||||||
enabled: boolean;
|
enabled?: boolean;
|
||||||
onEnabledChange: (enabled: boolean) => void;
|
onEnabledChange?: (enabled: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AutoFailoverConfigPanel({
|
export function AutoFailoverConfigPanel({
|
||||||
enabled,
|
enabled = true,
|
||||||
onEnabledChange: _onEnabledChange,
|
onEnabledChange: _onEnabledChange,
|
||||||
}: AutoFailoverConfigPanelProps) {
|
}: AutoFailoverConfigPanelProps = {}) {
|
||||||
// Note: onEnabledChange is currently unused but kept in the interface
|
// Note: onEnabledChange is currently unused but kept in the interface
|
||||||
// for potential future use by parent components
|
// for potential future use by parent components
|
||||||
void _onEnabledChange;
|
void _onEnabledChange;
|
||||||
|
|||||||
@@ -2,37 +2,14 @@
|
|||||||
* 故障转移队列管理组件
|
* 故障转移队列管理组件
|
||||||
*
|
*
|
||||||
* 允许用户管理代理模式下的故障转移队列,支持:
|
* 允许用户管理代理模式下的故障转移队列,支持:
|
||||||
* - 拖拽排序
|
|
||||||
* - 添加/移除供应商
|
* - 添加/移除供应商
|
||||||
* - 启用/禁用队列项
|
* - 队列顺序基于首页供应商列表的 sort_index
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { toast } from "sonner";
|
||||||
import {
|
import { Plus, Trash2, Loader2, Info, AlertTriangle } from "lucide-react";
|
||||||
GripVertical,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
Loader2,
|
|
||||||
Info,
|
|
||||||
AlertTriangle,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
@@ -51,8 +28,8 @@ import {
|
|||||||
useAvailableProvidersForFailover,
|
useAvailableProvidersForFailover,
|
||||||
useAddToFailoverQueue,
|
useAddToFailoverQueue,
|
||||||
useRemoveFromFailoverQueue,
|
useRemoveFromFailoverQueue,
|
||||||
useReorderFailoverQueue,
|
useAutoFailoverEnabled,
|
||||||
useSetFailoverItemEnabled,
|
useSetAutoFailoverEnabled,
|
||||||
} from "@/lib/query/failover";
|
} from "@/lib/query/failover";
|
||||||
|
|
||||||
interface FailoverQueueManagerProps {
|
interface FailoverQueueManagerProps {
|
||||||
@@ -67,6 +44,10 @@ export function FailoverQueueManager({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [selectedProviderId, setSelectedProviderId] = useState<string>("");
|
const [selectedProviderId, setSelectedProviderId] = useState<string>("");
|
||||||
|
|
||||||
|
// 故障转移开关状态(每个应用独立)
|
||||||
|
const { data: isFailoverEnabled = false } = useAutoFailoverEnabled(appType);
|
||||||
|
const setFailoverEnabled = useSetAutoFailoverEnabled();
|
||||||
|
|
||||||
// 查询数据
|
// 查询数据
|
||||||
const {
|
const {
|
||||||
data: queue,
|
data: queue,
|
||||||
@@ -79,59 +60,11 @@ export function FailoverQueueManager({
|
|||||||
// Mutations
|
// Mutations
|
||||||
const addToQueue = useAddToFailoverQueue();
|
const addToQueue = useAddToFailoverQueue();
|
||||||
const removeFromQueue = useRemoveFromFailoverQueue();
|
const removeFromQueue = useRemoveFromFailoverQueue();
|
||||||
const reorderQueue = useReorderFailoverQueue();
|
|
||||||
const setItemEnabled = useSetFailoverItemEnabled();
|
|
||||||
|
|
||||||
// 拖拽配置
|
// 切换故障转移开关
|
||||||
const sensors = useSensors(
|
const handleToggleFailover = (enabled: boolean) => {
|
||||||
useSensor(PointerSensor, {
|
setFailoverEnabled.mutate({ appType, enabled });
|
||||||
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", "队列顺序已更新"),
|
|
||||||
{ closeButton: true },
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
t("proxy.failoverQueue.reorderFailed", "更新顺序失败") +
|
|
||||||
": " +
|
|
||||||
String(error),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[sortedQueue, appType, reorderQueue, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 添加供应商到队列
|
// 添加供应商到队列
|
||||||
const handleAddProvider = async () => {
|
const handleAddProvider = async () => {
|
||||||
@@ -171,19 +104,6 @@ export function FailoverQueueManager({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 切换启用状态
|
|
||||||
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) {
|
if (isQueueLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
@@ -203,13 +123,41 @@ export function FailoverQueueManager({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* 自动故障转移开关 */}
|
||||||
|
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50 border border-border/50">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{t("proxy.failover.autoSwitch", {
|
||||||
|
defaultValue: "自动故障转移",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{isFailoverEnabled && (
|
||||||
|
<span className="px-2 py-0.5 text-xs rounded-full bg-emerald-500/20 text-emerald-600 dark:text-emerald-400">
|
||||||
|
{t("common.enabled", { defaultValue: "已开启" })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("proxy.failover.autoSwitchDescription", {
|
||||||
|
defaultValue: "开启后,请求失败时自动切换到队列中的下一个供应商",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={isFailoverEnabled}
|
||||||
|
onCheckedChange={handleToggleFailover}
|
||||||
|
disabled={disabled || setFailoverEnabled.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 说明信息 */}
|
{/* 说明信息 */}
|
||||||
<Alert className="border-blue-500/40 bg-blue-500/10">
|
<Alert className="border-blue-500/40 bg-blue-500/10">
|
||||||
<Info className="h-4 w-4" />
|
<Info className="h-4 w-4" />
|
||||||
<AlertDescription className="text-sm">
|
<AlertDescription className="text-sm">
|
||||||
{t(
|
{t(
|
||||||
"proxy.failoverQueue.info",
|
"proxy.failoverQueue.info",
|
||||||
"当前激活的供应商始终优先。当请求失败时,系统会按队列顺序依次尝试其他供应商。",
|
"队列顺序与首页供应商列表顺序一致。当请求失败时,系统会按顺序依次尝试队列中的供应商。",
|
||||||
)}
|
)}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -260,7 +208,7 @@ export function FailoverQueueManager({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 队列列表 */}
|
{/* 队列列表 */}
|
||||||
{sortedQueue.length === 0 ? (
|
{!queue || queue.length === 0 ? (
|
||||||
<div className="rounded-lg border border-dashed border-muted-foreground/40 p-8 text-center">
|
<div className="rounded-lg border border-dashed border-muted-foreground/40 p-8 text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
@@ -270,39 +218,26 @@ export function FailoverQueueManager({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DndContext
|
<div className="space-y-2">
|
||||||
sensors={sensors}
|
{queue.map((item, index) => (
|
||||||
collisionDetection={closestCenter}
|
<QueueItem
|
||||||
onDragEnd={handleDragEnd}
|
key={item.providerId}
|
||||||
>
|
item={item}
|
||||||
<SortableContext
|
index={index}
|
||||||
items={sortedQueue.map((item) => item.providerId)}
|
disabled={disabled}
|
||||||
strategy={verticalListSortingStrategy}
|
onRemove={handleRemoveProvider}
|
||||||
>
|
isRemoving={removeFromQueue.isPending}
|
||||||
<div className="space-y-2">
|
/>
|
||||||
{sortedQueue.map((item, index) => (
|
))}
|
||||||
<SortableQueueItem
|
</div>
|
||||||
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 && (
|
{queue && queue.length > 0 && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
"proxy.failoverQueue.dragHint",
|
"proxy.failoverQueue.orderHint",
|
||||||
"拖拽供应商可调整故障转移顺序,序号越小优先级越高。",
|
"队列顺序与首页供应商列表顺序一致,可在首页拖拽调整顺序。",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -310,65 +245,29 @@ export function FailoverQueueManager({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SortableQueueItemProps {
|
interface QueueItemProps {
|
||||||
item: FailoverQueueItem;
|
item: FailoverQueueItem;
|
||||||
index: number;
|
index: number;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
onToggleEnabled: (providerId: string, enabled: boolean) => void;
|
|
||||||
onRemove: (providerId: string) => void;
|
onRemove: (providerId: string) => void;
|
||||||
isRemoving: boolean;
|
isRemoving: boolean;
|
||||||
isToggling: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableQueueItem({
|
function QueueItem({
|
||||||
item,
|
item,
|
||||||
index,
|
index,
|
||||||
disabled,
|
disabled,
|
||||||
onToggleEnabled,
|
|
||||||
onRemove,
|
onRemove,
|
||||||
isRemoving,
|
isRemoving,
|
||||||
isToggling,
|
}: QueueItemProps) {
|
||||||
}: SortableQueueItemProps) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {
|
|
||||||
setNodeRef,
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({ id: item.providerId, disabled });
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-lg border bg-card p-3 transition-colors",
|
"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">
|
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-medium">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
@@ -376,24 +275,11 @@ function SortableQueueItem({
|
|||||||
|
|
||||||
{/* 供应商名称 */}
|
{/* 供应商名称 */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span
|
<span className="text-sm font-medium truncate block">
|
||||||
className={cn(
|
|
||||||
"text-sm font-medium truncate block",
|
|
||||||
!item.enabled && "text-muted-foreground line-through",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.providerName}
|
{item.providerName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 启用开关 */}
|
|
||||||
<Switch
|
|
||||||
checked={item.enabled}
|
|
||||||
onCheckedChange={(checked) => onToggleEnabled(item.providerId, checked)}
|
|
||||||
disabled={disabled || isToggling}
|
|
||||||
aria-label={t("proxy.failoverQueue.toggleEnabled", "启用/禁用")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 删除按钮 */}
|
{/* 删除按钮 */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -147,13 +147,10 @@ export function ProxyPanel() {
|
|||||||
<ProviderQueueGroup
|
<ProviderQueueGroup
|
||||||
appType="claude"
|
appType="claude"
|
||||||
appLabel="Claude"
|
appLabel="Claude"
|
||||||
targets={claudeQueue
|
targets={claudeQueue.map((item) => ({
|
||||||
.filter((item) => item.enabled)
|
id: item.providerId,
|
||||||
.sort((a, b) => a.queueOrder - b.queueOrder)
|
name: item.providerName,
|
||||||
.map((item) => ({
|
}))}
|
||||||
id: item.providerId,
|
|
||||||
name: item.providerName,
|
|
||||||
}))}
|
|
||||||
status={status}
|
status={status}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -163,13 +160,10 @@ export function ProxyPanel() {
|
|||||||
<ProviderQueueGroup
|
<ProviderQueueGroup
|
||||||
appType="codex"
|
appType="codex"
|
||||||
appLabel="Codex"
|
appLabel="Codex"
|
||||||
targets={codexQueue
|
targets={codexQueue.map((item) => ({
|
||||||
.filter((item) => item.enabled)
|
id: item.providerId,
|
||||||
.sort((a, b) => a.queueOrder - b.queueOrder)
|
name: item.providerName,
|
||||||
.map((item) => ({
|
}))}
|
||||||
id: item.providerId,
|
|
||||||
name: item.providerName,
|
|
||||||
}))}
|
|
||||||
status={status}
|
status={status}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -179,13 +173,10 @@ export function ProxyPanel() {
|
|||||||
<ProviderQueueGroup
|
<ProviderQueueGroup
|
||||||
appType="gemini"
|
appType="gemini"
|
||||||
appLabel="Gemini"
|
appLabel="Gemini"
|
||||||
targets={geminiQueue
|
targets={geminiQueue.map((item) => ({
|
||||||
.filter((item) => item.enabled)
|
id: item.providerId,
|
||||||
.sort((a, b) => a.queueOrder - b.queueOrder)
|
name: item.providerName,
|
||||||
.map((item) => ({
|
}))}
|
||||||
id: item.providerId,
|
|
||||||
name: item.providerName,
|
|
||||||
}))}
|
|
||||||
status={status}
|
status={status}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -47,10 +47,6 @@ import type { SettingsFormState } from "@/hooks/useSettings";
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||||
import {
|
|
||||||
useAutoFailoverEnabled,
|
|
||||||
useSetAutoFailoverEnabled,
|
|
||||||
} from "@/lib/query/failover";
|
|
||||||
|
|
||||||
interface SettingsDialogProps {
|
interface SettingsDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -187,17 +183,9 @@ export function SettingsPage({
|
|||||||
isPending: isProxyPending,
|
isPending: isProxyPending,
|
||||||
} = useProxyStatus();
|
} = useProxyStatus();
|
||||||
|
|
||||||
// 使用持久化的自动故障转移开关状态
|
|
||||||
const { data: failoverEnabled = false } = useAutoFailoverEnabled();
|
|
||||||
const setAutoFailoverEnabled = useSetAutoFailoverEnabled();
|
|
||||||
|
|
||||||
const handleToggleProxy = async (checked: boolean) => {
|
const handleToggleProxy = async (checked: boolean) => {
|
||||||
try {
|
try {
|
||||||
if (!checked) {
|
if (!checked) {
|
||||||
// 关闭代理时,同时关闭故障转移
|
|
||||||
if (failoverEnabled) {
|
|
||||||
setAutoFailoverEnabled.mutate(false);
|
|
||||||
}
|
|
||||||
await stopWithRestore();
|
await stopWithRestore();
|
||||||
} else {
|
} else {
|
||||||
await startProxyServer();
|
await startProxyServer();
|
||||||
@@ -207,19 +195,6 @@ export function SettingsPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理故障转移开关:开启时自动启动代理
|
|
||||||
const handleToggleFailover = async (checked: boolean) => {
|
|
||||||
try {
|
|
||||||
if (checked && !isRunning) {
|
|
||||||
// 开启故障转移时,先启动代理
|
|
||||||
await startProxyServer();
|
|
||||||
}
|
|
||||||
setAutoFailoverEnabled.mutate(checked);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Toggle failover failed:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] overflow-hidden px-6">
|
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] overflow-hidden px-6">
|
||||||
{isBusy ? (
|
{isBusy ? (
|
||||||
@@ -380,37 +355,36 @@ export function SettingsPage({
|
|||||||
|
|
||||||
<AccordionItem
|
<AccordionItem
|
||||||
value="failover"
|
value="failover"
|
||||||
className="rounded-xl glass-card overflow-hidden [&[data-state=open]>.accordion-header]:bg-muted/50"
|
className="rounded-xl glass-card overflow-hidden"
|
||||||
>
|
>
|
||||||
<AccordionPrimitive.Header className="accordion-header flex items-center justify-between px-6 py-4 hover:bg-muted/50">
|
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||||
<AccordionPrimitive.Trigger className="flex flex-1 items-center justify-between hover:no-underline [&[data-state=open]>svg]:rotate-180">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<Activity className="h-5 w-5 text-orange-500" />
|
||||||
<Activity className="h-5 w-5 text-orange-500" />
|
<div className="text-left">
|
||||||
<div className="text-left">
|
<h3 className="text-base font-semibold">
|
||||||
<h3 className="text-base font-semibold">
|
{t("settings.advanced.failover.title")}
|
||||||
{t("settings.advanced.failover.title")}
|
</h3>
|
||||||
</h3>
|
<p className="text-sm text-muted-foreground font-normal">
|
||||||
<p className="text-sm text-muted-foreground font-normal">
|
{t("settings.advanced.failover.description")}
|
||||||
{t("settings.advanced.failover.description")}
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
|
||||||
</AccordionPrimitive.Trigger>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 pl-4">
|
|
||||||
<Switch
|
|
||||||
checked={failoverEnabled && isRunning}
|
|
||||||
onCheckedChange={handleToggleFailover}
|
|
||||||
disabled={
|
|
||||||
setAutoFailoverEnabled.isPending || isProxyPending
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</AccordionPrimitive.Header>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 故障转移队列管理 */}
|
{/* 代理未运行时的提示 */}
|
||||||
|
{!isRunning && (
|
||||||
|
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||||
|
<p className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||||
|
{t("proxy.failover.proxyRequired", {
|
||||||
|
defaultValue:
|
||||||
|
"需要先启动代理服务才能配置故障转移",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 故障转移队列管理 - 每个应用独立 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold">
|
<h4 className="text-sm font-semibold">
|
||||||
@@ -429,30 +403,27 @@ export function SettingsPage({
|
|||||||
<TabsContent value="claude" className="mt-4">
|
<TabsContent value="claude" className="mt-4">
|
||||||
<FailoverQueueManager
|
<FailoverQueueManager
|
||||||
appType="claude"
|
appType="claude"
|
||||||
disabled={!failoverEnabled || !isRunning}
|
disabled={!isRunning}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="codex" className="mt-4">
|
<TabsContent value="codex" className="mt-4">
|
||||||
<FailoverQueueManager
|
<FailoverQueueManager
|
||||||
appType="codex"
|
appType="codex"
|
||||||
disabled={!failoverEnabled || !isRunning}
|
disabled={!isRunning}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="gemini" className="mt-4">
|
<TabsContent value="gemini" className="mt-4">
|
||||||
<FailoverQueueManager
|
<FailoverQueueManager
|
||||||
appType="gemini"
|
appType="gemini"
|
||||||
disabled={!failoverEnabled || !isRunning}
|
disabled={!isRunning}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 熔断器配置 */}
|
{/* 熔断器配置 - 全局共享 */}
|
||||||
<div className="border-t border-border/50 pt-6">
|
<div className="border-t border-border/50 pt-6">
|
||||||
<AutoFailoverConfigPanel
|
<AutoFailoverConfigPanel />
|
||||||
enabled={failoverEnabled && isRunning}
|
|
||||||
onEnabledChange={handleToggleFailover}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
|
|||||||
@@ -75,6 +75,11 @@ export function useDragSort(providers: Record<string, Provider>, appId: AppId) {
|
|||||||
queryKey: ["providers", appId],
|
queryKey: ["providers", appId],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 刷新故障转移队列(因为队列顺序依赖 sort_index)
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["failoverQueue", appId],
|
||||||
|
});
|
||||||
|
|
||||||
// 更新托盘菜单以反映新的排序(失败不影响主操作)
|
// 更新托盘菜单以反映新的排序(失败不影响主操作)
|
||||||
try {
|
try {
|
||||||
await providersApi.updateTrayMenu();
|
await providersApi.updateTrayMenu();
|
||||||
|
|||||||
@@ -73,8 +73,11 @@ export function useProxyStatus() {
|
|||||||
);
|
);
|
||||||
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
|
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
|
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
|
||||||
// 清除所有供应商健康状态缓存(后端已清空数据库记录)
|
// 彻底删除所有供应商健康状态缓存(后端已清空数据库记录)
|
||||||
queryClient.invalidateQueries({ queryKey: ["providerHealth"] });
|
queryClient.removeQueries({ queryKey: ["providerHealth"] });
|
||||||
|
// 彻底删除所有熔断器统计缓存(代理停止后熔断器状态已重置)
|
||||||
|
queryClient.removeQueries({ queryKey: ["circuitBreakerStats"] });
|
||||||
|
// 注意:故障转移队列和开关状态会保留,不需要刷新
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
const detail =
|
const detail =
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import {
|
|||||||
type StreamCheckResult,
|
type StreamCheckResult,
|
||||||
} from "@/lib/api/model-test";
|
} from "@/lib/api/model-test";
|
||||||
import type { AppId } from "@/lib/api";
|
import type { AppId } from "@/lib/api";
|
||||||
|
import { useResetCircuitBreaker } from "@/lib/query/failover";
|
||||||
|
|
||||||
export function useStreamCheck(appId: AppId) {
|
export function useStreamCheck(appId: AppId) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [checkingIds, setCheckingIds] = useState<Set<string>>(new Set());
|
const [checkingIds, setCheckingIds] = useState<Set<string>>(new Set());
|
||||||
|
const resetCircuitBreaker = useResetCircuitBreaker();
|
||||||
|
|
||||||
const checkProvider = useCallback(
|
const checkProvider = useCallback(
|
||||||
async (
|
async (
|
||||||
@@ -30,6 +32,9 @@ export function useStreamCheck(appId: AppId) {
|
|||||||
}),
|
}),
|
||||||
{ closeButton: true },
|
{ closeButton: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 测试通过后重置熔断器状态
|
||||||
|
resetCircuitBreaker.mutate({ providerId, appType: appId });
|
||||||
} else if (result.status === "degraded") {
|
} else if (result.status === "degraded") {
|
||||||
toast.warning(
|
toast.warning(
|
||||||
t("streamCheck.degraded", {
|
t("streamCheck.degraded", {
|
||||||
@@ -38,6 +43,9 @@ export function useStreamCheck(appId: AppId) {
|
|||||||
defaultValue: `${providerName} 响应较慢 (${result.responseTimeMs}ms)`,
|
defaultValue: `${providerName} 响应较慢 (${result.responseTimeMs}ms)`,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 降级状态也重置熔断器,因为至少能通信
|
||||||
|
resetCircuitBreaker.mutate({ providerId, appType: appId });
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
t("streamCheck.failed", {
|
t("streamCheck.failed", {
|
||||||
@@ -66,7 +74,7 @@ export function useStreamCheck(appId: AppId) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[appId, t],
|
[appId, t, resetCircuitBreaker],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isChecking = useCallback(
|
const isChecking = useCallback(
|
||||||
|
|||||||
+6
-24
@@ -84,34 +84,16 @@ export const failoverApi = {
|
|||||||
return invoke("remove_from_failover_queue", { appType, providerId });
|
return invoke("remove_from_failover_queue", { appType, providerId });
|
||||||
},
|
},
|
||||||
|
|
||||||
// 重新排序故障转移队列
|
// 获取指定应用的自动故障转移开关状态
|
||||||
async reorderFailoverQueue(
|
async getAutoFailoverEnabled(appType: string): Promise<boolean> {
|
||||||
appType: string,
|
return invoke("get_auto_failover_enabled", { appType });
|
||||||
providerIds: string[],
|
|
||||||
): Promise<void> {
|
|
||||||
return invoke("reorder_failover_queue", { appType, providerIds });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 设置故障转移队列项的启用状态
|
// 设置指定应用的自动故障转移开关状态
|
||||||
async setFailoverItemEnabled(
|
async setAutoFailoverEnabled(
|
||||||
appType: string,
|
appType: string,
|
||||||
providerId: string,
|
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return invoke("set_failover_item_enabled", {
|
return invoke("set_auto_failover_enabled", { appType, enabled });
|
||||||
appType,
|
|
||||||
providerId,
|
|
||||||
enabled,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取自动故障转移总开关状态
|
|
||||||
async getAutoFailoverEnabled(): Promise<boolean> {
|
|
||||||
return invoke("get_auto_failover_enabled");
|
|
||||||
},
|
|
||||||
|
|
||||||
// 设置自动故障转移总开关状态
|
|
||||||
async setAutoFailoverEnabled(enabled: boolean): Promise<void> {
|
|
||||||
return invoke("set_auto_failover_enabled", { enabled });
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+46
-99
@@ -18,6 +18,9 @@ export function useProviderHealth(providerId: string, appType: string) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 重置熔断器
|
* 重置熔断器
|
||||||
|
*
|
||||||
|
* 重置后后端会检查是否应该切回优先级更高的供应商,
|
||||||
|
* 因此需要同时刷新供应商列表和代理状态。
|
||||||
*/
|
*/
|
||||||
export function useResetCircuitBreaker() {
|
export function useResetCircuitBreaker() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -35,6 +38,14 @@ export function useResetCircuitBreaker() {
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["providerHealth", variables.providerId, variables.appType],
|
queryKey: ["providerHealth", variables.providerId, variables.appType],
|
||||||
});
|
});
|
||||||
|
// 刷新供应商列表(因为可能发生了自动恢复切换)
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["providers", variables.appType],
|
||||||
|
});
|
||||||
|
// 刷新代理状态(更新 active_targets)
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["proxyStatus"],
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -120,6 +131,9 @@ export function useAddToFailoverQueue() {
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["availableProvidersForFailover", variables.appType],
|
queryKey: ["availableProvidersForFailover", variables.appType],
|
||||||
});
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["providers", variables.appType],
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -145,146 +159,79 @@ export function useRemoveFromFailoverQueue() {
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["availableProvidersForFailover", variables.appType],
|
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({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["failoverQueue", variables.appType],
|
queryKey: ["providers", variables.appType],
|
||||||
});
|
});
|
||||||
},
|
// 清除该供应商的健康状态缓存(退出队列后不再需要健康监控)
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置故障转移队列项的启用状态
|
|
||||||
* 使用乐观更新(Optimistic Update)以提供即时反馈
|
|
||||||
*/
|
|
||||||
export function useSetFailoverItemEnabled() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
appType,
|
|
||||||
providerId,
|
|
||||||
enabled,
|
|
||||||
}: {
|
|
||||||
appType: string;
|
|
||||||
providerId: string;
|
|
||||||
enabled: boolean;
|
|
||||||
}) => failoverApi.setFailoverItemEnabled(appType, providerId, enabled),
|
|
||||||
|
|
||||||
// 乐观更新:立即更新缓存中的数据
|
|
||||||
onMutate: async (variables) => {
|
|
||||||
// 取消正在进行的查询,防止覆盖乐观更新
|
|
||||||
await queryClient.cancelQueries({
|
|
||||||
queryKey: ["failoverQueue", variables.appType],
|
|
||||||
});
|
|
||||||
|
|
||||||
// 保存之前的数据以便回滚
|
|
||||||
const previousQueue = queryClient.getQueryData<
|
|
||||||
import("@/types/proxy").FailoverQueueItem[]
|
|
||||||
>(["failoverQueue", variables.appType]);
|
|
||||||
|
|
||||||
// 乐观地更新缓存
|
|
||||||
if (previousQueue) {
|
|
||||||
queryClient.setQueryData<import("@/types/proxy").FailoverQueueItem[]>(
|
|
||||||
["failoverQueue", variables.appType],
|
|
||||||
previousQueue.map((item) =>
|
|
||||||
item.providerId === variables.providerId
|
|
||||||
? { ...item, enabled: variables.enabled }
|
|
||||||
: item,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 返回上下文供 onError 使用
|
|
||||||
return { previousQueue };
|
|
||||||
},
|
|
||||||
|
|
||||||
// 错误时回滚
|
|
||||||
onError: (_error, variables, context) => {
|
|
||||||
if (context?.previousQueue) {
|
|
||||||
queryClient.setQueryData(
|
|
||||||
["failoverQueue", variables.appType],
|
|
||||||
context.previousQueue,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// 无论成功失败,都重新获取最新数据以确保一致性
|
|
||||||
onSettled: (_, __, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["failoverQueue", variables.appType],
|
queryKey: ["providerHealth", variables.providerId, variables.appType],
|
||||||
|
});
|
||||||
|
// 清除该供应商的熔断器统计缓存
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [
|
||||||
|
"circuitBreakerStats",
|
||||||
|
variables.providerId,
|
||||||
|
variables.appType,
|
||||||
|
],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 自动故障转移总开关 Hooks ==========
|
// ========== 自动故障转移开关 Hooks ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取自动故障转移总开关状态
|
* 获取指定应用的自动故障转移开关状态
|
||||||
*/
|
*/
|
||||||
export function useAutoFailoverEnabled() {
|
export function useAutoFailoverEnabled(appType: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["autoFailoverEnabled"],
|
queryKey: ["autoFailoverEnabled", appType],
|
||||||
queryFn: () => failoverApi.getAutoFailoverEnabled(),
|
queryFn: () => failoverApi.getAutoFailoverEnabled(appType),
|
||||||
// 默认值为 false(与后端保持一致)
|
// 默认值为 false(与后端保持一致)
|
||||||
placeholderData: false,
|
placeholderData: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置自动故障转移总开关状态
|
* 设置指定应用的自动故障转移开关状态
|
||||||
*/
|
*/
|
||||||
export function useSetAutoFailoverEnabled() {
|
export function useSetAutoFailoverEnabled() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (enabled: boolean) =>
|
mutationFn: ({ appType, enabled }: { appType: string; enabled: boolean }) =>
|
||||||
failoverApi.setAutoFailoverEnabled(enabled),
|
failoverApi.setAutoFailoverEnabled(appType, enabled),
|
||||||
|
|
||||||
// 乐观更新
|
// 乐观更新
|
||||||
onMutate: async (enabled) => {
|
onMutate: async ({ appType, enabled }) => {
|
||||||
await queryClient.cancelQueries({ queryKey: ["autoFailoverEnabled"] });
|
await queryClient.cancelQueries({
|
||||||
|
queryKey: ["autoFailoverEnabled", appType],
|
||||||
|
});
|
||||||
const previousValue = queryClient.getQueryData<boolean>([
|
const previousValue = queryClient.getQueryData<boolean>([
|
||||||
"autoFailoverEnabled",
|
"autoFailoverEnabled",
|
||||||
|
appType,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
queryClient.setQueryData(["autoFailoverEnabled"], enabled);
|
queryClient.setQueryData(["autoFailoverEnabled", appType], enabled);
|
||||||
|
|
||||||
return { previousValue };
|
return { previousValue, appType };
|
||||||
},
|
},
|
||||||
|
|
||||||
// 错误时回滚
|
// 错误时回滚
|
||||||
onError: (_error, _enabled, context) => {
|
onError: (_error, _variables, context) => {
|
||||||
if (context?.previousValue !== undefined) {
|
if (context?.previousValue !== undefined) {
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
["autoFailoverEnabled"],
|
["autoFailoverEnabled", context.appType],
|
||||||
context.previousValue,
|
context.previousValue,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 无论成功失败,都重新获取
|
// 无论成功失败,都重新获取
|
||||||
onSettled: () => {
|
onSettled: (_, __, variables) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["autoFailoverEnabled"] });
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["autoFailoverEnabled", variables.appType],
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export interface Provider {
|
|||||||
// 图标配置
|
// 图标配置
|
||||||
icon?: string; // 图标名称(如 "openai", "anthropic")
|
icon?: string; // 图标名称(如 "openai", "anthropic")
|
||||||
iconColor?: string; // 图标颜色(Hex 格式,如 "#00A67E")
|
iconColor?: string; // 图标颜色(Hex 格式,如 "#00A67E")
|
||||||
|
// 是否加入故障转移队列
|
||||||
|
inFailoverQueue?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
|
|||||||
+1
-3
@@ -103,7 +103,5 @@ export interface ProxyUsageRecord {
|
|||||||
export interface FailoverQueueItem {
|
export interface FailoverQueueItem {
|
||||||
providerId: string;
|
providerId: string;
|
||||||
providerName: string;
|
providerName: string;
|
||||||
queueOrder: number;
|
sortIndex?: number;
|
||||||
enabled: boolean;
|
|
||||||
createdAt: number;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,22 @@ vi.mock("@dnd-kit/sortable", async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mock hooks that use QueryClient
|
||||||
|
vi.mock("@/hooks/useStreamCheck", () => ({
|
||||||
|
useStreamCheck: () => ({
|
||||||
|
checkProvider: vi.fn(),
|
||||||
|
isChecking: () => false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/query/failover", () => ({
|
||||||
|
useAutoFailoverEnabled: () => ({ data: false }),
|
||||||
|
useFailoverQueue: () => ({ data: [] }),
|
||||||
|
useAddToFailoverQueue: () => ({ mutate: vi.fn() }),
|
||||||
|
useRemoveFromFailoverQueue: () => ({ mutate: vi.fn() }),
|
||||||
|
useReorderFailoverQueue: () => ({ mutate: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
function createProvider(overrides: Partial<Provider> = {}): Provider {
|
function createProvider(overrides: Partial<Provider> = {}): Provider {
|
||||||
return {
|
return {
|
||||||
id: overrides.id ?? "provider-1",
|
id: overrides.id ?? "provider-1",
|
||||||
|
|||||||
Reference in New Issue
Block a user