Files
byre/web/src/status.ts
T

306 lines
9.8 KiB
TypeScript

import { get, type AgentProcess, type ByreEvent, type Status, type TurnRef, type Zombie } from "./api.js";
import { ago, errorText, h, openPanel, replace } from "./dom.js";
import { findTask, on, onEach, state } from "./state.js";
import { taskStyle } from "./taskcolor.js";
const refreshKinds = new Set(["session.created", "session.state", "task.created", "task.merged", "task.discarded", "project.created", "admin.health"]);
export function openStatus(): void {
openPanel("Status", mountStatus);
}
function mountStatus(root: HTMLElement): () => void {
let status: Status | undefined;
let error = "";
let inFlight = false;
let again = false;
let disposed = false;
const render = () => {
if (!status) {
replace(root, h("div", { class: error ? "empty error-text" : "empty" }, error || "Loading…"));
return;
}
const scrollTop = root.querySelector(".status-view")?.scrollTop ?? 0;
const open = new Set([...root.querySelectorAll<HTMLDetailsElement>(".problems details[open]")].map((d) => d.dataset["vendor"] ?? ""));
replace(
root,
h(
"div",
{ class: "status-view" },
error && h("p", { class: "error-text" }, error),
problemsSection(status, open),
turnsSection(status),
rateLimitsSection(status.rate_limits),
serverSection(status),
),
);
root.querySelector(".status-view")!.scrollTop = scrollTop;
};
const refresh = async () => {
if (inFlight) {
again = true;
return;
}
inFlight = true;
try {
const next = await get("/api/admin/status");
if (disposed) {
return;
}
status = next;
error = "";
} catch (err) {
error = `Couldn't load status: ${errorText(err)}`;
}
inFlight = false;
render();
if (again && !disposed) {
again = false;
void refresh();
}
};
const onEvent = (detail?: unknown) => {
const ev = detail as ByreEvent;
if (refreshKinds.has(ev.kind) || (ev.kind === "agent.vendor" && ev.payload["type"] === "rate_limit_event")) {
void refresh();
return;
}
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();
}
};
const offs = [
on("event", onEvent),
on("connection", () => {
if (state.connected) {
void refresh();
}
}),
onEach(["projects", "tasks"], render),
];
render();
void refresh();
return () => {
disposed = true;
for (const off of offs) {
off();
}
};
}
function turnsSection(s: Status): HTMLElement {
return h("section", { class: "status-section" }, h("h2", {}, "Running turns"), turnList(s.running_turns, s.agent_processes));
}
function turnList(turns: TurnRef[], procs: AgentProcess[]): HTMLElement {
return h(
"ul",
{ class: "task-list" },
...turns.map((t) => {
const task = findTask(t.task_id);
const project = task ? state.projects.find((p) => p.id === task.project_id) : undefined;
return h(
"li",
{ style: taskStyle(t.task_id) },
h(
"a",
{ href: `#/task/${t.task_id}` },
h("span", { class: "dot running", title: "running" }),
h("span", { class: "task-title" }, project && h("span", { class: "project-name" }, `${project.name} ▸ `), h("span", { class: "task-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" }, "No turns running."),
);
}
function taskLink(taskId: string | undefined): HTMLElement | string {
if (!taskId) {
return "lead";
}
const task = findTask(taskId);
const project = task ? state.projects.find((p) => p.id === task.project_id) : undefined;
return h("a", { href: `#/task/${taskId}`, class: "task-link", style: taskStyle(taskId) }, project ? `${project.name} ▸ ` : "", task?.title ?? taskId);
}
function since(iso: string): string {
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: AgentProcess | undefined): HTMLElement | false {
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: Record<Zombie["kind"], string> = {
running_without_process: "Running with no process",
process_without_session: "Process with no session",
};
function problemsSection(s: Status, openVendors: Set<string>): HTMLElement | false {
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: "status-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: Status["rate_limits"]): HTMLElement {
const vendors = Object.keys(limits).sort();
return h(
"section",
{ class: "status-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."),
);
}
function rateLimitCard(vendor: string, info: Record<string, unknown>): HTMLElement {
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 }>;
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(
"dl",
{ class: "kv" },
...Object.entries(rest).flatMap(([k, v]) => [h("dt", {}, k), h("dd", {}, typeof v === "object" ? JSON.stringify(v) : String(v))]),
),
);
}
function utilizationClass(u: number | undefined): string {
if (u === undefined) {
return "";
}
if (u >= 0.9) {
return "danger";
}
if (u >= 0.7) {
return "warn";
}
return "";
}
function resetText(unixSeconds: number): string {
const at = new Date(unixSeconds * 1000);
const time = at.toLocaleString(undefined, { weekday: "short", hour: "numeric", minute: "2-digit" });
const minutes = Math.round((at.getTime() - Date.now()) / 60000);
if (minutes <= 0) {
return `${time} (passed)`;
}
const rel = minutes < 60 ? `${minutes}m` : minutes < 1440 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${Math.floor(minutes / 1440)}d ${Math.floor((minutes % 1440) / 60)}h`;
return `${time} (in ${rel})`;
}
function serverSection(s: Status): HTMLElement {
const rows: [string, string][] = [
["Started", `${new Date(s.started_at).toLocaleString()} (${ago(s.started_at)})`],
["Agent processes", String(s.live_agents)],
["Projects", String(s.projects)],
["Active tasks", String(s.active_tasks)],
["Live clients", String(s.sse_clients)],
["Database", bytes(s.db_bytes)],
["Last event", `#${s.last_event_seq}`],
];
return h("section", { class: "status-section" }, h("h2", {}, "Server"), h("dl", { class: "kv" }, ...rows.flatMap(([k, v]) => [h("dt", {}, k), h("dd", {}, v)])));
}
function bytes(n: number): string {
const units = ["B", "KB", "MB", "GB", "TB"];
let i = 0;
let v = n;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}