Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/dashboard/src/call.js
atlas a4a3f81d5c dashboard: SCH3DUL3S is a read-only list with a removal banner
The tab lists the rows left in the hive's store so the operator can
recreate them with `swarmctl schedule add`: no create row, edit form,
fire/pause/cancel buttons or schedules_changed subscription. The next
column shows the stored next fire time as an absolute UTC timestamp (the
value for --at) instead of a countdown. A warning banner says the page is
slated for removal and points at swarmctl. The approvals view drops the
schedule_prompt card and the approval_added subscription.
2026-10-04 21:23:30 +02:00

352 lines
11 KiB
JavaScript

// Y3R C4LL domain — the dashboard pane for things blocked on an operator
// decision: approvals and the operator inbox (messages agents sent to
// `to="operator"`). Lifted out of the dashboard entry (tabs.js) into its
// own module so the entry stays a thin coordinator; mirrors the
// permissions.js / schedules.js extractions.
//
// Tab-count wiring: like schedules.js this module does not call the
// coordinator's `refreshTabCounts` directly (that would be a circular
// import). Instead it exposes count getters the coordinator pulls, and the
// live-mutation paths call an injected `onCountsChanged` callback the entry
// registers once via `initCall`.
import { $, form, appendLinkified } from "./common.js";
import { el } from "@hive/shared/dom.js";
import { epochSec, fmtAgo } from "./util.js";
// Registered by the dashboard entry at boot; defaults to a no-op so the
// module is safe to call before wiring.
let onCountsChanged = () => {};
export function initCall(opts = {}) {
if (typeof opts.onCountsChanged === "function")
onCountsChanged = opts.onCountsChanged;
}
// ─── operator inbox — unread agent→operator messages ────────────
// The Y3R C4LL tab surfaces messages agents `send(to: "operator")` so
// the operator stops missing them. Unread = broker rows to "operator"
// with `acked_at IS NULL`; cold-loaded from `/api/operator-inbox`,
// appended live from the broker `sent` stream, and cleared via the
// existing per-recipient ack (`POST /api/agent/operator/mark-all-read`).
// Count folds into the Y3R C4LL pill + browser-title prefix.
let operatorInbox = []; // [{ id, from, body, at, file_refs }], newest-first
export function operatorInboxCount() {
return operatorInbox.length;
}
export async function refreshOperatorInbox() {
try {
const r = await fetch("/api/operator-inbox");
if (r.ok) {
const data = await r.json();
operatorInbox = Array.isArray(data.messages) ? data.messages : [];
}
} catch {
/* keep prior list on transient failure */
}
renderOperatorInbox();
onCountsChanged();
}
function renderOperatorInbox() {
const root = $("operator-inbox-section");
if (!root) return;
root.replaceChildren();
if (!operatorInbox.length) {
root.append(el("p", { class: "meta" }, "no unread messages"));
return;
}
const mark = el(
"button",
{ type: "button", class: "btn", id: "op-inbox-mark-read" },
`✓ mark all read (${operatorInbox.length})`,
);
mark.addEventListener("click", markOperatorInboxRead);
root.append(el("div", { class: "inbox-toolbar" }, mark));
const fmt = (ts) => new Date(ts).toISOString().replace("T", " ").slice(0, 19);
const ul = el("ul", { class: "inbox" });
for (const m of operatorInbox) {
const body = el("span", { class: "msg-body" });
appendLinkified(body, m.body, m.file_refs);
ul.append(
el(
"li",
{},
el("span", { class: "msg-ts" }, fmt(m.at)),
" ",
el("span", { class: "msg-from" }, m.from),
" ",
el("span", { class: "msg-sep" }, "→ "),
body,
),
);
}
root.append(ul);
}
async function markOperatorInboxRead() {
try {
await fetch("/api/agent/operator/mark-all-read", { method: "POST" });
} catch {
/* best-effort; the next refresh reconciles */
}
operatorInbox = [];
renderOperatorInbox();
onCountsChanged();
}
// Live append from the broker stream — a `sent` frame addressed to
// "operator". De-dupes on broker row id so a history/live overlap or
// a refresh racing the stream doesn't double-list.
export function operatorInboxAppendFromEvent(ev) {
if (ev.id != null && operatorInbox.some((m) => m.id === ev.id)) return;
operatorInbox.unshift({
id: ev.id,
from: ev.from,
body: ev.body,
at: ev.at,
file_refs: ev.file_refs || [],
});
if (operatorInbox.length > 100) operatorInbox.length = 100;
renderOperatorInbox();
onCountsChanged();
}
// ─── approvals — the operator config-change approval queue ────────
const APPROVAL_TAB_KEY = "hyperhive:approvals:tab";
// Derived approval state — cold-loaded from /api/state, then mutated
// live by `approval_resolved` dashboard events.
// `pending` is the open queue (newest-first); `history` is the last
// 30 resolved rows.
const APPROVAL_HISTORY_LIMIT = 30;
const approvalsState = { pending: [], history: [] };
export function activeApprovalCount() {
return approvalsState.pending.length;
}
export function syncApprovalsFromSnapshot(s) {
approvalsState.pending = (s.approvals || []).slice();
approvalsState.history = (s.approval_history || []).slice();
}
export function applyApprovalResolved(ev) {
// Drop from pending; prepend to history (newest-first), cap at 30.
approvalsState.pending = approvalsState.pending.filter((a) => a.id !== ev.id);
// Idempotent: a snapshot re-sync (post-disconnect SSE catchup) can
// carry this same resolved row in `approval_history` while a live
// event also delivers it — guard the unshift so history can't
// double a row.
if (!approvalsState.history.some((h) => h.id === ev.id)) {
approvalsState.history.unshift({
id: ev.id,
agent: ev.agent,
kind: ev.approval_kind,
status: ev.status,
resolved_at: ev.resolved_at,
note: ev.note || null,
description: ev.description || null,
});
if (approvalsState.history.length > APPROVAL_HISTORY_LIMIT) {
approvalsState.history.length = APPROVAL_HISTORY_LIMIT;
}
}
renderApprovals();
}
export function renderApprovals() {
const root = $("approvals-section");
// #approvals-section only lives on /dashboard.html (Y3R C4LL tab);
// no-op elsewhere — `approval_resolved` SSE
// events route through here on every page that loads the bundle.
if (!root) return;
root.replaceChildren();
const pending = approvalsState.pending;
const history = approvalsState.history;
const active = localStorage.getItem(APPROVAL_TAB_KEY) || "pending";
const tabs = el("div", { class: "approval-tabs" });
const pendingTab = el(
"button",
{
type: "button",
class: "approval-tab" + (active === "pending" ? " active" : ""),
},
`pending · ${pending.length}`,
);
const historyTab = el(
"button",
{
type: "button",
class: "approval-tab" + (active === "history" ? " active" : ""),
},
`history · ${history.length}`,
);
pendingTab.addEventListener("click", () => {
localStorage.setItem(APPROVAL_TAB_KEY, "pending");
renderApprovals();
});
historyTab.addEventListener("click", () => {
localStorage.setItem(APPROVAL_TAB_KEY, "history");
renderApprovals();
});
tabs.append(pendingTab, historyTab);
root.append(tabs);
if (active === "history") {
renderApprovalHistory(root, history);
return;
}
if (!pending.length) {
root.append(el("p", { class: "empty" }, "queue empty"));
return;
}
const ul = el("ul", { class: "approvals" });
for (const a of pending) {
const isUpdateMeta = a.kind === "update_meta_inputs";
const li = el("li", { class: "approval-card" });
// ── identity header ──────────────────────────────────────────
const head = el(
"div",
{ class: "approval-head" },
el("span", { class: "glyph" }, "↻"),
el("span", { class: "id" }, "#" + a.id),
el("span", { class: "agent" }, a.agent),
el("span", { class: "kind" }, isUpdateMeta ? "meta-update" : a.kind),
);
// When the approval was requested — relative time, right-aligned.
// Goes amber once it's been pending an hour so a stale request is
// obvious at a glance (see docs/web-ui/dashboard.md::Approval card).
if (a.requested_at != null) {
const requestedSec = epochSec(a.requested_at);
const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - requestedSec));
head.append(
el(
"span",
{
class: "approval-ts" + (ageSec >= 3600 ? " stale" : ""),
title: "requested " + new Date(a.requested_at).toLocaleString(),
"data-requested-at": String(requestedSec),
},
"requested " + fmtAgo(a.requested_at),
),
);
}
li.append(head);
// ── what-changed body ────────────────────────────────────────
const body = el("div", { class: "approval-body" });
if (a.description) {
body.append(el("div", { class: "approval-description" }, a.description));
}
if (isUpdateMeta) {
let inputs;
try {
inputs = JSON.parse(a.commit_ref || "[]");
} catch (_) {
inputs = [];
}
body.append(
el(
"span",
{ class: "meta" },
inputs.length
? "bump flake inputs: " + inputs.join(", ")
: "bump all flake inputs",
),
);
} else {
body.append(
el(
"span",
{ class: "meta" },
isInit
? "scaffold proposed config repo — submitting agent customises agent.nix before spawn"
: "new sub-agent — container will be created on approve",
),
);
}
li.append(body);
// ── decision actions ─────────────────────────────────────────
// Deny prompts the operator for an optional reason; the submit
// handler stashes it into a hidden `note` input that rides along
// on the POST and is surfaced to the submitting agent via
// HelperEvent::ApprovalResolved { note }.
const denyForm = el("form", {
method: "POST",
action: "/api/deny/" + a.id,
class: "inline",
"data-async": "",
"data-no-refresh": "",
"data-prompt": "reason for denying (optional, sent to submitter):",
});
denyForm.append(
el("button", { type: "submit", class: "btn btn-deny" }, "DENY"),
);
li.append(
el(
"div",
{ class: "approval-actions" },
form(
"/api/approve/" + a.id,
"btn-approve",
"◆ APPR0VE",
null,
{},
{ noRefresh: true },
),
denyForm,
),
);
ul.append(li);
}
root.append(ul);
}
function renderApprovalHistory(root, history) {
if (!history.length) {
root.append(el("p", { class: "empty" }, "no resolved approvals yet"));
return;
}
const ul = el("ul", { class: "approvals approvals-history" });
for (const a of history) {
const li = el("li");
const row = el("div", { class: "row" });
const glyph =
a.status === "approved"
? "✓"
: a.status === "denied"
? "✗"
: a.status === "cancelled"
? "⊘"
: "⚠";
row.append(
el("span", { class: "glyph glyph-" + a.status }, glyph),
" ",
el("span", { class: "id" }, "#" + a.id),
" ",
el("span", { class: "agent" }, a.agent),
" ",
el(
"span",
{ class: "kind" },
a.kind === "update_meta_inputs" ? "meta-update" : a.kind,
),
" ",
);
if (a.sha_short) row.append(el("code", {}, a.sha_short), " ");
row.append(
el("span", { class: "status status-" + a.status }, a.status),
" ",
el("span", { class: "msg-ts" }, fmtAgo(a.resolved_at)),
);
li.append(row);
if (a.note) {
li.append(el("div", { class: "history-note" }, a.note));
}
ul.append(li);
}
root.append(ul);
}