Skip to content

Commit 8d88229

Browse files
committed
feat(admin): the budget lever and the trend lines on the insights page (issue #181)
The page that prices everything now shows the one dial the operator can actually turn, and trends read as lines. - BUDGET PANEL, second from the top beside the headline spend: reserved-vs-cap facts for the day/week/month job-slot windows and the daily token counter, a decorative meter bar only when both numbers are known (fill clamped at the track; overflow is the WORD's job), the soft-hold band as an amber tick, and the lever named. An overlay-unset cap is unknown/off with no bar and no percentage; an unreachable queue leaves caps as facts plus a banner; states are computed assembler-side with the worker's own windowState (and the old token rule, >= cap) and ride the payload as words the page never derives. readBudget gains the token counter as a fourth plain GET and a synchronous junk-URL parse guard (the readSchedulers failFast posture -- without it every canned not-a-url test invocation burns the 2.5s timeout); GET-only doctrine intact, CONST-BUDGET-BEFORE-TOKENS. - LINE CHARTS: per-flow daily spend as SMALL MULTIPLES (one panel per top flow, one shared dollar scale, identity carried by the panel title -- the palette has one non-reserved data hue and dashes already mean estimated), segments dashed wherever an estimated day touches, floors marked, gap days on the baseline; plus a cumulative mini-chart with its OWN scale under the daily columns (a running total dwarfs daily bars; a second axis on one plot is the dual-axis lie), demoted permanently from its first estimated day, end-labelled with the running typed total. - foldCosts gains dailyByFlow: the composite (day,flow) fold at the loop buildDaily and buildByFlow already walk separately, gap-padded per flow over the SHARED span, flowLabelOf extracted so the two flow folds cannot drift. Specs in the same PR: REQ-INSIGHTS-HTML-EXPORT gains clauses (g)/(h) and the content list; REQ-SPEND-CAPS-MULTI-WINDOW, REQ-TOKEN-ACCOUNTING-AND-CAPS, REQ-COST-ANALYTICS, CONST-BUDGET-BEFORE-TOKENS, DES-ADMIN-VIA-PI-EXTENSION all UNCHANGED, checked; DES-COST-FOLD-BY-SCAN amended for the series. Suite green in the CI posture: 2192 tests, 0 skipped, live Valkey. Headless-Chrome screenshot inspected: budget rows, soft-hold word and tick, unknown-cap row barless, demoted cumulative, four multiples on one scale. Signed-off-by: Rob Boerman <robboerman@live.nl>
1 parent 1e4e244 commit 8d88229

10 files changed

Lines changed: 742 additions & 31 deletions

admin/src/costs.mjs

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,9 @@ export function foldCosts({ records, subscriptions, pricing, nowMs, piAiPin = nu
265265
// gap-day discipline covers interior quiet days, but a month of leading zeros on a young
266266
// deployment would compress the visible history into the last few cells.
267267
daily: buildDaily(runs, firstRunMs ?? fromMs, toMs),
268+
// The per-flow daily series shares daily's origin and span, so the flow multiples and the
269+
// global columns describe the same days (issue #181).
270+
dailyByFlow: buildDailyByFlow(runs, firstRunMs ?? fromMs, toMs),
268271
byFlow: buildByFlow(runs, subs, pricing),
269272
byModel: buildByModel(runs),
270273
// null, not [], without a join: "not computed" and "nothing attributed" are different sentences,
@@ -296,12 +299,58 @@ function buildDaily(runs, fromMs, toMs) {
296299
return daily;
297300
}
298301

299-
/** Per-flow rollup, sorted by metered cost descending. A null flow reads "(no flow)" -- a display
300-
* label, not an id, so it can never collide with a real skill-charset flow name. */
302+
/** One label rule for both flow folds: a null/empty flow reads "(no flow)" -- a display label, not
303+
* an id, so it can never collide with a real skill-charset flow name. Extracted so buildByFlow and
304+
* buildDailyByFlow cannot drift on what a flow is called. */
305+
function flowLabelOf(record) {
306+
return typeof record.flow === "string" && record.flow !== "" ? record.flow : "(no flow)";
307+
}
308+
309+
/**
310+
* Per-flow DAILY series (issue #181, the line charts): the same composite facts buildDaily and
311+
* buildByFlow fold separately, folded together -- Map<flow, Map<day, contribs>> over the one sorted
312+
* runs pass. Every flow is gap-padded over the SAME span (the shared cursor loop), because small
313+
* multiples are only comparable when they share an x-domain; a flow's quiet day is a zero-run entry
314+
* exactly as the global series keeps it. Rows sort by window total descending then label, the
315+
* byFlow comparator applied to the sum, so "top N flows" means the same flows in both tables.
316+
*/
317+
function buildDailyByFlow(runs, fromMs, toMs) {
318+
if (runs.length === 0) return [];
319+
const byFlow = new Map();
320+
for (const r of runs) {
321+
const flow = flowLabelOf(r.record);
322+
if (!byFlow.has(flow)) byFlow.set(flow, new Map());
323+
const days = byFlow.get(flow);
324+
const day = utcDay(r.at);
325+
if (!days.has(day)) days.set(day, []);
326+
days.get(day).push(r.contribution);
327+
}
328+
const rows = [];
329+
for (const [flow, buckets] of byFlow) {
330+
const days = [];
331+
for (let cursor = Math.floor(fromMs / DAY_MS) * DAY_MS; cursor <= toMs; cursor += DAY_MS) {
332+
const day = utcDay(cursor);
333+
const contribs = buckets.get(day) ?? [];
334+
days.push({ day, cost: combineContributions(contribs), runs: contribs.length });
335+
}
336+
rows.push({
337+
flow,
338+
// The machine key beside the display label -- the byFlow lesson (issue #175) held here too.
339+
flowKey: flow === "(no flow)" ? null : flow,
340+
days,
341+
});
342+
}
343+
return rows.sort((a, b) => {
344+
const sum = (row) => row.days.reduce((acc, d) => acc + d.cost.usd, 0);
345+
return sum(b) - sum(a) || a.flow.localeCompare(b.flow);
346+
});
347+
}
348+
349+
/** Per-flow rollup, sorted by metered cost descending. The label rule is flowLabelOf's. */
301350
function buildByFlow(runs, subs, pricing) {
302351
const groups = new Map();
303352
for (const r of runs) {
304-
const flow = typeof r.record.flow === "string" && r.record.flow !== "" ? r.record.flow : "(no flow)";
353+
const flow = flowLabelOf(r.record);
305354
if (!groups.has(flow)) groups.set(flow, []);
306355
groups.get(flow).push(r);
307356
}

admin/src/index.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ import {
8686
import { buildGraphModel } from "./graph-model.mjs";
8787
import { buildInsightsHtml } from "./insights-html.mjs";
8888
import { openBrowser } from "@edgehero/pi-dispatch/open-browser";
89+
// The worker's OWN window classifier (the same one reserveBudget enforces), so the budget states the
90+
// insights payload carries are words the page never derives and the panel and enforcement cannot drift.
91+
import { windowState } from "@edgehero/pi-dispatch/budget";
8992
// The deployment pointer (INT-DEPLOYMENT-POINTER-CONTRACT): the wizard-written file that aims this
9093
// extension at a deployment built in another directory. Layered into process.env once at factory load
9194
// (the operator's env always wins), so resolvePaths stays env-only by contract while every one of its
@@ -1094,11 +1097,63 @@ const INSIGHTS_USAGE =
10941097
* window -- the artifact states both windows, and a badge whose window differed from the table
10951098
* beside it would be the quiet inconsistency this surface exists to kill.
10961099
*/
1100+
/** A positive integer or null -- the shape every overlay cap takes on its way into the payload. */
1101+
function posIntOrNull(v: any): number | null {
1102+
return Number.isInteger(v) && v > 0 ? v : null;
1103+
}
1104+
1105+
/**
1106+
* The budget slice of the insights payload (issue #181): reserved-vs-cap FACTS for the three job-slot
1107+
* windows and the daily token counter, with the per-window state computed HERE by the worker's own
1108+
* `windowState` (and the token rule the old dashboard token line used: `>=` cap, `>` the soft-hold
1109+
* floor -- tokens are a running count, not a reservation, so equality is already over). States ride
1110+
* the payload as WORDS the page never derives: the artifact builder cannot load the worker
1111+
* (its import allowlist is the two pure siblings), and duplicating threshold arithmetic behind a
1112+
* parity test would put policy in two places. Caps come from the settings overlay ALONE -- a cap the
1113+
* overlay does not set is null, "unknown", because the worker resolves it from env/defaults this
1114+
* process cannot read authoritatively, and the page must render that as absence, never a guess.
1115+
* `readBudget` is GET-only (CONST-BUDGET-BEFORE-TOKENS: observing the budget spends nothing).
1116+
*/
1117+
async function assembleBudgetView(paths: any): Promise<any> {
1118+
const raw: any = await readBudget({ url: paths.valkeyUrl });
1119+
const view: any = readSettingsView({ settingsFile: paths.settingsFile });
1120+
const overlay = view && !view.invalid && view.overlay ? view.overlay : {};
1121+
const pct = Number.isInteger(overlay.softHoldPct) ? overlay.softHoldPct : null;
1122+
const unreachable = raw?.unreachable ? String(raw.unreachable) : null;
1123+
const slotWindow = (key: string, capKey: string) => {
1124+
const cap = posIntOrNull(overlay[capKey]);
1125+
const reserved = unreachable === null ? Number(raw[key] ?? 0) : null;
1126+
const state = cap !== null && reserved !== null ? windowState(reserved, cap, pct) : "ok";
1127+
return { reserved, cap, state };
1128+
};
1129+
const tokensSpent = unreachable === null ? Number(raw.tokensToday ?? 0) : null;
1130+
const tokenCap = posIntOrNull(overlay.dailyTokenCap);
1131+
const tokenState =
1132+
tokenCap !== null && tokensSpent !== null
1133+
? tokensSpent >= tokenCap
1134+
? "over"
1135+
: pct !== null && tokensSpent > Math.floor((tokenCap * pct) / 100)
1136+
? "soft-hold"
1137+
: "ok"
1138+
: "ok";
1139+
return {
1140+
unreachable,
1141+
softHoldPct: pct,
1142+
day: slotWindow("day", "dailyCap"),
1143+
week: slotWindow("week", "weeklyCap"),
1144+
month: slotWindow("month", "monthlyCap"),
1145+
tokens: { spent: tokensSpent, cap: tokenCap, state: tokenState, maxTokens: posIntOrNull(overlay.maxTokens) },
1146+
};
1147+
}
1148+
10971149
async function assembleInsights(paths: any, window: string): Promise<any> {
10981150
const graph = await assembleGraph(paths);
10991151
const costs = assembleCosts(paths, window);
1152+
// The budget slice rides BOTH return shapes: the caps are the operator's one real lever on cost,
1153+
// and the lever does not depend on the spend scan being readable.
1154+
const budget = await assembleBudgetView(paths);
11001155
if (costs?.unreachable) {
1101-
return { graph, fold: null, costsUnreachable: String(costs.unreachable), window, costByTrigger: null };
1156+
return { graph, fold: null, costsUnreachable: String(costs.unreachable), window, costByTrigger: null, budget };
11021157
}
11031158
const fold = costs?.fold ?? null;
11041159
// Re-fold the spend map at the requested window so badge and table agree. assembleCosts already
@@ -1113,7 +1168,7 @@ async function assembleInsights(paths: any, window: string): Promise<any> {
11131168
const triggerJoin = attributeRunsToTriggers({ records, triggers: Array.isArray(triggersView?.triggers) ? triggersView.triggers : [] });
11141169
costByTrigger = foldTriggerCosts({ records, subscriptions: Array.isArray(subsView?.subscriptions) ? subsView.subscriptions : [], pricing: PRICING, triggerJoin });
11151170
}
1116-
return { graph, fold, costsUnreachable: null, window, costByTrigger };
1171+
return { graph, fold, costsUnreachable: null, window, costByTrigger, budget };
11171172
}
11181173

11191174
/**

0 commit comments

Comments
 (0)