Show agent liveness, stuck/zombie detection and parse errors in admin

This commit is contained in:
Ian Gulliver
2026-10-14 22:35:53 -07:00
parent 33e682a9d5
commit 1dc09c77e0
21 changed files with 1072 additions and 114 deletions
+65 -6
View File
@@ -1,7 +1,7 @@
import { get } from "./api.js";
import { ago, errorText, h, replace } from "./dom.js";
import { findTask, on, onEach, state } from "./state.js";
const refreshKinds = new Set(["session.created", "session.state", "task.created", "task.merged", "task.discarded", "project.created"]);
const refreshKinds = new Set(["session.created", "session.state", "task.created", "task.merged", "task.discarded", "project.created", "admin.health"]);
export function mountAdmin(root) {
let status;
let error = "";
@@ -13,7 +13,10 @@ export function mountAdmin(root) {
replace(root, h("div", { class: error ? "empty error-text" : "empty" }, error || "Loading…"));
return;
}
replace(root, h("div", { class: "admin-view" }, error && h("p", { class: "error-text" }, error), turnsSection(status), rateLimitsSection(status.rate_limits), serverSection(status)));
const scrollTop = root.querySelector(".admin-view")?.scrollTop ?? 0;
const open = new Set([...root.querySelectorAll(".problems details[open]")].map((d) => d.dataset["vendor"] ?? ""));
replace(root, h("div", { class: "admin-view" }, error && h("p", { class: "error-text" }, error), problemsSection(status, open), turnsSection(status), rateLimitsSection(status.rate_limits), serverSection(status)));
root.querySelector(".admin-view").scrollTop = scrollTop;
};
const refresh = async () => {
if (inFlight) {
@@ -45,8 +48,20 @@ export function mountAdmin(root) {
void refresh();
return;
}
if (status && ev.seq > status.last_event_seq) {
if (!status) {
return;
}
let changed = false;
const proc = status.agent_processes.find((p) => p.session_id === ev.agent_session_id);
if (proc && Date.parse(ev.created_at) - Date.parse(proc.last_event_at) >= 1000) {
proc.last_event_at = ev.created_at;
changed = true;
}
if (ev.seq > status.last_event_seq) {
status.last_event_seq = ev.seq;
changed = true;
}
if (changed) {
render();
}
};
@@ -71,19 +86,63 @@ export function mountAdmin(root) {
function turnsSection(s) {
const vendors = [...new Set([...Object.keys(s.caps.per_vendor), ...s.running_turns.map((t) => t.vendor), ...s.queued_turns.map((t) => t.vendor)])].sort();
const runningFor = (vendor) => s.running_turns.filter((t) => t.vendor === vendor).length;
return h("section", { class: "admin-section" }, h("h2", {}, "Turns"), h("div", { class: "caps" }, capTile("All vendors", s.running_turns.length, s.caps.global), ...vendors.map((v) => capTile(v, runningFor(v), s.caps.per_vendor[v])), h("div", { class: "cap-tile" }, h("div", { class: "cap-label" }, "Queued"), h("div", { class: s.queued_turns.length > 0 ? "cap-value warn" : "cap-value" }, s.queued_turns.length))), h("h3", {}, "Running"), turnList(s.running_turns, "running", "No turns running."), h("h3", {}, "Queued"), turnList(s.queued_turns, "queued", "Nothing queued."));
return h("section", { class: "admin-section" }, h("h2", {}, "Turns"), h("div", { class: "caps" }, capTile("All vendors", s.running_turns.length, s.caps.global), ...vendors.map((v) => capTile(v, runningFor(v), s.caps.per_vendor[v])), h("div", { class: "cap-tile" }, h("div", { class: "cap-label" }, "Queued"), h("div", { class: s.queued_turns.length > 0 ? "cap-value warn" : "cap-value" }, s.queued_turns.length))), h("h3", {}, "Running"), turnList(s.running_turns, "running", "No turns running.", s.agent_processes), h("h3", {}, "Queued"), turnList(s.queued_turns, "queued", "Nothing queued.", []));
}
function capTile(label, running, cap) {
const full = cap !== undefined && running >= cap;
return h("div", { class: "cap-tile" }, h("div", { class: "cap-label" }, label), h("div", { class: full ? "cap-value warn" : "cap-value" }, running, h("span", { class: "cap-max" }, cap === undefined ? " / no cap" : ` / ${cap}`)));
}
function turnList(turns, kind, empty) {
function turnList(turns, kind, empty, procs) {
return h("ul", { class: "task-list" }, ...turns.map((t, i) => {
const task = findTask(t.task_id);
const project = task ? state.projects.find((p) => p.id === task.project_id) : undefined;
return h("li", {}, h("a", { href: `#/task/${t.task_id}` }, kind === "queued" ? h("span", { class: "queue-pos" }, `${i + 1}.`) : h("span", { class: "dot running", title: "running" }), h("span", { class: "task-title" }, project && h("span", { class: "project-name" }, `${project.name} ▸ `), task?.title ?? t.task_id), h("span", { class: "task-state" }, t.vendor)));
return h("li", {}, h("a", { href: `#/task/${t.task_id}` }, kind === "queued" ? h("span", { class: "queue-pos" }, `${i + 1}.`) : h("span", { class: "dot running", title: "running" }), h("span", { class: "task-title" }, project && h("span", { class: "project-name" }, `${project.name} ▸ `), task?.title ?? t.task_id), liveness(procs.find((p) => p.session_id === t.session_id)), h("span", { class: "task-state" }, t.vendor)));
}), turns.length === 0 && h("li", { class: "empty-row" }, empty));
}
function taskLink(taskId) {
if (!taskId) {
return "—";
}
const task = findTask(taskId);
const project = task ? state.projects.find((p) => p.id === task.project_id) : undefined;
return h("a", { href: `#/task/${taskId}` }, project ? `${project.name} ▸ ` : "", task?.title ?? taskId);
}
function since(iso) {
const seconds = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000));
if (seconds < 60) {
return `${seconds}s ago`;
}
if (seconds < 3600) {
return `${Math.floor(seconds / 60)}m ${seconds % 60}s ago`;
}
return ago(iso);
}
function liveness(p) {
if (!p) {
return false;
}
if (p.health === "ok") {
return h("span", { class: "task-state" }, since(p.last_event_at));
}
return h("span", { class: "task-state danger" }, `${p.health} · ${since(p.last_event_at)}`);
}
const zombieLabel = {
running_without_process: "Running with no process",
process_without_session: "Process with no session",
};
function problemsSection(s, openVendors) {
const pe = s.protocol_errors;
const vendors = Object.keys(pe.counts).sort();
if (s.zombies.length === 0 && vendors.length === 0) {
return false;
}
return h("section", { class: "admin-section" }, h("h2", {}, "Problems"), h("ul", { class: "problems" }, ...s.zombies.map((z) => h("li", {}, h("span", { class: "danger" }, zombieLabel[z.kind]), " ", z.task_id ? taskLink(z.task_id) : "", " ", h("span", { class: "hint" }, z.detail))), ...vendors.map((v) => {
const latest = pe.recent.find((e) => e.vendor === v);
const n = pe.counts[v] ?? 0;
return h("li", {}, h("span", { class: "danger" }, `${v}: ${n} bad stream line${n === 1 ? "" : "s"}`), latest &&
h("details", { data: { vendor: v }, open: openVendors.has(v) }, h("summary", {}, `latest ${since(latest.at)}: ${latest.reason}`), h("pre", { class: "raw-line" }, latest.raw, new TextEncoder().encode(latest.raw).length < latest.size ? `… (${bytes(latest.size)})` : "")));
})));
}
function rateLimitsSection(limits) {
const vendors = Object.keys(limits).sort();
return h("section", { class: "admin-section" }, h("h2", {}, "Rate limits"), ...vendors.map((v) => rateLimitCard(v, limits[v] ?? {})), vendors.length === 0 && h("p", { class: "hint" }, "No vendor has reported a rate-limit status since byre started."));