fix(ui): show context metrics in topology views

Adds context percentage and token totals to topology table and terminal views using the existing node inventory context payload. Keeps pod table scoping aligned with pod pages and refreshes inventory polling with graph cadence.

Verification: focused UI tests, UI build, lint, and test:repo pass.
This commit is contained in:
mvschwarz committed 2026-05-09 04:36:20 -07:00
1 parent 279ced303e
commit c0fcccd102
7 files changed
+192 -11

No files matched your search

@@ -40,6 +40,22 @@ const TIER_BORDER_CLASS: Record<ContextUsageTier, string> = {
unknown: "border-stone-400 border-dotted",
};
const TIER_TEXT_CLASS: Record<ContextUsageTier, string> = {
critical: "text-red-600",
warning: "text-amber-600",
low: "text-green-700",
unknown: "text-stone-300",
};
export function contextUsageTextClass(
percent: number | null | undefined,
fresh?: boolean,
availability?: string | null,
): string {
const tier = deriveContextTier(percent, availability ?? undefined);
return `${TIER_TEXT_CLASS[tier]}${tier !== "unknown" && fresh === false ? " opacity-50" : ""}`;
}
export function ContextUsageRing({ percent, fresh, availability, testIdSuffix }: ContextUsageRingProps) {
const tier = deriveContextTier(percent, availability);
const titleParts: string[] = [];
@@ -280,7 +280,7 @@ export function PodScopePage() {
Graph view degrades to table on narrow viewports.
</p>
) : null}
<TopologyTableView rigIdScope={rigId} />
<TopologyTableView rigIdScope={rigId} podNameScope={podName} />
</div>
) : null}
{effectiveActive === "terminal" ? (
@@ -40,6 +40,8 @@ import {
} from "../../lib/topology-activity.js";
import { ActivityRing } from "./ActivityRing.js";
import { RuntimeBadge, ToolMark } from "../graphics/RuntimeMark.js";
import { formatCompactTokenCount, formatTokenTotalTitle, sumTokenCounts } from "../../lib/token-format.js";
import { contextUsageTextClass } from "../ContextUsageRing.js";
async function fetchNodeInventory(rigId: string): Promise<NodeInventoryEntry[]> {
const res = await fetch(`/api/rigs/${encodeURIComponent(rigId)}/nodes`);
@@ -56,6 +58,7 @@ interface AgentRow {
runtime: string;
status: string;
startupStatus: string | null;
contextUsage?: NodeInventoryEntry["contextUsage"] | null;
agentActivity?: TopologyActivityBaseline["agentActivity"];
currentQitems?: TopologyActivityBaseline["currentQitems"];
activityRing?: TopologyActivityVisual;
@@ -91,6 +94,42 @@ function CmuxButton({ row }: { row: AgentRow }) {
);
}
function ContextCell({ row }: { row: AgentRow }) {
const usage = row.contextUsage;
const known = usage?.availability === "known" && typeof usage.usedPercentage === "number";
return (
<span
data-testid={`topology-table-context-${row.logicalId}`}
className={`font-mono text-xs font-bold ${contextUsageTextClass(usage?.usedPercentage, usage?.fresh, usage?.availability)}`}
title={
known
? usage?.fresh === false
? "Context usage (stale sample)"
: "Context usage (fresh)"
: "Context sample unavailable"
}
>
{known ? `${usage.usedPercentage}%` : "--"}
</span>
);
}
function TokenCell({ row }: { row: AgentRow }) {
const usage = row.contextUsage;
const total = sumTokenCounts(usage?.totalInputTokens, usage?.totalOutputTokens);
const tokenLabel = formatCompactTokenCount(total);
const tokenTitle = formatTokenTotalTitle(usage?.totalInputTokens, usage?.totalOutputTokens);
return (
<span
data-testid={`topology-table-tokens-${row.logicalId}`}
className={`font-mono text-xs font-bold ${tokenLabel ? "text-stone-500" : "text-stone-300"}`}
title={tokenTitle ?? "Token sample unavailable"}
>
{tokenLabel ?? "--"}
</span>
);
}
function agentColumns(): ColumnDef<AgentRow>[] {
return [
{ accessorKey: "rigName", header: "Rig", cell: ({ getValue }) => <span className="font-mono text-xs">{String(getValue())}</span> },
@@ -121,6 +160,22 @@ function agentColumns(): ColumnDef<AgentRow>[] {
<RuntimeBadge runtime={String(getValue() ?? "")} size="xs" compact variant="inline" />
),
},
{
id: "context",
header: "Context",
sortingFn: (a, b) => (a.original.contextUsage?.usedPercentage ?? -1) - (b.original.contextUsage?.usedPercentage ?? -1),
cell: ({ row }) => <ContextCell row={row.original} />,
},
{
id: "tokens",
header: "Tokens",
sortingFn: (a, b) => {
const left = sumTokenCounts(a.original.contextUsage?.totalInputTokens, a.original.contextUsage?.totalOutputTokens) ?? -1;
const right = sumTokenCounts(b.original.contextUsage?.totalInputTokens, b.original.contextUsage?.totalOutputTokens) ?? -1;
return left - right;
},
cell: ({ row }) => <TokenCell row={row.original} />,
},
{
accessorKey: "status",
header: "Status",
@@ -137,7 +192,7 @@ function agentColumns(): ColumnDef<AgentRow>[] {
];
}
export function TopologyTableView({ rigIdScope }: { rigIdScope?: string }) {
export function TopologyTableView({ rigIdScope, podNameScope }: { rigIdScope?: string; podNameScope?: string }) {
// V1 polish slice Phase 5.1 P5.1-7: row click navigates to seat-scope
// center page (parity with graph node click + Explorer tree click +
// Topology Tree details-icon-retired contract).
@@ -159,6 +214,7 @@ export function TopologyTableView({ rigIdScope }: { rigIdScope?: string }) {
queries: scopedRigs.map((r) => ({
queryKey: ["rig", r.id, "nodes"] as const,
queryFn: () => fetchNodeInventory(r.id),
refetchInterval: 30_000,
})),
});
@@ -169,7 +225,10 @@ export function TopologyTableView({ rigIdScope }: { rigIdScope?: string }) {
const result = inventoryResults[i];
if (!rig || !result) continue;
const nodes: NodeInventoryEntry[] = result.data ?? [];
for (const n of nodes) {
const scopedNodes = podNameScope
? nodes.filter((n) => (n.podNamespace ?? n.podId) === podNameScope)
: nodes;
for (const n of scopedNodes) {
rows.push({
rigId: rig.id,
rigName: rig.name,
@@ -179,13 +238,14 @@ export function TopologyTableView({ rigIdScope }: { rigIdScope?: string }) {
runtime: (n.runtime ?? "-") as string,
status: (n.sessionStatus ?? "unknown") as string,
startupStatus: (n.startupStatus ?? null) as string | null,
contextUsage: n.contextUsage ?? null,
agentActivity: n.agentActivity ?? null,
currentQitems: n.currentQitems ?? [],
});
}
}
return rows;
}, [scopedRigs, inventoryResults]);
}, [scopedRigs, inventoryResults, podNameScope]);
const sessionIndex = useMemo(() => buildTopologySessionIndex(data.map((row) => ({
nodeId: `${row.rigId}::${row.logicalId}`,
@@ -225,7 +285,8 @@ export function TopologyTableView({ rigIdScope }: { rigIdScope?: string }) {
r.rigName.toLowerCase().includes(q) ||
r.podName.toLowerCase().includes(q) ||
r.logicalId.toLowerCase().includes(q) ||
r.runtime.toLowerCase().includes(q)
r.runtime.toLowerCase().includes(q) ||
r.status.toLowerCase().includes(q)
);
},
});
@@ -27,6 +27,8 @@ import { EmptyState } from "../ui/empty-state.js";
import { SectionHeader } from "../ui/section-header.js";
import { cn } from "../../lib/utils.js";
import { RuntimeBadge } from "../graphics/RuntimeMark.js";
import { formatCompactTokenCount, formatTokenTotalTitle, sumTokenCounts } from "../../lib/token-format.js";
import { contextUsageTextClass } from "../ContextUsageRing.js";
const SAFE_N = 12;
@@ -45,6 +47,42 @@ function isActiveRunning(seat: NodeInventoryEntry): boolean {
return seat.agentActivity?.state === "running";
}
function ContextMetric({ seat }: { seat: NodeInventoryEntry }) {
const usage = seat.contextUsage;
const known = usage?.availability === "known" && typeof usage.usedPercentage === "number";
return (
<span
data-testid={`terminal-card-context-${seat.rigId}-${seat.logicalId}`}
className={`font-mono text-[8px] font-bold uppercase tracking-wide ${contextUsageTextClass(usage?.usedPercentage, usage?.fresh, usage?.availability)}`}
title={
known
? usage?.fresh === false
? "Context usage (stale sample)"
: "Context usage (fresh)"
: "Context sample unavailable"
}
>
{known ? `${usage.usedPercentage}%` : "--"}
</span>
);
}
function TokenMetric({ seat }: { seat: NodeInventoryEntry }) {
const usage = seat.contextUsage;
const total = sumTokenCounts(usage?.totalInputTokens, usage?.totalOutputTokens);
const label = formatCompactTokenCount(total);
const title = formatTokenTotalTitle(usage?.totalInputTokens, usage?.totalOutputTokens);
return (
<span
data-testid={`terminal-card-tokens-${seat.rigId}-${seat.logicalId}`}
className={`font-mono text-[8px] font-bold uppercase tracking-wide ${label ? "text-stone-500" : "text-stone-300"}`}
title={title ?? "Token sample unavailable"}
>
{label ?? "--"}
</span>
);
}
function SeatTerminalCard({ seat }: { seat: NodeInventoryEntry }) {
const sessionName = seat.canonicalSessionName ?? seat.logicalId;
const active = isActiveRunning(seat);
@@ -66,11 +104,8 @@ function SeatTerminalCard({ seat }: { seat: NodeInventoryEntry }) {
</span>
<span className="inline-flex shrink-0 items-center gap-1">
<RuntimeBadge runtime={seat.runtime} size="xs" compact variant="inline" />
{typeof seat.contextUsage?.usedPercentage === "number" ? (
<span className="font-mono text-[8px] uppercase tracking-wide text-on-surface-variant">
{seat.contextUsage.usedPercentage}%
</span>
) : null}
<ContextMetric seat={seat} />
<TokenMetric seat={seat} />
</span>
</header>
<SessionPreviewPane
@@ -37,6 +37,8 @@ export interface NodeInventoryEntry {
availability: string | null;
sampledAt: string | null;
fresh: boolean;
totalInputTokens?: number | null;
totalOutputTokens?: number | null;
};
// PL-019: agent activity attached daemon-side via attachAgentActivity.
agentActivity?: AgentActivitySummary | null;
@@ -55,5 +57,6 @@ export function useNodeInventory(rigId: string | null) {
queryKey: ["rig", rigId, "nodes"],
queryFn: () => fetchNodeInventory(rigId!),
enabled: !!rigId,
refetchInterval: 30_000,
});
}
+19
View File
@@ -43,6 +43,16 @@ beforeEach(() => {
runtime: "claude-code",
sessionStatus: "running",
startupStatus: "ready",
contextUsage: {
usedPercentage: 42,
remainingPercentage: 58,
contextWindowSize: 320000,
availability: "known",
sampledAt: "2026-05-09T10:00:00Z",
fresh: true,
totalInputTokens: 120000,
totalOutputTokens: 14000,
},
restoreOutcome: "n-a",
tmuxAttachCommand: null,
resumeCommand: null,
@@ -74,6 +84,15 @@ describe("TopologyTableView P5.1-7 CMUX column + row click", () => {
expect((cmux as HTMLButtonElement).className).toContain("group-hover:opacity-100");
});
it("renders context percentage and token total from node inventory", async () => {
const { findByTestId } = withQueryClient(<TopologyTableView />);
const context = await findByTestId("topology-table-context-orch.lead");
const tokens = await findByTestId("topology-table-tokens-orch.lead");
expect(context.textContent).toBe("42%");
expect(tokens.textContent).toBe("134k");
expect(tokens.getAttribute("title")).toContain("Tokens: 134,000");
});
it("CMUX button click POSTs to /api/rigs/.../focus (existing useCmuxLaunch hook)", async () => {
const { findByTestId } = withQueryClient(<TopologyTableView />);
const cmux = await findByTestId("topology-table-cmux-orch.lead");
+48 -1
View File
@@ -45,6 +45,10 @@ function makeSeat(opts: {
logicalId: string;
pod?: string;
active?: boolean;
runtime?: string;
contextUsedPercentage?: number;
contextTotalInputTokens?: number;
contextTotalOutputTokens?: number;
}): NodeInventoryEntry {
return {
rigId: opts.rigId ?? "rig-1",
@@ -54,7 +58,7 @@ function makeSeat(opts: {
podNamespace: opts.pod ?? "default",
canonicalSessionName: `${opts.logicalId}@test-rig`,
nodeKind: "agent",
runtime: "claude-code",
runtime: opts.runtime ?? "claude-code",
sessionStatus: "running",
startupStatus: "ready",
restoreOutcome: "n-a",
@@ -69,6 +73,18 @@ function makeSeat(opts: {
sampledAt: "2026-05-06T18:00:00Z",
}
: { state: "idle", reason: "test", evidenceSource: "test", sampledAt: "2026-05-06T18:00:00Z" },
contextUsage: typeof opts.contextUsedPercentage === "number"
? {
usedPercentage: opts.contextUsedPercentage,
remainingPercentage: 100 - opts.contextUsedPercentage,
contextWindowSize: 320000,
availability: "known",
sampledAt: "2026-05-09T10:00:00Z",
fresh: true,
totalInputTokens: opts.contextTotalInputTokens ?? null,
totalOutputTokens: opts.contextTotalOutputTokens ?? null,
}
: undefined,
};
}
@@ -161,6 +177,37 @@ describe("TopologyTerminalView P5-7 grid", () => {
expect(container.querySelector("[data-testid='terminal-card-rig-1-qa.codex']")).toBeTruthy();
});
it("renders Codex context percentage and token total in terminal cards", async () => {
const seats = [
makeSeat({
logicalId: "guard.codex",
runtime: "codex",
contextUsedPercentage: 21,
contextTotalInputTokens: 54000,
contextTotalOutputTokens: 615,
}),
];
setupFetch({ rigs: [{ id: "rig-1", name: "test-rig" }], seatsByRig: { "rig-1": seats } });
const { findByTestId } = withQueryClient(
<TopologyTerminalView scope="rig" rigId="rig-1" />,
);
const context = await findByTestId("terminal-card-context-rig-1-guard.codex");
const tokens = await findByTestId("terminal-card-tokens-rig-1-guard.codex");
expect(context.textContent).toBe("21%");
expect(tokens.textContent).toBe("55k");
expect(tokens.getAttribute("title")).toContain("Tokens: 54,615");
});
it("renders unknown context affordance when terminal cards have no sample", async () => {
const seats = [makeSeat({ logicalId: "orch.lead" })];
setupFetch({ rigs: [{ id: "rig-1", name: "test-rig" }], seatsByRig: { "rig-1": seats } });
const { findByTestId } = withQueryClient(
<TopologyTerminalView scope="rig" rigId="rig-1" />,
);
expect((await findByTestId("terminal-card-context-rig-1-orch.lead")).textContent).toBe("--");
expect((await findByTestId("terminal-card-tokens-rig-1-orch.lead")).textContent).toBe("--");
});
it("empty state when scope has no agent seats", async () => {
setupFetch({ rigs: [{ id: "rig-1", name: "test-rig" }], seatsByRig: { "rig-1": [] } });
const { findByTestId } = withQueryClient(