jobq-graph: render Node-kind dep edges between siblings

<hive-jobq-graph> only ever drew the parent/child containment tree --
a dependency between two sibling nodes (same parent, e.g.
prebuild.after_ok(meta_sync)) was invisible on screen. Confirmed with
the operator that a Node-kind dep never crosses a group boundary
(always a sibling under the same parent), so this is a purely local
problem per sibling list, not a whole-graph layout question.

Each sibling list is reordered dependency-first (stable topo sort,
falls back to original order on ties or an unexpected cycle) and gets
a small connecting rail in its left gutter marking dep edges, with a
tooltip naming what a waiting node is blocked on. Groups with no deps
render exactly as before -- no extra markup, no cost.

Verified the ordering + rail-classification logic standalone against
constructed fixtures (9 + 12 checks) before trusting it in the real
component.
This commit is contained in:
iris 2026-08-04 00:57:25 +02:00 committed by mara
commit 86a7a62519
2 changed files with 125 additions and 8 deletions

View file

@ -36,6 +36,37 @@
flex-wrap: wrap;
}
/* Dependency-edge gutter (only present on rows in a sibling list that has
at least one `Node`-kind dep among it see hive-jobq-graph.js
`renderGroup`). A plain rail (no line, no dot) on every other row in
that list keeps the state glyph aligned; rows outside such a list carry
no rail element at all, so the common case is untouched. */
.jg-edge-rail {
position: relative;
align-self: stretch;
flex: none;
width: 0.7em;
}
.jg-edge-rail.jg-edge-on::after {
content: '';
position: absolute;
left: 50%;
top: var(--jg-rail-top, 50%);
bottom: var(--jg-rail-bottom, 50%);
width: 0;
border-left: 2px solid var(--cyan);
}
.jg-edge-rail.jg-edge-dot::before {
content: '';
position: absolute;
left: calc(50% - 0.19em);
top: calc(50% - 0.19em);
width: 0.38em;
height: 0.38em;
border-radius: 50%;
background: var(--cyan);
}
.jg-state {
font-weight: bold;
min-width: 1.2em;

View file

@ -10,6 +10,10 @@
// 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.
@ -39,11 +43,7 @@ const STATE_GLYPH = {
};
// 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 doc contract — no client-side topo-sort needed for *display* order
// (dependency edges are for a consumer's own logic, e.g. cancel-gating,
// not for render order).
// grouping) defines tree shape.
function buildTree(nodes) {
const byId = new Map(nodes.map((n) => [n.id, { ...n, _children: [] }]));
const roots = [];
@ -55,6 +55,52 @@ function buildTree(nodes) {
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 (stable:
// ties keep original array order, which is already root-then-subtree order),
// and returns the edges as index ranges into the *new* order for the
// gutter-rail renderer below.
//
// O(n^2) worst case (each pass rescans the remaining nodes) — fine here,
// sibling-list sizes are small (tens, not thousands) and this only 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 placed = new Array(list.length).fill(false);
const orderIdx = [];
let remaining = list.map((_, i) => i);
while (remaining.length) {
const ready = remaining.filter((i) => localDeps[i].every((d) => placed[d]));
// A cycle can't happen from a well-formed graph, but if it ever does,
// dump whatever's left in original order rather than looping forever.
const take = ready.length ? ready : remaining;
for (const i of take) {
orderIdx.push(i);
placed[i] = true;
}
remaining = remaining.filter((i) => !placed[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
@ -74,9 +120,23 @@ function renderDataList(data) {
return dl;
}
function renderNode(n) {
// 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 : ''),
@ -88,10 +148,36 @@ 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 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;
for (let k = lo + 1; k < hi; k++) { top[k] = true; bottom[k] = true; }
addTitle(hi, 'waits on: ' + order[lo].payload.label);
addTitle(lo, 'blocks: ' + order[hi].payload.label);
}
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>/
@ -131,7 +217,7 @@ 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));
for (const root of renderGroup(roots)) this._body.append(root);
}
this.dispatchEvent(new CustomEvent('hive-jobq-graph-update', {
detail: { nodes: nodes || [] },