jobq-graph: replace the dep-edge gutter rail with a plain text line
mara reported the rail still didn't make dependencies visible (screenshot on the PR). Root cause: the rail spans by sibling-array position, but a sibling with its own nested subtree renders many pixel-rows for one array slot -- in a real queue (agent_window with 7-ish children between two top-level siblings), the "line" breaks into disconnected ticks around every real subtree, never reading as a connection at all. My verification fixtures never tested a nested subtree sitting between two related siblings, so this never showed up before. Replaced with a "waits on: <label>" text line under the dependent node, resolved once in buildTree via a global id lookup (not scoped to siblings, so a label still resolves even if a dep ever does cross a group boundary despite the product decision that it shouldn't). Text has no positional-gap failure mode: it's legible regardless of how tall the node above or below it renders. No reordering, no rail, no interleaving-of-independent-pairs class of bug possible anymore -- this is close to the original design proposed on the issue before the heavier visual version was tried. Re-verified against 6 fixture checks including the exact shape from the screenshot (a dependency target with its own nested subtree rendered between it and the dependent node).
This commit is contained in:
parent
a5210311bf
commit
a858739b28
2 changed files with 38 additions and 168 deletions
|
|
@ -36,37 +36,6 @@
|
|||
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;
|
||||
|
|
@ -95,6 +64,12 @@
|
|||
.jg-data dt { font-weight: 600; }
|
||||
.jg-data dd { margin: 0; word-break: break-word; }
|
||||
|
||||
.jg-waits-on {
|
||||
margin: 0.1em 0 0 1.6em;
|
||||
font-size: 0.85em;
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
.jg-error {
|
||||
color: var(--red);
|
||||
font-size: 0.85em;
|
||||
|
|
|
|||
|
|
@ -10,15 +10,16 @@
|
|||
// 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.
|
||||
// `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.)
|
||||
//
|
||||
// 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`
|
||||
// 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.
|
||||
|
|
@ -43,7 +44,19 @@ const STATE_GLYPH = {
|
|||
};
|
||||
|
||||
// Build a parent/child tree from the flat wire array. `parent` (structural
|
||||
// grouping) defines tree shape.
|
||||
// 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 reordering; a dependency is named in text (see
|
||||
// `_waitsOn` below), not implied by render position.
|
||||
//
|
||||
// Also resolves each node's `Node`-kind deps to the referenced node's own
|
||||
// label, once, so rendering doesn't need a second lookup pass. Looked up
|
||||
// globally (`byId`, every node in this snapshot), not scoped to siblings —
|
||||
// a dep is expected to always name a sibling, but resolving globally means
|
||||
// a label still shows correctly even if that expectation is ever wrong,
|
||||
// instead of silently dropping the edge. A dep naming an id outside this
|
||||
// snapshot (a filtered view) or a `Resource`-kind dep has nothing to point
|
||||
// at and is simply not listed.
|
||||
function buildTree(nodes) {
|
||||
const byId = new Map(nodes.map((n) => [n.id, { ...n, _children: [] }]));
|
||||
const roots = [];
|
||||
|
|
@ -52,90 +65,16 @@ function buildTree(nodes) {
|
|||
if (p) p._children.push(n);
|
||||
else roots.push(n);
|
||||
}
|
||||
for (const n of byId.values()) {
|
||||
n._waitsOn = (n.deps || [])
|
||||
.filter((d) => d.kind === 'Node')
|
||||
.map((d) => byId.get(d.id))
|
||||
.filter(Boolean)
|
||||
.map((dep) => dep.payload.label);
|
||||
}
|
||||
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
|
||||
|
|
@ -155,23 +94,9 @@ function renderDataList(data) {
|
|||
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) {
|
||||
function renderNode(n) {
|
||||
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 : ''),
|
||||
|
|
@ -180,46 +105,16 @@ function renderNode(n, rail) {
|
|||
el('span', { class: 'jg-label' }, n.payload.label),
|
||||
);
|
||||
const wrap = el('div', { class: 'jg-node' }, row);
|
||||
if (n._waitsOn && n._waitsOn.length) {
|
||||
wrap.append(el('div', { class: 'jg-waits-on' }, 'waits on: ' + n._waitsOn.join(', ')));
|
||||
}
|
||||
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);
|
||||
for (const child of n._children) wrap.append(renderNode(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>/
|
||||
|
|
@ -259,7 +154,7 @@ class HiveJobqGraph extends HTMLElement {
|
|||
this._body.append(el('p', { class: 'jg-empty' }, 'empty'));
|
||||
} else {
|
||||
const roots = buildTree(nodes);
|
||||
for (const root of renderGroup(roots)) this._body.append(root);
|
||||
for (const root of roots) this._body.append(renderNode(root));
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent('hive-jobq-graph-update', {
|
||||
detail: { nodes: nodes || [] },
|
||||
|
|
|
|||
Loading…
Reference in a new issue