diff --git a/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.css b/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.css new file mode 100644 index 00000000..f9a486c0 --- /dev/null +++ b/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.css @@ -0,0 +1,79 @@ +/* hive-jobq-graph.css — shadow-scoped styles for . Theme + custom properties (--fg, --red, ...) pierce the shadow boundary by + inheritance and are used directly; only plain class rules live here, + same split every other shadow-DOM component (, ...) uses. */ + +:host { + display: block; + font-family: inherit; + font-size: inherit; + color: var(--fg); +} + +.jg-body { + display: flex; + flex-direction: column; + gap: 0.15em; +} + +.jg-node { + margin: 0; +} + +/* Nested groups indent + get a guide line, rather than a hand-rolled + connector-glyph tree — cheaper to build and reads fine at any depth. */ +.jg-node .jg-node { + margin-left: 1.1em; + padding-left: 0.6em; + border-left: 1px solid var(--muted); + margin-top: 0.15em; +} + +.jg-row { + display: flex; + align-items: baseline; + gap: 0.35em; + flex-wrap: wrap; +} + +.jg-state { + font-weight: bold; + min-width: 1.2em; + text-align: center; +} +.jg-state-pending { color: var(--muted); } +.jg-state-running { color: var(--cyan); } +.jg-state-finishing { color: var(--cyan); opacity: 0.75; } +.jg-state-done { color: var(--green); } +.jg-state-failed { color: var(--red); } +.jg-state-cancelled { color: var(--muted); text-decoration: line-through; } +.jg-state-skipped { color: var(--muted); opacity: 0.5; } + +.jg-label { + color: var(--fg); +} + +.jg-data { + margin: 0.1em 0 0 1.6em; + font-size: 0.85em; + color: var(--muted); + display: grid; + grid-template-columns: auto 1fr; + gap: 0 0.5em; +} +.jg-data dt { font-weight: 600; } +.jg-data dd { margin: 0; word-break: break-word; } + +.jg-error { + color: var(--red); + font-size: 0.85em; + margin: 0.2em 0 0.2em 1.6em; + white-space: pre-wrap; +} + +.jg-empty, +.jg-error-msg { + color: var(--muted); + font-style: italic; + margin: 0; +} diff --git a/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.js b/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.js new file mode 100644 index 00000000..c8cd3dcc --- /dev/null +++ b/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.js @@ -0,0 +1,139 @@ +// 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);