// hive-jobq-graph.js — , 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` 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: — // 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 // , 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 / // 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);