Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 160 additions & 5 deletions apps/web/app/(dashboard)/runtime/[vmId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,35 @@ import {
Cpu,
Activity,
StopCircle,
Pause,
Play,
GitFork,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import clsx from "clsx";
import { Button } from "@/components/button";
import { ConfirmModal } from "@/components/modal";
import Link from "next/link";
import { ConfirmModal, Modal, ModalField } from "@/components/modal";
import { PageHeader } from "@/components/page-header";
import { PageSkeleton } from "@/components/skeleton";
import { useToast } from "@/components/toast";
import { runtimeApi, UnauthorizedError } from "@/lib/runtime-api";
import { HttpError, runtimeApi, UnauthorizedError } from "@/lib/runtime-api";

// Response shapes mirror POST /v1/runtime/vms/{id}/{pause,resume,fork}.
interface ForkResult {
sourceVmId: string;
snapshotId: string;
forks: Array<{ vmId: string; state: string; error?: string }>;
}

/** Friendly copy for the contract's 402 (quota) / 409 (wrong state). */
function lifecycleError(verb: string, err: unknown): string {
if (err instanceof HttpError) {
if (err.status === 402) return `${verb} blocked: runtime quota exceeded`;
if (err.status === 409) return `${verb} not possible in the VM's current state`;
}
return `${verb} failed: ` + (err instanceof Error ? err.message : String(err));
}

// Shapes mirror the control-plane JSON (camelCase tags on vmRow + the
// events array — NOT snake_case, and the key is `events` not `audit`).
Expand All @@ -34,6 +54,8 @@ interface VmDetail {
createdAt: string;
terminatedAt?: string | null;
lastHeartbeatAt?: string | null;
snapshotId?: string | null;
parentVmId?: string | null;
};
events: Array<{ id: number; action: string; attrs: Record<string, unknown>; at: string }>;
}
Expand All @@ -47,6 +69,9 @@ export default function RuntimeVmPage() {
const [logs, setLogs] = useState<string[]>([]);
const [terminating, setTerminating] = useState(false);
const [confirmTerminate, setConfirmTerminate] = useState(false);
const [busy, setBusy] = useState<"pause" | "resume" | "fork" | null>(null);
const [forkOpen, setForkOpen] = useState(false);
const [forkCount, setForkCount] = useState("1");
const logBoxRef = useRef<HTMLDivElement>(null);

// Initial + polling load of details + audit. Dedup errors so a 401 / 500
Expand Down Expand Up @@ -124,6 +149,66 @@ export default function RuntimeVmPage() {
}
}, [vmId, toast]);

const reload = useCallback(async () => {
if (!vmId) return;
try {
setDetail(await runtimeApi.get<VmDetail>(`/v1/runtime/vms/${vmId}`));
} catch {
/* the 5s poll will retry */
}
}, [vmId]);

const pause = useCallback(async () => {
if (!vmId) return;
setBusy("pause");
try {
await runtimeApi.post(`/v1/runtime/vms/${vmId}/pause`);
toast.success("VM paused");
await reload();
} catch (err) {
if (!(err instanceof UnauthorizedError)) toast.error(lifecycleError("Pause", err));
} finally {
setBusy(null);
}
}, [vmId, toast, reload]);

const resume = useCallback(async () => {
if (!vmId) return;
setBusy("resume");
try {
await runtimeApi.post(`/v1/runtime/vms/${vmId}/resume`);
toast.success("VM resumed");
await reload();
} catch (err) {
if (!(err instanceof UnauthorizedError)) toast.error(lifecycleError("Resume", err));
} finally {
setBusy(null);
}
}, [vmId, toast, reload]);

const forkCountNum = Number(forkCount);
const forkCountValid = Number.isInteger(forkCountNum) && forkCountNum >= 1 && forkCountNum <= 10;

const fork = useCallback(async () => {
if (!vmId || !forkCountValid) return;
setBusy("fork");
try {
const r = await runtimeApi.post<ForkResult>(`/v1/runtime/vms/${vmId}/fork`, { count: forkCountNum });
const failed = r.forks.filter((f) => f.error);
if (failed.length === 0) {
toast.success(`Forked ${r.forks.length} VM${r.forks.length === 1 ? "" : "s"}`);
} else {
toast.warning(`Forked ${r.forks.length - failed.length} of ${r.forks.length}; first error: ${failed[0].error}`);
}
setForkOpen(false);
await reload();
} catch (err) {
if (!(err instanceof UnauthorizedError)) toast.error(lifecycleError("Fork", err));
} finally {
setBusy(null);
}
}, [vmId, forkCountValid, forkCountNum, toast, reload]);

if (!detail) return <PageSkeleton />;

const v = detail.vm;
Expand All @@ -145,9 +230,26 @@ export default function RuntimeVmPage() {
}
action={
active ? (
<Button variant="danger" size="sm" loading={terminating} onClick={() => setConfirmTerminate(true)} icon={<StopCircle className="h-3.5 w-3.5" />}>
Terminate
</Button>
<div className="flex items-center gap-2">
{v.state === "running" && (
<Button variant="secondary" size="sm" loading={busy === "pause"} onClick={pause} icon={<Pause className="h-3.5 w-3.5" />}>
Pause
</Button>
)}
{v.state === "paused" && (
<Button variant="secondary" size="sm" loading={busy === "resume"} onClick={resume} icon={<Play className="h-3.5 w-3.5" />}>
Resume
</Button>
)}
{(v.state === "running" || v.state === "paused") && (
<Button variant="secondary" size="sm" onClick={() => setForkOpen(true)} icon={<GitFork className="h-3.5 w-3.5" />}>
Fork
</Button>
)}
<Button variant="danger" size="sm" loading={terminating} onClick={() => setConfirmTerminate(true)} icon={<StopCircle className="h-3.5 w-3.5" />}>
Terminate
</Button>
</div>
) : null
}
/>
Expand All @@ -163,6 +265,36 @@ export default function RuntimeVmPage() {
onConfirm={terminate}
/>

<Modal
open={forkOpen}
onClose={() => setForkOpen(false)}
title={`Fork ${vmId}`}
description="Snapshot this VM and start copies from the snapshot."
size="sm"
footer={
<>
<Button variant="ghost" size="sm" onClick={() => setForkOpen(false)}>
Cancel
</Button>
<Button size="sm" loading={busy === "fork"} disabled={!forkCountValid} onClick={fork}>
Fork
</Button>
</>
}
>
<ModalField label="Number of forks" hint="1 to 10.">
<input
type="number"
min={1}
max={10}
step={1}
value={forkCount}
onChange={(e) => setForkCount(e.target.value)}
className="w-full rounded-lg border border-zinc-700 bg-surface-0 px-3 py-2 text-sm text-zinc-100 focus:border-lantern-500 focus:outline-none"
/>
</ModalField>
</Modal>

<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Logs — wide column */}
<div className="lg:col-span-2 space-y-3">
Expand Down Expand Up @@ -221,6 +353,29 @@ export default function RuntimeVmPage() {
)}
</Section>

{(v.snapshotId || v.parentVmId) && (
<Section title="Lineage" icon={<GitFork className="h-3.5 w-3.5" />}>
<dl className="space-y-1 px-1 py-1 text-[12px] text-zinc-400">
{v.snapshotId && (
<div className="flex justify-between gap-2">
<dt className="text-zinc-500">Snapshot</dt>
<dd className="break-all font-mono text-zinc-300">{v.snapshotId}</dd>
</div>
)}
{v.parentVmId && (
<div className="flex justify-between gap-2">
<dt className="text-zinc-500">Parent VM</dt>
<dd className="break-all font-mono">
<Link href={`/runtime/${encodeURIComponent(v.parentVmId)}`} className="text-lantern-500 hover:underline">
{v.parentVmId}
</Link>
</dd>
</div>
)}
</dl>
</Section>
)}

<Section title="Isolation" icon={<Shield className="h-3.5 w-3.5" />}>
<div className="px-1 py-1 text-[12px] text-zinc-400">{v.isolationClass}</div>
</Section>
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/(dashboard)/runtime/cockpit-ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const STATE_STYLES: Record<VmState, { dot: string; text: string; label: s
pending: { dot: "bg-zinc-500", text: "text-zinc-400", label: "Pending" },
spawning: { dot: "bg-zinc-400", text: "text-zinc-300", label: "Spawning" },
running: { dot: "bg-emerald-500/80", text: "text-zinc-200", label: "Running" },
paused: { dot: "bg-amber-500/70", text: "text-zinc-300", label: "Paused" },
draining: { dot: "bg-zinc-400", text: "text-zinc-300", label: "Draining" },
terminated: { dot: "bg-zinc-600", text: "text-zinc-500", label: "Terminated" },
failed: { dot: "bg-red-500/70", text: "text-zinc-300", label: "Failed" },
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/(dashboard)/runtime/fleet-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { StateDot, STATE_STYLES, IsolationBadge, Sparkline } from "./cockpit-ui"
type SortKey = "name" | "state" | "cost" | "cpu" | "age" | "heartbeat";
type SortDir = "asc" | "desc";

const STATE_ORDER: VmState[] = ["running", "spawning", "pending", "draining", "failed", "terminated"];
const STATE_ORDER: VmState[] = ["running", "paused", "spawning", "pending", "draining", "failed", "terminated"];

function heartbeatTone(iso?: string | null): { dot: string; label: string } {
if (!iso) return { dot: "bg-zinc-600", label: "—" };
Expand Down
13 changes: 12 additions & 1 deletion apps/web/lib/runtime-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ export class UnauthorizedError extends Error {
}
}

/** Non-2xx API response. `status` lets callers map 402/409 to friendly copy. */
export class HttpError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message);
this.name = "HttpError";
}
}

function purgeTokenAndRedirect() {
if (typeof window === "undefined") return;
try {
Expand Down Expand Up @@ -79,7 +90,7 @@ async function request<T>(

if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`API ${res.status}: ${text || res.statusText}`);
throw new HttpError(res.status, `API ${res.status}: ${text || res.statusText}`);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
Expand Down
5 changes: 5 additions & 0 deletions apps/web/lib/runtime-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type VmState =
| "pending"
| "spawning"
| "running"
| "paused"
| "draining"
| "terminated"
| "failed";
Expand All @@ -25,6 +26,10 @@ export interface VmRow {
terminatedAt?: string | null;
lastHeartbeatAt?: string | null;
spec: Record<string, unknown> | null;
/** Set once the VM has been paused/snapshotted. */
snapshotId?: string | null;
/** Set on VMs created by fork. */
parentVmId?: string | null;

// ---- cockpit-only, demo-seeded (see honesty contract in runtime-demo.ts)
/** Friendly workload / agent name. Real rows may not carry one. */
Expand Down
Loading
Loading