Skip to content

Commit 25cc9ce

Browse files
committed
feat(admin): the graph HTML export, and review hardening across the pipeline (issue #54)
Two things in one PR, both issue #54: the browser artifact, and the fixes from the three-agent review that ran against the whole delivery. The export. /dispatch graph html renders the same assembled model as every other graph surface into ONE self-contained HTML file: inline SVG/CSS/JS, zero external requests, works over file://, nothing listens anywhere. Node-RED shape language (pale category chips, 20px grid, ports, status dots, group rects per folder) on the repo's dark chrome; hand-rolled Sugiyama-lite layout; evidence-labelled wires with a legend, the caps line, and the honesty counters. The page carries its own refresh: a Reload button, off/5s/30s auto-reload, a live staleness stamp, and hash-persisted view state, which the STABLE artifact path makes useful: the command overwrites <graphDir>/graph.html atomically (tmp+rename), so an open tab picks up a re-run without losing its pan/zoom. The URL prints before the spawn; over SSH or without a display the spawn is skipped and said; --no-open always skips it; a write failure names the path and never opens. The hardening, every item CONFIRMED by an executing reviewer and now pinned by a regression test: - joinRunsToTriggers guarded only the index RANGE, so an in-range triggers.json edit slid one trigger's run history onto whatever entry took its row. The persisted triggerType (sitting unused) now must agree; the same-type-reorder residual is named in REQ-TOPOLOGY-GRAPH and pinned. - chainRefused counters folder-scoped (same-folder-only chaining makes same-named flows different flows). - Observed edges into unreachable folders minted phantom missing-at-HEAD endpoints off a read that never happened; now dropped into the counter. - LIST trigger rows carried the display position, not the raw file index: with a garbage entry above, x+y deleted the wrong entry and reported the real trigger gone while it kept firing. - Esc from a GRAPH-entered trigger drill returned to LIST, discarding the operator's graph position. - collectGraphInputs' folder-cap flag never reached the model, so the cap banner could never fire; folderless cron entries lost their config edge; unreadable injected dirs vanished silently; the mention heuristic tested only the first occurrence; the file:// URL was concatenated rather than pathToFileURL'd. - In the page: under-row routes collided with the next row of chips, the pan/zoom mapping ignored preserveAspectRatio letterboxing, every background pan wiped the selection, #sel=constructor threw at load, and parallel wires stacked their labels into garble. All fixed and mutation-verified. Specs: DES-ADMIN-VIA-PI-EXTENSION AMENDED (the artifact; a file with no server is not a surface, and the socket-to-file substitution is DES-JOB-OUTBOX-CHAINING's own); DES-QUEUE-BULLMQ-OVER-CUSTOM's 'drops the web surface entirely' REWORDED to 'the served web surface' rather than argued around; NEW REQ-GRAPH-HTML-EXPORT; REQ-TOPOLOGY-GRAPH (b) AMENDED to a promise the persisted fields can keep; NEW OQ-024 (the opener-spawn WATCH residual); SECURITY.md export paragraph; docs/graph.md and README updated. Also repaired in passing, because this commit's own pre-push scan caught it: six literal NUL bytes in graph-model.mjs and two in read-model.mjs, written as template-literal separators by an agent where spaces belonged (the recurring trap CLAUDE-adjacent history records twice). They were functionally invisible (both sides of every Map key used the same byte, and the NULs sat past git's 8000-byte binary-detection window, which is why no Bin marker ever showed), but a control byte in source is a control byte in source. Replaced with the spaces they were meant to be; the whole tree now scans clean. Verified end to end: the artifact generated from a real fixture deployment renders in headless Chrome with pan/zoom, tooltips, highlight, refresh controls and no console errors. Suite in the CI posture: 2166 pass, 0 skipped; admin bundle builds. Signed-off-by: Rob Boerman <robboerman@live.nl>
1 parent 1250123 commit 25cc9ce

18 files changed

Lines changed: 1927 additions & 42 deletions

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -381,8 +381,10 @@ Steer the running worker without stopping it, from any terminal:
381381

382382
The dashboard shown at the top of this README is a **pi extension**: it loads into your own interactive
383383
pi session. No daemon, no web app, no network port. Beside the dashboard, `/dispatch graph` renders the
384-
whole trigger and flow topology as text: what triggers what, what chained to what in the recorded runs,
385-
what a skill's own text says it might chain to, plus orphan skills and dangling triggers
384+
whole trigger and flow topology: what triggers what, what chained to what in the recorded runs, what a
385+
skill's own text says it might chain to, plus orphan skills and dangling triggers. It comes as a
386+
dashboard view (`g`), as plain text, and as `/dispatch graph html`, a self contained page your browser
387+
opens from disk (still no server and no port) with the topology drawn Node-RED style
386388
([`docs/graph.md`](docs/graph.md)).
387389

388390
```bash

SECURITY.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,13 @@ Stated openly rather than discovered later:
484484
of that package and every transitive dependency would run AS YOU, ON YOUR HOST**, at install time, which is
485485
a host compromise and not a job one. So: no port, no harness credential, and still the same trust as shell
486486
access, which is where `/dispatch setup` stops being theoretical.
487+
- **The graph export writes one static HTML file, on your keystroke, to a temp path it names.** What
488+
crosses into it: trigger configuration you authored, skill names and frontmatter from the repos you
489+
service, and the PII-free run-record fields the panel already shows. What never does: raw job log
490+
bytes, issue or task text, session material, or a host path beyond a folder's basename. Nothing
491+
listens and nothing serves the file — opening it is a local browser reading local bytes, and the
492+
page makes no network request of any kind. The browser spawn is best-effort, skipped and announced
493+
over SSH or without a display; the printed `file://` URL is the contract.
487494
- **The dashboard writes to your terminal's clipboard only on your keystroke.** The `y`/`Y` copy keys in
488495
the run drill-in emit an OSC 52 sequence — the standard way a terminal application hands text to the
489496
local clipboard, including over SSH. What crosses is a host-assigned job id or a target URL derived

admin/src/dashboard.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ export function createDashboardDeps(paths: any) {
157157
schedulers: Array.isArray(schedulers) ? schedulers : [],
158158
...collectGraphInputs({ triggers: triggerList }),
159159
cronStats: cronRunStats({ records: recs, schedulerIds: triggerList.filter((t) => t.type === "cron" && typeof t.id === "string").map((t) => t.id) }),
160-
runJoin: joinRunsToTriggers({ records: recs, triggerCount: triggersView?.count }),
160+
runJoin: joinRunsToTriggers({ records: recs, triggerCount: triggersView?.count, triggerTypes: Object.fromEntries(triggerList.map((t) => [t.index, t.type])) }),
161161
chainEdges: observedChainEdges({ records: recs }),
162162
caps: { chainDepthMax: paths.chainDepthMax, chainMaxPerJob: paths.chainMaxPerJob, windowDays: GRAPH_LIMITS.windowDays },
163163
nowMs,
@@ -226,6 +226,10 @@ export function makeDashboard({
226226
// not per second, and this module does no I/O of its own.
227227
let detailSandbox: any = null;
228228
let detailTrigger: any = null; // the trigger opened in TRIGGER_DETAIL (its display record + file index)
229+
// Which view TRIGGER_DETAIL returns to on Esc: the drill opens from LIST and from GRAPH, and Esc
230+
// pops ONE layer -- landing a graph-entered drill back in LIST would discard the operator's graph
231+
// position and force a fresh git-spawning fetch to get back (review finding).
232+
let detailReturnTo = "LIST";
229233
// LIVE_TAIL state, held here in dedicated component fields keyed only by the id-only `activeJobId`. The
230234
// raw `.log` bytes in `tail` are PII-bearing and untrusted: they live here and reach the TUI overlay via
231235
// render() alone -- never `snapshot`, never a shared renderer, never `sendMessage` (INT-RUN-HISTORY-FILE-CONTRACT).
@@ -521,7 +525,8 @@ export function makeDashboard({
521525
return;
522526
}
523527
if (matchesKey(data, "escape")) {
524-
view = "LIST";
528+
// Esc pops ONE layer: back to whichever view opened the drill (LIST or GRAPH).
529+
view = detailReturnTo;
525530
detailTrigger = null;
526531
tui?.requestRender?.();
527532
return;
@@ -757,6 +762,7 @@ export function makeDashboard({
757762
if (!record) return;
758763
detailTrigger = { record, index: row.node.index };
759764
pendingDelete = false;
765+
detailReturnTo = "GRAPH";
760766
view = "TRIGGER_DETAIL";
761767
tui?.requestRender?.();
762768
return;
@@ -781,6 +787,7 @@ export function makeDashboard({
781787
if (row.kind === "trigger") {
782788
detailTrigger = { record: row.trigger, index: row.index };
783789
pendingDelete = false;
790+
detailReturnTo = "LIST";
784791
view = "TRIGGER_DETAIL";
785792
tui?.requestRender?.();
786793
} else if (row.kind === "active") {
@@ -1708,7 +1715,13 @@ function renderGraphView({ graph, graphSel, graphAvailable, framed, width, style
17081715
const model = graph?.model ?? null;
17091716

17101717
if (!framed) {
1711-
const plain = model ? renderGraph(model).split("\n") : [graphAvailable ? "loading graph…" : "graph unavailable in this build"];
1718+
// The error outranks a stale model here too (review finding): a failed refresh at a tiny width
1719+
// must not render yesterday's topology -- or an eternal "loading" -- as if nothing happened.
1720+
const plain = graph?.error
1721+
? [`graph unreachable (${graph.error})`]
1722+
: model
1723+
? renderGraph(model).split("\n")
1724+
: [graphAvailable ? "loading graph…" : "graph unavailable in this build"];
17121725
return [styler.stripAnsi(title), "", ...plain, "", "↑↓ move · ↵ open/fold · r refresh · esc back"];
17131726
}
17141727

@@ -1754,6 +1767,7 @@ function graphCounterLine(model: any, styler: any): string | null {
17541767
if (t.skills) bits.push("skills truncated/unread");
17551768
if (t.edges) bits.push("edges truncated");
17561769
if ((meta.droppedObservedEdges ?? 0) > 0) bits.push(`${meta.droppedObservedEdges} observed edges dropped`);
1770+
if ((meta.injectedUnreachable ?? []).length > 0) bits.push(`${meta.injectedUnreachable.length} injected dirs unreadable`);
17571771
return bits.length ? styler.fg("warning", bits.join(" · ")) : null;
17581772
}
17591773

@@ -2131,9 +2145,13 @@ function countdownText(ms: number): string {
21312145
*/
21322146
function buildRows(snapshot: any, runSort = "time"): any[] {
21332147
// Triggers lead the selectable list (Enter -> TRIGGER_DETAIL), then the optional ACTIVE row, then runs.
2134-
// A trigger row carries its file `index` so a CRUD action can target the right entry in triggers.json.
2135-
// The ACTIVE row stays pinned above the runs whatever the sort: it is the one row that is not history.
2136-
const triggers = (snapshot?.triggers?.triggers ?? []).map((t: any, i: number) => ({ kind: "trigger", trigger: t, index: i }));
2148+
// A trigger row carries its RAW file `index` -- the one every display record now carries (issue #54)
2149+
// -- so a CRUD action targets the right entry in triggers.json even when the display dropped an
2150+
// unusable row above it. The display POSITION this used before was a live-fire wrong-delete: one
2151+
// garbage entry at row 0 and `x`+`y` on the visible trigger deleted the garbage while the real
2152+
// trigger kept firing, reported as deleted (review finding). Falls back to the position only for a
2153+
// record predating the field, where it is the best available claim.
2154+
const triggers = (snapshot?.triggers?.triggers ?? []).map((t: any, i: number) => ({ kind: "trigger", trigger: t, index: Number.isInteger(t?.index) ? t.index : i }));
21372155
const active = snapshot?.activeJobId ? [{ kind: "active", jobId: snapshot.activeJobId }] : [];
21382156
const runs = (Array.isArray(snapshot?.runs) ? snapshot.runs : []).map((record: any) => ({ kind: "run", record }));
21392157
return [...triggers, ...active, ...sortRuns(runs, runSort)];

0 commit comments

Comments
 (0)