// 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 that wants 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. // // Usage: // Self-fetches on connect. Call `.refresh()` whenever the host knows the // graph changed (e.g. on an SSE tick) — the element does not poll or // subscribe itself, since the right refresh trigger varies per page. // `.render(nodes)` is also public, for a host that already has a fresh // `Vec` (e.g. riding its own SSE payload) and wants to skip // the redundant fetch. // // Shadow DOM + own styles (not light-DOM like ): unlike a // tabbar, this renders a whole subtree of markup nothing else on the page // needs to select into, so scoping is a win here rather than a cost. // Theme custom properties (--fg, --red, ...) still apply — they pierce // the shadow boundary by inheritance, only plain class rules need to be // local, which is exactly what the own stylesheet is for. 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 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). 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; } // `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); 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 / // hit when a row cache moves an already-built // element without a real detach (see #2893). 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')); return; } const roots = buildTree(nodes); for (const root of roots) this._body.append(renderNode(root)); } } customElements.define('hive-jobq-graph', HiveJobqGraph);