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:
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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", {}),
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user