Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,8 +381,10 @@ Steer the running worker without stopping it, from any terminal:

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

```bash
Expand Down
7 changes: 7 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,13 @@ Stated openly rather than discovered later:
of that package and every transitive dependency would run AS YOU, ON YOUR HOST**, at install time, which is
a host compromise and not a job one. So: no port, no harness credential, and still the same trust as shell
access, which is where `/dispatch setup` stops being theoretical.
- **The graph export writes one static HTML file, on your keystroke, to a temp path it names.** What
crosses into it: trigger configuration you authored, skill names and frontmatter from the repos you
service, and the PII-free run-record fields the panel already shows. What never does: raw job log
bytes, issue or task text, session material, or a host path beyond a folder's basename. Nothing
listens and nothing serves the file — opening it is a local browser reading local bytes, and the
page makes no network request of any kind. The browser spawn is best-effort, skipped and announced
over SSH or without a display; the printed `file://` URL is the contract.
- **The dashboard writes to your terminal's clipboard only on your keystroke.** The `y`/`Y` copy keys in
the run drill-in emit an OSC 52 sequence — the standard way a terminal application hands text to the
local clipboard, including over SSH. What crosses is a host-assigned job id or a target URL derived
Expand Down
30 changes: 24 additions & 6 deletions admin/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ export function createDashboardDeps(paths: any) {
schedulers: Array.isArray(schedulers) ? schedulers : [],
...collectGraphInputs({ triggers: triggerList }),
cronStats: cronRunStats({ records: recs, schedulerIds: triggerList.filter((t) => t.type === "cron" && typeof t.id === "string").map((t) => t.id) }),
runJoin: joinRunsToTriggers({ records: recs, triggerCount: triggersView?.count }),
runJoin: joinRunsToTriggers({ records: recs, triggerCount: triggersView?.count, triggerTypes: Object.fromEntries(triggerList.map((t) => [t.index, t.type])) }),
chainEdges: observedChainEdges({ records: recs }),
caps: { chainDepthMax: paths.chainDepthMax, chainMaxPerJob: paths.chainMaxPerJob, windowDays: GRAPH_LIMITS.windowDays },
nowMs,
Expand Down Expand Up @@ -226,6 +226,10 @@ export function makeDashboard({
// not per second, and this module does no I/O of its own.
let detailSandbox: any = null;
let detailTrigger: any = null; // the trigger opened in TRIGGER_DETAIL (its display record + file index)
// Which view TRIGGER_DETAIL returns to on Esc: the drill opens from LIST and from GRAPH, and Esc
// pops ONE layer -- landing a graph-entered drill back in LIST would discard the operator's graph
// position and force a fresh git-spawning fetch to get back (review finding).
let detailReturnTo = "LIST";
// LIVE_TAIL state, held here in dedicated component fields keyed only by the id-only `activeJobId`. The
// raw `.log` bytes in `tail` are PII-bearing and untrusted: they live here and reach the TUI overlay via
// render() alone -- never `snapshot`, never a shared renderer, never `sendMessage` (INT-RUN-HISTORY-FILE-CONTRACT).
Expand Down Expand Up @@ -521,7 +525,8 @@ export function makeDashboard({
return;
}
if (matchesKey(data, "escape")) {
view = "LIST";
// Esc pops ONE layer: back to whichever view opened the drill (LIST or GRAPH).
view = detailReturnTo;
detailTrigger = null;
tui?.requestRender?.();
return;
Expand Down Expand Up @@ -757,6 +762,7 @@ export function makeDashboard({
if (!record) return;
detailTrigger = { record, index: row.node.index };
pendingDelete = false;
detailReturnTo = "GRAPH";
view = "TRIGGER_DETAIL";
tui?.requestRender?.();
return;
Expand All @@ -781,6 +787,7 @@ export function makeDashboard({
if (row.kind === "trigger") {
detailTrigger = { record: row.trigger, index: row.index };
pendingDelete = false;
detailReturnTo = "LIST";
view = "TRIGGER_DETAIL";
tui?.requestRender?.();
} else if (row.kind === "active") {
Expand Down Expand Up @@ -1708,7 +1715,13 @@ function renderGraphView({ graph, graphSel, graphAvailable, framed, width, style
const model = graph?.model ?? null;

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

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

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