multica/apps/web/features/runtimes/components/runtime-detail.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

115 lines
3.8 KiB
TypeScript

import type { AgentRuntime } from "@/shared/types";
import { formatLastSeen } from "../utils";
import { RuntimeModeIcon, StatusBadge, InfoField } from "./shared";
import { PingSection } from "./ping-section";
import { UpdateSection } from "./update-section";
import { UsageSection } from "./usage-section";
function getCliVersion(metadata: Record<string, unknown>): string | null {
if (metadata && typeof metadata.version === "string" && metadata.version) {
return metadata.version;
}
return null;
}
export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
const cliVersion =
runtime.runtime_mode === "local" ? getCliVersion(runtime.metadata) : null;
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<div className="flex min-w-0 items-center gap-2">
<div
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-md ${
runtime.status === "online" ? "bg-success/10" : "bg-muted"
}`}
>
<RuntimeModeIcon mode={runtime.runtime_mode} />
</div>
<div className="min-w-0">
<h2 className="text-sm font-semibold truncate">{runtime.name}</h2>
</div>
</div>
<StatusBadge status={runtime.status} />
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Info grid */}
<div className="grid grid-cols-2 gap-4">
<InfoField label="Runtime Mode" value={runtime.runtime_mode} />
<InfoField label="Provider" value={runtime.provider} />
<InfoField label="Status" value={runtime.status} />
<InfoField
label="Last Seen"
value={formatLastSeen(runtime.last_seen_at)}
/>
{runtime.device_info && (
<InfoField label="Device" value={runtime.device_info} />
)}
{runtime.daemon_id && (
<InfoField label="Daemon ID" value={runtime.daemon_id} mono />
)}
</div>
{/* CLI Version & Update */}
{runtime.runtime_mode === "local" && (
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-3">
CLI Version
</h3>
<UpdateSection
runtimeId={runtime.id}
currentVersion={cliVersion}
isOnline={runtime.status === "online"}
/>
</div>
)}
{/* Connection Test */}
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-3">
Connection Test
</h3>
<PingSection runtimeId={runtime.id} />
</div>
{/* Usage */}
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-3">
Token Usage
</h3>
<UsageSection runtimeId={runtime.id} />
</div>
{/* Metadata */}
{runtime.metadata && Object.keys(runtime.metadata).length > 0 && (
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-2">
Metadata
</h3>
<div className="rounded-lg border bg-muted/30 p-3">
<pre className="text-xs font-mono whitespace-pre-wrap break-all">
{JSON.stringify(runtime.metadata, null, 2)}
</pre>
</div>
</div>
)}
{/* Timestamps */}
<div className="grid grid-cols-2 gap-4 border-t pt-4">
<InfoField
label="Created"
value={new Date(runtime.created_at).toLocaleString()}
/>
<InfoField
label="Updated"
value={new Date(runtime.updated_at).toLocaleString()}
/>
</div>
</div>
</div>
);
}