Fixes a real bug argus caught: the flat batch-round topo sort could interleave two fully independent dep pairs in the same sibling list (e.g. W, X after_ok(W), Y, Z after_ok(Y) reordered to W, Y, X, Z), and the single-column rail then drew one continuous line across rows that have no relationship at all. Reorder is now scoped per connected component of the local dependency graph -- each component renders as a contiguous block (first-seen order, so an already-correct list doesn't reorder needlessly), so two unrelated pairs can never weave together. Within one component, overlapping ranges are still correct: they mean the nodes really are related (a diamond, for instance). Also: a pass-through row's tooltip now names what's passing through it (not just the edge it's itself an endpoint of) -- addresses the same disambiguation gap argus flagged as a secondary note. Re-verified against a wider fixture set including the exact interleaving case from the review (17 checks: prior 6 unaffected + argus's regression case, a shuffled-order variant, three simultaneous independent pairs, and a genuine diamond that's expected to overlap).
271 lines
11 KiB
JavaScript
271 lines
11 KiB
JavaScript
// hive-jobq-graph.js — <hive-jobq-graph>, a shadow-DOM custom element that
|
|
// renders any hive_jobq graph generically from the wire shape served by
|
|
// GET /api/jobq/graph (or any endpoint serving the same
|
|
// `Vec<hive_jobq_wire::GraphNode>` shape — see hive-jobq-wire's README).
|
|
// Renders each root + its subtree as an indented tree: state glyph,
|
|
// `payload.label` verbatim, and `payload.data` (if present) as a generic
|
|
// key/value list — this element never branches on what a label or a data
|
|
// key means, matching the "opaque payload" contract the wire type
|
|
// documents. A consumer wanting domain-specific rendering (an agent chip,
|
|
// 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 small connecting rail in each sibling list's
|
|
// left gutter — see `renderGroup`/`orderSiblings` below for why that's
|
|
// always sibling-local, never a whole-graph layout question.
|
|
//
|
|
// 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.
|
|
// Fetching lives here, not the host page (per the issue this element was
|
|
// built for) — 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
|
|
// select into. Theme custom properties (--fg, --red, ...) still pierce
|
|
// the shadow boundary by inheritance; only plain class rules are local.
|
|
|
|
import { el } from '../dom.js';
|
|
import { attachShadowCss } from '../shadow-css.js';
|
|
import graphCss from './hive-jobq-graph.css';
|
|
|
|
const STATE_GLYPH = {
|
|
Pending: '⏸',
|
|
Running: '▶',
|
|
Finishing: '◐',
|
|
Done: '✔',
|
|
Failed: '✖',
|
|
Cancelled: '⊘',
|
|
Skipped: '·',
|
|
};
|
|
|
|
// Build a parent/child tree from the flat wire array. `parent` (structural
|
|
// grouping) defines tree shape.
|
|
function buildTree(nodes) {
|
|
const byId = new Map(nodes.map((n) => [n.id, { ...n, _children: [] }]));
|
|
const roots = [];
|
|
for (const n of byId.values()) {
|
|
const p = n.parent != null ? byId.get(n.parent) : null;
|
|
if (p) p._children.push(n);
|
|
else roots.push(n);
|
|
}
|
|
return roots;
|
|
}
|
|
|
|
// A `Node`-kind dep only ever names a sibling under the same parent (product
|
|
// decision on the dep-edge-visibility issue — nothing crosses a group
|
|
// boundary), so a dependency edge is always local to one sibling list. This
|
|
// reorders that list so a dependency always renders before what depends on
|
|
// it, and returns the edges as index ranges into the *new* order for the
|
|
// gutter-rail renderer below.
|
|
//
|
|
// Ordering is scoped **per connected component** of the local dependency
|
|
// graph, not one flat topo sort over the whole list — two independent dep
|
|
// pairs (no edge relates them, directly or transitively) must never end up
|
|
// interleaved, or the single-column rail below would draw one continuous
|
|
// line across both and imply a relationship that doesn't exist. Emitting
|
|
// each component as a contiguous block (in first-seen order, so an already-
|
|
// correct list doesn't reorder unnecessarily) keeps every edge's [lo, hi]
|
|
// span either fully inside its own component's block or, within a
|
|
// component, genuinely overlapping because the nodes really are related
|
|
// (e.g. a diamond: two siblings both depending on the same third one).
|
|
//
|
|
// O(n^2) worst case (component discovery + each component's own topo sort
|
|
// rescans its remaining members per pass) — fine here, sibling-list sizes
|
|
// are small (tens, not thousands) and this runs once per render, not per
|
|
// frame.
|
|
function orderSiblings(list) {
|
|
if (list.length < 2) return { order: list, ranges: [] };
|
|
const idx = new Map(list.map((n, i) => [n.id, i]));
|
|
// Deps whose target isn't in this list (shouldn't happen per the product
|
|
// decision above, but a filtered view could still omit a target) are
|
|
// dropped rather than crashing the sort.
|
|
const localDeps = list.map((n) =>
|
|
(n.deps || [])
|
|
.filter((d) => d.kind === 'Node' && idx.has(d.id))
|
|
.map((d) => idx.get(d.id)),
|
|
);
|
|
|
|
const adjacency = list.map(() => []);
|
|
localDeps.forEach((deps, i) => {
|
|
for (const d of deps) { adjacency[i].push(d); adjacency[d].push(i); }
|
|
});
|
|
const componentOf = new Array(list.length).fill(-1);
|
|
let numComponents = 0;
|
|
for (let start = 0; start < list.length; start++) {
|
|
if (componentOf[start] !== -1) continue;
|
|
const stack = [start];
|
|
componentOf[start] = numComponents;
|
|
while (stack.length) {
|
|
const i = stack.pop();
|
|
for (const j of adjacency[i]) {
|
|
if (componentOf[j] === -1) { componentOf[j] = numComponents; stack.push(j); }
|
|
}
|
|
}
|
|
numComponents++;
|
|
}
|
|
|
|
const orderIdx = [];
|
|
const emitted = new Array(list.length).fill(false);
|
|
for (let start = 0; start < list.length; start++) {
|
|
if (emitted[start]) continue;
|
|
const members = [];
|
|
for (let i = 0; i < list.length; i++) if (componentOf[i] === componentOf[start]) members.push(i);
|
|
const placed = new Set();
|
|
let remaining = members;
|
|
while (remaining.length) {
|
|
const ready = remaining.filter((i) => localDeps[i].every((d) => placed.has(d)));
|
|
// A cycle can't happen from a well-formed graph, but if it ever
|
|
// does, dump whatever's left of this component in original order
|
|
// rather than looping forever.
|
|
const take = ready.length ? ready : remaining;
|
|
for (const i of take) { orderIdx.push(i); placed.add(i); emitted[i] = true; }
|
|
remaining = remaining.filter((i) => !placed.has(i));
|
|
}
|
|
}
|
|
|
|
const posOf = new Array(list.length);
|
|
orderIdx.forEach((origIdx, pos) => { posOf[origIdx] = pos; });
|
|
const ranges = [];
|
|
localDeps.forEach((deps, i) => {
|
|
for (const d of deps) ranges.push({ lo: Math.min(posOf[i], posOf[d]), hi: Math.max(posOf[i], posOf[d]) });
|
|
});
|
|
return { order: orderIdx.map((i) => list[i]), ranges };
|
|
}
|
|
|
|
// `payload.data` is an opaque JSON value from the host's `WireNode::data`
|
|
// — render it as a generic key/value list when it's a plain object (the
|
|
// only shape a host is expected to send; anything else falls back to a
|
|
// single stringified row rather than silently dropping it).
|
|
function renderDataList(data) {
|
|
if (data == null) return null;
|
|
const isPlainObject = typeof data === 'object' && !Array.isArray(data);
|
|
const entries = isPlainObject ? Object.entries(data) : [['data', data]];
|
|
if (!entries.length) return null;
|
|
const dl = el('dl', { class: 'jg-data' });
|
|
for (const [k, v] of entries) {
|
|
dl.append(
|
|
el('dt', {}, k),
|
|
el('dd', {}, typeof v === 'string' ? v : JSON.stringify(v)),
|
|
);
|
|
}
|
|
return dl;
|
|
}
|
|
|
|
// A rail span for the dependency-edge gutter (see `renderGroup` below).
|
|
// `top`/`bottom` say whether the connecting line extends into the top/
|
|
// bottom half of this row; `dot` marks the row that's actually waiting
|
|
// (the range's "hi" end). No edges touch this row → a plain empty span,
|
|
// same DOM shape rendering always had before edges existed.
|
|
function renderRail(top, bottom, dot, title) {
|
|
if (!top && !bottom && !dot) return el('span', { class: 'jg-edge-rail' });
|
|
const rail = el('span', { class: 'jg-edge-rail jg-edge-on' + (dot ? ' jg-edge-dot' : ''), title: title || '' });
|
|
rail.style.setProperty('--jg-rail-top', top ? '0' : '50%');
|
|
rail.style.setProperty('--jg-rail-bottom', bottom ? '0' : '50%');
|
|
return rail;
|
|
}
|
|
|
|
function renderNode(n, rail) {
|
|
const glyph = STATE_GLYPH[n.state] || '?';
|
|
const row = el('div', { class: 'jg-row' },
|
|
rail,
|
|
el('span', {
|
|
class: 'jg-state jg-state-' + n.state.toLowerCase(),
|
|
title: n.state + (n.error ? ' — ' + n.error : ''),
|
|
}, glyph),
|
|
' ',
|
|
el('span', { class: 'jg-label' }, n.payload.label),
|
|
);
|
|
const wrap = el('div', { class: 'jg-node' }, row);
|
|
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 renderGroup(n._children)) wrap.append(child);
|
|
return wrap;
|
|
}
|
|
|
|
// Render one sibling list (either `_children` of a node, or the top-level
|
|
// roots), reordered so dependencies render before what depends on them, with
|
|
// a gutter rail marking `Node`-kind dep edges between siblings. Node-kind
|
|
// deps only ever name a sibling under the same parent — nothing crosses a
|
|
// group boundary — so this is the only place edges need
|
|
// rendering; the common case (no deps in this list) skips the rail entirely,
|
|
// same markup as before edges existed.
|
|
function renderGroup(list) {
|
|
const { order, ranges } = orderSiblings(list);
|
|
if (!ranges.length) return order.map((n) => renderNode(n));
|
|
const top = new Array(order.length).fill(false);
|
|
const bottom = new Array(order.length).fill(false);
|
|
const dot = new Array(order.length).fill(false);
|
|
const titles = new Array(order.length).fill('');
|
|
const addTitle = (i, text) => { titles[i] = titles[i] ? titles[i] + '; ' + text : text; };
|
|
for (const { lo, hi } of ranges) {
|
|
bottom[lo] = true;
|
|
top[hi] = true;
|
|
dot[hi] = true;
|
|
addTitle(hi, 'waits on: ' + order[lo].payload.label);
|
|
addTitle(lo, 'blocks: ' + order[hi].payload.label);
|
|
for (let k = lo + 1; k < hi; k++) {
|
|
top[k] = true;
|
|
bottom[k] = true;
|
|
// This row isn't itself either end of the edge, just sitting between
|
|
// them in render order — say what's passing through so the rail
|
|
// doesn't read as an unexplained line.
|
|
addTitle(k, order[lo].payload.label + ' → ' + order[hi].payload.label + ' passes through here');
|
|
}
|
|
}
|
|
return order.map((n, i) => renderNode(n, renderRail(top[i], bottom[i], dot[i], titles[i])));
|
|
}
|
|
|
|
class HiveJobqGraph extends HTMLElement {
|
|
connectedCallback() {
|
|
// Reconnect-without-detach guard — same hazard <hive-menu>/
|
|
// <hive-agent-menu> hit when a row cache moves an already-built
|
|
// element without a real detach.
|
|
if (this._root) return;
|
|
this._root = attachShadowCss(this, graphCss);
|
|
this._body = el('div', { class: 'jg-body' });
|
|
this._root.append(this._body);
|
|
this.refresh();
|
|
}
|
|
|
|
// Re-fetch `endpoint` and re-render. Public so a host page can call it
|
|
// on its own refresh cadence (SSE tick, poll, whatever fits the page) —
|
|
// this element intentionally owns no transport of its own.
|
|
async refresh() {
|
|
const endpoint = this.getAttribute('endpoint');
|
|
if (!endpoint || !this._body) return;
|
|
let nodes;
|
|
try {
|
|
const r = await fetch(endpoint);
|
|
if (!r.ok) throw new Error('http ' + r.status);
|
|
nodes = await r.json();
|
|
} catch (err) {
|
|
this._body.replaceChildren(el('p', { class: 'jg-error-msg' }, 'fetch failed: ' + err));
|
|
return;
|
|
}
|
|
this.render(nodes);
|
|
}
|
|
|
|
// Render a pre-fetched node array directly, bypassing `endpoint` — for a
|
|
// host that already has the data and doesn't want a redundant fetch.
|
|
render(nodes) {
|
|
if (!this._body) return;
|
|
this._body.replaceChildren();
|
|
if (!nodes || !nodes.length) {
|
|
this._body.append(el('p', { class: 'jg-empty' }, 'empty'));
|
|
} else {
|
|
const roots = buildTree(nodes);
|
|
for (const root of renderGroup(roots)) this._body.append(root);
|
|
}
|
|
this.dispatchEvent(new CustomEvent('hive-jobq-graph-update', {
|
|
detail: { nodes: nodes || [] },
|
|
bubbles: true,
|
|
composed: true,
|
|
}));
|
|
}
|
|
}
|
|
customElements.define('hive-jobq-graph', HiveJobqGraph);
|