mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
feat(backup): add pre-migration backup, periodic backup, backfill warning, and backup management UI
Four improvements to the database backup mechanism: 1. Auto backup before schema migration - creates a snapshot when upgrading from an older database version, providing a safety net beyond the existing SAVEPOINT rollback mechanism. 2. Periodic startup backup - checks on app launch whether the latest backup is older than 24 hours and creates a new one if needed, ensuring all users have recent backups regardless of usage patterns. 3. Backfill failure notification - switch now returns SwitchResult with warnings instead of silently ignoring backfill errors, so users are informed when their manual config changes may not have been saved. 4. Backup management UI - new BackupListSection in Settings > Data Management showing all backup snapshots with restore capability, including a confirmation dialog and automatic safety backup before restore.
This commit is contained in:
@@ -8,6 +8,8 @@ use tauri_plugin_dialog::DialogExt;
|
|||||||
use crate::commands::sync_support::{
|
use crate::commands::sync_support::{
|
||||||
post_sync_warning_from_result, run_post_import_sync, success_payload_with_warning,
|
post_sync_warning_from_result, run_post_import_sync, success_payload_with_warning,
|
||||||
};
|
};
|
||||||
|
use crate::database::backup::BackupEntry;
|
||||||
|
use crate::database::Database;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::services::provider::ProviderService;
|
use crate::services::provider::ProviderService;
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
@@ -118,3 +120,24 @@ pub async fn open_zip_file_dialog<R: tauri::Runtime>(
|
|||||||
|
|
||||||
Ok(result.map(|p| p.to_string()))
|
Ok(result.map(|p| p.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Database backup management ─────────────────────────────
|
||||||
|
|
||||||
|
/// List all database backup files
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_db_backups() -> Result<Vec<BackupEntry>, String> {
|
||||||
|
Database::list_backups().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore database from a backup file
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn restore_db_backup(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
filename: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let db = state.db.clone();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || db.restore_from_backup(&filename))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Restore failed: {e}"))?
|
||||||
|
.map_err(|e: AppError| e.to_string())
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ use tauri::State;
|
|||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use crate::services::{EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService};
|
use crate::services::{
|
||||||
|
EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult,
|
||||||
|
};
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
@@ -67,7 +69,11 @@ pub fn remove_provider_from_live_config(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
fn switch_provider_internal(
|
||||||
|
state: &AppState,
|
||||||
|
app_type: AppType,
|
||||||
|
id: &str,
|
||||||
|
) -> Result<SwitchResult, AppError> {
|
||||||
ProviderService::switch(state, app_type, id)
|
ProviderService::switch(state, app_type, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +82,7 @@ pub fn switch_provider_test_hook(
|
|||||||
state: &AppState,
|
state: &AppState,
|
||||||
app_type: AppType,
|
app_type: AppType,
|
||||||
id: &str,
|
id: &str,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<SwitchResult, AppError> {
|
||||||
switch_provider_internal(state, app_type, id)
|
switch_provider_internal(state, app_type, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,11 +91,9 @@ pub fn switch_provider(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
app: String,
|
app: String,
|
||||||
id: String,
|
id: String,
|
||||||
) -> Result<bool, String> {
|
) -> Result<SwitchResult, String> {
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
switch_provider_internal(&state, app_type, &id)
|
switch_provider_internal(&state, app_type, &id).map_err(|e| e.to_string())
|
||||||
.map(|_| true)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
|
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ use tempfile::NamedTempFile;
|
|||||||
|
|
||||||
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
|
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
|
||||||
|
|
||||||
|
/// A database backup entry for the UI
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct BackupEntry {
|
||||||
|
pub filename: String,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
pub created_at: String, // ISO 8601
|
||||||
|
}
|
||||||
|
|
||||||
impl Database {
|
impl Database {
|
||||||
/// 导出为 SQLite 兼容的 SQL 文本(内存字符串)
|
/// 导出为 SQLite 兼容的 SQL 文本(内存字符串)
|
||||||
pub fn export_sql_string(&self) -> Result<String, AppError> {
|
pub fn export_sql_string(&self) -> Result<String, AppError> {
|
||||||
@@ -120,8 +129,41 @@ impl Database {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Periodic backup: create a new backup if the latest one is older than 24 hours (or none exists)
|
||||||
|
pub(crate) fn periodic_backup_if_needed(&self) -> Result<(), AppError> {
|
||||||
|
let backup_dir = get_app_config_dir().join("backups");
|
||||||
|
if !backup_dir.exists() {
|
||||||
|
self.backup_database_file()?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let latest = fs::read_dir(&backup_dir).ok().and_then(|entries| {
|
||||||
|
entries
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false))
|
||||||
|
.filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()))
|
||||||
|
.max()
|
||||||
|
});
|
||||||
|
|
||||||
|
let needs_backup = match latest {
|
||||||
|
None => true,
|
||||||
|
Some(last_modified) => {
|
||||||
|
last_modified.elapsed().unwrap_or_default()
|
||||||
|
> std::time::Duration::from_secs(24 * 3600)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if needs_backup {
|
||||||
|
log::info!(
|
||||||
|
"Periodic backup: latest backup is older than 24 hours, creating new backup"
|
||||||
|
);
|
||||||
|
self.backup_database_file()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
|
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
|
||||||
fn backup_database_file(&self) -> Result<Option<PathBuf>, AppError> {
|
pub(crate) fn backup_database_file(&self) -> Result<Option<PathBuf>, AppError> {
|
||||||
let db_path = get_app_config_dir().join("cc-switch.db");
|
let db_path = get_app_config_dir().join("cc-switch.db");
|
||||||
if !db_path.exists() {
|
if !db_path.exists() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -333,4 +375,91 @@ impl Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List all database backup files, sorted by creation time (newest first)
|
||||||
|
pub fn list_backups() -> Result<Vec<BackupEntry>, AppError> {
|
||||||
|
let backup_dir = get_app_config_dir().join("backups");
|
||||||
|
if !backup_dir.exists() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut entries: Vec<BackupEntry> = fs::read_dir(&backup_dir)
|
||||||
|
.map_err(|e| AppError::io(&backup_dir, e))?
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false))
|
||||||
|
.filter_map(|e| {
|
||||||
|
let metadata = e.metadata().ok()?;
|
||||||
|
let filename = e.file_name().to_string_lossy().to_string();
|
||||||
|
let size_bytes = metadata.len();
|
||||||
|
let created_at = metadata
|
||||||
|
.modified()
|
||||||
|
.ok()
|
||||||
|
.map(|t| {
|
||||||
|
let dt: chrono::DateTime<Utc> = t.into();
|
||||||
|
dt.to_rfc3339()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
Some(BackupEntry {
|
||||||
|
filename,
|
||||||
|
size_bytes,
|
||||||
|
created_at,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Sort by created_at descending (newest first)
|
||||||
|
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore database from a backup file. Returns the safety backup ID.
|
||||||
|
pub fn restore_from_backup(&self, filename: &str) -> Result<String, AppError> {
|
||||||
|
// Security: validate filename to prevent path traversal
|
||||||
|
if filename.contains("..")
|
||||||
|
|| filename.contains('/')
|
||||||
|
|| filename.contains('\\')
|
||||||
|
|| !filename.starts_with("db_backup_")
|
||||||
|
|| !filename.ends_with(".db")
|
||||||
|
{
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"Invalid backup filename".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let backup_dir = get_app_config_dir().join("backups");
|
||||||
|
let backup_path = backup_dir.join(filename);
|
||||||
|
|
||||||
|
if !backup_path.exists() {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Backup file not found: {filename}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Create safety backup of current database
|
||||||
|
let safety_backup = self.backup_database_file()?;
|
||||||
|
let safety_id = safety_backup
|
||||||
|
.and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Step 2: Open the backup file and restore it to the main database
|
||||||
|
let source_conn =
|
||||||
|
Connection::open(&backup_path).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut main_conn = lock_conn!(self.conn);
|
||||||
|
let backup = Backup::new(&source_conn, &mut main_conn)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
backup
|
||||||
|
.step(-1)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Run schema migrations (backup may be from an older version)
|
||||||
|
self.create_tables()?;
|
||||||
|
self.apply_schema_migrations()?;
|
||||||
|
self.ensure_model_pricing_seeded()?;
|
||||||
|
|
||||||
|
log::info!("Database restored from backup: {filename}, safety backup: {safety_id}");
|
||||||
|
Ok(safety_id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
//! └── settings.rs
|
//! └── settings.rs
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
mod backup;
|
pub(crate) mod backup;
|
||||||
mod dao;
|
mod dao;
|
||||||
mod migration;
|
mod migration;
|
||||||
mod schema;
|
mod schema;
|
||||||
@@ -110,6 +110,22 @@ impl Database {
|
|||||||
conn: Mutex::new(conn),
|
conn: Mutex::new(conn),
|
||||||
};
|
};
|
||||||
db.create_tables()?;
|
db.create_tables()?;
|
||||||
|
|
||||||
|
// Pre-migration backup: only when upgrading from an existing database
|
||||||
|
{
|
||||||
|
let conn = lock_conn!(db.conn);
|
||||||
|
let version = Self::get_user_version(&conn)?;
|
||||||
|
drop(conn);
|
||||||
|
if version > 0 && version < SCHEMA_VERSION {
|
||||||
|
log::info!(
|
||||||
|
"Creating pre-migration database backup (v{version} → v{SCHEMA_VERSION})"
|
||||||
|
);
|
||||||
|
if let Err(e) = db.backup_database_file() {
|
||||||
|
log::warn!("Pre-migration backup failed, continuing migration: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
db.apply_schema_migrations()?;
|
db.apply_schema_migrations()?;
|
||||||
db.ensure_model_pricing_seeded()?;
|
db.ensure_model_pricing_seeded()?;
|
||||||
|
|
||||||
|
|||||||
@@ -768,6 +768,11 @@ pub fn run() {
|
|||||||
|
|
||||||
// 检查 settings 表中的代理状态,自动恢复代理服务
|
// 检查 settings 表中的代理状态,自动恢复代理服务
|
||||||
restore_proxy_state_on_startup(&state).await;
|
restore_proxy_state_on_startup(&state).await;
|
||||||
|
|
||||||
|
// Periodic backup check
|
||||||
|
if let Err(e) = state.db.periodic_backup_if_needed() {
|
||||||
|
log::warn!("Periodic backup failed on startup: {e}");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Linux: 禁用 WebKitGTK 硬件加速,防止 EGL 初始化失败导致白屏
|
// Linux: 禁用 WebKitGTK 硬件加速,防止 EGL 初始化失败导致白屏
|
||||||
@@ -896,6 +901,8 @@ pub fn run() {
|
|||||||
commands::save_file_dialog,
|
commands::save_file_dialog,
|
||||||
commands::open_file_dialog,
|
commands::open_file_dialog,
|
||||||
commands::open_zip_file_dialog,
|
commands::open_zip_file_dialog,
|
||||||
|
commands::list_db_backups,
|
||||||
|
commands::restore_db_backup,
|
||||||
commands::sync_current_providers_live,
|
commands::sync_current_providers_live,
|
||||||
// Deep link import
|
// Deep link import
|
||||||
commands::parse_deeplink,
|
commands::parse_deeplink,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ pub use config::ConfigService;
|
|||||||
pub use mcp::McpService;
|
pub use mcp::McpService;
|
||||||
pub use omo::OmoService;
|
pub use omo::OmoService;
|
||||||
pub use prompt::PromptService;
|
pub use prompt::PromptService;
|
||||||
pub use provider::{ProviderService, ProviderSortUpdate};
|
pub use provider::{ProviderService, ProviderSortUpdate, SwitchResult};
|
||||||
pub use proxy::ProxyService;
|
pub use proxy::ProxyService;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
|
pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ use usage::validate_usage_script;
|
|||||||
/// Provider business logic service
|
/// Provider business logic service
|
||||||
pub struct ProviderService;
|
pub struct ProviderService;
|
||||||
|
|
||||||
|
/// Result of a provider switch operation, including any non-fatal warnings
|
||||||
|
#[derive(Debug, serde::Serialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SwitchResult {
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -447,7 +454,7 @@ impl ProviderService {
|
|||||||
/// c. Update database is_current (as default for new devices)
|
/// c. Update database is_current (as default for new devices)
|
||||||
/// d. Write target provider config to live files
|
/// d. Write target provider config to live files
|
||||||
/// e. Sync MCP configuration
|
/// e. Sync MCP configuration
|
||||||
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<SwitchResult, AppError> {
|
||||||
// Check if provider exists
|
// Check if provider exists
|
||||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
let _provider = providers
|
let _provider = providers
|
||||||
@@ -519,7 +526,7 @@ impl ProviderService {
|
|||||||
|
|
||||||
// Note: No Live config write, no MCP sync
|
// Note: No Live config write, no MCP sync
|
||||||
// The proxy server will route requests to the new provider via is_current
|
// The proxy server will route requests to the new provider via is_current
|
||||||
return Ok(());
|
return Ok(SwitchResult::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normal mode: full switch with Live config write
|
// Normal mode: full switch with Live config write
|
||||||
@@ -532,7 +539,7 @@ impl ProviderService {
|
|||||||
app_type: AppType,
|
app_type: AppType,
|
||||||
id: &str,
|
id: &str,
|
||||||
providers: &indexmap::IndexMap<String, Provider>,
|
providers: &indexmap::IndexMap<String, Provider>,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<SwitchResult, AppError> {
|
||||||
let provider = providers
|
let provider = providers
|
||||||
.get(id)
|
.get(id)
|
||||||
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
|
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
|
||||||
@@ -545,7 +552,7 @@ impl ProviderService {
|
|||||||
state,
|
state,
|
||||||
&crate::services::omo::STANDARD,
|
&crate::services::omo::STANDARD,
|
||||||
)?;
|
)?;
|
||||||
return Ok(());
|
return Ok(SwitchResult::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo-slim")
|
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo-slim")
|
||||||
@@ -554,9 +561,11 @@ impl ProviderService {
|
|||||||
.db
|
.db
|
||||||
.set_omo_provider_current(app_type.as_str(), id, "omo-slim")?;
|
.set_omo_provider_current(app_type.as_str(), id, "omo-slim")?;
|
||||||
crate::services::OmoService::write_config_to_file(state, &crate::services::omo::SLIM)?;
|
crate::services::OmoService::write_config_to_file(state, &crate::services::omo::SLIM)?;
|
||||||
return Ok(());
|
return Ok(SwitchResult::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut result = SwitchResult::default();
|
||||||
|
|
||||||
// Backfill: Backfill current live config to current provider
|
// Backfill: Backfill current live config to current provider
|
||||||
// Use effective current provider (validated existence) to ensure backfill targets valid provider
|
// Use effective current provider (validated existence) to ensure backfill targets valid provider
|
||||||
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
|
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
|
||||||
@@ -570,8 +579,14 @@ impl ProviderService {
|
|||||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
||||||
current_provider.settings_config = live_config;
|
current_provider.settings_config = live_config;
|
||||||
// Ignore backfill failure, don't affect switch flow
|
if let Err(e) =
|
||||||
let _ = state.db.save_provider(app_type.as_str(), ¤t_provider);
|
state.db.save_provider(app_type.as_str(), ¤t_provider)
|
||||||
|
{
|
||||||
|
log::warn!("Backfill failed: {e}");
|
||||||
|
result
|
||||||
|
.warnings
|
||||||
|
.push(format!("backfill_failed:{current_id}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -593,7 +608,7 @@ impl ProviderService {
|
|||||||
// Sync MCP
|
// Sync MCP
|
||||||
McpService::sync_all_enabled(state)?;
|
McpService::sync_all_enabled(state)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sync current provider to live configuration (re-export)
|
/// Sync current provider to live configuration (re-export)
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { HardDriveDownload, RotateCcw } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useBackupManager } from "@/hooks/useBackupManager";
|
||||||
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBackupDate(isoString: string): string {
|
||||||
|
try {
|
||||||
|
const date = new Date(isoString);
|
||||||
|
return date.toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return isoString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackupListSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { backups, isLoading, restore, isRestoring } = useBackupManager();
|
||||||
|
const [confirmFilename, setConfirmFilename] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleRestore = async () => {
|
||||||
|
if (!confirmFilename) return;
|
||||||
|
try {
|
||||||
|
const safetyId = await restore(confirmFilename);
|
||||||
|
setConfirmFilename(null);
|
||||||
|
toast.success(
|
||||||
|
t("settings.backupManager.restoreSuccess", {
|
||||||
|
defaultValue: "Restore successful! Safety backup created",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
description: safetyId
|
||||||
|
? `${t("settings.backupManager.safetyBackupId", { defaultValue: "Safety Backup ID" })}: ${safetyId}`
|
||||||
|
: undefined,
|
||||||
|
duration: 6000,
|
||||||
|
closeButton: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const detail =
|
||||||
|
extractErrorMessage(error) ||
|
||||||
|
t("settings.backupManager.restoreFailed", {
|
||||||
|
defaultValue: "Restore failed",
|
||||||
|
});
|
||||||
|
toast.error(detail);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 pt-4 border-t border-border/50">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<HardDriveDownload className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<h4 className="text-sm font-medium">
|
||||||
|
{t("settings.backupManager.title", {
|
||||||
|
defaultValue: "Database Backups",
|
||||||
|
})}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-3">
|
||||||
|
{t("settings.backupManager.description", {
|
||||||
|
defaultValue:
|
||||||
|
"Automatic database snapshots for restoring to a previous state",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-sm text-muted-foreground py-2">Loading...</div>
|
||||||
|
) : backups.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground py-2">
|
||||||
|
{t("settings.backupManager.empty", {
|
||||||
|
defaultValue: "No backups yet",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||||
|
{backups.map((backup) => (
|
||||||
|
<div
|
||||||
|
key={backup.filename}
|
||||||
|
className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-mono text-xs truncate">
|
||||||
|
{formatBackupDate(backup.createdAt)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{formatBytes(backup.sizeBytes)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs shrink-0"
|
||||||
|
disabled={isRestoring}
|
||||||
|
onClick={() => setConfirmFilename(backup.filename)}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3 mr-1" />
|
||||||
|
{isRestoring
|
||||||
|
? t("settings.backupManager.restoring", {
|
||||||
|
defaultValue: "Restoring...",
|
||||||
|
})
|
||||||
|
: t("settings.backupManager.restore", {
|
||||||
|
defaultValue: "Restore",
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirmation Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={!!confirmFilename}
|
||||||
|
onOpenChange={(open) => !open && setConfirmFilename(null)}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-md" zIndex="alert">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("settings.backupManager.confirmTitle", {
|
||||||
|
defaultValue: "Confirm Restore",
|
||||||
|
})}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("settings.backupManager.confirmMessage", {
|
||||||
|
defaultValue:
|
||||||
|
"Restoring this backup will overwrite the current database. A safety backup will be created first.",
|
||||||
|
})}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setConfirmFilename(null)}
|
||||||
|
disabled={isRestoring}
|
||||||
|
>
|
||||||
|
{t("common.cancel", { defaultValue: "Cancel" })}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleRestore} disabled={isRestoring}>
|
||||||
|
{isRestoring
|
||||||
|
? t("settings.backupManager.restoring", {
|
||||||
|
defaultValue: "Restoring...",
|
||||||
|
})
|
||||||
|
: t("settings.backupManager.restore", {
|
||||||
|
defaultValue: "Restore",
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ import { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSe
|
|||||||
import { TerminalSettings } from "@/components/settings/TerminalSettings";
|
import { TerminalSettings } from "@/components/settings/TerminalSettings";
|
||||||
import { DirectorySettings } from "@/components/settings/DirectorySettings";
|
import { DirectorySettings } from "@/components/settings/DirectorySettings";
|
||||||
import { ImportExportSection } from "@/components/settings/ImportExportSection";
|
import { ImportExportSection } from "@/components/settings/ImportExportSection";
|
||||||
|
import { BackupListSection } from "@/components/settings/BackupListSection";
|
||||||
import { WebdavSyncSection } from "@/components/settings/WebdavSyncSection";
|
import { WebdavSyncSection } from "@/components/settings/WebdavSyncSection";
|
||||||
import { AboutSection } from "@/components/settings/AboutSection";
|
import { AboutSection } from "@/components/settings/AboutSection";
|
||||||
import { ProxyTabContent } from "@/components/settings/ProxyTabContent";
|
import { ProxyTabContent } from "@/components/settings/ProxyTabContent";
|
||||||
@@ -324,6 +325,7 @@ export function SettingsPage({
|
|||||||
onExport={exportConfig}
|
onExport={exportConfig}
|
||||||
onClear={clearSelection}
|
onClear={clearSelection}
|
||||||
/>
|
/>
|
||||||
|
<BackupListSection />
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { backupsApi } from "@/lib/api";
|
||||||
|
|
||||||
|
export function useBackupManager() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: backups = [],
|
||||||
|
isLoading,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["db-backups"],
|
||||||
|
queryFn: () => backupsApi.listDbBackups(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const restoreMutation = useMutation({
|
||||||
|
mutationFn: (filename: string) => backupsApi.restoreDbBackup(filename),
|
||||||
|
onSuccess: async () => {
|
||||||
|
// Invalidate all queries to refresh data from restored database
|
||||||
|
await queryClient.invalidateQueries();
|
||||||
|
// Refetch backup list
|
||||||
|
await refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
backups,
|
||||||
|
isLoading,
|
||||||
|
restore: restoreMutation.mutateAsync,
|
||||||
|
isRestoring: restoreMutation.isPending,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -134,9 +134,20 @@ export function useProviderActions(activeApp: AppId) {
|
|||||||
const switchProvider = useCallback(
|
const switchProvider = useCallback(
|
||||||
async (provider: Provider) => {
|
async (provider: Provider) => {
|
||||||
try {
|
try {
|
||||||
await switchProviderMutation.mutateAsync(provider.id);
|
const result = await switchProviderMutation.mutateAsync(provider.id);
|
||||||
await syncClaudePlugin(provider);
|
await syncClaudePlugin(provider);
|
||||||
|
|
||||||
|
// Show backfill warning if present
|
||||||
|
if (result?.warnings?.length) {
|
||||||
|
toast.warning(
|
||||||
|
t("notifications.backfillWarning", {
|
||||||
|
defaultValue:
|
||||||
|
"切换成功,但旧供应商配置回填失败,您手动修改的配置可能未保存",
|
||||||
|
}),
|
||||||
|
{ duration: 5000 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 根据供应商类型显示不同的成功提示
|
// 根据供应商类型显示不同的成功提示
|
||||||
if (
|
if (
|
||||||
activeApp === "claude" &&
|
activeApp === "claude" &&
|
||||||
|
|||||||
@@ -174,7 +174,8 @@
|
|||||||
"openclawModelsRegistered": "Models have been registered to /model list",
|
"openclawModelsRegistered": "Models have been registered to /model list",
|
||||||
"openclawDefaultModelSet": "Set as default model",
|
"openclawDefaultModelSet": "Set as default model",
|
||||||
"openclawDefaultModelSetFailed": "Failed to set default model",
|
"openclawDefaultModelSetFailed": "Failed to set default model",
|
||||||
"openclawNoModels": "No models configured"
|
"openclawNoModels": "No models configured",
|
||||||
|
"backfillWarning": "Switched successfully, but failed to save changes back to the previous provider"
|
||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"deleteProvider": "Delete Provider",
|
"deleteProvider": "Delete Provider",
|
||||||
@@ -295,6 +296,18 @@
|
|||||||
"selectFileFailed": "Please choose a valid SQL backup file",
|
"selectFileFailed": "Please choose a valid SQL backup file",
|
||||||
"configCorrupted": "SQL file may be corrupted or invalid",
|
"configCorrupted": "SQL file may be corrupted or invalid",
|
||||||
"backupId": "Backup ID",
|
"backupId": "Backup ID",
|
||||||
|
"backupManager": {
|
||||||
|
"title": "Database Backups",
|
||||||
|
"description": "Automatic database snapshots for restoring to a previous state",
|
||||||
|
"empty": "No backups yet",
|
||||||
|
"restore": "Restore",
|
||||||
|
"restoring": "Restoring...",
|
||||||
|
"confirmTitle": "Confirm Restore",
|
||||||
|
"confirmMessage": "Restoring this backup will overwrite the current database. A safety backup will be created first.",
|
||||||
|
"restoreSuccess": "Restore successful! Safety backup created",
|
||||||
|
"restoreFailed": "Restore failed",
|
||||||
|
"safetyBackupId": "Safety Backup ID"
|
||||||
|
},
|
||||||
"webdavSync": {
|
"webdavSync": {
|
||||||
"title": "WebDAV Cloud Sync",
|
"title": "WebDAV Cloud Sync",
|
||||||
"description": "Sync database and skill configurations across devices via WebDAV.",
|
"description": "Sync database and skill configurations across devices via WebDAV.",
|
||||||
|
|||||||
@@ -174,7 +174,8 @@
|
|||||||
"openclawModelsRegistered": "モデルが /model リストに登録されました",
|
"openclawModelsRegistered": "モデルが /model リストに登録されました",
|
||||||
"openclawDefaultModelSet": "デフォルトモデルに設定しました",
|
"openclawDefaultModelSet": "デフォルトモデルに設定しました",
|
||||||
"openclawDefaultModelSetFailed": "デフォルトモデルの設定に失敗しました",
|
"openclawDefaultModelSetFailed": "デフォルトモデルの設定に失敗しました",
|
||||||
"openclawNoModels": "モデルが設定されていません"
|
"openclawNoModels": "モデルが設定されていません",
|
||||||
|
"backfillWarning": "切り替え成功しましたが、前のプロバイダーへの設定保存に失敗しました"
|
||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"deleteProvider": "プロバイダーを削除",
|
"deleteProvider": "プロバイダーを削除",
|
||||||
@@ -295,6 +296,18 @@
|
|||||||
"selectFileFailed": "有効な SQL バックアップファイルを選択してください",
|
"selectFileFailed": "有効な SQL バックアップファイルを選択してください",
|
||||||
"configCorrupted": "SQL ファイルが壊れているか形式が無効な可能性があります",
|
"configCorrupted": "SQL ファイルが壊れているか形式が無効な可能性があります",
|
||||||
"backupId": "バックアップ ID",
|
"backupId": "バックアップ ID",
|
||||||
|
"backupManager": {
|
||||||
|
"title": "データベースバックアップ",
|
||||||
|
"description": "以前の状態に復元するための自動データベーススナップショット",
|
||||||
|
"empty": "バックアップはまだありません",
|
||||||
|
"restore": "復元",
|
||||||
|
"restoring": "復元中...",
|
||||||
|
"confirmTitle": "バックアップの復元を確認",
|
||||||
|
"confirmMessage": "このバックアップを復元すると現在のデータベースが上書きされます。安全バックアップが先に作成されます。",
|
||||||
|
"restoreSuccess": "復元成功!安全バックアップが作成されました",
|
||||||
|
"restoreFailed": "復元に失敗しました",
|
||||||
|
"safetyBackupId": "安全バックアップID"
|
||||||
|
},
|
||||||
"webdavSync": {
|
"webdavSync": {
|
||||||
"title": "WebDAV クラウド同期",
|
"title": "WebDAV クラウド同期",
|
||||||
"description": "WebDAV を使ってデバイス間でデータベースとスキル設定を同期します。",
|
"description": "WebDAV を使ってデバイス間でデータベースとスキル設定を同期します。",
|
||||||
|
|||||||
@@ -174,7 +174,8 @@
|
|||||||
"openclawModelsRegistered": "模型已注册到 /model 列表",
|
"openclawModelsRegistered": "模型已注册到 /model 列表",
|
||||||
"openclawDefaultModelSet": "已设为默认模型",
|
"openclawDefaultModelSet": "已设为默认模型",
|
||||||
"openclawDefaultModelSetFailed": "设置默认模型失败",
|
"openclawDefaultModelSetFailed": "设置默认模型失败",
|
||||||
"openclawNoModels": "该供应商没有配置模型"
|
"openclawNoModels": "该供应商没有配置模型",
|
||||||
|
"backfillWarning": "切换成功,但旧供应商配置回填失败,您手动修改的配置可能未保存"
|
||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"deleteProvider": "删除供应商",
|
"deleteProvider": "删除供应商",
|
||||||
@@ -295,6 +296,18 @@
|
|||||||
"selectFileFailed": "请选择有效的 SQL 备份文件",
|
"selectFileFailed": "请选择有效的 SQL 备份文件",
|
||||||
"configCorrupted": "SQL 文件可能已损坏或格式不正确",
|
"configCorrupted": "SQL 文件可能已损坏或格式不正确",
|
||||||
"backupId": "备份ID",
|
"backupId": "备份ID",
|
||||||
|
"backupManager": {
|
||||||
|
"title": "数据库备份",
|
||||||
|
"description": "自动备份的数据库快照,可用于恢复到之前的状态",
|
||||||
|
"empty": "暂无备份",
|
||||||
|
"restore": "恢复",
|
||||||
|
"restoring": "恢复中...",
|
||||||
|
"confirmTitle": "确认恢复备份",
|
||||||
|
"confirmMessage": "恢复到此备份将覆盖当前数据库。恢复前会自动创建安全备份。",
|
||||||
|
"restoreSuccess": "恢复成功!安全备份已创建",
|
||||||
|
"restoreFailed": "恢复失败",
|
||||||
|
"safetyBackupId": "安全备份ID"
|
||||||
|
},
|
||||||
"webdavSync": {
|
"webdavSync": {
|
||||||
"title": "WebDAV 云同步",
|
"title": "WebDAV 云同步",
|
||||||
"description": "通过 WebDAV 在多设备间同步数据库和技能配置。",
|
"description": "通过 WebDAV 在多设备间同步数据库和技能配置。",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export type { AppId } from "./types";
|
export type { AppId } from "./types";
|
||||||
export { providersApi, universalProvidersApi } from "./providers";
|
export { providersApi, universalProvidersApi } from "./providers";
|
||||||
export { settingsApi } from "./settings";
|
export { settingsApi } from "./settings";
|
||||||
|
export { backupsApi } from "./settings";
|
||||||
export { mcpApi } from "./mcp";
|
export { mcpApi } from "./mcp";
|
||||||
export { promptsApi } from "./prompts";
|
export { promptsApi } from "./prompts";
|
||||||
export { skillsApi } from "./skills";
|
export { skillsApi } from "./skills";
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export interface ProviderSwitchEvent {
|
|||||||
providerId: string;
|
providerId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SwitchResult {
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export const providersApi = {
|
export const providersApi = {
|
||||||
async getAll(appId: AppId): Promise<Record<string, Provider>> {
|
async getAll(appId: AppId): Promise<Record<string, Provider>> {
|
||||||
return await invoke("get_providers", { app: appId });
|
return await invoke("get_providers", { app: appId });
|
||||||
@@ -46,7 +50,7 @@ export const providersApi = {
|
|||||||
return await invoke("remove_provider_from_live_config", { id, app: appId });
|
return await invoke("remove_provider_from_live_config", { id, app: appId });
|
||||||
},
|
},
|
||||||
|
|
||||||
async switch(id: string, appId: AppId): Promise<boolean> {
|
async switch(id: string, appId: AppId): Promise<SwitchResult> {
|
||||||
return await invoke("switch_provider", { id, app: appId });
|
return await invoke("switch_provider", { id, app: appId });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -215,3 +215,19 @@ export interface LogConfig {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
level: "error" | "warn" | "info" | "debug" | "trace";
|
level: "error" | "warn" | "info" | "debug" | "trace";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BackupEntry {
|
||||||
|
filename: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const backupsApi = {
|
||||||
|
async listDbBackups(): Promise<BackupEntry[]> {
|
||||||
|
return await invoke("list_db_backups");
|
||||||
|
},
|
||||||
|
|
||||||
|
async restoreDbBackup(filename: string): Promise<string> {
|
||||||
|
return await invoke("restore_db_backup", { filename });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { providersApi, settingsApi, type AppId } from "@/lib/api";
|
import { providersApi, settingsApi, type AppId } from "@/lib/api";
|
||||||
|
import type { SwitchResult } from "@/lib/api/providers";
|
||||||
import type { Provider, Settings } from "@/types";
|
import type { Provider, Settings } from "@/types";
|
||||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||||
import { generateUUID } from "@/utils/uuid";
|
import { generateUUID } from "@/utils/uuid";
|
||||||
@@ -171,7 +172,7 @@ export const useSwitchProviderMutation = (appId: AppId) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (providerId: string) => {
|
mutationFn: async (providerId: string): Promise<SwitchResult> => {
|
||||||
return await providersApi.switch(providerId, appId);
|
return await providersApi.switch(providerId, appId);
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user