status page usage
This commit is contained in:
+2
-2
@@ -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.
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+19
-1
@@ -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 "";
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+33
-6
@@ -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 "";
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user