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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
207
src/components/UpgradeDialog.tsx
Normal file
207
src/components/UpgradeDialog.tsx
Normal 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">→</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>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user