dashboard: drop the approvals queue
Y3R C4LL is the operator inbox alone: no P3NDING APPR0VALS section, no approval cards or history, no approval_resolved subscription, no approval browser notifications, and the tab count and title prefix count unread operator messages only. The H0M3 tile text drops "approvals". The deny form was the only data-prompt form, so bindAsyncForms' prompt branch, themedPrompt and its dialog styles go too, along with the approval-only CSS (.approval*, .approvals-history, .btn-deny, the status/glyph colours and .history-note).
This commit is contained in:
parent
58ef299c3c
commit
ac1caef972
13 changed files with 32 additions and 584 deletions
|
|
@ -1,5 +1,5 @@
|
|||
// Y3R C4LL domain — the dashboard pane for things blocked on an operator
|
||||
// decision: approvals and the operator inbox (messages agents sent to
|
||||
// decision: 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.
|
||||
|
|
@ -10,9 +10,8 @@
|
|||
// live-mutation paths call an injected `onCountsChanged` callback the entry
|
||||
// registers once via `initCall`.
|
||||
|
||||
import { $, form, appendLinkified } from "./common.js";
|
||||
import { $, 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.
|
||||
|
|
@ -113,240 +112,3 @@ export function operatorInboxAppendFromEvent(ev) {
|
|||
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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue