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 <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
zhixian
2026-02-19 19:03:20 +09:00
parent b7799627ea
commit 92e1b7e4c8
5 changed files with 293 additions and 130 deletions

View File

@@ -4017,6 +4017,148 @@ pub fn delete_backup(backup_name: String) -> Result<bool, String> {
Ok(true) Ok(true)
} }
// ---- Remote Backup / Restore (via SSH) ----
#[tauri::command]
pub async fn remote_backup_before_upgrade(
pool: State<'_, SshConnectionPool>,
host_id: String,
) -> Result<BackupInfo, String> {
let now_secs = unix_timestamp_secs();
let now_dt = chrono::DateTime::<chrono::Utc>::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<Vec<BackupInfo>, 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<String> = 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<String> = 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<BackupInfo> = 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<String, String> {
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<bool, String> {
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<String> { fn resolve_model_provider_base_url(cfg: &Value, provider: &str) -> Option<String> {
let provider = provider.trim(); let provider = provider.trim();
if provider.is_empty() { if provider.is_empty() {

View File

@@ -32,6 +32,7 @@ use crate::commands::{
remote_chat_via_openclaw, remote_check_openclaw_update, remote_chat_via_openclaw, remote_check_openclaw_update,
remote_save_config_baseline, remote_check_config_dirty, remote_discard_config_changes, remote_apply_pending_changes, remote_save_config_baseline, remote_check_config_dirty, remote_discard_config_changes, remote_apply_pending_changes,
run_openclaw_upgrade, remote_run_openclaw_upgrade, run_openclaw_upgrade, remote_run_openclaw_upgrade,
remote_backup_before_upgrade, remote_list_backups, remote_restore_from_backup, remote_delete_backup,
RemoteConfigBaselines, RemoteConfigBaselines,
}; };
use crate::ssh::SshConnectionPool; use crate::ssh::SshConnectionPool;
@@ -149,6 +150,10 @@ pub fn run() {
remote_apply_pending_changes, remote_apply_pending_changes,
run_openclaw_upgrade, run_openclaw_upgrade,
remote_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!()) .run(tauri::generate_context!())
.expect("failed to run app"); .expect("failed to run app");

View File

@@ -48,20 +48,17 @@ export function UpgradeDialog({
}; };
const startUpgrade = async () => { const startUpgrade = async () => {
if (isRemote) { setStep("backup");
setStep("upgrading"); runBackup();
runUpgrade();
} else {
setStep("backup");
runBackup();
}
}; };
const runBackup = async () => { const runBackup = async () => {
setLoading(true); setLoading(true);
setError(""); setError("");
try { try {
const info = await api.backupBeforeUpgrade(); const info = isRemote
? await api.remoteBackupBeforeUpgrade(instanceId)
: await api.backupBeforeUpgrade();
setBackupName(info.name); setBackupName(info.name);
setLoading(false); setLoading(false);
setStep("upgrading"); setStep("upgrading");
@@ -112,9 +109,7 @@ export function UpgradeDialog({
<code className="font-medium text-primary">{latestVersion}</code> <code className="font-medium text-primary">{latestVersion}</code>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{isRemote This will back up your config and upgrade OpenClaw{isRemote ? " on the remote instance" : ""}.
? "This will upgrade OpenClaw on the remote instance."
: "This will back up your config and upgrade OpenClaw."}
</p> </p>
</div> </div>
)} )}

View File

@@ -201,6 +201,16 @@ export const api = {
remoteApplyPendingChanges: (hostId: string): Promise<boolean> => remoteApplyPendingChanges: (hostId: string): Promise<boolean> =>
invoke("remote_apply_pending_changes", { hostId }), invoke("remote_apply_pending_changes", { hostId }),
// Remote backup
remoteBackupBeforeUpgrade: (hostId: string): Promise<BackupInfo> =>
invoke("remote_backup_before_upgrade", { hostId }),
remoteListBackups: (hostId: string): Promise<BackupInfo[]> =>
invoke("remote_list_backups", { hostId }),
remoteRestoreFromBackup: (hostId: string, backupName: string): Promise<string> =>
invoke("remote_restore_from_backup", { hostId, backupName }),
remoteDeleteBackup: (hostId: string, backupName: string): Promise<boolean> =>
invoke("remote_delete_backup", { hostId, backupName }),
// Upgrade // Upgrade
runOpenclawUpgrade: (): Promise<string> => runOpenclawUpgrade: (): Promise<string> =>
invoke("run_openclaw_upgrade", {}), invoke("run_openclaw_upgrade", {}),

View File

@@ -139,11 +139,15 @@ export function Home({
}, []); }, []);
const refreshBackups = () => { const refreshBackups = () => {
if (isRemote) { setBackups([]); return; } if (isRemote) {
api.listBackups().then(setBackups).catch((e) => console.error("Failed to load backups:", e)); 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 // eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(refreshBackups, [isRemote]); useEffect(refreshBackups, [isRemote, isConnected, instanceId]);
useEffect(() => { useEffect(() => {
if (isRemote) { if (isRemote) {
@@ -477,123 +481,130 @@ export function Home({
)} )}
{/* Backups */} {/* Backups */}
{!isRemote && ( <div className="flex items-center justify-between mt-6 mb-3">
<> <h3 className="text-lg font-semibold">Backups</h3>
<div className="flex items-center justify-between mt-6 mb-3"> <Button
<h3 className="text-lg font-semibold">Backups</h3> size="sm"
<Button variant="outline"
size="sm" disabled={backingUp}
variant="outline" onClick={() => {
disabled={backingUp} setBackingUp(true);
onClick={() => { setBackupMessage("");
setBackingUp(true); const backupPromise = isRemote
setBackupMessage(""); ? api.remoteBackupBeforeUpgrade(instanceId)
api.backupBeforeUpgrade() : api.backupBeforeUpgrade();
.then((info) => { backupPromise
setBackupMessage(`Created backup: ${info.name}`); .then((info) => {
refreshBackups(); setBackupMessage(`Created backup: ${info.name}`);
}) refreshBackups();
.catch((e) => setBackupMessage(`Backup failed: ${e}`)) })
.finally(() => setBackingUp(false)); .catch((e) => setBackupMessage(`Backup failed: ${e}`))
}} .finally(() => setBackingUp(false));
> }}
{backingUp ? "Creating..." : "Create Backup"} >
</Button> {backingUp ? "Creating..." : "Create Backup"}
</div> </Button>
{backupMessage && ( </div>
<p className="text-sm text-muted-foreground mb-2">{backupMessage}</p> {backupMessage && (
)} <p className="text-sm text-muted-foreground mb-2">{backupMessage}</p>
{backups === null ? ( )}
<div className="space-y-2"> {backups === null ? (
<Skeleton className="h-16 w-full" /> <div className="space-y-2">
<Skeleton className="h-16 w-full" /> <Skeleton className="h-16 w-full" />
</div> <Skeleton className="h-16 w-full" />
) : backups.length === 0 ? ( </div>
<p className="text-muted-foreground text-sm">No backups available.</p> ) : backups.length === 0 ? (
) : ( <p className="text-muted-foreground text-sm">No backups available.</p>
<div className="space-y-2"> ) : (
{backups.map((backup) => ( <div className="space-y-2">
<Card key={backup.name}> {backups.map((backup) => (
<CardContent className="flex items-center justify-between"> <Card key={backup.name}>
<div> <CardContent className="flex items-center justify-between">
<div className="font-medium text-sm">{backup.name}</div> <div>
<div className="text-xs text-muted-foreground"> <div className="font-medium text-sm">{backup.name}</div>
{formatTime(backup.createdAt)} {formatBytes(backup.sizeBytes)} <div className="text-xs text-muted-foreground">
</div> {formatTime(backup.createdAt)} {formatBytes(backup.sizeBytes)}
</div> </div>
<div className="flex gap-1.5"> </div>
<Button <div className="flex gap-1.5">
size="sm" {!isRemote && (
variant="outline" <Button
onClick={() => api.openUrl(backup.path)} size="sm"
> variant="outline"
Show onClick={() => api.openUrl(backup.path)}
>
Show
</Button>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="outline">
Restore
</Button> </Button>
<AlertDialog> </AlertDialogTrigger>
<AlertDialogTrigger asChild> <AlertDialogContent>
<Button size="sm" variant="outline"> <AlertDialogHeader>
Restore <AlertDialogTitle>Restore from backup?</AlertDialogTitle>
</Button> <AlertDialogDescription>
</AlertDialogTrigger> This will restore config and workspace files from backup "{backup.name}". Current files will be overwritten.
<AlertDialogContent> </AlertDialogDescription>
<AlertDialogHeader> </AlertDialogHeader>
<AlertDialogTitle>Restore from backup?</AlertDialogTitle> <AlertDialogFooter>
<AlertDialogDescription> <AlertDialogCancel>Cancel</AlertDialogCancel>
This will restore config and workspace files from backup "{backup.name}". Current files will be overwritten. <AlertDialogAction
</AlertDialogDescription> onClick={() => {
</AlertDialogHeader> const restorePromise = isRemote
<AlertDialogFooter> ? api.remoteRestoreFromBackup(instanceId, backup.name)
<AlertDialogCancel>Cancel</AlertDialogCancel> : api.restoreFromBackup(backup.name);
<AlertDialogAction restorePromise
onClick={() => { .then((msg) => setBackupMessage(msg))
api.restoreFromBackup(backup.name) .catch((e) => setBackupMessage(`Restore failed: ${e}`));
.then((msg) => setBackupMessage(msg)) }}
.catch((e) => setBackupMessage(`Restore failed: ${e}`)); >
}} Restore
> </AlertDialogAction>
Restore </AlertDialogFooter>
</AlertDialogAction> </AlertDialogContent>
</AlertDialogFooter> </AlertDialog>
</AlertDialogContent> <AlertDialog>
</AlertDialog> <AlertDialogTrigger asChild>
<AlertDialog> <Button size="sm" variant="destructive">
<AlertDialogTrigger asChild> Delete
<Button size="sm" variant="destructive"> </Button>
Delete </AlertDialogTrigger>
</Button> <AlertDialogContent>
</AlertDialogTrigger> <AlertDialogHeader>
<AlertDialogContent> <AlertDialogTitle>Delete backup?</AlertDialogTitle>
<AlertDialogHeader> <AlertDialogDescription>
<AlertDialogTitle>Delete backup?</AlertDialogTitle> This will permanently delete backup "{backup.name}". This action cannot be undone.
<AlertDialogDescription> </AlertDialogDescription>
This will permanently delete backup "{backup.name}". This action cannot be undone. </AlertDialogHeader>
</AlertDialogDescription> <AlertDialogFooter>
</AlertDialogHeader> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogFooter> <AlertDialogAction
<AlertDialogCancel>Cancel</AlertDialogCancel> className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
<AlertDialogAction onClick={() => {
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" const deletePromise = isRemote
onClick={() => { ? api.remoteDeleteBackup(instanceId, backup.name)
api.deleteBackup(backup.name) : api.deleteBackup(backup.name);
.then(() => { deletePromise
setBackupMessage(`Deleted backup "${backup.name}"`); .then(() => {
refreshBackups(); setBackupMessage(`Deleted backup "${backup.name}"`);
}) refreshBackups();
.catch((e) => setBackupMessage(`Delete failed: ${e}`)); })
}} .catch((e) => setBackupMessage(`Delete failed: ${e}`));
> }}
Delete >
</AlertDialogAction> Delete
</AlertDialogFooter> </AlertDialogAction>
</AlertDialogContent> </AlertDialogFooter>
</AlertDialog> </AlertDialogContent>
</div> </AlertDialog>
</CardContent> </div>
</Card> </CardContent>
))} </Card>
</div> ))}
)} </div>
</>
)} )}
{/* Create Agent Dialog */} {/* Create Agent Dialog */}