Skip to content

Commit e3fdaf2

Browse files
committed
feat(admin): the insights artifact -- one page for topology and spend (issue #175)
The fourth insights slice, the one the series exists for: /dispatch insights html [7d|30d|mtd] writes <graphDir>/insights.html, one self-contained file:// document -- the trigger/flow topology with spend badged onto its triggers, beside the cost fold drawn as hand-rolled inline SVG charts (KPI tiles, plan verdict cards, a daily spend column chart, the four breakdown bar lists). - graph-html.mjs gains exports, not loads: buildGraphScene is the normalize+layout+SVG-emission half of buildGraphHtml extracted whole (behavior-preserving; every existing pin green untouched), plus escapeHtml/embedJson/fmt/PAGE_JS/legendHtml/bannersHtml/PAGE_THEME. The purity pin is substring-level and directional on purpose: the module may be a source of truth for a sibling emitter, it may never load one itself. - insights-html.mjs: two loads only (graph-html, panel), so the money strings come from the REAL fmtCost -- a plan-covered bucket draws a plan:<id> chip and NO dollar bar, estimates are dashed and translucent beside their ~ est. text (hue never the sole encoding), floors carry >= on the chart, gap days render as baseline ticks, a null byTrigger renders 'not computed'. Byte-deterministic under permutation, total on junk, allowlist-normalized, one script element, no fetching, no innerHTML, textContent-only tips, row->node cross-highlight by replaying a guarded synthetic click so PAGE_JS keeps sole ownership of selection. The topology pane takes the scene's own aspect ratio (a fixed pane letterboxed a flat topology into a mostly-empty band; caught by a headless-Chrome screenshot). - index.ts: the insights subcommand (bare insights answers usage; the artifact IS the feature), USAGE/KNOWN_SUBCOMMANDS/description in step, completion for insights html <window>, assembleInsights composing the two existing assemblers, insightsHtmlCommand as graphHtmlCommand's structural twin (atomic stable path, URL first, headless skip-and-say, write-failure never opens). Default window 30d, deliberately not costs' mtd: the topology half is pinned at a 30d record window and one page's halves should agree. Specs in the same PR: NEW REQ-INSIGHTS-HTML-EXPORT; REQ-COST-ANALYTICS amended one sentence (the artifact joins the named surfaces; rules (a)-(g) unchanged); DES-ADMIN-VIA-PI-EXTENSION amended with the factoring facts and rejections (shared third emitter, duplication, in-place folding, a charting dependency); REQ-GRAPH-HTML-EXPORT UNCHANGED, checked (graph html stays the lighter topology-only export); REQ-TOPOLOGY-GRAPH UNCHANGED, checked; DES-GRAPH-EDGE-DERIVATION UNCHANGED, checked; DES-COST-FOLD-BY-SCAN UNCHANGED, checked. New docs/insights.md; cross-links in docs/graph.md and docs/costs.md. Suite green in the CI posture: 2224 tests, 0 skipped, live Valkey. Signed-off-by: Rob Boerman <robboerman@live.nl>
1 parent 2de0208 commit e3fdaf2

11 files changed

Lines changed: 1775 additions & 11 deletions

admin/src/graph-html.mjs

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,22 @@ const STATUS_OTHER = "#6e7681";
9393
const BADGE_ORANGE = "#db6d28";
9494
const BADGE_GREEN = "#3fb950";
9595
const DANGER = "#f85149";
96+
97+
// The page palette as one frozen bag, for the sibling artifact builder (insights-html.mjs): this
98+
// module may be a source of truth for other pure emitters, it may just never load one itself --
99+
// the purity test's ban is directional on purpose.
100+
export const PAGE_THEME = Object.freeze({
101+
canvas: PAGE_CANVAS,
102+
panel: PAGE_PANEL,
103+
border: PAGE_BORDER,
104+
fg: PAGE_FG,
105+
dim: PAGE_DIM,
106+
accent: PAGE_ACCENT,
107+
amber: PAGE_AMBER,
108+
danger: DANGER,
109+
green: STATUS_COMPLETED,
110+
chipStroke: CHIP_STROKE,
111+
});
96112
const GROUP_FILL = "#E6E0F8";
97113
const WIRE_OBSERVED = "#999";
98114
const WIRE_CONFIG = "#6e7681";
@@ -114,14 +130,14 @@ const GLYPH = Object.freeze({
114130
// The 5-entity escape, byte-for-byte the worker's buildFormPage helper: every string interpolated
115131
// into markup goes through this, operator-authored or not, because "charset-bound upstream" is an
116132
// assumption and an entity is a guarantee.
117-
function escapeHtml(s) {
133+
export function escapeHtml(s) {
118134
return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
119135
}
120136

121137
// JSON destined for the inline script: `<` becomes < so no value can spell `</script>` and
122138
// break out of the script element, and U+2028/2029 become escapes because they are line
123139
// terminators to a JS parser while being invisible to JSON.
124-
function embedJson(value) {
140+
export function embedJson(value) {
125141
return JSON.stringify(value)
126142
.replace(/</g, "\\u003c")
127143
.replace(/\u2028/g, "\\u2028")
@@ -130,7 +146,7 @@ function embedJson(value) {
130146

131147
// Every number that reaches markup goes through this: a non-finite value becomes 0 rather than
132148
// serialising as one of the three words the well-formedness test bans outright.
133-
function fmt(v) {
149+
export function fmt(v) {
134150
const n = Number.isFinite(v) ? Math.round(v * 10) / 10 : 0;
135151
return String(n);
136152
}
@@ -908,7 +924,7 @@ function legendSwatch(inner) {
908924
return `<svg width="16" height="12" aria-hidden="true">${inner}</svg>`;
909925
}
910926

911-
function legendHtml(norm) {
927+
export function legendHtml(norm) {
912928
const rows = [];
913929
const row = (sample, text) => rows.push(`<div class="row">${sample}<span>${escapeHtml(text)}</span></div>`);
914930
rows.push("<h2>edges</h2>");
@@ -938,7 +954,7 @@ function legendHtml(norm) {
938954
return `<div id="legend">${rows.join("")}</div>`;
939955
}
940956

941-
function bannersHtml(norm) {
957+
export function bannersHtml(norm) {
942958
const banners = [];
943959
if (!norm.ok) banners.push("no graph model supplied; the page has nothing to draw");
944960
if (norm.meta.triggersMissing) banners.push("no triggers file found");
@@ -976,7 +992,7 @@ svg.canvas{display:block;width:100%;height:100%;cursor:grab;touch-action:none}
976992
// sit inside one), and with three hard rules the tests pin: no fetching of any kind, no markup
977993
// assembly on the client (textContent only), and the clock read spelled without the static
978994
// accessor this module's purity regex bans.
979-
const PAGE_JS = `
995+
export const PAGE_JS = `
980996
(function () {
981997
"use strict";
982998
var svg = document.getElementById("graph");
@@ -1166,7 +1182,15 @@ const PAGE_JS = `
11661182
* the available outcomes. `now` is the injected generation instant (ms epoch); the module never
11671183
* reads a clock of its own, so the same model and the same now are byte-identical forever.
11681184
*/
1169-
export function buildGraphHtml(model, { now, fullPaths } = {}) {
1185+
/**
1186+
* The scene half of the page: normalize, lay out, and emit the SVG body plus the per-node data the
1187+
* page script needs. Split from buildGraphHtml so a sibling artifact (insights-html.mjs) can place
1188+
* the same topology inside a larger document without a second layout engine or a second escaping
1189+
* discipline. The layout's placed nodes still hold their normalised node objects (original ids and
1190+
* all) -- that is server-side composition state for the caller; only `svgBody`/`graphData` strings
1191+
* belong in a page, and they carry minted ordinals alone.
1192+
*/
1193+
export function buildGraphScene(model, { now, fullPaths } = {}) {
11701194
let norm;
11711195
try {
11721196
norm = normalizeModel(model);
@@ -1222,7 +1246,11 @@ export function buildGraphHtml(model, { now, fullPaths } = {}) {
12221246
layout.wires.map((w) => wireSvg(w, nowMs)).join(""),
12231247
nodeParts.join(""),
12241248
].join("");
1225-
const vb = layout.viewBox;
1249+
return { norm, layout, svgBody, viewBox: layout.viewBox, graphData, nowMs };
1250+
}
1251+
1252+
export function buildGraphHtml(model, { now, fullPaths } = {}) {
1253+
const { norm, svgBody, viewBox: vb, graphData, nowMs } = buildGraphScene(model, { now, fullPaths });
12261254
const svgEl = [
12271255
`<svg id="graph" class="canvas" viewBox="${fmt(vb.x)} ${fmt(vb.y)} ${fmt(vb.w)} ${fmt(vb.h)}" role="img" aria-label="pi-dispatch trigger and flow graph" preserveAspectRatio="xMidYMid meet">`,
12281256
`<g id="root">${svgBody}</g>`,

admin/src/index.ts

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ import {
8585
} from "./read-model.mjs";
8686
import { buildGraphModel } from "./graph-model.mjs";
8787
import { buildGraphHtml } from "./graph-html.mjs";
88+
import { buildInsightsHtml } from "./insights-html.mjs";
8889
import { openBrowser } from "@edgehero/pi-dispatch/open-browser";
8990
// The deployment pointer (INT-DEPLOYMENT-POINTER-CONTRACT): the wizard-written file that aims this
9091
// extension at a deployment built in another directory. Layered into process.env once at factory load
@@ -162,7 +163,7 @@ const REBUILT_NOTICE = (reason: string) =>
162163
`replaced invalid settings file (${reason}) — other keys were lost`;
163164

164165
const USAGE =
165-
"usage: /dispatch <status|pause|resume|run|runs|logs|budget|costs|graph|triggers|settings|set|unset|setup>";
166+
"usage: /dispatch <status|pause|resume|run|runs|logs|budget|costs|graph|insights|triggers|settings|set|unset|setup>";
166167

167168
const KNOWN_SUBCOMMANDS = [
168169
"status",
@@ -174,6 +175,7 @@ const KNOWN_SUBCOMMANDS = [
174175
"budget",
175176
"costs",
176177
"graph",
178+
"insights",
177179
"triggers",
178180
"settings",
179181
"set",
@@ -217,8 +219,10 @@ export default function admin(pi: ExtensionAPI): void {
217219
}
218220

219221
pi.registerCommand("dispatch", {
222+
// The operator-visible summary; `graph` was missing from it once while USAGE carried it, which
223+
// is exactly the drift the USAGE/KNOWN_SUBCOMMANDS pin exists to catch -- keep all three in step.
220224
description:
221-
"pi-dispatch admin: status|pause|resume|run|runs|logs|budget|costs|triggers|settings|set|unset|setup",
225+
"pi-dispatch admin: status|pause|resume|run|runs|logs|budget|costs|graph|insights|triggers|settings|set|unset|setup",
222226
getArgumentCompletions: (prefix) => completeArguments(prefix),
223227
handler: async (args, ctx) => dispatch(pi, args, ctx),
224228
});
@@ -944,6 +948,17 @@ async function dispatch(pi: ExtensionAPI, args: string, ctx: any): Promise<void>
944948
send(pi, renderGraph(await assembleGraph(paths)));
945949
return;
946950
}
951+
case "insights": {
952+
// `insights html` is the only verb: the artifact IS the feature (issue #175), and a bare
953+
// `insights` answering usage beats it silently aliasing either half's text renderer. Same
954+
// no-LLM-tool posture as `graph`: the assembly spawns git per folder.
955+
if (tokens[1] === "html") {
956+
await insightsHtmlCommand(paths, tokens, notify);
957+
return;
958+
}
959+
notify?.(INSIGHTS_USAGE, "warning");
960+
return;
961+
}
947962
case "run": {
948963
const folder = tokens[1];
949964
const flow = tokens[2];
@@ -1147,6 +1162,79 @@ export async function graphHtmlCommand(paths: any, tokens: string[], notify: Not
11471162
deps.openBrowser(url);
11481163
}
11491164

1165+
const INSIGHTS_USAGE = "usage: /dispatch insights html [7d|30d|mtd] [--no-open] [--full-paths]";
1166+
1167+
/**
1168+
* Assemble the unified insights payload: the graph model and the cost fold the two existing
1169+
* assemblers already build, plus the per-trigger spend map keyed by graph node id. The spend map is
1170+
* derived from the SPEND window's records (the operator's question), not the graph's fixed record
1171+
* window -- the artifact states both windows, and a badge whose window differed from the table
1172+
* beside it would be the quiet inconsistency this surface exists to kill.
1173+
*/
1174+
async function assembleInsights(paths: any, window: string): Promise<any> {
1175+
const graph = await assembleGraph(paths);
1176+
const costs = assembleCosts(paths, window);
1177+
if (costs?.unreachable) {
1178+
return { graph, fold: null, costsUnreachable: String(costs.unreachable), window, costByTrigger: null };
1179+
}
1180+
const fold = costs?.fold ?? null;
1181+
// Re-fold the spend map at the requested window so badge and table agree. assembleCosts already
1182+
// scanned; scanning again here costs one directory pass and keeps the two assemblers untouched.
1183+
const nowMs = Date.now();
1184+
const sinceMs = costsSinceMs(window, nowMs);
1185+
const records = scanRunRecords({ logsDir: paths.logsDir, sinceMs, nowMs });
1186+
let costByTrigger: any = null;
1187+
if (Array.isArray(records)) {
1188+
const triggersView: any = readTriggers({ triggersPath: paths.triggersPath });
1189+
const subsView: any = readSubscriptions({ subscriptionsPath: paths.subscriptionsPath });
1190+
const triggerJoin = attributeRunsToTriggers({ records, triggers: Array.isArray(triggersView?.triggers) ? triggersView.triggers : [] });
1191+
costByTrigger = foldTriggerCosts({ records, subscriptions: Array.isArray(subsView?.subscriptions) ? subsView.subscriptions : [], pricing: PRICING, triggerJoin });
1192+
}
1193+
return { graph, fold, costsUnreachable: null, window, costByTrigger };
1194+
}
1195+
1196+
/**
1197+
* `/dispatch insights html [7d|30d|mtd] [--no-open] [--full-paths]` (REQ-INSIGHTS-HTML-EXPORT):
1198+
* graphHtmlCommand's structural twin -- assemble, render the self-contained artifact, write it
1199+
* ATOMICALLY to the STABLE path `<graphDir>/insights.html`, print the file:// URL FIRST, then
1200+
* best-effort open unless headless/--no-open. A cost-side degrade (scan unreachable) still writes
1201+
* the page with its banner: the artifact is total, never a stack trace. Default window 30d, NOT
1202+
* costs' mtd: the topology half is pinned at a 30d record window, and the one page's two halves
1203+
* should describe the same period unless the operator asks otherwise. Exported for its tests.
1204+
*/
1205+
export async function insightsHtmlCommand(paths: any, tokens: string[], notify: Notify, deps: any = realGraphHtmlDeps()): Promise<void> {
1206+
const rest = tokens.slice(2);
1207+
const noOpen = rest.includes("--no-open");
1208+
const fullPaths = rest.includes("--full-paths");
1209+
const positional = rest.filter((t) => t !== "--no-open" && t !== "--full-paths");
1210+
const window = positional.length > 0 ? positional[0] : "30d";
1211+
if (positional.length > 1 || !COSTS_WINDOWS.includes(window)) {
1212+
notify?.(INSIGHTS_USAGE, "warning");
1213+
return;
1214+
}
1215+
const payload = await assembleInsights(paths, window);
1216+
const html = buildInsightsHtml(payload, { now: deps.now(), fullPaths });
1217+
const file = `${paths.graphDir}/insights.html`;
1218+
try {
1219+
deps.fs.mkdirSync(paths.graphDir, { recursive: true });
1220+
const tmp = `${file}.tmp`;
1221+
deps.fs.writeFileSync(tmp, html, { mode: 0o644 });
1222+
deps.fs.renameSync(tmp, file);
1223+
} catch (err: any) {
1224+
notify?.(`insights html: could not write ${file} (${err?.message ?? err})`, "error");
1225+
return;
1226+
}
1227+
const url = pathToFileURL(file).href;
1228+
notify?.(`insights written: ${url}`, "info");
1229+
if (noOpen) return;
1230+
const headless = isHeadlessEnv(deps.env, deps.platform);
1231+
if (headless) {
1232+
notify?.(`not opening a browser (${headless}): open the URL on this machine's desktop, or scp the file there`, "info");
1233+
return;
1234+
}
1235+
deps.openBrowser(url);
1236+
}
1237+
11501238
/**
11511239
* `/dispatch costs [7d|30d|mtd]` renders the fold; `costs whatif …` estimates one flow at another model's
11521240
* rates. Both send PII-free text into the admin channel -- the same records `runs` already sends,
@@ -1742,6 +1830,16 @@ function completeArguments(prefix: string) {
17421830
const items = ["html"].filter((w) => w.startsWith(partial)).map((w) => ({ value: `graph ${w}`, label: w }));
17431831
return items.length > 0 ? items : null;
17441832
}
1833+
if (parts[0] === "insights" && parts.length === 2) {
1834+
const partial = parts[1];
1835+
const items = ["html"].filter((w) => w.startsWith(partial)).map((w) => ({ value: `insights ${w}`, label: w }));
1836+
return items.length > 0 ? items : null;
1837+
}
1838+
if (parts[0] === "insights" && parts[1] === "html" && parts.length === 3) {
1839+
const partial = parts[2];
1840+
const items = ["7d", "30d", "mtd"].filter((w) => w.startsWith(partial)).map((w) => ({ value: `insights html ${w}`, label: w }));
1841+
return items.length > 0 ? items : null;
1842+
}
17451843
if ((parts[0] === "set" || parts[0] === "unset") && parts.length === 2) {
17461844
const partial = parts[1];
17471845
const items = KNOWN_KEYS.filter((k) => k.startsWith(partial)).map((k) => ({

0 commit comments

Comments
 (0)