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).
166 lines
6.7 KiB
JavaScript
166 lines
6.7 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 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.
|
|
// 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. 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 = [];
|
|
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);
|
|
}
|
|
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;
|
|
}
|
|
|
|
// `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;
|
|
}
|
|
|
|
function renderNode(n) {
|
|
const glyph = STATE_GLYPH[n.state] || '?';
|
|
const row = el('div', { class: 'jg-row' },
|
|
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);
|
|
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 n._children) wrap.append(renderNode(child));
|
|
return wrap;
|
|
}
|
|
|
|
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 roots) this._body.append(renderNode(root));
|
|
}
|
|
this.dispatchEvent(new CustomEvent('hive-jobq-graph-update', {
|
|
detail: { nodes: nodes || [] },
|
|
bubbles: true,
|
|
composed: true,
|
|
}));
|
|
}
|
|
}
|
|
customElements.define('hive-jobq-graph', HiveJobqGraph);
|