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
|
// 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
|
// `to="operator"`). Lifted out of the dashboard entry (tabs.js) into its
|
||||||
// own module so the entry stays a thin coordinator; mirrors the
|
// own module so the entry stays a thin coordinator; mirrors the
|
||||||
// permissions.js / schedules.js extractions.
|
// permissions.js / schedules.js extractions.
|
||||||
|
|
@ -10,9 +10,8 @@
|
||||||
// live-mutation paths call an injected `onCountsChanged` callback the entry
|
// live-mutation paths call an injected `onCountsChanged` callback the entry
|
||||||
// registers once via `initCall`.
|
// registers once via `initCall`.
|
||||||
|
|
||||||
import { $, form, appendLinkified } from "./common.js";
|
import { $, appendLinkified } from "./common.js";
|
||||||
import { el } from "@hive/shared/dom.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
|
// Registered by the dashboard entry at boot; defaults to a no-op so the
|
||||||
// module is safe to call before wiring.
|
// module is safe to call before wiring.
|
||||||
|
|
@ -113,240 +112,3 @@ export function operatorInboxAppendFromEvent(ev) {
|
||||||
renderOperatorInbox();
|
renderOperatorInbox();
|
||||||
onCountsChanged();
|
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);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -194,10 +194,6 @@ code {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
border-color: var(--green);
|
border-color: var(--green);
|
||||||
}
|
}
|
||||||
.btn-deny {
|
|
||||||
color: var(--red);
|
|
||||||
border-color: var(--red);
|
|
||||||
}
|
|
||||||
.btn-destroy {
|
.btn-destroy {
|
||||||
color: var(--red);
|
color: var(--red);
|
||||||
border-color: var(--red);
|
border-color: var(--red);
|
||||||
|
|
|
||||||
|
|
@ -348,7 +348,7 @@ export function openBuildLogStream(id, pre, { onDone, onError } = {}) {
|
||||||
|
|
||||||
// ─── side panel ─────────────────────────────────────────────────────────
|
// ─── side panel ─────────────────────────────────────────────────────────
|
||||||
// Singleton drawer that swipes in from the right. Long content
|
// Singleton drawer that swipes in from the right. Long content
|
||||||
// (file previews, approval diffs, journald logs, applied config)
|
// (file previews, journald logs, applied config)
|
||||||
// opens here via `sidePanel.open(title, node)` instead of expanding
|
// opens here via `sidePanel.open(title, node)` instead of expanding
|
||||||
// inline. Body is swapped on each open; closing just slides out so
|
// inline. Body is swapped on each open; closing just slides out so
|
||||||
// the content stays visible through the transition.
|
// the content stays visible through the transition.
|
||||||
|
|
@ -566,9 +566,7 @@ export function appendLinkified(parent, text, refs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── browser notifications ──────────────────────────────────────────────
|
// ─── browser notifications ──────────────────────────────────────────────
|
||||||
// Fires OS notifications on two operator-bound signals:
|
// Fires OS notifications on broker messages sent `to: "operator"`.
|
||||||
// - new approval landed in the queue
|
|
||||||
// - broker message sent `to: "operator"`
|
|
||||||
// Permission grant is per-browser; a localStorage "muted" toggle lets
|
// Permission grant is per-browser; a localStorage "muted" toggle lets
|
||||||
// the operator silence without revoking. Secure-context only (HTTPS /
|
// the operator silence without revoking. Secure-context only (HTTPS /
|
||||||
// localhost) — on other origins the API is unavailable and we hide
|
// localhost) — on other origins the API is unavailable and we hide
|
||||||
|
|
|
||||||
|
|
@ -496,55 +496,6 @@ hive-agent-menu {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pending approval: a card with three stacked sections — identity
|
|
||||||
header, what-changed body, decision actions. */
|
|
||||||
.approvals {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0.4em 0 0;
|
|
||||||
}
|
|
||||||
.approval-card {
|
|
||||||
background: var(--bg-elev);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-left: 3px solid var(--purple);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.6em 0.8em;
|
|
||||||
margin-bottom: 0.6em;
|
|
||||||
}
|
|
||||||
.approval-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.3em;
|
|
||||||
}
|
|
||||||
.approval-ts {
|
|
||||||
margin-left: auto;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.85em;
|
|
||||||
}
|
|
||||||
.approval-ts.stale {
|
|
||||||
color: var(--amber);
|
|
||||||
text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 50%, transparent);
|
|
||||||
}
|
|
||||||
.approval-body {
|
|
||||||
margin: 0.45em 0;
|
|
||||||
padding-left: 1.3em;
|
|
||||||
}
|
|
||||||
.approval-description {
|
|
||||||
font-size: 0.9em;
|
|
||||||
color: var(--fg);
|
|
||||||
white-space: pre-wrap;
|
|
||||||
margin-bottom: 0.35em;
|
|
||||||
}
|
|
||||||
.approval-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5em;
|
|
||||||
padding-top: 0.45em;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.approval-actions form.inline {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
/* Inline drill-in triggers (logs / config repo / view diff). */
|
/* Inline drill-in triggers (logs / config repo / view diff). */
|
||||||
.drill-ins {
|
.drill-ins {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -556,91 +507,6 @@ hive-agent-menu {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.approval-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.4em;
|
|
||||||
margin: 0.6em 0 0.4em;
|
|
||||||
}
|
|
||||||
.approval-tab {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--muted);
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.85em;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
padding: 0.25em 0.9em;
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
color 0.15s ease,
|
|
||||||
border-color 0.15s ease,
|
|
||||||
background 0.15s ease;
|
|
||||||
}
|
|
||||||
.approval-tab:hover {
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
.approval-tab.active {
|
|
||||||
color: var(--purple);
|
|
||||||
border-color: var(--purple);
|
|
||||||
background: color-mix(in srgb, var(--purple) 8%, transparent);
|
|
||||||
text-shadow: 0 0 4px currentColor;
|
|
||||||
}
|
|
||||||
/* History items: lighter card treatment so they're visually coherent with
|
|
||||||
the pending .approval-card items above them. Left accent colour reflects
|
|
||||||
the outcome via :has() on the per-status glyph span. */
|
|
||||||
.approvals-history li {
|
|
||||||
background: color-mix(in srgb, var(--bg-elev) 40%, transparent);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-left: 3px solid var(--subtext0);
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 0.35em 0.8em;
|
|
||||||
margin-bottom: 0.4em;
|
|
||||||
}
|
|
||||||
.approvals-history li:has(.glyph-approved) {
|
|
||||||
border-left-color: var(--green);
|
|
||||||
}
|
|
||||||
.approvals-history li:has(.glyph-denied) {
|
|
||||||
border-left-color: var(--red);
|
|
||||||
}
|
|
||||||
.approvals-history li:has(.glyph-failed) {
|
|
||||||
border-left-color: var(--amber);
|
|
||||||
}
|
|
||||||
.approvals-history .status {
|
|
||||||
font-size: 0.85em;
|
|
||||||
padding: 0 0.5em;
|
|
||||||
}
|
|
||||||
.status-approved {
|
|
||||||
color: var(--green);
|
|
||||||
}
|
|
||||||
.status-denied {
|
|
||||||
color: var(--red);
|
|
||||||
}
|
|
||||||
.status-failed {
|
|
||||||
color: var(--amber);
|
|
||||||
}
|
|
||||||
.status-cancelled {
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
.glyph-approved {
|
|
||||||
color: var(--green);
|
|
||||||
}
|
|
||||||
.glyph-denied {
|
|
||||||
color: var(--red);
|
|
||||||
}
|
|
||||||
.glyph-failed {
|
|
||||||
color: var(--amber);
|
|
||||||
}
|
|
||||||
.glyph-cancelled {
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-note {
|
|
||||||
margin-left: 1.8em;
|
|
||||||
margin-top: 0.2em;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.85em;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
ul form.inline {
|
ul form.inline {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -134,8 +134,8 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Y3R C4LL: things blocked on operator decision — the approval
|
<!-- Y3R C4LL: things blocked on operator decision — the operator
|
||||||
queue; surfaces full bodies inline so the operator can decide
|
inbox; surfaces full bodies inline so the operator can decide
|
||||||
without leaving the pane. -->
|
without leaving the pane. -->
|
||||||
<section
|
<section
|
||||||
class="tab-pane"
|
class="tab-pane"
|
||||||
|
|
@ -157,20 +157,12 @@
|
||||||
<p class="meta">loading…</p>
|
<p class="meta">loading…</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>◆ P3NDING APPR0VALS ◆</h2>
|
|
||||||
<div class="divider">
|
|
||||||
══════════════════════════════════════════════════════════════
|
|
||||||
</div>
|
|
||||||
<div id="approvals-section">
|
|
||||||
<p class="meta">loading…</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Operator-local preferences (browser notifications). Was its own
|
<!-- Operator-local preferences (browser notifications). Was its own
|
||||||
S3TT1NGS page (`/settings.html`); mara moved it in here since a
|
S3TT1NGS page (`/settings.html`); mara moved it in here since a
|
||||||
standalone page for one toggle didn't earn its own tile. State
|
standalone page for one toggle didn't earn its own tile. State
|
||||||
lives in the browser's localStorage — NOTIF.bind() (common.js)
|
lives in the browser's localStorage — NOTIF.bind() (common.js)
|
||||||
wires the buttons, same dispatch NOTIF.show() already used for
|
wires the buttons, same dispatch NOTIF.show() already used for
|
||||||
approval/inbox notifications above. -->
|
inbox notifications above. -->
|
||||||
<h2>◆ PR3F3R3NC3S ◆</h2>
|
<h2>◆ PR3F3R3NC3S ◆</h2>
|
||||||
<div class="divider">
|
<div class="divider">
|
||||||
══════════════════════════════════════════════════════════════
|
══════════════════════════════════════════════════════════════
|
||||||
|
|
@ -182,10 +174,9 @@
|
||||||
</p>
|
</p>
|
||||||
<h3>◇ browser notifications</h3>
|
<h3>◇ browser notifications</h3>
|
||||||
<p class="meta">
|
<p class="meta">
|
||||||
desktop notifications for new approvals and broker messages
|
desktop notifications for broker messages addressed to you. requires
|
||||||
addressed to you. requires a secure context (https or localhost).
|
a secure context (https or localhost). mute silences the
|
||||||
mute silences the notifications without revoking the OS-level
|
notifications without revoking the OS-level permission.
|
||||||
permission.
|
|
||||||
</p>
|
</p>
|
||||||
<div id="notif-row" class="notif-row">
|
<div id="notif-row" class="notif-row">
|
||||||
<button
|
<button
|
||||||
|
|
@ -344,8 +335,8 @@
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<!-- Slide-in detail panel (clicked file previews, approval diffs,
|
<!-- Slide-in detail panel (clicked file previews, journald logs,
|
||||||
journald logs, applied config) is a <hive-side-panel> element
|
applied config) is a <hive-side-panel> element
|
||||||
(@hive/shared/side-panel.js) — the Panel singleton in common.js
|
(@hive/shared/side-panel.js) — the Panel singleton in common.js
|
||||||
creates + appends it to <body> lazily on first use, so nothing
|
creates + appends it to <body> lazily on first use, so nothing
|
||||||
needs to be pre-declared here. -->
|
needs to be pre-declared here. -->
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
<span class="home-tile-label">Dashboard</span>
|
<span class="home-tile-label">Dashboard</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="home-tile-desc"
|
<span class="home-tile-desc"
|
||||||
>containers · approvals · permissions · schedules · system</span
|
>containers · permissions · schedules · system</span
|
||||||
>
|
>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -594,7 +594,7 @@ export function renderContainers(s) {
|
||||||
// Belt-and-suspenders for any future page that adds tabs.js
|
// Belt-and-suspenders for any future page that adds tabs.js
|
||||||
// without a #containers-section — matches the
|
// without a #containers-section — matches the
|
||||||
// no-op-when-target-absent convention the other renderers
|
// no-op-when-target-absent convention the other renderers
|
||||||
// (renderApprovals, etc.) follow.
|
// (renderOperatorInbox, etc.) follow.
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
|
|
||||||
// Containers come from the derived map (event-driven) rather than
|
// Containers come from the derived map (event-driven) rather than
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
// /dashboard.html entry point: tab renderers + tab routing + refreshState
|
// /dashboard.html entry point: tab renderers + tab routing + refreshState.
|
||||||
// + notification deltas. Reads /api/state on cold load and after every
|
// Reads /api/state on cold load and after every async-form submit; live
|
||||||
// async-form submit; live updates run through `applyXxx` mutation
|
// updates run through `applyXxx` mutation handlers triggered by the
|
||||||
// handlers triggered by the dashboard event stream (subscribed via
|
// dashboard event stream (subscribed via `openStream` from common.js). See
|
||||||
// `openStream` from common.js). See docs/web-ui/shape.md::Shape (shared by
|
// docs/web-ui/shape.md::Shape (shared by both) for the broader contract.
|
||||||
// both) for the broader contract.
|
|
||||||
//
|
//
|
||||||
// Pure helpers (DOM, side panel, OS notifications, path linkification)
|
// Pure helpers (DOM, side panel, OS notifications, path linkification)
|
||||||
// live in `./common.js`; the flow-page surface (operator inbox, broker
|
// live in `./common.js`; the flow-page surface (operator inbox, broker
|
||||||
|
|
@ -17,7 +16,6 @@ import { el } from "@hive/shared/dom.js";
|
||||||
import { bindAsyncForms } from "@hive/shared/forms.js";
|
import { bindAsyncForms } from "@hive/shared/forms.js";
|
||||||
import { createTabStrip } from "@hive/shared/tabs.js";
|
import { createTabStrip } from "@hive/shared/tabs.js";
|
||||||
import { containersState, syncContainersFromSnapshot } from "./state.js";
|
import { containersState, syncContainersFromSnapshot } from "./state.js";
|
||||||
import { fmtAgo } from "./util.js";
|
|
||||||
import {
|
import {
|
||||||
applyCapabilitiesChanged,
|
applyCapabilitiesChanged,
|
||||||
applyToolGroupsChanged,
|
applyToolGroupsChanged,
|
||||||
|
|
@ -31,10 +29,6 @@ import {
|
||||||
refreshOperatorInbox,
|
refreshOperatorInbox,
|
||||||
operatorInboxAppendFromEvent,
|
operatorInboxAppendFromEvent,
|
||||||
operatorInboxCount,
|
operatorInboxCount,
|
||||||
syncApprovalsFromSnapshot,
|
|
||||||
applyApprovalResolved,
|
|
||||||
renderApprovals,
|
|
||||||
activeApprovalCount,
|
|
||||||
} from "./call.js";
|
} from "./call.js";
|
||||||
import {
|
import {
|
||||||
initJobqRollup,
|
initJobqRollup,
|
||||||
|
|
@ -54,58 +48,12 @@ import {
|
||||||
window.marked = marked;
|
window.marked = marked;
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
// Track which items we've already notified about so a re-render
|
|
||||||
// doesn't re-fire for the same row. Keyed by stable ids; reset only
|
|
||||||
// when the page reloads.
|
|
||||||
const seenApprovals = new Set();
|
|
||||||
let seededNotify = false;
|
|
||||||
|
|
||||||
function notifyDeltas(s) {
|
|
||||||
const approvals = s.approvals || [];
|
|
||||||
if (!seededNotify) {
|
|
||||||
// First render after page load — fill the "seen" set without
|
|
||||||
// firing notifications. We only want to notify on NEW items
|
|
||||||
// that arrived while the page is open. The inbox no longer
|
|
||||||
// needs seeding here: it's derived from the broker stream which
|
|
||||||
// does its own per-event notification on live arrival, and
|
|
||||||
// history-replayed events are silent by virtue of `fromHistory`.
|
|
||||||
for (const a of approvals) seenApprovals.add(a.id);
|
|
||||||
seededNotify = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const a of approvals) {
|
|
||||||
if (seenApprovals.has(a.id)) continue;
|
|
||||||
seenApprovals.add(a.id);
|
|
||||||
NOTIF.show(
|
|
||||||
"◆ approval #" + a.id,
|
|
||||||
`config commit for ${a.agent}`,
|
|
||||||
"hyperhive:approval:" + a.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── async forms ────────────────────────────────────────────────────────
|
// ─── async forms ────────────────────────────────────────────────────────
|
||||||
// Shared `data-async` submit interceptor (now in common.js so /core.html's
|
// Shared `data-async` submit interceptor (now in common.js so /core.html's
|
||||||
// bundle gets it too). On success it re-runs refreshState unless the form
|
// bundle gets it too). On success it re-runs refreshState unless the form
|
||||||
// opts out via `data-no-refresh` (its mutation arrives via an SSE event).
|
// opts out via `data-no-refresh` (its mutation arrives via an SSE event).
|
||||||
bindAsyncForms(() => refreshState());
|
bindAsyncForms(() => refreshState());
|
||||||
|
|
||||||
// Refreshes approval request-age chips (`.approval-ts`, flips `.stale`
|
|
||||||
// at the 1h mark), which would otherwise only update on the next
|
|
||||||
// SSE-triggered re-render.
|
|
||||||
setInterval(() => {
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
document
|
|
||||||
.querySelectorAll(".approval-ts[data-requested-at]")
|
|
||||||
.forEach((node) => {
|
|
||||||
const requestedAt = Number(node.getAttribute("data-requested-at"));
|
|
||||||
if (!Number.isFinite(requestedAt)) return;
|
|
||||||
const ageSec = Math.max(0, now - requestedAt);
|
|
||||||
node.textContent = "requested " + fmtAgo(requestedAt);
|
|
||||||
node.classList.toggle("stale", ageSec >= 3600);
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
// ─── state polling ──────────────────────────────────────────────────────
|
// ─── state polling ──────────────────────────────────────────────────────
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
// Sections whose innerHTML gets blown away on each refresh. If the
|
// Sections whose innerHTML gets blown away on each refresh. If the
|
||||||
|
|
@ -114,7 +62,6 @@ window.marked = marked;
|
||||||
const MANAGED_SECTION_IDS = [
|
const MANAGED_SECTION_IDS = [
|
||||||
"containers-section",
|
"containers-section",
|
||||||
"inbox-section",
|
"inbox-section",
|
||||||
"approvals-section",
|
|
||||||
"schedules-section",
|
"schedules-section",
|
||||||
"capabilities-section",
|
"capabilities-section",
|
||||||
"tool-groups-section",
|
"tool-groups-section",
|
||||||
|
|
@ -211,18 +158,12 @@ window.marked = marked;
|
||||||
// mount's own effect handles the actual fetch.
|
// mount's own effect handles the actual fetch.
|
||||||
initJobqRollup();
|
initJobqRollup();
|
||||||
renderContainers(s);
|
renderContainers(s);
|
||||||
// Sync the derived approvals store from the snapshot, then
|
|
||||||
// render. Live `*_added` / `*_resolved` events mutate the store
|
|
||||||
// directly and re-render without a snapshot refetch.
|
|
||||||
syncApprovalsFromSnapshot(s);
|
|
||||||
renderApprovals();
|
|
||||||
refreshSchedules();
|
refreshSchedules();
|
||||||
restoreOpenDetails(openDetails);
|
restoreOpenDetails(openDetails);
|
||||||
notifyDeltas(s);
|
|
||||||
// No periodic refresh timer. Phase 6 covers every container
|
// No periodic refresh timer. Phase 6 covers every container
|
||||||
// mutation with `ContainerStateChanged` / `ContainerRemoved`
|
// mutation with `ContainerStateChanged` / `ContainerRemoved`
|
||||||
// (lifecycle ops, destroy, rebuild, crash_watch's 10s poll);
|
// (lifecycle ops, destroy, rebuild, crash_watch's 10s poll);
|
||||||
// approvals + transients have their own events;
|
// transients have their own events;
|
||||||
// broker traffic flows through the SSE channel. The only
|
// broker traffic flows through the SSE channel. The only
|
||||||
// /api/state fetches are the initial cold load and the
|
// /api/state fetches are the initial cold load and the
|
||||||
// post-submit refetch on forms without `data-no-refresh`
|
// post-submit refetch on forms without `data-no-refresh`
|
||||||
|
|
@ -246,7 +187,7 @@ window.marked = marked;
|
||||||
refreshOperatorInbox();
|
refreshOperatorInbox();
|
||||||
// NOTIF.bind() wires the enable/mute/unmute toggle buttons that now
|
// NOTIF.bind() wires the enable/mute/unmute toggle buttons that now
|
||||||
// live in the Y3R C4LL tab's ◆ PR3F3R3NC3S ◆ section. NOTIF.show()
|
// live in the Y3R C4LL tab's ◆ PR3F3R3NC3S ◆ section. NOTIF.show()
|
||||||
// (approval/inbox notifications) reads the same browser permission +
|
// (inbox notifications) reads the same browser permission +
|
||||||
// localStorage mute flag regardless of which page fires it — bind()
|
// localStorage mute flag regardless of which page fires it — bind()
|
||||||
// only needs to run once, here, where the buttons live. The side
|
// only needs to run once, here, where the buttons live. The side
|
||||||
// panel (<hive-side-panel>) wires its own internals in
|
// panel (<hive-side-panel>) wires its own internals in
|
||||||
|
|
@ -270,7 +211,6 @@ window.marked = marked;
|
||||||
// and filter client-side — the dashboard ignores broker traffic
|
// and filter client-side — the dashboard ignores broker traffic
|
||||||
// and the inbox ignores mutation events.
|
// and the inbox ignores mutation events.
|
||||||
const MUTATION_HANDLERS = {
|
const MUTATION_HANDLERS = {
|
||||||
approval_resolved: applyApprovalResolved,
|
|
||||||
transient_set: applyTransientSet,
|
transient_set: applyTransientSet,
|
||||||
transient_cleared: applyTransientCleared,
|
transient_cleared: applyTransientCleared,
|
||||||
container_state_changed: applyContainerStateChanged,
|
container_state_changed: applyContainerStateChanged,
|
||||||
|
|
@ -293,12 +233,12 @@ window.marked = marked;
|
||||||
//
|
//
|
||||||
// kinds= matches MUTATION_HANDLERS below verbatim, plus `sent`
|
// kinds= matches MUTATION_HANDLERS below verbatim, plus `sent`
|
||||||
// (checked separately, just above, for the operator inbox) —
|
// (checked separately, just above, for the operator inbox) —
|
||||||
// narrows this from all 13 wire kinds down to the 9 this page
|
// narrows this from all 12 wire kinds down to the 8 this page
|
||||||
// actually acts on. This page was one of 4 unfiltered
|
// actually acts on. This page was one of 4 unfiltered
|
||||||
// `/api/dashboard/stream` subscribers before this (subscription
|
// `/api/dashboard/stream` subscribers before this (subscription
|
||||||
// discipline, part 1 of the dashboard-event-stream-split issue).
|
// discipline, part 1 of the dashboard-event-stream-split issue).
|
||||||
const es = openStream(
|
const es = openStream(
|
||||||
"/api/dashboard/stream?kinds=sent,approval_resolved," +
|
"/api/dashboard/stream?kinds=sent," +
|
||||||
"transient_set,transient_cleared," +
|
"transient_set,transient_cleared," +
|
||||||
"container_state_changed,container_removed,rebuild_queue_changed," +
|
"container_state_changed,container_removed,rebuild_queue_changed," +
|
||||||
"capabilities_changed,tool_groups_changed",
|
"capabilities_changed,tool_groups_changed",
|
||||||
|
|
@ -358,8 +298,7 @@ window.marked = marked;
|
||||||
document.body.dataset.activeTab = target;
|
document.body.dataset.activeTab = target;
|
||||||
renderSelectionBar(Array.from(containersState.values()));
|
renderSelectionBar(Array.from(containersState.values()));
|
||||||
// Re-fetch on activation as a safety net: SSE covers live mutations,
|
// Re-fetch on activation as a safety net: SSE covers live mutations,
|
||||||
// re-sync covers disconnect windows / approval-path inserts that
|
// re-sync covers disconnect windows.
|
||||||
// don't yet emit.
|
|
||||||
if (target === "schedules") {
|
if (target === "schedules") {
|
||||||
refreshSchedules();
|
refreshSchedules();
|
||||||
}
|
}
|
||||||
|
|
@ -410,8 +349,8 @@ window.marked = marked;
|
||||||
if (c.needs_update) swarm++;
|
if (c.needs_update) swarm++;
|
||||||
}
|
}
|
||||||
setTabCount("swarm", swarm);
|
setTabCount("swarm", swarm);
|
||||||
// Y3R C4LL — pending approvals + unread agent->operator messages.
|
// Y3R C4LL — unread agent->operator messages.
|
||||||
const callCount = activeApprovalCount() + operatorInboxCount();
|
const callCount = operatorInboxCount();
|
||||||
setTabCount("call", callCount);
|
setTabCount("call", callCount);
|
||||||
// Browser tab title prefix — lets the operator see the pending
|
// Browser tab title prefix — lets the operator see the pending
|
||||||
// call count without switching to the window. Strips any existing
|
// call count without switching to the window. Strips any existing
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Shared dashboard render + format helpers.
|
// Shared dashboard render + format helpers.
|
||||||
//
|
//
|
||||||
// Small pure utilities used across most dashboard tabs (the container
|
// Small pure utilities used across most dashboard tabs (the container
|
||||||
// tree, the rebuild queue, approvals, schedules + reminders):
|
// tree, the rebuild queue, schedules + reminders):
|
||||||
// an atomic-swap render helper and a handful of relative-time / duration
|
// an atomic-swap render helper and a handful of relative-time / duration
|
||||||
// formatters. They live here — a dashboard-internal module — rather than
|
// formatters. They live here — a dashboard-internal module — rather than
|
||||||
// in the cross-page `common.js`, because they're specific to the
|
// in the cross-page `common.js`, because they're specific to the
|
||||||
|
|
|
||||||
|
|
@ -47,11 +47,10 @@ export function asyncBtn(btn, fn) {
|
||||||
// successful submit unless the form opts out with `data-no-refresh` (forms
|
// successful submit unless the form opts out with `data-no-refresh` (forms
|
||||||
// whose mutation arrives faster via an SSE event).
|
// whose mutation arrives faster via an SSE event).
|
||||||
//
|
//
|
||||||
// Confirmation (`data-confirm`) and free-text prompts (`data-prompt` /
|
// Confirmation (`data-confirm`) is surfaced via the themed dialog in
|
||||||
// `data-prompt-field`) are surfaced via the themed dialogs in `modal.js`
|
// `modal.js` rather than native `confirm()`, and errors via `themedToast`
|
||||||
// rather than native `confirm()`/`prompt()`, and errors via `themedToast`
|
|
||||||
// rather than `alert()`, so every page gets the same in-theme experience.
|
// rather than `alert()`, so every page gets the same in-theme experience.
|
||||||
import { themedConfirm, themedPrompt, themedToast } from "./modal.js";
|
import { themedConfirm, themedToast } from "./modal.js";
|
||||||
|
|
||||||
export function bindAsyncForms(onSuccess) {
|
export function bindAsyncForms(onSuccess) {
|
||||||
document.addEventListener("submit", async (e) => {
|
document.addEventListener("submit", async (e) => {
|
||||||
|
|
@ -64,21 +63,6 @@ export function bindAsyncForms(onSuccess) {
|
||||||
!(await themedConfirm({ message: f.dataset.confirm }))
|
!(await themedConfirm({ message: f.dataset.confirm }))
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
if (f.dataset.prompt) {
|
|
||||||
const ans = await themedPrompt({ message: f.dataset.prompt });
|
|
||||||
if (ans === null) return; // operator hit Cancel
|
|
||||||
// Drop into a hidden input named after `data-prompt-field` (or
|
|
||||||
// 'note' by default) so the value rides along on the POST.
|
|
||||||
const field = f.dataset.promptField || "note";
|
|
||||||
let input = f.querySelector(`input[name="${field}"]`);
|
|
||||||
if (!input) {
|
|
||||||
input = document.createElement("input");
|
|
||||||
input.type = "hidden";
|
|
||||||
input.name = field;
|
|
||||||
f.append(input);
|
|
||||||
}
|
|
||||||
input.value = ans;
|
|
||||||
}
|
|
||||||
const btn = f.querySelector(
|
const btn = f.querySelector(
|
||||||
'button[type="submit"], button:not([type]), .btn-inline',
|
'button[type="submit"], button:not([type]), .btn-inline',
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -63,31 +63,3 @@
|
||||||
gap: 0.6em;
|
gap: 0.6em;
|
||||||
margin-top: 0.2em;
|
margin-top: 0.2em;
|
||||||
}
|
}
|
||||||
.promptfield {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.4em;
|
|
||||||
}
|
|
||||||
.promptlabel {
|
|
||||||
color: var(--subtext0);
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
.input {
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 1em;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: var(--crust);
|
|
||||||
color: var(--fg);
|
|
||||||
border: 1px solid var(--purple-dim);
|
|
||||||
padding: 0.4em 0.6em;
|
|
||||||
}
|
|
||||||
.input:focus {
|
|
||||||
outline: 1px solid var(--green);
|
|
||||||
}
|
|
||||||
.textarea {
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 4.5em;
|
|
||||||
line-height: 1.4;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
// (see each component's own header comment for the design rationale).
|
// (see each component's own header comment for the design rationale).
|
||||||
// This file is the orchestration layer on top: `openDialog` is the
|
// This file is the orchestration layer on top: `openDialog` is the
|
||||||
// general primitive (any title/message/content + a row of buttons);
|
// general primitive (any title/message/content + a row of buttons);
|
||||||
// `themedConfirm`/`themedPrompt`/`themedToast` are thin wrappers built on
|
// `themedConfirm`/`themedToast` are thin wrappers built on
|
||||||
// it. It's the single entry point external packages import
|
// it. It's the single entry point external packages import
|
||||||
// (`@hive/shared/modal.js`) — the component internals aren't exported
|
// (`@hive/shared/modal.js`) — the component internals aren't exported
|
||||||
// individually.
|
// individually.
|
||||||
|
|
@ -107,66 +107,6 @@ export function themedConfirm(opts = {}) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// themedPrompt({ title, message, label, placeholder, value, confirmLabel, cancelLabel })
|
|
||||||
// → Promise<string | null>. Themed replacement for window.prompt(): a
|
|
||||||
// resizable <textarea> dialog that resolves to the entered string on confirm,
|
|
||||||
// or null on cancel. Chat-box key behaviour: **Enter submits**, **Shift+Enter
|
|
||||||
// inserts a newline** — so short answers are one keystroke while multi-line
|
|
||||||
// reasons (e.g. an approval deny note) are still possible. Escape cancels
|
|
||||||
// (via openDialog).
|
|
||||||
export function themedPrompt(opts = {}) {
|
|
||||||
const {
|
|
||||||
title = "",
|
|
||||||
message = "",
|
|
||||||
label = "",
|
|
||||||
placeholder = "",
|
|
||||||
value = "",
|
|
||||||
confirmLabel = "ok",
|
|
||||||
cancelLabel = "cancel",
|
|
||||||
} = opts;
|
|
||||||
const input = el("textarea", {
|
|
||||||
class: "input textarea",
|
|
||||||
rows: "3",
|
|
||||||
placeholder,
|
|
||||||
});
|
|
||||||
if (value) input.value = value;
|
|
||||||
// Enter submits (clicks the confirm button mounted by openDialog);
|
|
||||||
// Shift+Enter falls through to the textarea's default newline insert.
|
|
||||||
// `closest()`/`querySelector()` stay within the shadow tree the input
|
|
||||||
// lives in, so this resolves to the dialog's own `.box`/confirm button
|
|
||||||
// without leaking across instances. The confirm button is selected by
|
|
||||||
// its `variant` attribute now (hive-btn.js), not a CSS class.
|
|
||||||
input.addEventListener("keydown", (e) => {
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
input.closest(".box")?.querySelector('[variant="confirm"]')?.click();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const content = el(
|
|
||||||
"div",
|
|
||||||
{ class: "promptfield" },
|
|
||||||
label ? el("label", { class: "promptlabel" }, label) : null,
|
|
||||||
input,
|
|
||||||
);
|
|
||||||
const result = openDialog({
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
content,
|
|
||||||
buttons: [
|
|
||||||
{ label: cancelLabel, value: "__cancel__", class: "cancel" },
|
|
||||||
{
|
|
||||||
label: confirmLabel,
|
|
||||||
value: "__ok__",
|
|
||||||
class: "confirm",
|
|
||||||
autofocus: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).then((v) => (v === "__ok__" ? input.value : null));
|
|
||||||
// Prefer focusing the field over the OK button once the dialog has mounted.
|
|
||||||
setTimeout(() => input.focus(), 0);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// themedToast(message, { type, duration }) — non-blocking transient
|
// themedToast(message, { type, duration }) — non-blocking transient
|
||||||
// notification; a lighter alternative to a modal for feedback that needs no
|
// notification; a lighter alternative to a modal for feedback that needs no
|
||||||
// decision (errors, validation, status). Stacks in a fixed top-right
|
// decision (errors, validation, status). Stacks in a fixed top-right
|
||||||
|
|
|
||||||
|
|
@ -509,7 +509,7 @@ export function create(opts) {
|
||||||
// Seq-dedupe only events of a kind that actually appeared in
|
// Seq-dedupe only events of a kind that actually appeared in
|
||||||
// the history replay — those are the only ones that could
|
// the history replay — those are the only ones that could
|
||||||
// double (once via history, once via the live buffer).
|
// double (once via history, once via the live buffer).
|
||||||
// Mutation events (approval/container/…) are never
|
// Mutation events (container/transient/…) are never
|
||||||
// carried by the history endpoint; deduping them against the
|
// carried by the history endpoint; deduping them against the
|
||||||
// broker-history seq would wrongly drop ones that fired
|
// broker-history seq would wrongly drop ones that fired
|
||||||
// between a consumer's own snapshot read and this history
|
// between a consumer's own snapshot read and this history
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue