feat(lanes): lane runtime UI, docs, and env additions (A1+A2)

Client-side rendering for the per-lane runtime facts (slot, ports,
database, Redis index, service liveness) added in the server-side
A1/A2 work, plus the doc updates (README, CLAUDE.md, docs/API.md,
client/server READMEs) describing the new profile.env keys, hook
environment contract, and REST endpoints.
This commit is contained in:
2026-08-04 10:04:58 +07:00
parent 9d145865dd
commit 4d9a385c5e
14 changed files with 614 additions and 24 deletions
+181 -1
View File
@@ -15,13 +15,19 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { DestructiveLaneModal } from "./DestructiveLaneModal";
import { api } from "../../lib/api";
import type { Lane, LaneGitFacts } from "../../lib/types";
import { eventBus } from "../../lib/eventBus";
import type { Lane, LaneGitFacts, LaneRuntime } from "../../lib/types";
/** How often a mounted card re-reads its working-copy facts. Slow on purpose:
* each call is three git subprocesses server-side, and a branch name does not
* change on the timescale the lane list is polled at. */
const GIT_REFRESH_MS = 30_000;
/** How often a mounted card re-probes its stack. Faster than the git refresh
* because a stack dying is exactly what the user needs to see, and slower than
* the lane poll because each call opens a socket per declared port. */
const RUNTIME_REFRESH_MS = 10_000;
/**
* The lane's own working copy, fetched per card rather than folded into the
* polled lane list. Absent facts are not an error state: a lane may point at a
@@ -55,6 +61,40 @@ function useLaneGitFacts(laneId: number): LaneGitFacts | null {
return facts;
}
/**
* The lane's own application stack. Same shape and same silence as the git
* facts above: a lane without a `.ccam/profile` simply has no runtime row, which
* is the common case and not an error worth a banner.
*
* `bump` lets an up/down action re-read immediately instead of waiting out the
* poll interval.
*/
function useLaneRuntime(laneId: number, bump: number): LaneRuntime | null {
const [runtime, setRuntime] = useState<LaneRuntime | null>(null);
useEffect(() => {
let alive = true;
const read = () => {
api.lanes
.runtime(laneId)
.then((r) => {
if (alive) setRuntime(r);
})
.catch(() => {
if (alive) setRuntime({ available: false });
});
};
read();
const timer = setInterval(read, RUNTIME_REFRESH_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, [laneId, bump]);
return runtime;
}
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-status-success",
idle: "bg-surface-4",
@@ -80,7 +120,61 @@ export default function LaneCard({
null
);
const [menuOpen, setMenuOpen] = useState(false);
const [runtimeBump, setRuntimeBump] = useState(0);
const [runtimeBusy, setRuntimeBusy] = useState<"up" | "down" | null>(null);
const [bootLine, setBootLine] = useState<string | null>(null);
const git = useLaneGitFacts(lane.id);
const runtime = useLaneRuntime(lane.id, runtimeBump);
/**
* Boot or stop the lane's stack. Deliberately NOT routed through `onAction`:
* that prop drives the lane's Claude run, and folding a second lifecycle into
* it would make "stop" ambiguous about which thing it stops.
*
* `up` answers 202 and keeps booting in the background, so the button stays
* busy until the server's `lane_runtime` message says the attempt finished —
* resolving the request is not the same as the stack being up.
*/
const runtimeAction = async (which: "up" | "down") => {
setRuntimeBusy(which);
setBootLine(null);
try {
if (which === "down") {
await api.lanes.down(lane.id);
setRuntimeBusy(null);
} else {
await api.lanes.up(lane.id);
}
} catch {
// The failure surfaces as the runtime row's lastError on the next read; a
// toast here would say the same thing twice.
setRuntimeBusy(null);
} finally {
setRuntimeBump((n) => n + 1);
}
};
/**
* Live boot feedback. A build can run for minutes, and a card showing only a
* disabled button through all of it reads as a hang. The hook's own output
* lines are the honest progress indicator.
*/
useEffect(
() =>
eventBus.subscribe((msg) => {
const data = msg.data as { laneId?: number; line?: string } | undefined;
if (!data || data.laneId !== lane.id) return;
if (msg.type === "lane_hook_output" && typeof data.line === "string") {
setBootLine(data.line);
}
if (msg.type === "lane_runtime") {
setRuntimeBusy(null);
setBootLine(null);
setRuntimeBump((n) => n + 1);
}
}),
[lane.id]
);
return (
<>
@@ -144,6 +238,70 @@ export default function LaneCard({
</div>
)}
{/* Runtime strip: the lane's own app stack, shown only for lanes that
declare a profile. A port that drifted from `base + slot` is called
out — the number is otherwise predictable from the slot, and silently
serving on a different one is exactly the surprise worth flagging. */}
{runtime?.available && runtime.provisioned && (
<div
data-testid={`lane-runtime-${lane.id}`}
className="mb-3 rounded border border-border bg-surface-2/50 px-2 py-1.5 font-mono text-[11px]"
>
<div className="mb-1 flex items-center justify-between">
<span className="text-fg-muted">{t("runtime.slot", { slot: runtime.slot })}</span>
<span
className={runtime.healthy ? "text-status-success" : "text-fg-muted"}
data-testid="lane-runtime-state"
>
{runtime.healthy
? t("runtime.healthy")
: runtime.up
? t("runtime.partial")
: t("runtime.down")}
</span>
</div>
{Object.entries(runtime.ports).map(([name, info]) => (
<div key={name} className="flex items-center gap-1.5 truncate">
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
info.listening ? "bg-status-success" : "bg-surface-4"
}`}
/>
<span className="text-fg-secondary">{name}</span>
<span className="text-fg-muted">:{info.port ?? "—"}</span>
{info.port !== null && info.port !== info.expected && (
<span
className="truncate text-status-warning/80"
title={t("runtime.steppedAsideTitle", { expected: info.expected })}
>
{info.expected}
</span>
)}
</div>
))}
{/* While a boot is in flight the hook's own latest line IS the
progress bar — a build can take minutes, and a disabled button
with nothing moving reads as a hang. */}
{bootLine !== null && (
<div
data-testid="lane-runtime-bootline"
className="mt-1 truncate text-fg-muted"
title={bootLine}
>
{bootLine}
</div>
)}
{bootLine === null && runtime.lastError && (
<div
className="mt-1 truncate text-status-danger/90"
title={runtime.lastError.message}
>
{runtime.lastError.code || "error"}: {runtime.lastError.message}
</div>
)}
</div>
)}
<dl className="mb-3 space-y-1 font-mono text-[11px] text-fg-secondary">
{git?.available && (
<div data-testid="lane-git" className="space-y-1">
@@ -191,6 +349,28 @@ export default function LaneCard({
{t(`action.${a}`)}
</button>
))}
{/* Stack controls, only for a lane whose repo declares a profile.
Separate from start/stop above: those drive the lane's Claude run,
these drive the application it is working on. */}
{runtime?.available && (
<button
type="button"
data-testid="lane-runtime-toggle"
disabled={runtimeBusy !== null}
onClick={(e) => {
e.stopPropagation();
void runtimeAction(runtime.provisioned && runtime.up ? "down" : "up");
}}
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
title={t("runtime.toggleTitle")}
>
{runtimeBusy
? t(`runtime.busy.${runtimeBusy}`)
: runtime.provisioned && runtime.up
? t("runtime.stop")
: t("runtime.boot")}
</button>
)}
{/* Deleting the lane and deleting its history are each their own
button, by request: hiding "delete" behind a ⋯ made it unfindable,
and the one label that read as "delete" was `clear`. Neither fires