multica/apps/web/features/runtimes/components/update-section.tsx
LinYushen fdba410f11
feat(runtime): support CLI update from web runtime page (#331)
* feat(runtime): support CLI update from web runtime page

Add the ability to update the CLI daemon from the web Runtime detail page.
When a newer version is available on GitHub Releases, an update button
appears. Clicking it sends an update command through the server to the
daemon via the heartbeat mechanism (same pattern as ping). The daemon
executes `brew upgrade`, reports the result, and restarts itself with the
new binary.

Changes across all three layers:
- Frontend: version display, GitHub latest check, UpdateSection component
- Server: UpdateStore (in-memory), heartbeat extension, 3 new endpoints
- CLI: shared update logic, daemon handleUpdate + graceful restart

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(runtime): handle 'running' status in ReportUpdateResult

The daemon sends {"status":"running"} when it starts executing the
update, but ReportUpdateResult treated any non-"completed" status as
failure — immediately marking the update as failed before brew upgrade
even ran.

Fix: use a switch statement to handle "running" as a no-op (status is
already "running" from PopPending), and also timeout running updates
after 120 seconds in case brew upgrade hangs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:12:49 +08:00

227 lines
6.2 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from "react";
import {
Loader2,
CheckCircle2,
XCircle,
ArrowUpCircle,
Check,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { api } from "@/shared/api";
import type { RuntimeUpdateStatus } from "@/shared/types";
const GITHUB_RELEASES_URL =
"https://api.github.com/repos/multica-ai/multica/releases/latest";
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
let cachedLatestVersion: string | null = null;
let cachedAt = 0;
async function fetchLatestVersion(): Promise<string | null> {
if (cachedLatestVersion && Date.now() - cachedAt < CACHE_TTL_MS) {
return cachedLatestVersion;
}
try {
const resp = await fetch(GITHUB_RELEASES_URL, {
headers: { Accept: "application/vnd.github+json" },
});
if (!resp.ok) return null;
const data = await resp.json();
cachedLatestVersion = data.tag_name ?? null;
cachedAt = Date.now();
return cachedLatestVersion;
} catch {
return null;
}
}
function stripV(v: string): string {
return v.replace(/^v/, "");
}
function isNewer(latest: string, current: string): boolean {
const l = stripV(latest).split(".").map(Number);
const c = stripV(current).split(".").map(Number);
for (let i = 0; i < Math.max(l.length, c.length); i++) {
const lv = l[i] ?? 0;
const cv = c[i] ?? 0;
if (lv > cv) return true;
if (lv < cv) return false;
}
return false;
}
const statusConfig: Record<
RuntimeUpdateStatus,
{ label: string; icon: typeof Loader2; color: string }
> = {
pending: {
label: "Waiting for daemon...",
icon: Loader2,
color: "text-muted-foreground",
},
running: {
label: "Updating...",
icon: Loader2,
color: "text-info",
},
completed: {
label: "Update complete. Daemon is restarting...",
icon: CheckCircle2,
color: "text-success",
},
failed: { label: "Update failed", icon: XCircle, color: "text-destructive" },
timeout: { label: "Timeout", icon: XCircle, color: "text-warning" },
};
interface UpdateSectionProps {
runtimeId: string;
currentVersion: string | null;
isOnline: boolean;
}
export function UpdateSection({
runtimeId,
currentVersion,
isOnline,
}: UpdateSectionProps) {
const [latestVersion, setLatestVersion] = useState<string | null>(null);
const [status, setStatus] = useState<RuntimeUpdateStatus | null>(null);
const [error, setError] = useState("");
const [output, setOutput] = useState("");
const [updating, setUpdating] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const cleanup = useCallback(() => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
}, []);
useEffect(() => cleanup, [cleanup]);
// Fetch latest version on mount.
useEffect(() => {
fetchLatestVersion().then(setLatestVersion);
}, []);
const handleUpdate = async () => {
if (!latestVersion) return;
cleanup();
setUpdating(true);
setStatus("pending");
setError("");
setOutput("");
try {
const update = await api.initiateUpdate(runtimeId, latestVersion);
pollRef.current = setInterval(async () => {
try {
const result = await api.getUpdateResult(runtimeId, update.id);
setStatus(result.status as RuntimeUpdateStatus);
if (result.status === "completed") {
setOutput(result.output ?? "");
setUpdating(false);
cleanup();
} else if (
result.status === "failed" ||
result.status === "timeout"
) {
setError(result.error ?? "Unknown error");
setUpdating(false);
cleanup();
}
} catch {
// ignore poll errors
}
}, 2000);
} catch {
setStatus("failed");
setError("Failed to initiate update");
setUpdating(false);
}
};
const hasUpdate =
currentVersion &&
latestVersion &&
isNewer(latestVersion, currentVersion);
const config = status ? statusConfig[status] : null;
const Icon = config?.icon;
const isActive = status === "pending" || status === "running";
return (
<div className="space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">CLI Version:</span>
<span className="text-xs font-mono">
{currentVersion ?? "unknown"}
</span>
{!hasUpdate && currentVersion && latestVersion && !status && (
<span className="inline-flex items-center gap-1 text-xs text-success">
<Check className="h-3 w-3" />
Latest
</span>
)}
{hasUpdate && !status && (
<>
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs font-mono text-info">
{latestVersion}
</span>
<span className="text-xs text-muted-foreground">available</span>
</>
)}
{hasUpdate && isOnline && !status && (
<Button
variant="outline"
size="xs"
onClick={handleUpdate}
disabled={updating}
>
<ArrowUpCircle className="h-3 w-3" />
Update
</Button>
)}
{config && Icon && (
<span
className={`inline-flex items-center gap-1 text-xs ${config.color}`}
>
<Icon className={`h-3 w-3 ${isActive ? "animate-spin" : ""}`} />
{config.label}
</span>
)}
</div>
{status === "completed" && output && (
<div className="rounded-lg border bg-success/5 px-3 py-2">
<p className="text-xs text-success">{output}</p>
</div>
)}
{(status === "failed" || status === "timeout") && error && (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2">
<p className="text-xs text-destructive">{error}</p>
{status === "failed" && (
<Button
variant="ghost"
size="xs"
className="mt-1"
onClick={handleUpdate}
>
Retry
</Button>
)}
</div>
)}
</div>
);
}