jobq graph: state filter on /api/jobq/graph + multi-select checkboxes
GET /api/jobq/graph gains a states query param (comma-separated hive_jobq::State names): narrows the served root groups to the named states, keeping a group whole (filtering by a root's own state, which is already its subtree's rolled-up answer). Absent, empty, or fully unrecognised is the identity filter, matching prior behaviour. hive-jobq-graph.js gains a row of per-state checkboxes above the tree, re-fetching the endpoint with the selection on toggle. Default selection hides Done and Skipped. Server-side filtering (not client-side hiding) so hive-jobq-graph-update's node list, and everything downstream of it in builds.js (count pill, live-log panel), only ever sees what's actually shown.
This commit is contained in:
parent
ec476dfaae
commit
1e13b88c8c
6 changed files with 236 additions and 27 deletions
|
|
@ -10,6 +10,26 @@
|
|||
color: var(--fg);
|
||||
}
|
||||
|
||||
.jg-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.15em 0.9em;
|
||||
margin: 0 0 0.5em;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.jg-filter-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3em;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.jg-filter-label:has(input:checked) {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.jg-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -14,20 +14,20 @@
|
|||
// `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.
|
||||
// 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 for something the
|
||||
// tree doesn't show (a count badge, a live-log panel) listens instead of
|
||||
// running its own parallel fetch.
|
||||
// (`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, 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.
|
||||
// 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';
|
||||
|
|
@ -43,6 +43,14 @@ const STATE_GLYPH = {
|
|||
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.
|
||||
|
|
@ -133,9 +141,14 @@ class HiveJobqGraph extends HTMLElement {
|
|||
// <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._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.
|
||||
|
|
@ -151,15 +164,51 @@ class HiveJobqGraph extends HTMLElement {
|
|||
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() {
|
||||
// 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 || !this._body) return;
|
||||
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(endpoint);
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error('http ' + r.status);
|
||||
nodes = await r.json();
|
||||
} catch (err) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue