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}"))?;
|
let cfg: Value = serde_json::from_str(&raw).map_err(|e| format!("Failed to parse remote config: {e}"))?;
|
||||||
Ok(collect_model_catalog(&cfg))
|
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_extract_model_profiles_from_config, remote_refresh_model_catalog,
|
||||||
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,
|
||||||
RemoteConfigBaselines,
|
RemoteConfigBaselines,
|
||||||
};
|
};
|
||||||
use crate::ssh::SshConnectionPool;
|
use crate::ssh::SshConnectionPool;
|
||||||
@@ -146,6 +147,8 @@ pub fn run() {
|
|||||||
remote_check_config_dirty,
|
remote_check_config_dirty,
|
||||||
remote_discard_config_changes,
|
remote_discard_config_changes,
|
||||||
remote_apply_pending_changes,
|
remote_apply_pending_changes,
|
||||||
|
run_openclaw_upgrade,
|
||||||
|
remote_run_openclaw_upgrade,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("failed to run app");
|
.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 }),
|
invoke("remote_discard_config_changes", { hostId }),
|
||||||
remoteApplyPendingChanges: (hostId: string): Promise<boolean> =>
|
remoteApplyPendingChanges: (hostId: string): Promise<boolean> =>
|
||||||
invoke("remote_apply_pending_changes", { hostId }),
|
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,
|
AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { CreateAgentDialog } from "@/components/CreateAgentDialog";
|
import { CreateAgentDialog } from "@/components/CreateAgentDialog";
|
||||||
|
import { UpgradeDialog } from "@/components/UpgradeDialog";
|
||||||
import { RecipeCard } from "@/components/RecipeCard";
|
import { RecipeCard } from "@/components/RecipeCard";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import type { StatusLight, AgentOverview, Recipe, BackupInfo, ModelProfile, RemoteSystemStatus } from "../lib/types";
|
import type { StatusLight, AgentOverview, Recipe, BackupInfo, ModelProfile, RemoteSystemStatus } from "../lib/types";
|
||||||
@@ -81,6 +82,7 @@ export function Home({
|
|||||||
|
|
||||||
// Create agent dialog
|
// Create agent dialog
|
||||||
const [showCreateAgent, setShowCreateAgent] = useState(false);
|
const [showCreateAgent, setShowCreateAgent] = useState(false);
|
||||||
|
const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
|
||||||
|
|
||||||
// Health status with grace period: retry quickly when unhealthy, then slow-poll
|
// Health status with grace period: retry quickly when unhealthy, then slow-poll
|
||||||
const [statusSettled, setStatusSettled] = useState(false);
|
const [statusSettled, setStatusSettled] = useState(false);
|
||||||
@@ -286,37 +288,13 @@ export function Home({
|
|||||||
>
|
>
|
||||||
View
|
View
|
||||||
</Button>
|
</Button>
|
||||||
{isRemote ? (
|
<Button
|
||||||
<Button
|
size="sm"
|
||||||
size="sm"
|
className="text-xs h-6"
|
||||||
className="text-xs h-6"
|
onClick={() => setShowUpgradeDialog(true)}
|
||||||
onClick={() => api.openUrl("https://github.com/openclaw/openclaw/releases")}
|
>
|
||||||
>
|
Upgrade
|
||||||
Upgrade
|
</Button>
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -625,6 +603,16 @@ export function Home({
|
|||||||
modelProfiles={modelProfiles}
|
modelProfiles={modelProfiles}
|
||||||
onCreated={() => refreshAgents()}
|
onCreated={() => refreshAgents()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Upgrade Dialog */}
|
||||||
|
<UpgradeDialog
|
||||||
|
open={showUpgradeDialog}
|
||||||
|
onOpenChange={setShowUpgradeDialog}
|
||||||
|
isRemote={isRemote}
|
||||||
|
instanceId={instanceId}
|
||||||
|
currentVersion={version || ""}
|
||||||
|
latestVersion={updateInfo?.latest || ""}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user