dashboard: replace <hive-jobq-graph> with a shared Preact component
Ports the shadow-DOM <hive-jobq-graph> custom element
(frontend/packages/shared/src/jobq-graph/) to a Preact component
(JobqGraph.js) shared by the dashboard and swarm-ui, per hyperhive#3310.
- JobqGraph.js: written with plain h() calls (no JSX) so the same file
compiles unmodified under both the dashboard's text-loader CSS config
and swarm-ui's JSX config. Exports `JobqGraph` for JSX use and
`mountJobqGraph(container, props)` for the dashboard's non-JSX
imperative mount, returning a `{refresh(), update()}` handle matching
the old custom element's public surface. Same rendering contract as
before: indented state tree, payload.label verbatim, payload.data as
a generic key/value list, "waits on: <label>" text for Node-kind deps,
per-state filter checkboxes, optional cancel button.
- jobq-graph.css: light-DOM adaptation of the old shadow-scoped
stylesheet (:host -> .jg-root, otherwise unchanged).
- dashboard/src/builds.js: local mountJobqGraph() renamed to
mountRebuildQueue() to avoid colliding with the newly-imported shared
mountJobqGraph; cancel handling is now a plain onCancel callback
instead of a DOM CustomEvent listener (no shadow boundary to cross
anymore).
- dashboard + shared package.json: added preact as a dependency (matches
swarm-ui's existing pin, 10.29.8) - the dashboard was a vanilla-JS MPA
with no Preact/JSX pipeline before this.
- Removed the old hive-jobq-graph.js/.css entirely (confirmed via grep
it had exactly one consumer, dashboard/src/builds.js, so this is a
clean swap, not parallel maintenance of two implementations).
- Updated stale doc-comment references to the old element name in
builds.html, tabs.js, swarm.js, docs/web-ui/dashboard.md, and
hive-c0re/src/job_queue/mod.rs.
Verified: npm run build (whole frontend workspace) and npm run
typecheck (swarm-ui) both clean; cargo build/clippy/test -p hive-c0re
all clean (331 tests, 0 failures); headless-chromium screenshot of
/builds.html against a mock GET /api/jobq/graph payload confirms full
visual/behavioral parity with the old custom element (tree, filter
checkboxes, cancel buttons, error text, waits-on line, data list, live
build log panel).
This covers the dashboard-replacement half of hyperhive#3310 only. The
swarm-ui half (rendering the CreateAgent DAG on the agent-creation page)
is downstream of hyperhive#3306/#3124 landing - no swarm-ui page exists
yet to mount it in.
This commit is contained in:
parent
bad5285f2e
commit
37161cd136
13 changed files with 344 additions and 336 deletions
229
frontend/packages/shared/src/jobq-graph/JobqGraph.js
Normal file
229
frontend/packages/shared/src/jobq-graph/JobqGraph.js
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
// JobqGraph.js — <JobqGraph>, a Preact component rendering any
|
||||
// hive_jobq graph from the wire shape GET /api/jobq/graph serves (any
|
||||
// endpoint serving `Vec<hive_jobq_wire::GraphNode>` works — see
|
||||
// hive-jobq-wire's README). Preact port of the former shadow-DOM
|
||||
// `<hive-jobq-graph>` custom element, same contract (indented state
|
||||
// tree, `payload.label` verbatim, `payload.data` as a generic key/value
|
||||
// list) — light DOM now, since both the dashboard (no JSX pipeline) and
|
||||
// swarm-ui (JSX) consume it. Written with plain `h()` calls, not JSX, so
|
||||
// one file compiles unmodified under both consumers' esbuild configs.
|
||||
//
|
||||
// `cancellable` adds a per-node cancel button calling `onCancel(id)`
|
||||
// directly (a plain prop, not a DOM CustomEvent — no shadow boundary to
|
||||
// cross anymore). `onUpdate(nodes)` fires after every fetch, for a host
|
||||
// needing the raw list (a count badge, a live-log panel) without its own
|
||||
// parallel fetch.
|
||||
//
|
||||
// Two ways to use this: JSX (swarm-ui) — `<JobqGraph endpoint="..."
|
||||
// cancellable onCancel={...} onUpdate={...} />`, a normal component. Or
|
||||
// imperative mount (dashboard, no JSX) — `mountJobqGraph(container,
|
||||
// props)` returns a `{ refresh(), update(props) }` handle; see
|
||||
// dashboard/src/builds.js.
|
||||
//
|
||||
// Styles live in `@hive/shared/jobq-graph.css`, `@import`ed from a
|
||||
// page/component CSS file rather than imported here — the dashboard's
|
||||
// JS bundle treats `.css` imports as raw text, swarm-ui's extracts real
|
||||
// stylesheets, and importing here would silently pick whichever the
|
||||
// last consumer's config wanted.
|
||||
|
||||
import { h, render } from 'preact';
|
||||
import { useState, useEffect, useCallback } from 'preact/hooks';
|
||||
|
||||
const STATE_GLYPH = {
|
||||
Pending: '⏸',
|
||||
Running: '▶',
|
||||
Finishing: '◐',
|
||||
Done: '✔',
|
||||
Failed: '✖',
|
||||
Cancelled: '⊘',
|
||||
Skipped: '·',
|
||||
};
|
||||
|
||||
// Declaration order doubles as render order for the filter checkboxes —
|
||||
// matches `hive_jobq_wire::ALL_STATES` on the wire, so the row reads in
|
||||
// the same lifecycle order the rollup endpoint counts in.
|
||||
const ALL_STATES = Object.keys(STATE_GLYPH);
|
||||
|
||||
// Product call: "default selection filters out skipped and done."
|
||||
const DEFAULT_HIDDEN_STATES = new Set(['Done', 'Skipped']);
|
||||
|
||||
// Non-terminal states a cancel button makes sense on. Finishing is
|
||||
// included — "own logic done, children still running" is still a subtree
|
||||
// worth stopping early.
|
||||
const CANCELLABLE_STATES = new Set(['Pending', 'Running', 'Finishing']);
|
||||
|
||||
// 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 DataList({ 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;
|
||||
return h('dl', { class: 'jg-data' },
|
||||
entries.map(([k, v]) => [
|
||||
h('dt', { key: k + '-dt' }, k),
|
||||
h('dd', { key: k + '-dd' }, typeof v === 'string' ? v : JSON.stringify(v)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function NodeView({ n, cancellable, onCancel }) {
|
||||
const glyph = STATE_GLYPH[n.state] || '?';
|
||||
const showCancel = cancellable && CANCELLABLE_STATES.has(n.state);
|
||||
return h('div', { class: 'jg-node' },
|
||||
h('div', { class: 'jg-row' },
|
||||
h('span', {
|
||||
class: 'jg-state jg-state-' + n.state.toLowerCase(),
|
||||
title: n.state + (n.error ? ' — ' + n.error : ''),
|
||||
}, glyph),
|
||||
' ',
|
||||
h('span', { class: 'jg-label' }, n.payload.label),
|
||||
showCancel
|
||||
? h('button', {
|
||||
type: 'button', class: 'jg-cancel-btn', title: 'cancel ' + n.payload.label,
|
||||
onClick: () => onCancel && onCancel(n.id),
|
||||
}, '✕')
|
||||
: null,
|
||||
),
|
||||
n._waitsOn && n._waitsOn.length
|
||||
? h('div', { class: 'jg-waits-on' }, 'waits on: ' + n._waitsOn.join(', '))
|
||||
: null,
|
||||
h(DataList, { data: n.payload.data }),
|
||||
n.error ? h('pre', { class: 'jg-error' }, n.error) : null,
|
||||
n._children.map((child) => h(NodeView, { key: child.id, n: child, cancellable, onCancel })),
|
||||
);
|
||||
}
|
||||
|
||||
function FilterBar({ selectedStates, onToggle }) {
|
||||
return h('div', { class: 'jg-filter' },
|
||||
ALL_STATES.map((state) => {
|
||||
const id = 'jg-filter-' + state.toLowerCase();
|
||||
return h('label', {
|
||||
key: state, for: id, class: 'jg-filter-label jg-state-' + state.toLowerCase(),
|
||||
},
|
||||
h('input', {
|
||||
type: 'checkbox', id, checked: selectedStates.has(state),
|
||||
onChange: () => onToggle(state),
|
||||
}),
|
||||
' ', STATE_GLYPH[state] + ' ' + state,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// `endpoint` plus the current filter selection as a `states=` query
|
||||
// param — omitted entirely when every state is checked, so the
|
||||
// unfiltered default case sends the exact same request as before this
|
||||
// filter existed.
|
||||
function fetchUrl(endpoint, selectedStates) {
|
||||
if (!endpoint) return null;
|
||||
if (selectedStates.size >= ALL_STATES.length) return endpoint;
|
||||
const url = new URL(endpoint, window.location.origin);
|
||||
url.searchParams.set('states', Array.from(selectedStates).join(','));
|
||||
return url.pathname + url.search;
|
||||
}
|
||||
|
||||
// `refreshToken` is not read anywhere in the body — its only job is to
|
||||
// change identity so the effect below re-runs, giving a host (or
|
||||
// `mountJobqGraph`) an explicit "refetch now" lever without needing an
|
||||
// imperative ref into this component. Same rationale `hive-jobq-graph`'s
|
||||
// public `.refresh()` method served, Preact-idiomatic shape instead of a
|
||||
// custom-element method.
|
||||
export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, refreshToken = 0 }) {
|
||||
const [selectedStates, setSelectedStates] = useState(
|
||||
() => new Set(ALL_STATES.filter((s) => !DEFAULT_HIDDEN_STATES.has(s))),
|
||||
);
|
||||
const [nodes, setNodes] = useState(null); // null = loading, [] = empty-but-loaded
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const toggleState = useCallback((state) => {
|
||||
setSelectedStates((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(state)) next.delete(state); else next.add(state);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const url = fetchUrl(endpoint, selectedStates);
|
||||
if (!url) return undefined;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error('http ' + r.status);
|
||||
const data = await r.json();
|
||||
if (cancelled) return;
|
||||
setNodes(data);
|
||||
setError(null);
|
||||
if (onUpdate) onUpdate(data);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setError(String(err));
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- selectedStates is a Set;
|
||||
// its *contents* are what should retrigger the fetch, not its identity, and the
|
||||
// string form below already changes identity exactly when contents do.
|
||||
}, [endpoint, Array.from(selectedStates).sort().join(','), refreshToken]);
|
||||
|
||||
return h('div', { class: 'jg-root' },
|
||||
h(FilterBar, { selectedStates, onToggle: toggleState }),
|
||||
h('div', { class: 'jg-body' },
|
||||
error ? h('p', { class: 'jg-error-msg' }, 'fetch failed: ' + error)
|
||||
: nodes === null ? h('p', { class: 'jg-empty' }, 'loading…')
|
||||
: !nodes.length ? h('p', { class: 'jg-empty' }, 'empty')
|
||||
: buildTree(nodes).map((root) => h(NodeView, { key: root.id, n: root, cancellable, onCancel })),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Imperative mount helper for a non-JSX host (the dashboard). Returns a
|
||||
// handle mirroring the old custom element's public surface:
|
||||
// `.refresh()` (re-fetch with the current props) and `.update(props)`
|
||||
// (merge new props — e.g. a different `endpoint` — and re-render).
|
||||
export function mountJobqGraph(container, initialProps) {
|
||||
let props = initialProps;
|
||||
let token = 0;
|
||||
const draw = () => render(h(JobqGraph, { ...props, refreshToken: token }), container);
|
||||
draw();
|
||||
return {
|
||||
refresh() { token += 1; draw(); },
|
||||
update(next) { props = { ...props, ...next }; draw(); },
|
||||
};
|
||||
}
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
// 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>" text line — see
|
||||
// `buildTree`'s `_waitsOn` resolution. `cancellable` attribute adds a
|
||||
// per-node cancel button dispatching `hive-jobq-graph-cancel` instead of
|
||||
// POSTing anything itself — see `CANCELLABLE_STATES` + the click-delegate
|
||||
// in `connectedCallback` below for both. A per-state checkbox row above
|
||||
// the tree re-fetches `endpoint` with `?states=<checked>` on toggle — see
|
||||
// `_buildFilterBar`/`_fetchUrl` below and docs/web-ui/dashboard.md's
|
||||
// R3BU1LD QU3U3 section for the full filter rationale.
|
||||
//
|
||||
// Usage: <hive-jobq-graph endpoint="/api/jobq/graph"></hive-jobq-graph> —
|
||||
// self-fetches on connect. `.refresh()` re-fetches + re-renders;
|
||||
// `.render(nodes)` 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 (a count badge, a
|
||||
// live-log panel) listens instead of running its own parallel fetch.
|
||||
//
|
||||
// Shadow DOM + own styles: theme custom properties (--fg, --red, ...)
|
||||
// pierce the shadow boundary by inheritance; only plain class rules 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: '·',
|
||||
};
|
||||
|
||||
// Declaration order doubles as render order for the filter checkboxes —
|
||||
// matches `hive_jobq_wire::ALL_STATES` on the wire, so the row reads in the
|
||||
// same lifecycle order the rollup endpoint counts in.
|
||||
const ALL_STATES = Object.keys(STATE_GLYPH);
|
||||
|
||||
// Product call: "default selection filters out skipped and done."
|
||||
const DEFAULT_HIDDEN_STATES = new Set(['Done', 'Skipped']);
|
||||
|
||||
// Non-terminal states a cancel button makes sense on. Finishing is
|
||||
// included — "own logic done, children still running" is still a subtree
|
||||
// worth stopping early.
|
||||
const CANCELLABLE_STATES = new Set(['Pending', 'Running', 'Finishing']);
|
||||
|
||||
// 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, cancellable) {
|
||||
const glyph = STATE_GLYPH[n.state] || '?';
|
||||
const showCancel = cancellable && CANCELLABLE_STATES.has(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),
|
||||
showCancel
|
||||
? el('button', {
|
||||
type: 'button', class: 'jg-cancel-btn', 'data-cancel-id': String(n.id),
|
||||
title: 'cancel ' + n.payload.label,
|
||||
}, '✕')
|
||||
: null,
|
||||
);
|
||||
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, cancellable));
|
||||
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;
|
||||
// Set before the first `refresh()` call below, so the very first fetch
|
||||
// already carries the default filter rather than flashing every state
|
||||
// and re-fetching a moment later.
|
||||
this._selectedStates = new Set(ALL_STATES.filter((s) => !DEFAULT_HIDDEN_STATES.has(s)));
|
||||
this._root = attachShadowCss(this, graphCss);
|
||||
this._filterBar = this._buildFilterBar();
|
||||
this._body = el('div', { class: 'jg-body' });
|
||||
this._root.append(this._filterBar, this._body);
|
||||
// One delegated listener rather than a per-button one — cancel buttons
|
||||
// come and go on every re-render, a delegated listener on the stable
|
||||
// container doesn't need rebinding.
|
||||
this._body.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.jg-cancel-btn');
|
||||
if (!btn) return;
|
||||
this.dispatchEvent(new CustomEvent('hive-jobq-graph-cancel', {
|
||||
detail: { id: Number(btn.dataset.cancelId) },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
});
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
// One checkbox per `ALL_STATES` entry, pre-ticked per `_selectedStates`.
|
||||
// Built once at connect — toggling a box mutates `_selectedStates` and
|
||||
// re-fetches rather than rebuilding the row, so focus/scroll position in
|
||||
// the row itself is never disturbed by a data refresh.
|
||||
_buildFilterBar() {
|
||||
const bar = el('div', { class: 'jg-filter' });
|
||||
for (const state of ALL_STATES) {
|
||||
const id = 'jg-filter-' + state.toLowerCase();
|
||||
const cb = el('input', { type: 'checkbox', id });
|
||||
cb.checked = this._selectedStates.has(state);
|
||||
cb.addEventListener('change', () => {
|
||||
if (cb.checked) this._selectedStates.add(state);
|
||||
else this._selectedStates.delete(state);
|
||||
this.refresh();
|
||||
});
|
||||
const label = el('label', { for: id, class: 'jg-filter-label jg-state-' + state.toLowerCase() },
|
||||
cb, ' ', STATE_GLYPH[state] + ' ' + state);
|
||||
bar.append(label);
|
||||
}
|
||||
return bar;
|
||||
}
|
||||
|
||||
// `endpoint` plus the current filter selection as a `states=` query
|
||||
// param — omitted entirely when every state is checked, so the
|
||||
// unfiltered default case sends the exact same request as before this
|
||||
// filter existed.
|
||||
_fetchUrl() {
|
||||
const endpoint = this.getAttribute('endpoint');
|
||||
if (!endpoint) return null;
|
||||
if (!this._selectedStates || this._selectedStates.size >= ALL_STATES.length) return endpoint;
|
||||
const url = new URL(endpoint, window.location.origin);
|
||||
url.searchParams.set('states', Array.from(this._selectedStates).join(','));
|
||||
return url.pathname + url.search;
|
||||
}
|
||||
|
||||
// Re-fetch `endpoint` (filtered by the current checkbox selection) 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 url = this._fetchUrl();
|
||||
if (!url || !this._body) return;
|
||||
let nodes;
|
||||
try {
|
||||
const r = await fetch(url);
|
||||
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);
|
||||
const cancellable = this.hasAttribute('cancellable');
|
||||
for (const root of roots) this._body.append(renderNode(root, cancellable));
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent('hive-jobq-graph-update', {
|
||||
detail: { nodes: nodes || [] },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
customElements.define('hive-jobq-graph', HiveJobqGraph);
|
||||
|
|
@ -1,9 +1,14 @@
|
|||
/* hive-jobq-graph.css — shadow-scoped styles for <hive-jobq-graph>. 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 (<hive-dialog>, ...) uses. */
|
||||
/* jobq-graph.css — styles for the `JobqGraph` Preact component
|
||||
(`./JobqGraph.js`). Light-DOM adaptation of the former shadow-scoped
|
||||
`<hive-jobq-graph>` stylesheet — `:host` became `.jg-root`, everything
|
||||
else unchanged. `@import` this from a page-level CSS file (dashboard)
|
||||
or a component-level one (swarm-ui), matching how every other
|
||||
`@hive/shared` stylesheet is consumed — see JobqGraph.js's top comment
|
||||
for why this file is never imported from JS. Theme custom properties
|
||||
(--fg, --red, ...) are plain inherited custom properties here, same as
|
||||
any other light-DOM rule — no shadow boundary to pierce anymore. */
|
||||
|
||||
:host {
|
||||
.jg-root {
|
||||
display: block;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
Loading…
Reference in a new issue