status page usage

This commit is contained in:
Ian Gulliver
2026-10-16 10:47:53 -07:00
parent a800ac4f56
commit 919e1f8002
7 changed files with 106 additions and 10 deletions
+2 -2
View File
@@ -152,7 +152,7 @@ Codex runs with its own home, `codex` under the data directory (`CODEX_HOME`, se
## Status
`GET /api/admin/status` reports `running_turns` (task sessions now `running`, as session, task and vendor; lead turns aren't listed); `rate_limits`, the latest status each vendor sent since byre started (Claude's `rate_limit_info` unchanged; Codex's `account/rateLimits/updated` reshaped by `codexRateLimit` in `internal/agents/codex.go` into the same `status`, `rateLimitType` and `unifiedWindows` fields). Claude only reports during its turns; Codex is also asked once at startup (`readCodexRateLimit`, a short-lived `codex app-server` that calls `account/rateLimits/read` without opening a thread), so its entry is there before any Codex turn runs, and every Codex update broadcasts `admin.health`; `agent_processes`, `zombies`, `stuck_after_seconds` and `protocol_errors` (below); and server counts (start time, agent processes, projects, active tasks, SSE clients, database size including WAL, last event seq).
`GET /api/admin/status` reports `running_turns` (task sessions now `running`, as session, task and vendor; lead turns aren't listed); `rate_limits`, the latest status each vendor sent since byre started (Claude's `rate_limit_info`, with `windowMins` added to each `unifiedWindows` entry by `addClaudeWindowMins` in `internal/agents/manager.go` from the window's name, since Claude doesn't send a length; Codex's `account/rateLimits/updated` reshaped by `codexRateLimit` in `internal/agents/codex.go` into the same `status`, `rateLimitType` and `unifiedWindows` fields, `windowMins` from its `windowDurationMins`). Claude only reports during its turns; Codex is also asked once at startup (`readCodexRateLimit`, a short-lived `codex app-server` that calls `account/rateLimits/read` without opening a thread), so its entry is there before any Codex turn runs, and every Codex update broadcasts `admin.health`; `agent_processes`, `zombies`, `stuck_after_seconds` and `protocol_errors` (below); and server counts (start time, agent processes, projects, active tasks, SSE clients, database size including WAL, last event seq).
Agent health is `internal/agents/health.go`:
@@ -161,7 +161,7 @@ Agent health is `internal/agents/health.go`:
- **Watching.** `Manager.Watch` recomputes health every `healthInterval`. When the set of problems (non-`ok` processes and zombies) changes, it logs each new one as `agent health problem` and broadcasts an unstored `admin.health` event (`seq` 0). Nothing is killed or repaired automatically.
- **Protocol errors.** `internal/claude` reports a stdout line that isn't a JSON object, doesn't parse, or has a `type` outside `knownTypes` (the line is still passed on), and the manager adds lines whose content it fails to decode. `protocol_errors.counts` counts them per vendor since byre started; `recent` holds the last `keptBadLines`, newest first, each with session, time, reason, the raw line cut to `badLineBytes` and its full `size`. They live in memory only, and each one also broadcasts `admin.health`.
The left pane's **Status** tab shows, from the top: a Problems section only when there are zombies or protocol errors (one line per zombie, and per vendor the error count with the latest line folded under it); each running turn, linking to its task, with the time since its process's last event and `stuck` or `exited` when it is; each vendor's rate-limit status with its reset time and per-window utilization, and the server counts. It refetches the status when a `session.created`, `session.state`, `task.*`, `project.created` or `admin.health` event arrives, when an `agent.vendor` event carries a `rate_limit_event`, and when the SSE stream reconnects. Other events advance the last-event seq it shows, and any event carrying a process's `agent_session_id` (stored or not, such as `agent.delta`) moves that process's last-event time forward, re-rendering at most once a second per process. A re-render keeps the scroll position and which folded lines are open. There is no timer, so "time since last event" and the "in 2h 10m" beside a reset time are as of the last render; a process going quiet long enough to be stuck shows up when the next health check broadcasts `admin.health`.
The left pane's **Status** tab shows, from the top: a Problems section only when there are zombies or protocol errors (one line per zombie, and per vendor the error count with the latest line folded under it); each running turn, linking to its task, with the time since its process's last event and `stuck` or `exited` when it is; each vendor's rate-limit status with its reset time and, per window, the share used, the share of the window's time elapsed (from `resetsAt` and `windowMins`), and a pace bar whose fill is the share used and whose vertical line is the share elapsed, the fill warn-coloured when usage is ahead of the line; and the server counts. It refetches the status when a `session.created`, `session.state`, `task.*`, `project.created` or `admin.health` event arrives, when an `agent.vendor` event carries a `rate_limit_event`, and when the SSE stream reconnects. Other events advance the last-event seq it shows, and any event carrying a process's `agent_session_id` (stored or not, such as `agent.delta`) moves that process's last-event time forward, re-rendering at most once a second per process. A re-render keeps the scroll position and which folded lines are open. There is no timer, so "time since last event" and the "in 2h 10m" beside a reset time are as of the last render; a process going quiet long enough to be stuck shows up when the next health check broadcasts `admin.health`.
Agent activity becomes events with `agent_session_id` set: `user.message`, `agent.message`, `agent.thinking`, `agent.tool_call`, `agent.tool_result`, `turn.ended`, `session.state`, `session.model`, and `session.context`. Anything else Claude Code emits (rate-limit status, task progress, state changes) is recorded unchanged as `agent.vendor`. Codex items map onto the same kinds (`handleCodex` in `internal/agents/codex.go`): an `agentMessage` becomes `agent.message`, a `reasoning` item's summary `agent.thinking`, and `commandExecution` (as `Bash`), `fileChange` (as `apply_patch`), `dynamicToolCall`, `mcpToolCall` and `webSearch` items an `agent.tool_call` when they start and an `agent.tool_result` when they complete. Other completed items and the `error` and `warning` notifications are recorded as `agent.vendor`; the remaining notifications (thread status, MCP startup and the like) aren't recorded. `item/agentMessage/delta` becomes `agent.delta`, and `thread/tokenUsage/updated` `session.context`. The one exception is partial output: each top-level `text_delta` is broadcast to SSE clients as an `agent.delta` event (`payload.text`, `seq` 0) that is never stored, and the transcript grows a live message from them until the finished `agent.message` replaces it. A `turn.ended` event carries `summary: true` when its `result` is the turn's summary (see above). Activity inside a subagent (`agent.message`, `agent.thinking`, `agent.tool_call`, `agent.tool_result`) carries `payload.parent_tool_use_id`, the ID of the tool call that started the subagent, and never updates `session.context`. `session.context` records the context size whenever it changes: `used` is the last assistant message's input, cache-read, cache-creation and output tokens, and `window` is the model's context window from the turn's `result` (`modelUsage[model].contextWindow`), so it is 0 until the session's first turn after a server start ends. The task header's **Compact** button shows the latest one as a background fill across the button, proportional to the window, tinted amber from 70% and red from 90%; the exact count is in its tooltip.
+3
View File
@@ -232,6 +232,9 @@ func codexRateLimit(r codex.RateLimitSnapshot) map[string]any {
if w.ResetsAt != 0 {
window["resetsAt"] = w.ResetsAt
}
if w.WindowDurationMins != 0 {
window["windowMins"] = w.WindowDurationMins
}
windows[name] = window
}
info := map[string]any{"status": status, "unifiedWindows": windows, "planType": r.PlanType}
+18
View File
@@ -779,6 +779,8 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
return err
}
addClaudeWindowMins(info.RateLimitInfo)
m.mu.Lock()
m.rateLimits[l.vendor] = info.RateLimitInfo
if info.RateLimitInfo["status"] == "rejected" {
@@ -1037,6 +1039,22 @@ func (m *Manager) event(l *live, actor, kind string, payload map[string]any) api
return ev
}
func addClaudeWindowMins(info map[string]any) {
windows, _ := info["unifiedWindows"].(map[string]any)
for name, w := range windows {
window, ok := w.(map[string]any)
if !ok {
continue
}
switch {
case strings.HasPrefix(name, "five_hour"):
window["windowMins"] = 5 * 60
case strings.HasPrefix(name, "seven_day"):
window["windowMins"] = 7 * 24 * 60
}
}
}
func decode(raw json.RawMessage) (any, error) {
if len(raw) == 0 {
return nil, nil
+19 -1
View File
@@ -148,9 +148,27 @@ function rateLimitCard(vendor, info) {
const tone = statusText === "allowed" ? "ok" : statusText === "rejected" ? "danger" : "warn";
const windows = (unifiedWindows ?? {});
return h("div", { class: "card rate-limit" }, h("header", {}, h("span", { class: "vendor" }, vendor), h("span", { class: `rl-status ${tone}` }, statusText), rateLimitType !== undefined && h("span", {}, String(rateLimitType)), typeof resetsAt === "number" && h("span", { class: "resets" }, "resets ", resetText(resetsAt))), Object.keys(windows).length > 0 &&
h("table", { class: "rl-windows" }, h("tr", {}, h("th", {}, "Window"), h("th", {}, "Used"), h("th", {}, "Resets")), ...Object.entries(windows).map(([name, w]) => h("tr", {}, h("td", {}, name), h("td", { class: utilizationClass(w.utilization) }, w.utilization === undefined ? "—" : `${Math.round(w.utilization * 100)}%`), h("td", {}, w.resetsAt === undefined ? "—" : resetText(w.resetsAt))))), Object.keys(rest).length > 0 &&
h("table", { class: "rl-windows" }, h("tr", {}, h("th", {}, "Window"), h("th", {}, "Used"), h("th", {}, "Elapsed"), h("th", { class: "rl-pace-col" }, "Pace"), h("th", {}, "Resets")), ...Object.entries(windows).map(([name, w]) => {
const elapsed = elapsedFraction(w);
return h("tr", {}, h("td", {}, name), h("td", { class: utilizationClass(w.utilization) }, w.utilization === undefined ? "—" : `${Math.round(w.utilization * 100)}%`), h("td", {}, elapsed === undefined ? "—" : `${Math.round(elapsed * 100)}%`), h("td", { class: "rl-pace-col" }, paceBar(w.utilization, elapsed)), h("td", {}, w.resetsAt === undefined ? "—" : resetText(w.resetsAt)));
})), Object.keys(rest).length > 0 &&
h("dl", { class: "kv" }, ...Object.entries(rest).flatMap(([k, v]) => [h("dt", {}, k), h("dd", {}, typeof v === "object" ? JSON.stringify(v) : String(v))])));
}
function elapsedFraction(w) {
if (w.resetsAt === undefined || w.windowMins === undefined) {
return undefined;
}
const remaining = w.resetsAt * 1000 - Date.now();
return Math.min(1, Math.max(0, 1 - remaining / (w.windowMins * 60000)));
}
function paceBar(used, elapsed) {
if (used === undefined || elapsed === undefined) {
return "—";
}
const diff = Math.round((used - elapsed) * 100);
const title = diff > 0 ? `${diff} points ahead of an even pace` : diff < 0 ? `${-diff} points behind an even pace` : "on an even pace";
return h("div", { class: `rl-pace ${used > elapsed ? "ahead" : "behind"}`, title }, h("div", { class: "rl-pace-used", style: `width: ${Math.min(1, used) * 100}%` }), h("div", { class: "rl-pace-now", style: `left: ${elapsed * 100}%` }));
}
function utilizationClass(u) {
if (u === undefined) {
return "";
+1 -1
View File
File diff suppressed because one or more lines are too long
+33 -6
View File
@@ -215,7 +215,7 @@ function rateLimitCard(vendor: string, info: Record<string, unknown>): HTMLEleme
const { status, rateLimitType, resetsAt, unifiedWindows, ...rest } = info;
const statusText = String(status ?? "unknown");
const tone = statusText === "allowed" ? "ok" : statusText === "rejected" ? "danger" : "warn";
const windows = (unifiedWindows ?? {}) as Record<string, { utilization?: number; resetsAt?: number }>;
const windows = (unifiedWindows ?? {}) as Record<string, RateWindow>;
return h(
"div",
@@ -232,16 +232,19 @@ function rateLimitCard(vendor: string, info: Record<string, unknown>): HTMLEleme
h(
"table",
{ class: "rl-windows" },
h("tr", {}, h("th", {}, "Window"), h("th", {}, "Used"), h("th", {}, "Resets")),
...Object.entries(windows).map(([name, w]) =>
h(
h("tr", {}, h("th", {}, "Window"), h("th", {}, "Used"), h("th", {}, "Elapsed"), h("th", { class: "rl-pace-col" }, "Pace"), h("th", {}, "Resets")),
...Object.entries(windows).map(([name, w]) => {
const elapsed = elapsedFraction(w);
return h(
"tr",
{},
h("td", {}, name),
h("td", { class: utilizationClass(w.utilization) }, w.utilization === undefined ? "—" : `${Math.round(w.utilization * 100)}%`),
h("td", {}, elapsed === undefined ? "—" : `${Math.round(elapsed * 100)}%`),
h("td", { class: "rl-pace-col" }, paceBar(w.utilization, elapsed)),
h("td", {}, w.resetsAt === undefined ? "—" : resetText(w.resetsAt)),
),
),
);
}),
),
Object.keys(rest).length > 0 &&
h(
@@ -252,6 +255,30 @@ function rateLimitCard(vendor: string, info: Record<string, unknown>): HTMLEleme
);
}
type RateWindow = { utilization?: number; resetsAt?: number; windowMins?: number };
function elapsedFraction(w: RateWindow): number | undefined {
if (w.resetsAt === undefined || w.windowMins === undefined) {
return undefined;
}
const remaining = w.resetsAt * 1000 - Date.now();
return Math.min(1, Math.max(0, 1 - remaining / (w.windowMins * 60000)));
}
function paceBar(used: number | undefined, elapsed: number | undefined): HTMLElement | string {
if (used === undefined || elapsed === undefined) {
return "—";
}
const diff = Math.round((used - elapsed) * 100);
const title = diff > 0 ? `${diff} points ahead of an even pace` : diff < 0 ? `${-diff} points behind an even pace` : "on an even pace";
return h(
"div",
{ class: `rl-pace ${used > elapsed ? "ahead" : "behind"}`, title },
h("div", { class: "rl-pace-used", style: `width: ${Math.min(1, used) * 100}%` }),
h("div", { class: "rl-pace-now", style: `left: ${elapsed * 100}%` }),
);
}
function utilizationClass(u: number | undefined): string {
if (u === undefined) {
return "";
+30
View File
@@ -2282,6 +2282,36 @@ button.danger-primary {
border-bottom: 1px solid var(--border);
}
.rl-windows .rl-pace-col {
width: 40%;
}
.rl-pace {
position: relative;
height: 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 2px;
}
.rl-pace-used {
height: 100%;
background: color-mix(in srgb, var(--ok) 55%, var(--raised));
}
.rl-pace.ahead .rl-pace-used {
background: color-mix(in srgb, var(--warn) 55%, var(--raised));
}
.rl-pace-now {
position: absolute;
top: -3px;
bottom: -3px;
width: 2px;
margin-left: -1px;
background: var(--text);
}
.problems {
list-style: none;
margin: 0;