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:
parent
a858739b28
commit
aa14339be7
5 changed files with 117 additions and 176 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 top→center. 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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 || [] },
|
||||
|
|
|
|||
Loading…
Reference in a new issue