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)
}
// ---- 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> {
let provider = provider.trim();
if provider.is_empty() {

View File

@@ -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");

View File

@@ -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({
<code className="font-medium text-primary">{latestVersion}</code>
</div>
<p className="text-sm text-muted-foreground">
{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" : ""}.
</p>
</div>
)}

View File

@@ -201,6 +201,16 @@ export const api = {
remoteApplyPendingChanges: (hostId: string): Promise<boolean> =>
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
runOpenclawUpgrade: (): Promise<string> =>
invoke("run_openclaw_upgrade", {}),

View File

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