feat: in-app OpenClaw upgrade dialog with backup flow

Replace the old "Backup & Upgrade" button (which opened GitHub releases)
with a proper multi-step upgrade dialog: confirm → backup → run upgrade
command → done. Works for both local and remote (SSH) instances.

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 17:47:59 +09:00
parent bed34f9d69
commit b7799627ea
5 changed files with 278 additions and 31 deletions

View File

@@ -5368,3 +5368,46 @@ pub async fn remote_refresh_model_catalog(
let cfg: Value = serde_json::from_str(&raw).map_err(|e| format!("Failed to parse remote config: {e}"))?;
Ok(collect_model_catalog(&cfg))
}
#[tauri::command]
pub async fn run_openclaw_upgrade() -> Result<String, String> {
let output = Command::new("bash")
.args(["-c", "curl -fsSL https://openclaw.ai/install.sh | bash"])
.output()
.map_err(|e| format!("Failed to run upgrade: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let combined = if stderr.is_empty() {
stdout
} else {
format!("{stdout}\n{stderr}")
};
if output.status.success() {
Ok(combined)
} else {
Err(combined)
}
}
#[tauri::command]
pub async fn remote_run_openclaw_upgrade(
pool: State<'_, SshConnectionPool>,
host_id: String,
) -> Result<String, String> {
let result = pool
.exec_login(
&host_id,
"curl -fsSL https://openclaw.ai/install.sh | bash",
)
.await?;
let combined = if result.stderr.is_empty() {
result.stdout.clone()
} else {
format!("{}\n{}", result.stdout, result.stderr)
};
if result.exit_code == 0 {
Ok(combined)
} else {
Err(combined)
}
}

View File

@@ -31,6 +31,7 @@ use crate::commands::{
remote_extract_model_profiles_from_config, remote_refresh_model_catalog,
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,
RemoteConfigBaselines,
};
use crate::ssh::SshConnectionPool;
@@ -146,6 +147,8 @@ pub fn run() {
remote_check_config_dirty,
remote_discard_config_changes,
remote_apply_pending_changes,
run_openclaw_upgrade,
remote_run_openclaw_upgrade,
])
.run(tauri::generate_context!())
.expect("failed to run app");

View File

@@ -0,0 +1,207 @@
import { useState } from "react";
import { api } from "../lib/api";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
type Step = "confirm" | "backup" | "upgrading" | "done";
export function UpgradeDialog({
open,
onOpenChange,
isRemote,
instanceId,
currentVersion,
latestVersion,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
isRemote: boolean;
instanceId: string;
currentVersion: string;
latestVersion: string;
}) {
const [step, setStep] = useState<Step>("confirm");
const [backupName, setBackupName] = useState("");
const [output, setOutput] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const reset = () => {
setStep("confirm");
setBackupName("");
setOutput("");
setError("");
setLoading(false);
};
const handleClose = (open: boolean) => {
if (!open && !loading) {
reset();
onOpenChange(false);
}
};
const startUpgrade = async () => {
if (isRemote) {
setStep("upgrading");
runUpgrade();
} else {
setStep("backup");
runBackup();
}
};
const runBackup = async () => {
setLoading(true);
setError("");
try {
const info = await api.backupBeforeUpgrade();
setBackupName(info.name);
setLoading(false);
setStep("upgrading");
runUpgrade();
} catch (e) {
setError(String(e));
setLoading(false);
}
};
const runUpgrade = async () => {
setLoading(true);
setError("");
setOutput("");
try {
const result = isRemote
? await api.remoteRunOpenclawUpgrade(instanceId)
: await api.runOpenclawUpgrade();
setOutput(result);
setStep("done");
} catch (e) {
setOutput(String(e));
setError("Upgrade failed. See output below.");
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
{step === "confirm" && "Upgrade OpenClaw"}
{step === "backup" && "Creating Backup..."}
{step === "upgrading" && "Upgrading..."}
{step === "done" && "Upgrade Complete"}
</DialogTitle>
</DialogHeader>
{step === "confirm" && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Current:</span>
<code className="font-medium">{currentVersion}</code>
<span className="text-muted-foreground mx-1">&rarr;</span>
<span className="text-muted-foreground">New:</span>
<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."}
</p>
</div>
)}
{step === "backup" && (
<div className="space-y-3">
{loading && (
<div className="flex items-center gap-2 text-sm">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span>Creating backup...</span>
</div>
)}
{error && (
<div className="text-sm text-destructive">{error}</div>
)}
</div>
)}
{step === "upgrading" && (
<div className="space-y-3">
{backupName && (
<p className="text-sm text-muted-foreground">
Backup created: <code>{backupName}</code>
</p>
)}
{loading && (
<div className="flex items-center gap-2 text-sm">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span>Running upgrade...</span>
</div>
)}
{error && (
<div className="text-sm text-destructive">{error}</div>
)}
{output && (
<pre className="max-h-60 overflow-auto rounded-md bg-muted p-3 text-xs font-mono whitespace-pre-wrap">
{output}
</pre>
)}
</div>
)}
{step === "done" && (
<div className="space-y-3">
{backupName && (
<p className="text-sm text-muted-foreground">
Backup: <code>{backupName}</code>
</p>
)}
<p className="text-sm font-medium text-green-600">
Upgrade completed successfully.
</p>
{output && (
<pre className="max-h-60 overflow-auto rounded-md bg-muted p-3 text-xs font-mono whitespace-pre-wrap">
{output}
</pre>
)}
</div>
)}
<DialogFooter>
{step === "confirm" && (
<>
<Button variant="outline" onClick={() => handleClose(false)}>
Cancel
</Button>
<Button onClick={startUpgrade}>Start Upgrade</Button>
</>
)}
{step === "backup" && error && (
<>
<Button variant="outline" onClick={() => handleClose(false)}>
Cancel
</Button>
<Button onClick={runBackup}>Retry Backup</Button>
</>
)}
{step === "upgrading" && error && (
<Button variant="outline" onClick={() => handleClose(false)}>
Close
</Button>
)}
{step === "done" && (
<Button onClick={() => handleClose(false)}>Close</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -200,4 +200,10 @@ export const api = {
invoke("remote_discard_config_changes", { hostId }),
remoteApplyPendingChanges: (hostId: string): Promise<boolean> =>
invoke("remote_apply_pending_changes", { hostId }),
// Upgrade
runOpenclawUpgrade: (): Promise<string> =>
invoke("run_openclaw_upgrade", {}),
remoteRunOpenclawUpgrade: (hostId: string): Promise<string> =>
invoke("remote_run_openclaw_upgrade", { hostId }),
};

View File

@@ -24,6 +24,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { CreateAgentDialog } from "@/components/CreateAgentDialog";
import { UpgradeDialog } from "@/components/UpgradeDialog";
import { RecipeCard } from "@/components/RecipeCard";
import { Skeleton } from "@/components/ui/skeleton";
import type { StatusLight, AgentOverview, Recipe, BackupInfo, ModelProfile, RemoteSystemStatus } from "../lib/types";
@@ -81,6 +82,7 @@ export function Home({
// Create agent dialog
const [showCreateAgent, setShowCreateAgent] = useState(false);
const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
// Health status with grace period: retry quickly when unhealthy, then slow-poll
const [statusSettled, setStatusSettled] = useState(false);
@@ -286,37 +288,13 @@ export function Home({
>
View
</Button>
{isRemote ? (
<Button
size="sm"
className="text-xs h-6"
onClick={() => api.openUrl("https://github.com/openclaw/openclaw/releases")}
>
Upgrade
</Button>
) : (
<Button
size="sm"
className="text-xs h-6"
disabled={backingUp}
onClick={() => {
setBackingUp(true);
setBackupMessage("");
api.backupBeforeUpgrade()
.then((info) => {
setBackupMessage(`Backup: ${info.name}`);
api.openUrl("https://github.com/openclaw/openclaw/releases");
})
.catch((e) => setBackupMessage(`Backup failed: ${e}`))
.finally(() => setBackingUp(false));
}}
>
{backingUp ? "Backing up..." : "Backup & Upgrade"}
</Button>
)}
{backupMessage && (
<span className="text-xs text-muted-foreground">{backupMessage}</span>
)}
<Button
size="sm"
className="text-xs h-6"
onClick={() => setShowUpgradeDialog(true)}
>
Upgrade
</Button>
</>
)}
</div>
@@ -625,6 +603,16 @@ export function Home({
modelProfiles={modelProfiles}
onCreated={() => refreshAgents()}
/>
{/* Upgrade Dialog */}
<UpgradeDialog
open={showUpgradeDialog}
onOpenChange={setShowUpgradeDialog}
isRemote={isRemote}
instanceId={instanceId}
currentVersion={version || ""}
latestVersion={updateInfo?.latest || ""}
/>
</div>
);
}