Admin view in the web UI
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
import { get, type ByreEvent, type Status, type TurnRef } 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"]);
|
||||
|
||||
export function mountAdmin(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;
|
||||
}
|
||||
replace(
|
||||
root,
|
||||
h(
|
||||
"div",
|
||||
{ class: "admin-view" },
|
||||
error && h("p", { class: "error-text" }, error),
|
||||
turnsSection(status),
|
||||
rateLimitsSection(status.rate_limits),
|
||||
serverSection(status),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
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 && ev.seq > status.last_event_seq) {
|
||||
status.last_event_seq = ev.seq;
|
||||
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 {
|
||||
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: string) => 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."),
|
||||
);
|
||||
}
|
||||
|
||||
function capTile(label: string, running: number, cap: number | undefined): HTMLElement {
|
||||
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: TurnRef[], kind: "running" | "queued", empty: string): HTMLElement {
|
||||
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),
|
||||
),
|
||||
);
|
||||
}),
|
||||
turns.length === 0 && h("li", { class: "empty-row" }, empty),
|
||||
);
|
||||
}
|
||||
|
||||
function rateLimitsSection(limits: Status["rate_limits"]): HTMLElement {
|
||||
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."),
|
||||
);
|
||||
}
|
||||
|
||||
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: "admin-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]}`;
|
||||
}
|
||||
+18
-2
@@ -655,8 +655,11 @@ export interface components {
|
||||
Status: {
|
||||
/** @description Agent processes currently running. */
|
||||
live_agents: number;
|
||||
running_turns: number;
|
||||
queued_turns: number;
|
||||
/** @description Turns running now, oldest session first. */
|
||||
running_turns: components["schemas"]["TurnRef"][];
|
||||
/** @description Turns waiting for room under the caps, in the order they will start. */
|
||||
queued_turns: components["schemas"]["TurnRef"][];
|
||||
caps: components["schemas"]["Caps"];
|
||||
/** @description The latest rate-limit status each vendor reported, keyed by vendor. */
|
||||
rate_limits: {
|
||||
[key: string]: Record<string, never>;
|
||||
@@ -671,6 +674,19 @@ export interface components {
|
||||
active_tasks: number;
|
||||
sse_clients: number;
|
||||
};
|
||||
Caps: {
|
||||
/** @description Most turns running at once across all vendors. */
|
||||
global: number;
|
||||
/** @description Most turns running at once per vendor, keyed by vendor. */
|
||||
per_vendor: {
|
||||
[key: string]: number;
|
||||
};
|
||||
};
|
||||
TurnRef: {
|
||||
session_id: string;
|
||||
task_id: string;
|
||||
vendor: string;
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Error. */
|
||||
|
||||
@@ -12,6 +12,7 @@ export type InboxResponseCreate = Schemas["InboxResponseCreate"];
|
||||
export type Attachment = Schemas["Attachment"];
|
||||
export type ByreEvent = Schemas["Event"];
|
||||
export type Status = Schemas["Status"];
|
||||
export type TurnRef = Schemas["TurnRef"];
|
||||
|
||||
type Method = "get" | "post" | "put";
|
||||
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ async function main(): Promise<void> {
|
||||
const right = new Pane("right", "inbox");
|
||||
replace(document.body, navbar, banner, h("div", { class: "panes" }, left.el, right.el));
|
||||
|
||||
mountNavbar(navbar, () => right.show("inbox"));
|
||||
mountNavbar(navbar, (view) => right.show(view));
|
||||
identityBanner(banner);
|
||||
|
||||
const status = await get("/api/admin/status");
|
||||
|
||||
+7
-3
@@ -1,12 +1,16 @@
|
||||
import { h, replace } from "./dom.js";
|
||||
import type { View } from "./panes.js";
|
||||
import { activeTasks, findTask, onEach, state } from "./state.js";
|
||||
|
||||
export function mountNavbar(root: HTMLElement, showInbox: () => void): void {
|
||||
export function mountNavbar(root: HTMLElement, show: (view: View) => void): void {
|
||||
const crumb = h("button", { class: "crumb", title: "Switch task (⌘K)" });
|
||||
crumb.onclick = openSwitcher;
|
||||
|
||||
const inbox = h("button", { class: "inbox-count" });
|
||||
inbox.onclick = showInbox;
|
||||
inbox.onclick = () => show("inbox");
|
||||
|
||||
const admin = h("button", { class: "nav-link" }, "Admin");
|
||||
admin.onclick = () => show("admin");
|
||||
|
||||
const conn = h("span", { class: "connection down", title: "connecting" });
|
||||
const me = h("span", { class: "me" });
|
||||
@@ -26,7 +30,7 @@ export function mountNavbar(root: HTMLElement, showInbox: () => void): void {
|
||||
};
|
||||
|
||||
onEach(["me", "projects", "tasks", "inbox", "selection", "connection"], render);
|
||||
replace(root, h("a", { href: "#/", class: "brand" }, "byre"), crumb, h("span", { class: "spacer" }), inbox, conn, me);
|
||||
replace(root, h("a", { href: "#/", class: "brand" }, "byre"), crumb, h("span", { class: "spacer" }), inbox, admin, conn, me);
|
||||
render();
|
||||
|
||||
addEventListener("keydown", (e) => {
|
||||
|
||||
+4
-2
@@ -1,19 +1,21 @@
|
||||
import { mountAdmin } from "./admin.js";
|
||||
import { mountChanges } from "./changes.js";
|
||||
import { h, replace } from "./dom.js";
|
||||
import { mountInbox } from "./inbox.js";
|
||||
import { mountTranscript } from "./task.js";
|
||||
import { mountTasks } from "./tasks.js";
|
||||
|
||||
export type View = "transcript" | "changes" | "tasks" | "inbox";
|
||||
export type View = "transcript" | "changes" | "tasks" | "inbox" | "admin";
|
||||
|
||||
const views: Record<View, { label: string; mount: (root: HTMLElement) => () => void }> = {
|
||||
transcript: { label: "Transcript", mount: mountTranscript },
|
||||
changes: { label: "Changes", mount: (root) => mountChanges(root) },
|
||||
tasks: { label: "Tasks", mount: mountTasks },
|
||||
inbox: { label: "Inbox", mount: mountInbox },
|
||||
admin: { label: "Admin", mount: mountAdmin },
|
||||
};
|
||||
|
||||
const order: View[] = ["transcript", "changes", "tasks", "inbox"];
|
||||
const order: View[] = ["transcript", "changes", "tasks", "inbox", "admin"];
|
||||
|
||||
export class Pane {
|
||||
readonly el: HTMLElement;
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
import { get, type AgentSession, type ByreEvent, type InboxItem, type Project, type Task, type User } from "./api.js";
|
||||
|
||||
export type Topic = "me" | "projects" | "tasks" | "sessions" | "inbox" | "selection" | "transcript" | "stream" | "connection";
|
||||
export type Topic = "me" | "projects" | "tasks" | "sessions" | "inbox" | "selection" | "transcript" | "stream" | "connection" | "event";
|
||||
|
||||
export const state = {
|
||||
me: undefined as User | undefined,
|
||||
@@ -170,4 +170,6 @@ export function handleEvent(ev: ByreEvent): void {
|
||||
void loadInbox();
|
||||
break;
|
||||
}
|
||||
|
||||
notify("event", ev);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user