jobq-graph: re-add per-node cancel button

Fixes #3067.

<hive-jobq-graph> gains a `cancellable` attribute: any non-terminal
node (Pending/Running/Finishing) gets a small cancel button, and a
click dispatches `hive-jobq-graph-cancel` (`detail: { id }`) rather
than POSTing anything itself -- which endpoint actually cancels a
node is the host's domain concept, same "push data out, host decides"
shape `hive-jobq-graph-update` already uses.

builds.js turns it on for R3BU1LD QU3U3, confirms via themedConfirm,
then POSTs the existing `/api/rebuild-queue/{id}/cancel` endpoint.
No manual refresh needed -- cancelling flips node state, which
already fires rebuild_queue_changed over SSE, and the page's existing
handler for that tick already calls jobqGraphEl.refresh().

Also removed ~130 lines of dead `.rqe-*` CSS in system-sections.css
left over from the bespoke pre-<hive-jobq-graph> queue renderer
(confirmed zero JS references before deleting each rule; kept the
still-used `.rqe-kind`/`.rqe-agent`/`.rqe-source*`).

docs/web-ui/dashboard.md's R3BU1LD QU3U3 section updated to match
current behaviour (cancel button, waits-on text instead of the old
"no per-node actions" note, sibling order no longer implies anything
since deps render as text not a reordered rail).
This commit is contained in:
iris 2026-08-09 17:40:39 +02:00
commit aa14339be7
5 changed files with 117 additions and 176 deletions

View file

@ -232,21 +232,28 @@ dashboard pages treat the tick as a pure refetch trigger).
Each row is one root graph node (`parent: null`); a multi-step op's
per-agent subgraphs and sub-steps render as nodes within that one
entry (structural `parent` edges define the tree, `deps` edges order
siblings). A row shows a state glyph (`⏸` pending / `▶` running /
`◐` finishing — own work done, a sub-node still running / `✔` done /
`✖` failed / `⊘` cancelled / `·` skipped) and each step's own
label/agent. **No source chip, kind label, cancel button, timing, or
build-log deep-link on rows** — the generic component has no
per-node action affordances or entry-level metadata (no equivalent of
the old `DagView`'s `source`/`reason`/`created_at`, which were
`NodeKind::Dag`-specific fields the generic wire doesn't carry); per
mara's steer on hyperhive#2812 ("dont feel constrained by what the ui
does currently"), the first cut presents what the endpoint actually
gives rather than reconstructing the old per-row chrome. Settled
entries render their **full step tree**, not just a bare summary —
unlike the old `DagView` projection, this wire does not filter `Done`
nodes out.
entry (structural `parent` edges define the tree; sibling order is
array order, unchanged from the wire). A row shows a state glyph (`⏸`
pending / `▶` running / `◐` finishing — own work done, a sub-node
still running / `✔` done / `✖` failed / `⊘` cancelled / `·` skipped)
and each step's own label/agent. A `Node`-kind dep on a sibling shows
as a plain "waits on: `<label>`" text line under the row — an earlier
gutter-rail version was reverted (it broke visually whenever a nested
subtree sat between the two related rows; text has no such gap).
`builds.js` mounts the element with `cancellable` set, which turns on
a per-node cancel button (`✕`) on any non-terminal row — the button
dispatches `hive-jobq-graph-cancel`, and the page does the actual
`POST /api/rebuild-queue/{id}/cancel`, matching the wire event as its
own domain concept (the component knows nothing about that endpoint).
**Still no source chip, kind label, timing, or build-log deep-link on
rows** — no equivalent of the old `DagView`'s `source`/`reason`/
`created_at`, which were `NodeKind::Dag`-specific fields the generic
wire doesn't carry; per mara's original steer ("dont feel constrained
by what the ui does currently"), rows present what the endpoint
actually gives rather than reconstructing the old per-row chrome.
Settled entries render their **full step tree**, not just a bare
summary — unlike the old `DagView` projection, this wire does not
filter `Done` nodes out.
Below the queue, a **live build-log panel** (`#rebuild-live-log`,
`renderRebuildLiveLog`) shows the currently-running rebuild's output

View file

@ -13,6 +13,7 @@
import { $, fmtAgeSecs, openStream, openBuildLogStream, initServerWarnings } from './common.js';
import { el } from '@hive/shared/dom.js';
import { bindAsyncForms } from '@hive/shared/forms.js';
import { themedConfirm } from '@hive/shared/modal.js';
import { fmtAgo, fmtDuration, truncate } from './util.js';
import '@hive/shared/hive-tab-strip.js';
import '@hive/shared/jobq-graph.js';
@ -128,25 +129,46 @@ function renderMetaInputs(s) {
// ─── rebuild queue ────────────────────────────────────────────────────────────
// R3BU1LD QU3U3 is <hive-jobq-graph> directly (mara: "replace the build
// queue tab with this component") — no hand-rolled
// tree/roll-up/cancel-button rendering here anymore. The component owns
// fetching GET /api/jobq/graph and its own refresh(); this page just
// listens for its `hive-jobq-graph-update` event to keep `jobqNodes` (the
// flat array) in sync for the two things the generic view doesn't render:
// the count-pill and the live-log panel below. No cancel button or
// build-log deep-link on rows either — the generic component has no
// per-node action affordances; per "dont feel constrained by what the ui
// does currently," not reinventing those here for the first cut.
// queue tab with this component") — no hand-rolled tree/roll-up rendering
// here anymore. The component owns fetching GET /api/jobq/graph and its
// own refresh(); this page just listens for its `hive-jobq-graph-update`
// event to keep `jobqNodes` (the flat array) in sync for the two things
// the generic view doesn't render itself: the count-pill and the live-log
// panel below.
//
// Cancel is the one action this page *does* wire up: the
// `cancellable` attribute turns on the component's own per-node cancel
// button, which dispatches `hive-jobq-graph-cancel` rather than posting
// anything — the endpoint (`/api/rebuild-queue/{id}/cancel`) is this
// page's domain concept, not the generic component's.
function mountJobqGraph() {
const root = $('rebuild-queue-section');
if (!root) return;
root.replaceChildren();
jobqGraphEl = el('hive-jobq-graph', { endpoint: '/api/jobq/graph' });
jobqGraphEl = el('hive-jobq-graph', { endpoint: '/api/jobq/graph', cancellable: '' });
jobqGraphEl.addEventListener('hive-jobq-graph-update', (e) => {
jobqNodes = e.detail.nodes || [];
renderRebuildLiveLog();
updateRebuildCount();
});
jobqGraphEl.addEventListener('hive-jobq-graph-cancel', async (e) => {
const { id } = e.detail;
const node = jobqNodes.find((n) => n.id === id);
const label = node ? node.payload.label : 'node ' + id;
if (!(await themedConfirm({
message: `cancel ${label}? a group root cancels the whole subtree; a mid-tree node cancels just that branch.`,
danger: true, confirmLabel: '✕ cancel',
}))) return;
try {
const r = await fetch('/api/rebuild-queue/' + id + '/cancel', { method: 'POST' });
if (!r.ok) throw new Error('http ' + r.status);
// No manual refresh: cancel flips node state, which fires
// rebuild_queue_changed over SSE — the existing handler below
// already calls jobqGraphEl.refresh() on that tick.
} catch (err) {
console.error('cancel failed', err);
}
});
root.append(jobqGraphEl);
}

View file

@ -105,15 +105,6 @@
align-items: baseline;
gap: 0.4em;
}
.rebuild-queue-entry.rqe-running {
border-color: var(--purple);
background: color-mix(in srgb, var(--purple) 12%, transparent);
animation: badge-pulse 1.6s ease-in-out infinite;
}
.rebuild-queue-entry.rqe-failed { border-color: var(--red); color: var(--red); }
.rebuild-queue-entry.rqe-cancelled { opacity: 0.6; }
.rebuild-queue-entry.rqe-done { opacity: 0.7; color: var(--green); }
.rqe-state { font-weight: bold; min-width: 1.2em; text-align: center; }
.rqe-kind { color: var(--cyan); }
.rqe-agent { color: var(--amber); font-weight: bold; }
.rqe-source {
@ -130,134 +121,6 @@
.rqe-source-auto_update { color: var(--muted); }
.rqe-source-crash_recover { color: var(--amber); border-color: var(--amber); }
.rqe-source-approval { color: var(--green); border-color: var(--green); }
.rqe-when { color: var(--muted); font-size: 0.85em; }
.rqe-reason { color: var(--muted); font-size: 0.85em; flex: 1 1 auto; }
/* Per-node DAG chain: one chip per primitive node, dependency order. */
.rqe-nodes {
flex-basis: 100%;
margin: 0.15em 0 0 1.8em;
font-size: 0.85em;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.15em;
}
/* ─── jobq node tree (replaces .rqe-nodes for tree-structured payloads) ──── */
.rqe-nodes-tree {
flex-basis: 100%;
margin: 0.25em 0 0 1.8em;
font-size: 0.85em;
display: flex;
flex-direction: column;
gap: 3px;
}
.rqe-tree-row {
display: flex;
align-items: center;
gap: 0;
min-height: 1.6em;
}
/* Guide column: fixed-width spacer that optionally draws a vertical guide
line through rows where a sibling of an ancestor continues below. */
.rqe-tree-guide,
.rqe-tree-connector {
flex-shrink: 0;
width: 1.1em;
align-self: stretch;
position: relative;
}
.rqe-tree-guide-line::before {
content: '';
position: absolute;
left: 50%;
top: 0;
bottom: 0;
border-left: 1px solid var(--border);
}
/* Connector: vertical stem from top, horizontal spur to the right.
Last child (): stem goes topcenter. Mid child (): stem is full height. */
.rqe-tree-connector::before {
content: '';
position: absolute;
left: 50%;
top: 0;
bottom: 50%;
border-left: 1px solid var(--border);
}
.rqe-tree-connector-mid::before {
bottom: 0;
}
.rqe-tree-connector::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
right: 0;
border-top: 1px solid var(--border);
}
.rqe-node {
padding: 0.05em 0.45em;
border: 1px solid var(--border);
border-radius: 0.7em;
color: var(--muted);
white-space: nowrap;
}
.rqe-node-running {
color: var(--purple);
border-color: var(--purple);
animation: badge-pulse 1.6s ease-in-out infinite;
}
.rqe-node-done { color: var(--green); border-color: color-mix(in srgb, var(--green) 45%, transparent); }
.rqe-node-failed { color: var(--red); border-color: var(--red); }
.rqe-node-cancelled { opacity: 0.55; text-decoration: line-through; }
/* Not-taken outcome branch expected, quiet, distinct from cancelled
(dimmed only, no strikethrough: this wasn't dropped mid-flight, it was
never going to run). */
.rqe-node-skipped { opacity: 0.5; }
.rqe-node-arrow { color: var(--muted); }
.rqe-node-log { margin-left: 0.1em; text-decoration: none; }
.rqe-error {
flex-basis: 100%;
margin: 0.3em 0 0;
padding: 0.3em 0.5em;
background: color-mix(in srgb, var(--red) 10%, transparent);
border-left: 2px solid var(--red);
color: var(--red);
font-size: 0.8em;
white-space: pre-wrap;
}
.rqe-cancel {
margin-left: auto;
}
.rqe-cancel-btn {
background: transparent;
border: 1px solid var(--purple-dim);
color: var(--muted);
border-radius: 999px;
width: 1.6em;
height: 1.6em;
font-size: 0.85em;
line-height: 1;
padding: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.rqe-cancel-btn:hover:not(:disabled),
.rqe-cancel-btn:focus-visible {
color: var(--red);
border-color: var(--red);
box-shadow: 0 0 8px -2px var(--red);
outline: none;
}
.rqe-cancel-btn:disabled {
opacity: 0.5;
cursor: default;
}
/* running-rebuild live log (renderRebuildLiveLog in builds.js)
Streams the currently-running rebuild's build log inline under the queue.
Hidden (native `hidden` attr) when nothing is building. Used on

View file

@ -53,6 +53,30 @@
color: var(--fg);
}
.jg-cancel-btn {
margin-left: auto;
background: transparent;
border: 1px solid var(--muted);
color: var(--muted);
border-radius: 999px;
width: 1.4em;
height: 1.4em;
font-size: 0.8em;
line-height: 1;
padding: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: color 0.15s ease, border-color 0.15s ease;
}
.jg-cancel-btn:hover,
.jg-cancel-btn:focus-visible {
color: var(--red);
border-color: var(--red);
outline: none;
}
.jg-data {
margin: 0.1em 0 0 1.6em;
font-size: 0.85em;

View file

@ -10,19 +10,19 @@
// a build-log link, ...) does its own thing on top; this is the generic
// floor every jobq gets for free.
//
// `Node`-kind dep edges get a plain "waits on: <label>" line under the
// dependent node — see `buildTree`'s `_waitsOn` resolution below. (A
// gutter rail was tried first; dropped since it spans by sibling-array
// position, and a nested subtree between two siblings breaks that into
// disconnected ticks. Text has no such gap.)
// `Node`-kind dep edges get a plain "waits on: <label>" text line — see
// `buildTree`'s `_waitsOn` resolution. `cancellable` attribute adds a
// per-node cancel button dispatching `hive-jobq-graph-cancel` instead of
// POSTing anything itself — see `CANCELLABLE_STATES` + the click-delegate
// in `connectedCallback` below for both.
//
// Usage: <hive-jobq-graph endpoint="/api/jobq/graph"></hive-jobq-graph> —
// self-fetches on connect. `.refresh()` (public) re-fetches + re-renders;
// `.render(nodes)` (public) renders host-pushed data directly, no fetch.
// Every render dispatches a bubbling/composed `hive-jobq-graph-update`
// event (`detail: { nodes }`) so a host needing the raw list for
// something the tree doesn't show (a count badge, a live-log panel)
// listens instead of running its own parallel fetch.
// self-fetches on connect. `.refresh()` re-fetches + re-renders;
// `.render(nodes)` renders host-pushed data directly, no fetch. Every
// render dispatches a bubbling/composed `hive-jobq-graph-update` event
// (`detail: { nodes }`) so a host needing the raw list for something the
// tree doesn't show (a count badge, a live-log panel) listens instead of
// running its own parallel fetch.
//
// Shadow DOM + own styles, per instruction — unlike light-DOM
// <hive-tab-strip>, this renders a whole subtree nothing else needs to
@ -43,6 +43,11 @@ const STATE_GLYPH = {
Skipped: '·',
};
// Non-terminal states a cancel button makes sense on. Finishing is
// included — "own logic done, children still running" is still a subtree
// worth stopping early.
const CANCELLABLE_STATES = new Set(['Pending', 'Running', 'Finishing']);
// Build a parent/child tree from the flat wire array. `parent` (structural
// grouping) defines tree shape. Sibling order follows array order, which is
// already root-then-subtree per root per `GraphWire::wire_snapshot`'s own
@ -94,8 +99,9 @@ function renderDataList(data) {
return dl;
}
function renderNode(n) {
function renderNode(n, cancellable) {
const glyph = STATE_GLYPH[n.state] || '?';
const showCancel = cancellable && CANCELLABLE_STATES.has(n.state);
const row = el('div', { class: 'jg-row' },
el('span', {
class: 'jg-state jg-state-' + n.state.toLowerCase(),
@ -103,6 +109,12 @@ function renderNode(n) {
}, glyph),
' ',
el('span', { class: 'jg-label' }, n.payload.label),
showCancel
? el('button', {
type: 'button', class: 'jg-cancel-btn', 'data-cancel-id': String(n.id),
title: 'cancel ' + n.payload.label,
}, '✕')
: null,
);
const wrap = el('div', { class: 'jg-node' }, row);
if (n._waitsOn && n._waitsOn.length) {
@ -111,7 +123,7 @@ function renderNode(n) {
const data = renderDataList(n.payload.data);
if (data) wrap.append(data);
if (n.error) wrap.append(el('pre', { class: 'jg-error' }, n.error));
for (const child of n._children) wrap.append(renderNode(child));
for (const child of n._children) wrap.append(renderNode(child, cancellable));
return wrap;
}
@ -124,6 +136,18 @@ class HiveJobqGraph extends HTMLElement {
this._root = attachShadowCss(this, graphCss);
this._body = el('div', { class: 'jg-body' });
this._root.append(this._body);
// One delegated listener rather than a per-button one — cancel buttons
// come and go on every re-render, a delegated listener on the stable
// container doesn't need rebinding.
this._body.addEventListener('click', (e) => {
const btn = e.target.closest('.jg-cancel-btn');
if (!btn) return;
this.dispatchEvent(new CustomEvent('hive-jobq-graph-cancel', {
detail: { id: Number(btn.dataset.cancelId) },
bubbles: true,
composed: true,
}));
});
this.refresh();
}
@ -154,7 +178,8 @@ class HiveJobqGraph extends HTMLElement {
this._body.append(el('p', { class: 'jg-empty' }, 'empty'));
} else {
const roots = buildTree(nodes);
for (const root of roots) this._body.append(renderNode(root));
const cancellable = this.hasAttribute('cancellable');
for (const root of roots) this._body.append(renderNode(root, cancellable));
}
this.dispatchEvent(new CustomEvent('hive-jobq-graph-update', {
detail: { nodes: nodes || [] },