From 92e1b7e4c8e240307db3fb6edb1ced9ef6e40f46 Mon Sep 17 00:00:00 2001 From: zhixian Date: Thu, 19 Feb 2026 19:03:20 +0900 Subject: [PATCH] feat: add remote backup support (create, list, restore, delete) Remote instances now get the same backup experience as local: - 4 new SSH-based Rust commands for remote backup operations - UpgradeDialog always runs backup step for both local and remote - Home page Backups section visible and functional for remote instances - Show button hidden for remote (no local path to open) Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- src-tauri/src/commands.rs | 142 ++++++++++++++++++ src-tauri/src/lib.rs | 5 + src/components/UpgradeDialog.tsx | 17 +-- src/lib/api.ts | 10 ++ src/pages/Home.tsx | 249 ++++++++++++++++--------------- 5 files changed, 293 insertions(+), 130 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f2f9ac3..493ecd6 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -4017,6 +4017,148 @@ pub fn delete_backup(backup_name: String) -> Result { Ok(true) } +// ---- Remote Backup / Restore (via SSH) ---- + +#[tauri::command] +pub async fn remote_backup_before_upgrade( + pool: State<'_, SshConnectionPool>, + host_id: String, +) -> Result { + let now_secs = unix_timestamp_secs(); + let now_dt = chrono::DateTime::::from_timestamp(now_secs as i64, 0); + let name = now_dt + .map(|dt| dt.format("%Y-%m-%d_%H%M%S").to_string()) + .unwrap_or_else(|| format!("{now_secs}")); + + let cmd = format!( + concat!( + "set -e; ", + "BDIR=\"$HOME/.openclaw/.clawpal/backups/{name}\"; ", + "mkdir -p \"$BDIR\"; ", + "cp \"$HOME/.openclaw/openclaw.json\" \"$BDIR/\" 2>/dev/null || true; ", + "cp -r \"$HOME/.openclaw/agents\" \"$BDIR/\" 2>/dev/null || true; ", + "cp -r \"$HOME/.openclaw/memory\" \"$BDIR/\" 2>/dev/null || true; ", + "du -sk \"$BDIR\" 2>/dev/null | awk '{{print $1 * 1024}}' || echo 0" + ), + name = name + ); + + let result = pool.exec_login(&host_id, &cmd).await?; + if result.exit_code != 0 { + return Err(format!("Remote backup failed (exit {}): {}", result.exit_code, result.stderr)); + } + + let size_bytes: u64 = result.stdout.trim().lines().last() + .and_then(|l| l.trim().parse().ok()) + .unwrap_or(0); + + Ok(BackupInfo { + name, + path: String::new(), + created_at: format_timestamp_from_unix(now_secs), + size_bytes, + }) +} + +#[tauri::command] +pub async fn remote_list_backups( + pool: State<'_, SshConnectionPool>, + host_id: String, +) -> Result, String> { + // List backup directory names + let list_result = pool + .exec_login(&host_id, "ls -1d \"$HOME/.openclaw/.clawpal/backups\"/*/ 2>/dev/null || true") + .await?; + + let dirs: Vec = list_result + .stdout + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| l.trim().trim_end_matches('/').to_string()) + .collect(); + + if dirs.is_empty() { + return Ok(Vec::new()); + } + + // Build a single command to get sizes for all backup dirs (du -sk is POSIX portable) + let du_parts: Vec = dirs + .iter() + .map(|d| format!("du -sk '{}' 2>/dev/null || echo '0\t{}'", d, d)) + .collect(); + let du_cmd = du_parts.join("; "); + let du_result = pool.exec_login(&host_id, &du_cmd).await?; + + let mut size_map = std::collections::HashMap::new(); + for line in du_result.stdout.lines() { + let parts: Vec<&str> = line.splitn(2, '\t').collect(); + if parts.len() == 2 { + let size_kb: u64 = parts[0].trim().parse().unwrap_or(0); + let path = parts[1].trim().trim_end_matches('/'); + size_map.insert(path.to_string(), size_kb * 1024); + } + } + + let mut backups: Vec = dirs + .iter() + .map(|d| { + let name = d.rsplit('/').next().unwrap_or(d).to_string(); + let size_bytes = size_map.get(d.trim_end_matches('/')).copied().unwrap_or(0); + BackupInfo { + name: name.clone(), + path: String::new(), + created_at: name.clone(), // Name is the timestamp + size_bytes, + } + }) + .collect(); + + backups.sort_by(|a, b| b.name.cmp(&a.name)); + Ok(backups) +} + +#[tauri::command] +pub async fn remote_restore_from_backup( + pool: State<'_, SshConnectionPool>, + host_id: String, + backup_name: String, +) -> Result { + let cmd = format!( + concat!( + "set -e; ", + "BDIR=\"$HOME/.openclaw/.clawpal/backups/{name}\"; ", + "[ -d \"$BDIR\" ] || {{ echo 'Backup not found'; exit 1; }}; ", + "cp \"$BDIR/openclaw.json\" \"$HOME/.openclaw/openclaw.json\" 2>/dev/null || true; ", + "[ -d \"$BDIR/agents\" ] && cp -r \"$BDIR/agents\" \"$HOME/.openclaw/\" 2>/dev/null || true; ", + "[ -d \"$BDIR/memory\" ] && cp -r \"$BDIR/memory\" \"$HOME/.openclaw/\" 2>/dev/null || true; ", + "echo 'Restored from backup '\"'\"'{name}'\"'\"''" + ), + name = backup_name + ); + + let result = pool.exec_login(&host_id, &cmd).await?; + if result.exit_code != 0 { + return Err(format!("Remote restore failed: {}", result.stderr)); + } + + Ok(format!("Restored from backup '{}'", backup_name)) +} + +#[tauri::command] +pub async fn remote_delete_backup( + pool: State<'_, SshConnectionPool>, + host_id: String, + backup_name: String, +) -> Result { + let cmd = format!( + "BDIR=\"$HOME/.openclaw/.clawpal/backups/{name}\"; [ -d \"$BDIR\" ] && rm -rf \"$BDIR\" && echo 'deleted' || echo 'not_found'", + name = backup_name + ); + + let result = pool.exec_login(&host_id, &cmd).await?; + Ok(result.stdout.trim() == "deleted") +} + fn resolve_model_provider_base_url(cfg: &Value, provider: &str) -> Option { let provider = provider.trim(); if provider.is_empty() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9fede57..30b22bf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -32,6 +32,7 @@ use crate::commands::{ remote_chat_via_openclaw, remote_check_openclaw_update, remote_save_config_baseline, remote_check_config_dirty, remote_discard_config_changes, remote_apply_pending_changes, run_openclaw_upgrade, remote_run_openclaw_upgrade, + remote_backup_before_upgrade, remote_list_backups, remote_restore_from_backup, remote_delete_backup, RemoteConfigBaselines, }; use crate::ssh::SshConnectionPool; @@ -149,6 +150,10 @@ pub fn run() { remote_apply_pending_changes, run_openclaw_upgrade, remote_run_openclaw_upgrade, + remote_backup_before_upgrade, + remote_list_backups, + remote_restore_from_backup, + remote_delete_backup, ]) .run(tauri::generate_context!()) .expect("failed to run app"); diff --git a/src/components/UpgradeDialog.tsx b/src/components/UpgradeDialog.tsx index 5d05812..91915f9 100644 --- a/src/components/UpgradeDialog.tsx +++ b/src/components/UpgradeDialog.tsx @@ -48,20 +48,17 @@ export function UpgradeDialog({ }; const startUpgrade = async () => { - if (isRemote) { - setStep("upgrading"); - runUpgrade(); - } else { - setStep("backup"); - runBackup(); - } + setStep("backup"); + runBackup(); }; const runBackup = async () => { setLoading(true); setError(""); try { - const info = await api.backupBeforeUpgrade(); + const info = isRemote + ? await api.remoteBackupBeforeUpgrade(instanceId) + : await api.backupBeforeUpgrade(); setBackupName(info.name); setLoading(false); setStep("upgrading"); @@ -112,9 +109,7 @@ export function UpgradeDialog({ {latestVersion}

- {isRemote - ? "This will upgrade OpenClaw on the remote instance." - : "This will back up your config and upgrade OpenClaw."} + This will back up your config and upgrade OpenClaw{isRemote ? " on the remote instance" : ""}.

)} diff --git a/src/lib/api.ts b/src/lib/api.ts index 8d879e0..3996690 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -201,6 +201,16 @@ export const api = { remoteApplyPendingChanges: (hostId: string): Promise => invoke("remote_apply_pending_changes", { hostId }), + // Remote backup + remoteBackupBeforeUpgrade: (hostId: string): Promise => + invoke("remote_backup_before_upgrade", { hostId }), + remoteListBackups: (hostId: string): Promise => + invoke("remote_list_backups", { hostId }), + remoteRestoreFromBackup: (hostId: string, backupName: string): Promise => + invoke("remote_restore_from_backup", { hostId, backupName }), + remoteDeleteBackup: (hostId: string, backupName: string): Promise => + invoke("remote_delete_backup", { hostId, backupName }), + // Upgrade runOpenclawUpgrade: (): Promise => invoke("run_openclaw_upgrade", {}), diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 9d08d32..5972999 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -139,11 +139,15 @@ export function Home({ }, []); const refreshBackups = () => { - if (isRemote) { setBackups([]); return; } - api.listBackups().then(setBackups).catch((e) => console.error("Failed to load backups:", e)); + if (isRemote) { + if (!isConnected) return; + api.remoteListBackups(instanceId).then(setBackups).catch((e) => console.error("Failed to load remote backups:", e)); + } else { + api.listBackups().then(setBackups).catch((e) => console.error("Failed to load backups:", e)); + } }; // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(refreshBackups, [isRemote]); + useEffect(refreshBackups, [isRemote, isConnected, instanceId]); useEffect(() => { if (isRemote) { @@ -477,123 +481,130 @@ export function Home({ )} {/* Backups */} - {!isRemote && ( - <> -
-

Backups

- -
- {backupMessage && ( -

{backupMessage}

- )} - {backups === null ? ( -
- - -
- ) : backups.length === 0 ? ( -

No backups available.

- ) : ( -
- {backups.map((backup) => ( - - -
-
{backup.name}
-
- {formatTime(backup.createdAt)} — {formatBytes(backup.sizeBytes)} -
-
-
- +
+ {backupMessage && ( +

{backupMessage}

+ )} + {backups === null ? ( +
+ + +
+ ) : backups.length === 0 ? ( +

No backups available.

+ ) : ( +
+ {backups.map((backup) => ( + + +
+
{backup.name}
+
+ {formatTime(backup.createdAt)} — {formatBytes(backup.sizeBytes)} +
+
+
+ {!isRemote && ( + + )} + + + - - - - - - - Restore from backup? - - This will restore config and workspace files from backup "{backup.name}". Current files will be overwritten. - - - - Cancel - { - api.restoreFromBackup(backup.name) - .then((msg) => setBackupMessage(msg)) - .catch((e) => setBackupMessage(`Restore failed: ${e}`)); - }} - > - Restore - - - - - - - - - - - Delete backup? - - This will permanently delete backup "{backup.name}". This action cannot be undone. - - - - Cancel - { - api.deleteBackup(backup.name) - .then(() => { - setBackupMessage(`Deleted backup "${backup.name}"`); - refreshBackups(); - }) - .catch((e) => setBackupMessage(`Delete failed: ${e}`)); - }} - > - Delete - - - - -
-
-
- ))} -
- )} - + + + + Restore from backup? + + This will restore config and workspace files from backup "{backup.name}". Current files will be overwritten. + + + + Cancel + { + const restorePromise = isRemote + ? api.remoteRestoreFromBackup(instanceId, backup.name) + : api.restoreFromBackup(backup.name); + restorePromise + .then((msg) => setBackupMessage(msg)) + .catch((e) => setBackupMessage(`Restore failed: ${e}`)); + }} + > + Restore + + + + + + + + + + + Delete backup? + + This will permanently delete backup "{backup.name}". This action cannot be undone. + + + + Cancel + { + const deletePromise = isRemote + ? api.remoteDeleteBackup(instanceId, backup.name) + : api.deleteBackup(backup.name); + deletePromise + .then(() => { + setBackupMessage(`Deleted backup "${backup.name}"`); + refreshBackups(); + }) + .catch((e) => setBackupMessage(`Delete failed: ${e}`)); + }} + > + Delete + + + + +
+ + + ))} + )} {/* Create Agent Dialog */}