@vimoxshah/tokenflow 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +180 -0
- package/Dockerfile.team +20 -0
- package/README.md +30 -11
- package/bin/tokenflow.js +147 -12
- package/design/tokens.yaml +330 -0
- package/docs/architecture.md +5 -4
- package/docs/cli.md +204 -0
- package/docs/configuration.md +117 -2
- package/docs/design-system.md +187 -0
- package/docs/exports-and-budgets.md +85 -0
- package/docs/guard-codex.md +132 -0
- package/docs/ledger.md +144 -0
- package/docs/live-mode.md +40 -0
- package/docs/media/overview-aurora-dark.png +0 -0
- package/docs/media/receipts-aurora-dark.png +0 -0
- package/docs/providers-otel.md +179 -0
- package/docs/providers.md +54 -1
- package/docs/receipt-schema.md +74 -0
- package/docs/roadmap.md +182 -0
- package/docs/team-server.md +170 -0
- package/docs/ui-views.md +322 -0
- package/package.json +7 -2
- package/schemas/receipt.v0.json +160 -0
- package/scripts/build-menubar-app.sh +3 -1
- package/scripts/design-build.js +475 -0
- package/src/analytics/anatomy.js +467 -0
- package/src/analytics/branch-compare.js +159 -0
- package/src/analytics/cache-health.js +141 -0
- package/src/analytics/live-view.js +266 -0
- package/src/analytics/receipt-schema.js +214 -0
- package/src/analytics/receipt.js +709 -0
- package/src/analytics/rhythm.js +184 -0
- package/src/analytics/whatif.js +263 -0
- package/src/commands/budget-scopes.js +133 -0
- package/src/commands/doctor-checks.js +400 -0
- package/src/commands/guard.js +531 -0
- package/src/commands/hooks.js +238 -0
- package/src/commands/pricing-diff.js +316 -0
- package/src/commands/receipt.js +226 -0
- package/src/commands/team-serve.js +407 -0
- package/src/commands/week.js +86 -0
- package/src/core/annotations.js +97 -0
- package/src/core/budget.js +33 -0
- package/src/core/bundle.js +45 -2
- package/src/core/ingest.js +33 -0
- package/src/core/live-status.js +227 -2
- package/src/core/policy.js +103 -0
- package/src/core/receipt-note.js +123 -0
- package/src/core/repo.js +64 -0
- package/src/core/sync.js +163 -26
- package/src/core/team.js +0 -0
- package/src/export/html-snapshot.js +28 -1
- package/src/export/menubar.js +21 -0
- package/src/export/receipt-card.js +210 -0
- package/src/export/week-card.js +185 -0
- package/src/providers/mock/index.js +383 -52
- package/src/providers/openai/index.js +31 -1
- package/src/providers/otel/index.js +656 -0
- package/src/server/routes/annotations.js +42 -0
- package/src/server/routes/cache-health.js +95 -0
- package/src/server/routes/index.js +54 -0
- package/src/server/routes/session.js +157 -0
- package/src/server/server.js +47 -1
- package/src/ui/app.js +541 -308
- package/src/ui/charts.js +95 -0
- package/src/ui/first-run.js +144 -0
- package/src/ui/index.html +4 -1
- package/src/ui/palette.js +335 -0
- package/src/ui/styles/anatomy.css +117 -0
- package/src/ui/styles/annotations.css +40 -0
- package/src/ui/styles/branches.css +99 -0
- package/src/ui/styles/cache.css +6 -0
- package/src/ui/styles/first-run.css +31 -0
- package/src/ui/styles/live.css +100 -0
- package/src/ui/styles/palette.css +85 -0
- package/src/ui/styles/rhythm.css +8 -0
- package/src/ui/styles/whatif.css +55 -0
- package/src/ui/styles.css +303 -196
- package/src/ui/views/anatomy.js +567 -0
- package/src/ui/views/annotations.js +121 -0
- package/src/ui/views/branches.js +304 -0
- package/src/ui/views/cache.js +232 -0
- package/src/ui/views/index.js +85 -0
- package/src/ui/views/live.js +683 -0
- package/src/ui/views/rhythm.js +206 -0
- package/src/ui/views/whatif.js +196 -0
package/src/ui/app.js
CHANGED
|
@@ -11,11 +11,16 @@ import { indexCube, filterCube } from '../analytics/aggregate.js';
|
|
|
11
11
|
import { calculateDimensionSeries } from '../analytics/dimensions.js';
|
|
12
12
|
import { compact, int, usd, pct, signedPct, shortDate, longDate, hourLabel, hourWindow, relativeTime, humanDuration, countdown, DOW } from '../core/units.js';
|
|
13
13
|
import { INTERFACE_ORDER } from '../core/schema.js';
|
|
14
|
+
import { renderReceiptMarkdown } from '../analytics/receipt.js';
|
|
14
15
|
import {
|
|
15
16
|
el, svg, timeSeries, columns, hbars, donut, compositionBar, calendarHeatmap,
|
|
16
17
|
matrix, scatter, sparkline, legend, table, miniBar, tooltip, observeWidth,
|
|
17
18
|
ColorScale, SERIES_VARS, OTHER_COLOR, scaleLegend,
|
|
18
19
|
} from './charts.js';
|
|
20
|
+
import * as charts from './charts.js';
|
|
21
|
+
import { VIEWS } from './views/index.js';
|
|
22
|
+
import { mountPalette } from './palette.js';
|
|
23
|
+
import { maybeShowFirstRun } from './first-run.js';
|
|
19
24
|
|
|
20
25
|
const SNAPSHOT = typeof window !== 'undefined' && !!window.__TOKENFLOW_BUNDLE__;
|
|
21
26
|
|
|
@@ -55,22 +60,73 @@ const COMP_COLORS = {
|
|
|
55
60
|
cacheWrite: 'var(--series-4)',
|
|
56
61
|
};
|
|
57
62
|
|
|
63
|
+
/**
|
|
64
|
+
* The tabs app.js renders itself, as `[id, label, order]`.
|
|
65
|
+
*
|
|
66
|
+
* Orders are spaced by 10 so a view registered in ./views/index.js can slot
|
|
67
|
+
* anywhere without renumbering anything. 30 is deliberately absent: the Live
|
|
68
|
+
* tab is a registered view and claims it from the registry.
|
|
69
|
+
*/
|
|
58
70
|
const TABS = [
|
|
59
|
-
['overview', 'Overview'],
|
|
60
|
-
['
|
|
61
|
-
['providers', 'Providers'],
|
|
62
|
-
['models', 'Models'],
|
|
63
|
-
['interfaces', 'Interfaces'],
|
|
64
|
-
['time', 'Time patterns'],
|
|
65
|
-
['peaks', 'Peaks'],
|
|
66
|
-
['efficiency', 'Efficiency'],
|
|
67
|
-
['cost', 'Cost'],
|
|
68
|
-
['productivity', 'Productivity'],
|
|
69
|
-
['compare', 'Compare'],
|
|
70
|
-
['explorer', 'Data explorer'],
|
|
71
|
-
['health', 'Data health'],
|
|
71
|
+
['overview', 'Overview', 10],
|
|
72
|
+
['receipts', 'Receipts', 20],
|
|
73
|
+
['providers', 'Providers', 40],
|
|
74
|
+
['models', 'Models', 50],
|
|
75
|
+
['interfaces', 'Interfaces', 60],
|
|
76
|
+
['time', 'Time patterns', 70],
|
|
77
|
+
['peaks', 'Peaks', 80],
|
|
78
|
+
['efficiency', 'Efficiency', 90],
|
|
79
|
+
['cost', 'Cost', 100],
|
|
80
|
+
['productivity', 'Productivity', 110],
|
|
81
|
+
['compare', 'Compare', 120],
|
|
82
|
+
['explorer', 'Data explorer', 130],
|
|
83
|
+
['health', 'Data health', 140],
|
|
72
84
|
];
|
|
73
85
|
|
|
86
|
+
const BUILTIN_TAB_IDS = new Set(TABS.map(([id]) => id));
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Registered views that are safe to mount: a unique id that does not collide
|
|
90
|
+
* with a built-in tab, and the three required exports.
|
|
91
|
+
*
|
|
92
|
+
* A malformed entry is dropped with a console message rather than allowed to
|
|
93
|
+
* break every other tab. Several people add views to the same registry, and one
|
|
94
|
+
* bad module must not take the dashboard down with it.
|
|
95
|
+
*
|
|
96
|
+
* @returns {object[]}
|
|
97
|
+
*/
|
|
98
|
+
function registeredViews() {
|
|
99
|
+
const seen = new Set();
|
|
100
|
+
const out = [];
|
|
101
|
+
for (const v of VIEWS) {
|
|
102
|
+
const where = v && v.id ? `view "${v.id}"` : 'a view module';
|
|
103
|
+
if (!v || typeof v.id !== 'string' || !v.id) { console.error(`registry: ${where} has no id — skipped`); continue; }
|
|
104
|
+
if (typeof v.view !== 'function' || typeof v.label !== 'string' || typeof v.order !== 'number') {
|
|
105
|
+
console.error(`registry: ${where} needs label, order and view() — skipped`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (BUILTIN_TAB_IDS.has(v.id) || seen.has(v.id)) { console.error(`registry: ${where} duplicates an existing tab id — skipped`); continue; }
|
|
109
|
+
seen.add(v.id);
|
|
110
|
+
out.push(v);
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Built-in tabs and registered views merged into one ordered tab list.
|
|
117
|
+
* @returns {{id:string,label:string,order:number,module:object|null}[]}
|
|
118
|
+
*/
|
|
119
|
+
function allTabs() {
|
|
120
|
+
const builtin = TABS.map(([id, label, order]) => ({ id, label, order, module: null }));
|
|
121
|
+
const registered = registeredViews().map((v) => ({ id: v.id, label: v.label, order: v.order, module: v }));
|
|
122
|
+
return [...builtin, ...registered].sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The registered module owning a tab id, or null for a built-in tab. */
|
|
126
|
+
function viewModule(id) {
|
|
127
|
+
return allTabs().find((t) => t.id === id)?.module || null;
|
|
128
|
+
}
|
|
129
|
+
|
|
74
130
|
/**
|
|
75
131
|
* Skins restyle the room; they never restyle the data. The categorical series
|
|
76
132
|
* steps live in the mode (dark/light) and were validated against every skin's
|
|
@@ -84,6 +140,177 @@ export const SKINS = [
|
|
|
84
140
|
{ id: 'editorial', name: 'Editorial', note: 'Warm charcoal, serif figures' },
|
|
85
141
|
];
|
|
86
142
|
|
|
143
|
+
// =========================================================== view registry ==
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* One `<link>` per registered view's stylesheet, injected once.
|
|
147
|
+
*
|
|
148
|
+
* Development only. A saved snapshot has no server to fetch src/ from, so the
|
|
149
|
+
* exporter inlines the same files instead — see src/export/html-snapshot.js.
|
|
150
|
+
*/
|
|
151
|
+
let viewStylesInjected = false;
|
|
152
|
+
function injectViewStyles() {
|
|
153
|
+
if (SNAPSHOT || viewStylesInjected) return;
|
|
154
|
+
viewStylesInjected = true;
|
|
155
|
+
const already = new Set();
|
|
156
|
+
document.querySelectorAll('link[rel="stylesheet"]').forEach((l) => already.add(l.getAttribute('href')));
|
|
157
|
+
for (const v of registeredViews()) {
|
|
158
|
+
if (!v.css) continue;
|
|
159
|
+
const href = '/src/ui/' + String(v.css).replace(/^\.?\//, '');
|
|
160
|
+
if (already.has(href)) continue;
|
|
161
|
+
already.add(href);
|
|
162
|
+
document.head.appendChild(el('link', { rel: 'stylesheet', href }));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The stylesheets for palette.js and first-run.js: not registered views (they
|
|
168
|
+
* are app.js features, not tabs), so injectViewStyles() never sees them. They
|
|
169
|
+
* still live under src/ui/styles/, which means html-snapshot.js's collectCss
|
|
170
|
+
* inlines them into an offline snapshot for free — this function only has to
|
|
171
|
+
* cover the dev server, where nothing else links them.
|
|
172
|
+
*
|
|
173
|
+
* A static `<link>` in index.html would do the same job in dev, but the
|
|
174
|
+
* snapshot's `<link>` removal is a single, non-global replace (see
|
|
175
|
+
* html-snapshot.js): a second `<link>` there would survive into the saved
|
|
176
|
+
* file and fail to load from file://. Injecting by script, the same way
|
|
177
|
+
* injectViewStyles() does, avoids that entirely.
|
|
178
|
+
*/
|
|
179
|
+
const OWN_STYLES = ['./styles/palette.css', './styles/first-run.css'];
|
|
180
|
+
let ownStylesInjected = false;
|
|
181
|
+
function injectOwnStyles() {
|
|
182
|
+
if (SNAPSHOT || ownStylesInjected) return;
|
|
183
|
+
ownStylesInjected = true;
|
|
184
|
+
for (const css of OWN_STYLES) {
|
|
185
|
+
const href = '/src/ui/' + css.replace(/^\.?\//, '');
|
|
186
|
+
document.head.appendChild(el('link', { rel: 'stylesheet', href }));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Set once boot() mounts it; the chip's click handler is the only other
|
|
192
|
+
* caller. Declared here — before `boot().catch(...)` is invoked below — and
|
|
193
|
+
* NOT after boot()'s own definition: in a snapshot, boot() never hits a real
|
|
194
|
+
* `await` (the bundle is already on `window`, so the SNAPSHOT branch of the
|
|
195
|
+
* ternary never evaluates the `await` branch), so it runs start to finish
|
|
196
|
+
* synchronously in one go. A `let` declared later in the file would still be
|
|
197
|
+
* in its temporal dead zone at that point, exactly like `viewStylesInjected`
|
|
198
|
+
* had to be declared before boot() for the same reason.
|
|
199
|
+
*/
|
|
200
|
+
let palette = null;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Intervals a view asked for, in two lifetimes: one registered from `onEnter`
|
|
204
|
+
* lives until the view is left, one registered from `view()` only until the
|
|
205
|
+
* next render — because that render runs `view()` again and it would otherwise
|
|
206
|
+
* stack a duplicate every time a filter changed.
|
|
207
|
+
*/
|
|
208
|
+
const viewTimers = { enter: [], render: [] };
|
|
209
|
+
/** @type {'enter'|'render'} */
|
|
210
|
+
let timerPhase = 'render';
|
|
211
|
+
let enteredTab = null;
|
|
212
|
+
|
|
213
|
+
function clearViewTimers(phase) {
|
|
214
|
+
for (const t of viewTimers[phase]) clearInterval(t);
|
|
215
|
+
viewTimers[phase] = [];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The object every registered view is given. `bundle`, `view` and `filters` are
|
|
220
|
+
* getters over live state, so a ctx held in a closure never reads a stale one.
|
|
221
|
+
*
|
|
222
|
+
* @returns {import('./views/index.js').ViewContext}
|
|
223
|
+
*/
|
|
224
|
+
function viewContext() {
|
|
225
|
+
return {
|
|
226
|
+
S,
|
|
227
|
+
get bundle() { return S.bundle; },
|
|
228
|
+
get view() { return S.view; },
|
|
229
|
+
get filters() { return S.filters; },
|
|
230
|
+
snapshot: SNAPSHOT,
|
|
231
|
+
el,
|
|
232
|
+
card,
|
|
233
|
+
chartCard,
|
|
234
|
+
btn,
|
|
235
|
+
kpi,
|
|
236
|
+
deltaChip,
|
|
237
|
+
sectionTitle,
|
|
238
|
+
emptyCard,
|
|
239
|
+
openModal,
|
|
240
|
+
closeModal,
|
|
241
|
+
drillTo,
|
|
242
|
+
fmt: {
|
|
243
|
+
compact, int, usd, pct, signedPct, shortDate, longDate,
|
|
244
|
+
hourLabel, hourWindow, relativeTime, humanDuration, countdown, DOW,
|
|
245
|
+
},
|
|
246
|
+
charts,
|
|
247
|
+
// A snapshot is a file: there is no API to call, and a view must get a
|
|
248
|
+
// plain "no data" rather than an exception it has to catch.
|
|
249
|
+
fetchJson: (path, opt) => (SNAPSHOT ? Promise.resolve(null) : fetchJson(path, opt)),
|
|
250
|
+
schedule: (fn, ms) => {
|
|
251
|
+
const t = setInterval(fn, ms);
|
|
252
|
+
viewTimers[timerPhase].push(t);
|
|
253
|
+
return t;
|
|
254
|
+
},
|
|
255
|
+
rerender: (opt = {}) => {
|
|
256
|
+
if (opt.recompute !== false) recompute();
|
|
257
|
+
render();
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The object mountPalette(ctx) is given, once, at boot.
|
|
264
|
+
*
|
|
265
|
+
* Unlike viewContext(), nothing here needs to be a getter: the palette only
|
|
266
|
+
* reads `getTabs()` (and nothing else state-shaped) at the moment it opens,
|
|
267
|
+
* never while it is closed, so a stale closure is not a risk.
|
|
268
|
+
*
|
|
269
|
+
* @returns {import('./palette.js').PaletteContext}
|
|
270
|
+
*/
|
|
271
|
+
function paletteContext() {
|
|
272
|
+
return {
|
|
273
|
+
el,
|
|
274
|
+
getTabs: () => allTabs().map((t) => ({ id: t.id, label: t.label })),
|
|
275
|
+
goToTab,
|
|
276
|
+
ranges: QUICK_RANGES.filter((r) => r.id !== 'custom'),
|
|
277
|
+
applyRange: (id) => applyRange(id),
|
|
278
|
+
skins: SKINS,
|
|
279
|
+
setSkin: (id) => { applyTheme(id, document.documentElement.dataset.mode); savePrefs(); renderShell(); render(); },
|
|
280
|
+
modes: [{ id: 'dark', label: 'Dark' }, { id: 'light', label: 'Light' }],
|
|
281
|
+
setMode: (id) => { applyTheme(document.documentElement.dataset.skin, id); savePrefs(); renderShell(); render(); },
|
|
282
|
+
canRefresh: () => !SNAPSHOT,
|
|
283
|
+
refresh: () => doRefresh(),
|
|
284
|
+
exportCsv: () => exportMenu(),
|
|
285
|
+
exportHtmlInfo: () => htmlExportInfoModal(),
|
|
286
|
+
clearFilters,
|
|
287
|
+
copyDeepLink,
|
|
288
|
+
activeTabButton: () => document.querySelector('nav.tabs button[aria-selected="true"]'),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Fire onLeave/onEnter when the active tab changes, and drop the timers the
|
|
294
|
+
* departing view owned. Called from render(), so it catches every route into a
|
|
295
|
+
* tab: the tab bar, a deep link, and the KPI tiles that jump between views.
|
|
296
|
+
*/
|
|
297
|
+
function enterTab(ctx) {
|
|
298
|
+
if (enteredTab === S.tab) return;
|
|
299
|
+
const prev = enteredTab === null ? null : viewModule(enteredTab);
|
|
300
|
+
if (prev && typeof prev.onLeave === 'function') {
|
|
301
|
+
try { prev.onLeave(ctx); } catch (err) { console.error(err); }
|
|
302
|
+
}
|
|
303
|
+
clearViewTimers('enter');
|
|
304
|
+
clearViewTimers('render');
|
|
305
|
+
enteredTab = S.tab;
|
|
306
|
+
const next = viewModule(S.tab);
|
|
307
|
+
if (next && typeof next.onEnter === 'function') {
|
|
308
|
+
timerPhase = 'enter';
|
|
309
|
+
try { next.onEnter(ctx); } catch (err) { console.error(err); }
|
|
310
|
+
timerPhase = 'render';
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
87
314
|
// ============================================================ bootstrapping ==
|
|
88
315
|
|
|
89
316
|
boot().catch((err) => {
|
|
@@ -94,8 +321,16 @@ boot().catch((err) => {
|
|
|
94
321
|
});
|
|
95
322
|
|
|
96
323
|
async function boot() {
|
|
324
|
+
// Before the bundle fetch, so each view's stylesheet loads in parallel with
|
|
325
|
+
// the data and is applied by the time anything paints.
|
|
326
|
+
injectViewStyles();
|
|
327
|
+
injectOwnStyles();
|
|
97
328
|
const prefs = loadPrefs();
|
|
98
329
|
S.bundle = SNAPSHOT ? window.__TOKENFLOW_BUNDLE__ : await fetchJson('/api/bundle');
|
|
330
|
+
// Every daily chart overlays annotations from this module-level list, not
|
|
331
|
+
// from S.bundle directly, so a marker must not wait for someone to visit
|
|
332
|
+
// the Annotations tab before it appears anywhere else.
|
|
333
|
+
charts.setAnnotations(S.bundle.annotations || []);
|
|
99
334
|
// Config supplies the default look; a choice made in the browser wins.
|
|
100
335
|
applyTheme(
|
|
101
336
|
prefs.skin || S.bundle.meta?.skin || SKINS[0].id,
|
|
@@ -105,9 +340,24 @@ async function boot() {
|
|
|
105
340
|
S.rangeId = prefs.rangeId || S.bundle.meta.defaultRange || 'all';
|
|
106
341
|
if (prefs.granularity) S.granularity = prefs.granularity;
|
|
107
342
|
if (prefs.tab) S.tab = prefs.tab;
|
|
343
|
+
// Deep links: #tab=receipts&skin=terminal&mode=light. A link wins over a
|
|
344
|
+
// remembered preference for this load only; nothing is persisted from it.
|
|
345
|
+
const link = new URLSearchParams(location.hash.replace(/^#/, ''));
|
|
346
|
+
if (link.get('tab') && allTabs().some((t) => t.id === link.get('tab'))) S.tab = link.get('tab');
|
|
347
|
+
if (link.get('skin') || link.get('mode')) {
|
|
348
|
+
applyTheme(
|
|
349
|
+
SKINS.some((s) => s.id === link.get('skin')) ? link.get('skin') : document.documentElement.dataset.skin,
|
|
350
|
+
['dark', 'light'].includes(link.get('mode')) ? link.get('mode') : document.documentElement.dataset.mode,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
108
353
|
if (S.bundle.meta?.includeOverlayDefault) S.filters.includeOverlay = true;
|
|
109
354
|
applyRange(S.rangeId, { silent: true });
|
|
110
355
|
recompute();
|
|
356
|
+
// Mounted once: the chip and the Cmd+K/Ctrl+K shortcut both open the same
|
|
357
|
+
// instance for the rest of this page's life.
|
|
358
|
+
palette = mountPalette(paletteContext());
|
|
359
|
+
const chip = document.getElementById('palette-chip');
|
|
360
|
+
if (chip) chip.addEventListener('click', () => palette.open());
|
|
111
361
|
renderShell();
|
|
112
362
|
render();
|
|
113
363
|
// Handed over from a saved snapshot's "Refresh & open live" button. The
|
|
@@ -117,6 +367,16 @@ async function boot() {
|
|
|
117
367
|
doRefresh();
|
|
118
368
|
}
|
|
119
369
|
ensureLiveLoop();
|
|
370
|
+
// Never in a snapshot: a saved file has no /api/providers to ask, and
|
|
371
|
+
// nothing new to report since it was written.
|
|
372
|
+
if (!SNAPSHOT) {
|
|
373
|
+
maybeShowFirstRun({
|
|
374
|
+
el,
|
|
375
|
+
appVersion: S.bundle.meta.appVersion,
|
|
376
|
+
sources: S.bundle.meta.sources,
|
|
377
|
+
fetchProviders: () => fetch('/api/providers').then((r) => (r.ok ? r.json() : null)).catch(() => null),
|
|
378
|
+
});
|
|
379
|
+
}
|
|
120
380
|
}
|
|
121
381
|
|
|
122
382
|
// ============================================================ live polling ==
|
|
@@ -128,6 +388,9 @@ let liveTimer = null;
|
|
|
128
388
|
* what makes the header pill and the Live tab's watcher strip current without
|
|
129
389
|
* any user action. In a static snapshot there is no server: the loop never
|
|
130
390
|
* starts, and the Live tab renders purely from the bundle.
|
|
391
|
+
*
|
|
392
|
+
* boot() starts it whichever tab is open, so the pill works everywhere and the
|
|
393
|
+
* Live view (now src/ui/views/live.js) does not have to ask for it.
|
|
131
394
|
*/
|
|
132
395
|
function ensureLiveLoop() {
|
|
133
396
|
if (SNAPSHOT || liveTimer) return;
|
|
@@ -191,7 +454,6 @@ function recompute() {
|
|
|
191
454
|
savePrefs();
|
|
192
455
|
}
|
|
193
456
|
|
|
194
|
-
// ==================================================================== theme ==
|
|
195
457
|
|
|
196
458
|
function applyTheme(skin, mode) {
|
|
197
459
|
const r = document.documentElement;
|
|
@@ -251,6 +513,28 @@ function themePicker() {
|
|
|
251
513
|
|
|
252
514
|
// ==================================================================== shell ==
|
|
253
515
|
|
|
516
|
+
/**
|
|
517
|
+
* Switch the active tab. The tab bar's own buttons and the command palette's
|
|
518
|
+
* "Go to tab" commands both funnel through here, so a deep link built from
|
|
519
|
+
* one behaves exactly like a deep link built from the other.
|
|
520
|
+
* @param {string} id
|
|
521
|
+
*/
|
|
522
|
+
function goToTab(id) {
|
|
523
|
+
if (S.tab !== id) S.viewEntered = false; // a view change earns the one entry stagger
|
|
524
|
+
S.tab = id; savePrefs(); renderShell(); render();
|
|
525
|
+
// Keep the URL shareable without adding history entries for every click.
|
|
526
|
+
try { history.replaceState(null, '', `#${currentDeepLinkHash()}`); } catch { /* file:// or a sandboxed frame may refuse; the tab still switched */ }
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** The `tab=…&skin=…&mode=…` hash boot() parses back. Shared by goToTab's address-bar update and copyDeepLink, so the two never disagree on shape. */
|
|
530
|
+
function currentDeepLinkHash() {
|
|
531
|
+
return new URLSearchParams({
|
|
532
|
+
tab: S.tab,
|
|
533
|
+
skin: document.documentElement.dataset.skin,
|
|
534
|
+
mode: document.documentElement.dataset.mode,
|
|
535
|
+
}).toString();
|
|
536
|
+
}
|
|
537
|
+
|
|
254
538
|
function renderShell() {
|
|
255
539
|
const acts = document.getElementById('header-actions');
|
|
256
540
|
acts.textContent = '';
|
|
@@ -263,9 +547,9 @@ function renderShell() {
|
|
|
263
547
|
|
|
264
548
|
const tabs = document.getElementById('tabs');
|
|
265
549
|
tabs.textContent = '';
|
|
266
|
-
for (const
|
|
550
|
+
for (const { id, label } of allTabs()) {
|
|
267
551
|
const b = el('button', { role: 'tab', text: label, 'aria-selected': String(S.tab === id) });
|
|
268
|
-
b.addEventListener('click', () =>
|
|
552
|
+
b.addEventListener('click', () => goToTab(id));
|
|
269
553
|
tabs.appendChild(b);
|
|
270
554
|
}
|
|
271
555
|
|
|
@@ -285,11 +569,31 @@ function render() {
|
|
|
285
569
|
renderBanners();
|
|
286
570
|
renderFilters();
|
|
287
571
|
renderCrumbs();
|
|
572
|
+
const ctx = viewContext();
|
|
573
|
+
enterTab(ctx);
|
|
288
574
|
const host = document.getElementById('view');
|
|
289
575
|
host.textContent = '';
|
|
576
|
+
// Cards stagger in only when the VIEW changes. A filter change re-renders the
|
|
577
|
+
// same view and must feel instant, so it does not re-run the entrance.
|
|
578
|
+
host.classList.toggle('view-enter', S.viewEntered === false);
|
|
579
|
+
S.viewEntered = true;
|
|
580
|
+
const mod = viewModule(S.tab);
|
|
581
|
+
if (mod) {
|
|
582
|
+
clearViewTimers('render');
|
|
583
|
+
// One broken registered view must cost its own tab, not the whole page.
|
|
584
|
+
try {
|
|
585
|
+
host.appendChild(mod.view(ctx));
|
|
586
|
+
} catch (err) {
|
|
587
|
+
console.error(err);
|
|
588
|
+
host.appendChild(el('div', { class: 'banner' }, [
|
|
589
|
+
el('span', { text: `The ${mod.label} tab could not render: ${err.message}` }),
|
|
590
|
+
]));
|
|
591
|
+
}
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
290
594
|
const fn = {
|
|
291
595
|
overview: viewOverview,
|
|
292
|
-
|
|
596
|
+
receipts: viewReceipts,
|
|
293
597
|
providers: () => viewDimension('provider', 'Provider intelligence'),
|
|
294
598
|
models: viewModels,
|
|
295
599
|
interfaces: viewInterfaces,
|
|
@@ -437,14 +741,25 @@ function renderFilters() {
|
|
|
437
741
|
box.appendChild(el('div', { class: 'grp' }, [el('label', { class: 'fld' }, [el('span', { text: 'Scope' }), toggles])]));
|
|
438
742
|
|
|
439
743
|
if (activeFilterCount()) {
|
|
440
|
-
box.appendChild(btn(`Clear ${activeFilterCount()} filter(s)`,
|
|
441
|
-
S.filters = { ...EMPTY_FILTERS, includeOverlay: S.filters.includeOverlay, includeActivity: S.filters.includeActivity };
|
|
442
|
-
S.drillDate = null;
|
|
443
|
-
applyRange('all');
|
|
444
|
-
}, 'ghost sm'));
|
|
744
|
+
box.appendChild(btn(`Clear ${activeFilterCount()} filter(s)`, clearFilters, 'ghost sm'));
|
|
445
745
|
}
|
|
446
746
|
}
|
|
447
747
|
|
|
748
|
+
/**
|
|
749
|
+
* Reset every filter and the date range to "all". The overlay/activity scope
|
|
750
|
+
* toggles are preserved — they widen or narrow what counts as data, not a
|
|
751
|
+
* filter on it, so "Clear filters" leaving them alone matches what the
|
|
752
|
+
* "Clear N filter(s)" button already only counted as filters.
|
|
753
|
+
*
|
|
754
|
+
* Shared by the filter panel's own button, the "All data" breadcrumb, and the
|
|
755
|
+
* command palette's "Clear filters", so the three cannot drift apart.
|
|
756
|
+
*/
|
|
757
|
+
function clearFilters() {
|
|
758
|
+
S.filters = { ...EMPTY_FILTERS, includeOverlay: S.filters.includeOverlay, includeActivity: S.filters.includeActivity };
|
|
759
|
+
S.drillDate = null;
|
|
760
|
+
applyRange('all');
|
|
761
|
+
}
|
|
762
|
+
|
|
448
763
|
function activeFilterCount() {
|
|
449
764
|
let n = 0;
|
|
450
765
|
for (const k of ['provider', 'model', 'model_family', 'client', 'interface', 'gateway', 'project', 'repository', 'service_tier']) {
|
|
@@ -532,7 +847,7 @@ function multi(label, options, selected, onChange) {
|
|
|
532
847
|
function renderCrumbs() {
|
|
533
848
|
const box = document.getElementById('crumbs');
|
|
534
849
|
box.textContent = '';
|
|
535
|
-
const parts = [{ label: 'All data', reset:
|
|
850
|
+
const parts = [{ label: 'All data', reset: clearFilters }];
|
|
536
851
|
for (const [key, label] of [['provider', 'Provider'], ['model', 'Model'], ['client', 'Client'], ['interface', 'Interface'], ['project', 'Project'], ['gateway', 'Gateway'], ['service_tier', 'Tier']]) {
|
|
537
852
|
const v = S.filters[key];
|
|
538
853
|
if (v && v.length) {
|
|
@@ -660,6 +975,8 @@ function emptyCard(text, detail) {
|
|
|
660
975
|
function viewOverview() {
|
|
661
976
|
const v = S.view;
|
|
662
977
|
const root = el('div', { class: 'grid' });
|
|
978
|
+
const story = storyStrip();
|
|
979
|
+
if (story) root.appendChild(story);
|
|
663
980
|
root.appendChild(kpiRow());
|
|
664
981
|
|
|
665
982
|
const gran = el('div', { class: 'chips' });
|
|
@@ -995,6 +1312,160 @@ function dayDetailBox(d) {
|
|
|
995
1312
|
return box;
|
|
996
1313
|
}
|
|
997
1314
|
|
|
1315
|
+
/**
|
|
1316
|
+
* The three sentences that matter for this slice, before any chart. Each
|
|
1317
|
+
* insight already carries its own condition and weight (insights.js); this
|
|
1318
|
+
* only picks the top three and sets the numbers in a heavier face so the eye
|
|
1319
|
+
* lands on them first. Nothing here is computed.
|
|
1320
|
+
*/
|
|
1321
|
+
function storyStrip() {
|
|
1322
|
+
const ins = (S.view.insights || []).filter((i) => i.kind !== 'empty' && i.kind !== 'quality');
|
|
1323
|
+
if (ins.length < 2) return null;
|
|
1324
|
+
const top = [...ins].sort((a, b) => (b.weight || 0) - (a.weight || 0)).slice(0, 3);
|
|
1325
|
+
const strip = el('div', { class: 'story' });
|
|
1326
|
+
for (const i of top) {
|
|
1327
|
+
const p = el('p', { class: 'story-text' });
|
|
1328
|
+
// Numbers, money, ratios and percentages get the figure face; words stay words.
|
|
1329
|
+
// A figure is a number with its unit: "4.37M", "97.9%", "2.3×", "30 days", "$12.40", "r = 0.44".
|
|
1330
|
+
const re = /((?:\$|[+\-−]|r = )?\d+(?:[.,]\d+)*(?:\s?(?:[KMB](?![a-z])|×|x(?![a-z])|%|days?|hours?|min(?![a-z])))?)/g;
|
|
1331
|
+
let last = 0;
|
|
1332
|
+
for (const m of i.text.matchAll(re)) {
|
|
1333
|
+
if (m.index > last) p.appendChild(document.createTextNode(i.text.slice(last, m.index)));
|
|
1334
|
+
p.appendChild(el('strong', { text: m[0] }));
|
|
1335
|
+
last = m.index + m[0].length;
|
|
1336
|
+
}
|
|
1337
|
+
if (last < i.text.length) p.appendChild(document.createTextNode(i.text.slice(last)));
|
|
1338
|
+
strip.appendChild(el('div', { class: 'story-line ' + (i.kind || '') }, [
|
|
1339
|
+
el('span', { class: 'story-ico', text: i.icon || '•', 'aria-hidden': 'true' }),
|
|
1340
|
+
p,
|
|
1341
|
+
]));
|
|
1342
|
+
}
|
|
1343
|
+
return strip;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// ================================================================= receipts ==
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* Spend attributed to the unit of work: per repository, per branch. The
|
|
1350
|
+
* receipts arrive in the bundle (computed once per refresh on the server, or
|
|
1351
|
+
* baked into a snapshot), so this view works offline and never scans records
|
|
1352
|
+
* in the browser. It covers the whole store: a receipt is bounded by its
|
|
1353
|
+
* branch, not by the date filter, and the view says so.
|
|
1354
|
+
*/
|
|
1355
|
+
function viewReceipts() {
|
|
1356
|
+
const R = S.bundle.receipts;
|
|
1357
|
+
const root = el('div', { class: 'grid' });
|
|
1358
|
+
if (!R || !R.repos || !R.repos.length || !(R.totals.cost > 0)) {
|
|
1359
|
+
root.appendChild(card('Receipts', 'Spend attributed to a branch, per repository.', emptyCard(
|
|
1360
|
+
'No branch-attributed spend yet',
|
|
1361
|
+
'Receipts need sessions that recorded a git branch and a priced model. Claude Code and OpenCode sessions do; sessions on a detached HEAD are reported as unattributed.',
|
|
1362
|
+
)));
|
|
1363
|
+
return root;
|
|
1364
|
+
}
|
|
1365
|
+
const all = R.repos.flatMap((r) => r.branches.filter((b) => b.cost !== null).map((b) => ({ ...b, repo: r.repo })));
|
|
1366
|
+
const costs = all.map((b) => b.cost).sort((a, b) => a - b);
|
|
1367
|
+
const median = costs.length ? costs[Math.floor(costs.length / 2)] : null;
|
|
1368
|
+
const top = all.length ? all.reduce((a, b) => (b.cost > a.cost ? b : a)) : null;
|
|
1369
|
+
const unattributed = R.repos.reduce((a, r) => a + (r.unattributed.cost ?? 0), 0);
|
|
1370
|
+
|
|
1371
|
+
const box = el('div', { class: 'cards' });
|
|
1372
|
+
const heroCard = kpi('Attributed to a branch', pct(R.totals.attributedShare, 0), `${usd(R.totals.attributedCost)} of ${usd(R.totals.cost)} estimated`, { hero: true, title: 'Share of estimated spend whose turns ran on a named branch. The rest ran on a detached HEAD or with no branch recorded.' });
|
|
1373
|
+
heroCard.classList.add('wide');
|
|
1374
|
+
box.appendChild(heroCard);
|
|
1375
|
+
box.appendChild(kpi('Branches', int(R.totals.branches), `${int(R.repos.length)} repositor${R.repos.length === 1 ? 'y' : 'ies'}`));
|
|
1376
|
+
box.appendChild(kpi('Median branch', median !== null ? usd(median) : '—', 'half of all branches cost less'));
|
|
1377
|
+
if (top) box.appendChild(kpi('Most expensive branch', usd(top.cost), `${top.key} · ${top.repo}`, { title: `${top.sessions} sessions · ${top.turns} turns`, onClick: () => receiptDetail(R.repos.find((r) => r.repo === top.repo), top) }));
|
|
1378
|
+
box.appendChild(kpi('Unattributed', usd(unattributed), 'detached HEAD or no branch', { title: 'Reported, never guessed: these turns cannot be tied to a unit of work.' }));
|
|
1379
|
+
root.appendChild(box);
|
|
1380
|
+
|
|
1381
|
+
root.appendChild(el('div', { class: 'banner info' }, [el('span', {
|
|
1382
|
+
text: `Receipts cover the whole store (${int(R.totals.records)} turns, computed ${relativeTime(R.computedAt)}). The date and provider filters above do not apply here: a receipt is bounded by its branch, not by a window. Pull-request joins run from the CLI: tokenflow receipt --repo <path> --gh.`,
|
|
1383
|
+
})]));
|
|
1384
|
+
|
|
1385
|
+
for (const repo of R.repos.slice(0, 12)) {
|
|
1386
|
+
const maxCost = Math.max(...repo.branches.map((b) => b.cost ?? 0), 0);
|
|
1387
|
+
const rows = repo.branches.slice(0, 15);
|
|
1388
|
+
const columns = [
|
|
1389
|
+
{ key: 'key', label: 'Branch', text: true, value: (b) => el('span', { class: 'branch-cell' }, [
|
|
1390
|
+
miniBar(maxCost ? (b.cost ?? 0) / maxCost : 0, 'var(--seq-5)'),
|
|
1391
|
+
el('span', { class: 'branch-name', text: b.key }),
|
|
1392
|
+
b.longLived ? el('span', { class: 'badge', text: 'long-lived', title: 'A branch that lives forever: this is a receipt for a period of work on it, not for one change.' }) : null,
|
|
1393
|
+
]) },
|
|
1394
|
+
{ key: 'cost', label: 'Spend', value: (b) => (b.cost === null ? null : usd(b.cost)), title: 'Estimated from the price table; unpriced turns excluded' },
|
|
1395
|
+
{ key: 'contextShare', label: 'Context', value: (b) => (b.contextShare === null ? null : pct(b.contextShare, 0)), title: 'Share of spend that paid to re-send earlier context (cache reads + writes)' },
|
|
1396
|
+
{ key: 'sessions', label: 'Sessions' },
|
|
1397
|
+
{ key: 'turns', label: 'Turns' },
|
|
1398
|
+
{ key: 'subagentShare', label: 'Subagent', value: (b) => (b.subagentTurns ? pct(b.subagentShare, 0) : '0%') },
|
|
1399
|
+
{ key: 'vsMedian', label: '× median', value: (b) => (b.vsMedian === null ? null : `${b.vsMedian >= 10 ? Math.round(b.vsMedian) : b.vsMedian.toFixed(1)}×`), title: 'This branch against the median priced branch in the same repository' },
|
|
1400
|
+
];
|
|
1401
|
+
const tbl = table(columns, rows, { onRowClick: (b) => receiptDetail(repo, b), emptyText: 'No attributed branches in this repository.' });
|
|
1402
|
+
const hint = [
|
|
1403
|
+
`${usd(repo.cost)} · ${int(repo.branches.length)} branch${repo.branches.length === 1 ? '' : 'es'}`,
|
|
1404
|
+
repo.medianBranchCost !== null ? `median ${usd(repo.medianBranchCost)}` : null,
|
|
1405
|
+
repo.unattributed.turns ? `unattributed ${usd(repo.unattributed.cost)} across ${int(repo.unattributed.sessions)} session${repo.unattributed.sessions === 1 ? '' : 's'}` : null,
|
|
1406
|
+
repo.branches.length > rows.length ? `showing the top ${rows.length}` : null,
|
|
1407
|
+
].filter(Boolean).join(' · ');
|
|
1408
|
+
root.appendChild(card(repo.repo, hint, tbl));
|
|
1409
|
+
}
|
|
1410
|
+
if (R.repos.length > 12) root.appendChild(el('p', { class: 'muted', text: `${R.repos.length - 12} smaller repositories not shown. The CLI lists every one: tokenflow receipt.` }));
|
|
1411
|
+
return root;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/** One receipt, as a card with a copy-as-PR-comment action. */
|
|
1415
|
+
function receiptDetail(repo, b) {
|
|
1416
|
+
const body = el('div', { class: 'receipt' });
|
|
1417
|
+
const head = el('div', { class: 'receipt-head' });
|
|
1418
|
+
head.appendChild(el('div', { class: 'receipt-kicker', text: repo ? repo.repo : '' }));
|
|
1419
|
+
head.appendChild(el('div', { class: 'receipt-branch' }, [
|
|
1420
|
+
document.createTextNode(b.key),
|
|
1421
|
+
b.longLived ? el('span', { class: 'badge', style: 'margin-left:8px;vertical-align:middle', text: 'long-lived branch · a period of work, not one change' }) : null,
|
|
1422
|
+
]));
|
|
1423
|
+
body.appendChild(head);
|
|
1424
|
+
|
|
1425
|
+
body.appendChild(el('div', { class: 'receipt-total' }, [
|
|
1426
|
+
el('span', { class: 'receipt-amount', text: b.cost === null ? '—' : usd(b.cost) }),
|
|
1427
|
+
el('span', { class: 'receipt-amount-sub', text: b.cost === null ? 'no priced turns' : 'estimated spend on this branch' }),
|
|
1428
|
+
]));
|
|
1429
|
+
|
|
1430
|
+
if (b.contextShare !== null) {
|
|
1431
|
+
const bar = el('div', { class: 'split-bar', role: 'img', 'aria-label': `${pct(b.contextShare, 0)} re-sent context, ${pct(1 - b.contextShare, 0)} fresh work` });
|
|
1432
|
+
const ctx = el('i', { class: 'seg ctx', title: 'Context: cache reads and writes — the cost of re-sending the conversation so far' });
|
|
1433
|
+
ctx.style.width = `${Math.round(b.contextShare * 1000) / 10}%`;
|
|
1434
|
+
const work = el('i', { class: 'seg work', title: 'Work: fresh input and generated output' });
|
|
1435
|
+
bar.appendChild(ctx);
|
|
1436
|
+
bar.appendChild(work);
|
|
1437
|
+
body.appendChild(bar);
|
|
1438
|
+
body.appendChild(el('div', { class: 'split-legend' }, [
|
|
1439
|
+
el('span', {}, [el('i', { class: 'sw ctx' }), document.createTextNode(` ${pct(b.contextShare, 0)} re-sent context`)]),
|
|
1440
|
+
el('span', {}, [el('i', { class: 'sw work' }), document.createTextNode(` ${pct(1 - b.contextShare, 0)} fresh work`)]),
|
|
1441
|
+
]));
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
const dl = el('dl', { class: 'kv receipt-kv' });
|
|
1445
|
+
const row = (k, v) => { dl.appendChild(el('dt', { text: k })); dl.appendChild(el('dd', { text: v })); };
|
|
1446
|
+
row('Sessions · turns', `${int(b.sessions)} · ${int(b.turns)}${b.subagentTurns ? ` (${pct(b.subagentShare, 0)} subagent)` : ''}`);
|
|
1447
|
+
if (b.models.length) row('Models', b.models.slice(0, 3).map((m) => `${m.model} ${pct(m.share, 0)}`).join(', ') + (b.models.length > 3 ? ', …' : ''));
|
|
1448
|
+
if (b.vsMedian !== null) row('vs repository median', `${b.vsMedian >= 10 ? Math.round(b.vsMedian) : b.vsMedian.toFixed(1)}×`);
|
|
1449
|
+
if (b.maxPrompt !== null) row('Largest prompt', `${compact(b.maxPrompt)} tokens`);
|
|
1450
|
+
if (b.first && b.last) row('Window', `${shortDate(b.first.slice(0, 10))} → ${shortDate(b.last.slice(0, 10))}`);
|
|
1451
|
+
if (b.unpricedTurns) row('Unpriced turns', `${int(b.unpricedTurns)} (not in the total)`);
|
|
1452
|
+
body.appendChild(dl);
|
|
1453
|
+
body.appendChild(el('p', { class: 'receipt-foot', text: `Estimated locally from the session logs on this machine with price table ${S.bundle.meta.pricingTableVersion}. No prompt or code content was read. Bounded by branch, not by a pull request — join PRs from the CLI with tokenflow receipt --gh.` }));
|
|
1454
|
+
|
|
1455
|
+
const copy = btn('Copy as PR comment', async () => {
|
|
1456
|
+
const md = renderReceiptMarkdown(b, { repo: repo ? repo.repo : undefined, pricingVersion: S.bundle.meta.pricingTableVersion });
|
|
1457
|
+
try {
|
|
1458
|
+
await navigator.clipboard.writeText(md);
|
|
1459
|
+
copy.textContent = 'Copied ✓';
|
|
1460
|
+
setTimeout(() => { copy.textContent = 'Copy as PR comment'; }, 1400);
|
|
1461
|
+
} catch {
|
|
1462
|
+
// The clipboard API needs a secure context or a user gesture the browser accepted; fall back to showing the text.
|
|
1463
|
+
openModal('Receipt (markdown)', el('pre', { class: 'mono', text: md }));
|
|
1464
|
+
}
|
|
1465
|
+
}, 'primary');
|
|
1466
|
+
openModal('AI cost receipt', body, [copy]);
|
|
1467
|
+
}
|
|
1468
|
+
|
|
998
1469
|
function insightsCard() {
|
|
999
1470
|
const body = el('div', { class: 'insights' });
|
|
1000
1471
|
for (const i of S.view.insights) {
|
|
@@ -1969,6 +2440,7 @@ async function doRefresh() {
|
|
|
1969
2440
|
const keep = { ...S.filters };
|
|
1970
2441
|
const keepRange = S.rangeId;
|
|
1971
2442
|
S.bundle = await fetchJson('/api/bundle');
|
|
2443
|
+
charts.setAnnotations(S.bundle.annotations || []);
|
|
1972
2444
|
S.filters = keep;
|
|
1973
2445
|
if (keepRange !== 'custom') applyRange(keepRange, { silent: true });
|
|
1974
2446
|
recompute();
|
|
@@ -2069,6 +2541,52 @@ function downloadCsv(name, cols, rows) {
|
|
|
2069
2541
|
a.remove();
|
|
2070
2542
|
}
|
|
2071
2543
|
|
|
2544
|
+
/**
|
|
2545
|
+
* "Export HTML snapshot" (command palette). Writing a self-contained offline
|
|
2546
|
+
* file is a filesystem operation, and every route this server exposes is
|
|
2547
|
+
* read-only or config-writing — there is no `/api/export.html` to call from
|
|
2548
|
+
* here. This states the one CLI command that does it, the same way
|
|
2549
|
+
* freshnessBar() states `npm start` when no live dashboard answers.
|
|
2550
|
+
*/
|
|
2551
|
+
function htmlExportInfoModal() {
|
|
2552
|
+
const body = el('div');
|
|
2553
|
+
body.appendChild(el('p', { class: 'hint', text: 'This writes one self-contained HTML file: the analytics, the charts and the data bundle, all inlined. It opens later from a file:// URL with no server and no network.' }));
|
|
2554
|
+
const row = el('div', { style: 'display:flex;gap:8px;align-items:center' });
|
|
2555
|
+
row.appendChild(el('code', { text: 'tokenflow export --html' }));
|
|
2556
|
+
const copy = btn('Copy', async () => {
|
|
2557
|
+
try { await navigator.clipboard.writeText('tokenflow export --html'); copy.textContent = 'Copied'; } catch { copy.textContent = 'tokenflow export --html'; }
|
|
2558
|
+
}, 'ghost sm');
|
|
2559
|
+
row.appendChild(copy);
|
|
2560
|
+
body.appendChild(row);
|
|
2561
|
+
openModal('Export HTML snapshot', body);
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
/** "Copy deep link" (command palette): the current tab, skin and mode, via the same hash shape goToTab() writes to the address bar. */
|
|
2565
|
+
async function copyDeepLink() {
|
|
2566
|
+
// Built from location.href, not location.origin + location.pathname:
|
|
2567
|
+
// Chromium (and others) return the literal string "null" for `origin` on a
|
|
2568
|
+
// file:// page, which would silently produce a "null/…" link for anyone
|
|
2569
|
+
// copying a deep link out of a saved snapshot.
|
|
2570
|
+
const u = new URL(location.href);
|
|
2571
|
+
u.search = ''; // drop a stale ?refresh=1 from a snapshot's "Refresh & open live" handoff
|
|
2572
|
+
u.hash = currentDeepLinkHash();
|
|
2573
|
+
const url = u.href;
|
|
2574
|
+
let ok = true;
|
|
2575
|
+
try {
|
|
2576
|
+
await navigator.clipboard.writeText(url);
|
|
2577
|
+
} catch {
|
|
2578
|
+
// No Clipboard permission, or an insecure context (file://): the banner
|
|
2579
|
+
// still shows the link so it can be copied by hand.
|
|
2580
|
+
ok = false;
|
|
2581
|
+
}
|
|
2582
|
+
const box = document.getElementById('banners');
|
|
2583
|
+
const status = el('div', { class: 'banner info' }, [el('span', {
|
|
2584
|
+
text: ok ? `Copied: ${url}` : `Could not copy automatically. Deep link: ${url}`,
|
|
2585
|
+
})]);
|
|
2586
|
+
box.prepend(status);
|
|
2587
|
+
setTimeout(() => status.remove(), 9000);
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2072
2590
|
// ================================================================== pricing ==
|
|
2073
2591
|
|
|
2074
2592
|
function pricingModal() {
|
|
@@ -2186,288 +2704,3 @@ window.addEventListener('keydown', (ev) => {
|
|
|
2186
2704
|
if (ev.key === 'r' && (ev.metaKey || ev.ctrlKey) === false && ev.target === document.body && !SNAPSHOT) doRefresh();
|
|
2187
2705
|
if (ev.key === 'Escape') tooltip.hide();
|
|
2188
2706
|
});
|
|
2189
|
-
|
|
2190
|
-
// ============================================================== live view ==
|
|
2191
|
-
|
|
2192
|
-
const SEV = {
|
|
2193
|
-
high: { label: 'high', cls: 'sev-high' },
|
|
2194
|
-
warn: { label: 'watch', cls: 'sev-warn' },
|
|
2195
|
-
info: { label: 'info', cls: 'sev-info' },
|
|
2196
|
-
};
|
|
2197
|
-
|
|
2198
|
-
function liveWatcherCard() {
|
|
2199
|
-
const body = el('div');
|
|
2200
|
-
const w = S.live?.watcher;
|
|
2201
|
-
if (w) {
|
|
2202
|
-
const age = S.live.freshness?.ageMs;
|
|
2203
|
-
body.appendChild(el('div', { class: 'chips', style: 'padding:10px 14px' }, [
|
|
2204
|
-
el('span', { class: 'badge ok', text: `● watcher running · pid ${w.pid}` }),
|
|
2205
|
-
el('span', { class: 'muted', text: `every ${w.intervalSeconds ?? '?'}s · ${int(w.cycles)} cycles` + (age != null ? ` · snapshot ${relativeTime(S.live.generatedAt)}` : '') }),
|
|
2206
|
-
]));
|
|
2207
|
-
} else {
|
|
2208
|
-
const c = el('code', { text: 'tokenflow watch', style: 'font-size:12px' });
|
|
2209
|
-
body.appendChild(el('div', { class: 'chips', style: 'padding:10px 14px;gap:8px;flex-wrap:wrap' }, [
|
|
2210
|
-
el('span', { class: 'badge stale', text: '○ watcher not running' }),
|
|
2211
|
-
el('span', { class: 'muted', text: 'run ' }),
|
|
2212
|
-
c,
|
|
2213
|
-
el('span', { class: 'muted', text: ' to keep the status file, menu bar and alerts current' }),
|
|
2214
|
-
]));
|
|
2215
|
-
}
|
|
2216
|
-
return card('Real-time engine', 'The watcher refreshes incrementally and rewrites data/status.json after every cycle.', body);
|
|
2217
|
-
}
|
|
2218
|
-
|
|
2219
|
-
function limitRow(s) {
|
|
2220
|
-
// Past ~10× a cap, percentages stop communicating; multiples do.
|
|
2221
|
-
const pctText = s.pctUsed == null ? '—'
|
|
2222
|
-
: s.pctUsed >= 10 ? `${Math.round(s.pctUsed)}×`
|
|
2223
|
-
: `${(s.pctUsed * 100).toFixed(1)}%`;
|
|
2224
|
-
const color = s.status === 'exceeded' ? 'var(--critical)' : s.status === 'warn' ? 'var(--warning)' : 'var(--series-1)';
|
|
2225
|
-
const row = el('div', { style: 'display:flex;align-items:center;gap:12px;padding:8px 0;border-top:1px solid var(--hairline)' });
|
|
2226
|
-
const glyph = s.status === 'exceeded' ? '✗' : s.status === 'warn' ? '⚠' : '✓';
|
|
2227
|
-
const left = el('div', { style: 'min-width:220px' });
|
|
2228
|
-
left.appendChild(el('div', {}, [document.createTextNode(`${glyph} ${s.label}`), s.provider ? el('span', { class: 'muted', text: ` [${s.provider}]` }) : null]));
|
|
2229
|
-
left.appendChild(el('div', { class: 'hint', text: `${s.scope} · ${s.metric}` }));
|
|
2230
|
-
row.appendChild(left);
|
|
2231
|
-
const barWrap = el('div', { style: 'flex:1;min-width:120px' });
|
|
2232
|
-
barWrap.appendChild(miniBar(Math.max(0, Math.min(1, s.pctUsed ?? 0)), color));
|
|
2233
|
-
row.appendChild(barWrap);
|
|
2234
|
-
const right = el('div', { style: 'text-align:right;min-width:190px' });
|
|
2235
|
-
right.appendChild(el('div', { text: `${pctText} of ${compact(s.cap)}` }));
|
|
2236
|
-
const sub = [];
|
|
2237
|
-
if (s.status !== 'exceeded' && s.etaHours != null) sub.push(`ETA ${countdown(s.etaHours * 3600000)}`);
|
|
2238
|
-
if (s.resetsInMs > 0) sub.push(`resets in ${countdown(s.resetsInMs)}`);
|
|
2239
|
-
if (sub.length) right.appendChild(el('div', { class: 'hint', text: sub.join(' · ') }));
|
|
2240
|
-
row.appendChild(right);
|
|
2241
|
-
return row;
|
|
2242
|
-
}
|
|
2243
|
-
|
|
2244
|
-
function capacityCard() {
|
|
2245
|
-
const cap = S.view.capacity || { states: [], invalid: [], summary: {} };
|
|
2246
|
-
const body = el('div', { style: 'padding:6px 14px 14px' });
|
|
2247
|
-
|
|
2248
|
-
if (!cap.states.length) {
|
|
2249
|
-
const yaml = [
|
|
2250
|
-
'# ~/.tokenflow/config.yaml',
|
|
2251
|
-
'limits:',
|
|
2252
|
-
' - id: anthropic-monthly',
|
|
2253
|
-
' provider: anthropic # optional: provider | model | project',
|
|
2254
|
-
' scope: month # day | week | month',
|
|
2255
|
-
' metric: tokens # tokens | input | output | requests | cost',
|
|
2256
|
-
' cap: 120000000 # tokens (or $ for metric: cost)',
|
|
2257
|
-
' warnAt: 0.8 # optional warn threshold',
|
|
2258
|
-
].join('\n');
|
|
2259
|
-
body.appendChild(el('p', { class: 'hint', text: 'TokenFlow never invents vendor quota numbers — a limit exists only if you declare it. Declare one here or paste this into your config:' }));
|
|
2260
|
-
const pre = el('pre', { class: 'mono', text: yaml, style: 'background:var(--surface-2);padding:10px;border-radius:8px;overflow:auto;font-size:11.5px;line-height:1.55' });
|
|
2261
|
-
body.appendChild(pre);
|
|
2262
|
-
const actions = btn('⧉ Copy YAML', () => {
|
|
2263
|
-
navigator.clipboard.writeText(yaml).then(() => { actions.textContent = '✓ Copied'; setTimeout(() => { actions.textContent = '⧉ Copy YAML'; }, 1500); }).catch(() => {});
|
|
2264
|
-
}, 'ghost sm');
|
|
2265
|
-
return card('Capacity & budgets', 'Burn rate, exhaustion ETA and reset countdowns for your declared limits.', body, actions);
|
|
2266
|
-
}
|
|
2267
|
-
|
|
2268
|
-
const sum = cap.summary || {};
|
|
2269
|
-
if (sum.counts && (sum.counts.exceeded || sum.counts.warn)) {
|
|
2270
|
-
body.appendChild(el('div', { class: 'chips', style: 'padding:2px 0 8px' }, [
|
|
2271
|
-
sum.counts.exceeded ? el('span', { class: 'badge demo', text: `${sum.counts.exceeded} exceeded` }) : null,
|
|
2272
|
-
sum.counts.warn ? el('span', { class: 'badge warn', text: `${sum.counts.warn} approaching` }) : null,
|
|
2273
|
-
sum.firstToHit ? el('span', { class: 'muted', text: `first projected hit: ${sum.firstToHit.label} in ${countdown(sum.firstToHit.etaHours * 3600000)}` }) : null,
|
|
2274
|
-
].filter(Boolean)));
|
|
2275
|
-
}
|
|
2276
|
-
for (const s of cap.states) body.appendChild(limitRow(s));
|
|
2277
|
-
if (cap.invalid?.length) {
|
|
2278
|
-
body.appendChild(el('p', { class: 'hint', text: `${cap.invalid.length} invalid limit definition(s) in config were ignored — check \`tokenflow capacity\`.` }));
|
|
2279
|
-
}
|
|
2280
|
-
const manage = SNAPSHOT
|
|
2281
|
-
? null
|
|
2282
|
-
: btn('⚙ Manage limits', openLimitEditor, 'ghost sm');
|
|
2283
|
-
return card('Capacity & budgets', 'Evaluated against all primary usage regardless of dashboard filters — quota windows are facts about your accounts, not filter states.', body, manage);
|
|
2284
|
-
}
|
|
2285
|
-
|
|
2286
|
-
function openLimitEditor() {
|
|
2287
|
-
const cur = (S.bundle.limits || []).map((l) => ({ ...l }));
|
|
2288
|
-
const body = el('div');
|
|
2289
|
-
|
|
2290
|
-
// A simple editable list is clearer than a grid here.
|
|
2291
|
-
const rows = el('div');
|
|
2292
|
-
const renderRows = () => {
|
|
2293
|
-
rows.textContent = '';
|
|
2294
|
-
for (const l of cur) {
|
|
2295
|
-
const r = el('div', { style: 'display:flex;gap:8px;align-items:center;padding:4px 0' });
|
|
2296
|
-
r.appendChild(el('span', { class: 'mono', text: `${l.id}`, style: 'min-width:140px' }));
|
|
2297
|
-
r.appendChild(el('span', { class: 'muted', text: `${[l.provider, l.model, l.project].filter(Boolean).join('/') || 'all sources'} · ${l.scope} · ${l.metric} · cap ${compact(l.cap)}` }));
|
|
2298
|
-
const spacer = el('div', { style: 'flex:1' });
|
|
2299
|
-
r.appendChild(spacer);
|
|
2300
|
-
r.appendChild(btn('Remove', () => { cur.splice(cur.indexOf(l), 1); renderRows(); }, 'ghost sm'));
|
|
2301
|
-
rows.appendChild(r);
|
|
2302
|
-
}
|
|
2303
|
-
if (!cur.length) rows.appendChild(el('p', { class: 'hint', text: 'No limits yet — add one below.' }));
|
|
2304
|
-
};
|
|
2305
|
-
renderRows();
|
|
2306
|
-
body.appendChild(rows);
|
|
2307
|
-
|
|
2308
|
-
const f = {};
|
|
2309
|
-
const field = (key, placeholder, type = 'text') => {
|
|
2310
|
-
const input = el('input', { placeholder, type, 'aria-label': key });
|
|
2311
|
-
input.style.cssText = 'flex:1;min-width:90px';
|
|
2312
|
-
f[key] = input;
|
|
2313
|
-
return input;
|
|
2314
|
-
};
|
|
2315
|
-
const scopeSel = el('select', { 'aria-label': 'scope' });
|
|
2316
|
-
for (const o of ['day', 'week', 'month']) scopeSel.appendChild(el('option', { value: o, text: o }));
|
|
2317
|
-
const metricSel = el('select', { 'aria-label': 'metric' });
|
|
2318
|
-
for (const o of ['tokens', 'input', 'output', 'requests', 'cost']) metricSel.appendChild(el('option', { value: o, text: o }));
|
|
2319
|
-
|
|
2320
|
-
const form = el('div', { style: 'display:flex;gap:6px;flex-wrap:wrap;margin-top:10px' }, [
|
|
2321
|
-
field('id', 'id (required)'),
|
|
2322
|
-
field('provider', 'provider (optional)'),
|
|
2323
|
-
field('model', 'model (optional)'),
|
|
2324
|
-
scopeSel, metricSel,
|
|
2325
|
-
field('cap', 'cap', 'number'),
|
|
2326
|
-
field('warnAt', 'warnAt 0–1', 'number'),
|
|
2327
|
-
]);
|
|
2328
|
-
for (const c of form.children) c.style.flexGrow = '0';
|
|
2329
|
-
body.appendChild(form);
|
|
2330
|
-
|
|
2331
|
-
const errBox = el('p', { class: 'hint', style: 'color:var(--critical)' });
|
|
2332
|
-
body.appendChild(errBox);
|
|
2333
|
-
|
|
2334
|
-
const foot = el('div', { style: 'display:flex;gap:8px;justify-content:flex-end;width:100%' });
|
|
2335
|
-
foot.appendChild(btn('Cancel', () => document.getElementById('modal-close').click(), 'ghost sm'));
|
|
2336
|
-
foot.appendChild(btn('Save limits', async () => {
|
|
2337
|
-
errBox.textContent = '';
|
|
2338
|
-
// The form is only part of the save when the user actually named a new
|
|
2339
|
-
// limit. Removal-only saves must not inject an empty draft — that bug
|
|
2340
|
-
// made every "remove" also POST a junk row and fail validation.
|
|
2341
|
-
const wantsAdd = f.id.value.trim() !== '' || f.cap.value !== '';
|
|
2342
|
-
if (wantsAdd && f.id.value.trim() === '') {
|
|
2343
|
-
errBox.textContent = 'New limit needs an id (or clear the form to save removals only).';
|
|
2344
|
-
return;
|
|
2345
|
-
}
|
|
2346
|
-
const def = {
|
|
2347
|
-
id: f.id.value.trim(),
|
|
2348
|
-
provider: f.provider.value.trim() || undefined,
|
|
2349
|
-
model: f.model.value.trim() || undefined,
|
|
2350
|
-
scope: scopeSel.value,
|
|
2351
|
-
metric: metricSel.value,
|
|
2352
|
-
cap: Number(f.cap.value),
|
|
2353
|
-
...(f.warnAt.value !== '' ? { warnAt: Number(f.warnAt.value) } : {}),
|
|
2354
|
-
};
|
|
2355
|
-
const next = wantsAdd ? [...cur, def] : [...cur];
|
|
2356
|
-
try {
|
|
2357
|
-
const res = await fetch('/api/config', {
|
|
2358
|
-
method: 'POST',
|
|
2359
|
-
headers: { 'content-type': 'application/json' },
|
|
2360
|
-
body: JSON.stringify({ limits: next }),
|
|
2361
|
-
});
|
|
2362
|
-
const out = await res.json();
|
|
2363
|
-
if (!res.ok || !out.ok) {
|
|
2364
|
-
errBox.textContent = `Invalid: ${(out.invalid || []).map((x) => `${x.id ? x.id + ': ' : ''}${x.errors.join('; ')}`).join(' | ')}`;
|
|
2365
|
-
return;
|
|
2366
|
-
}
|
|
2367
|
-
S.bundle.limits = out.limits;
|
|
2368
|
-
recompute();
|
|
2369
|
-
render();
|
|
2370
|
-
document.getElementById('modal-close').click();
|
|
2371
|
-
} catch (e) {
|
|
2372
|
-
errBox.textContent = `Save failed: ${e.message}`;
|
|
2373
|
-
}
|
|
2374
|
-
}, 'sm'));
|
|
2375
|
-
body.appendChild(foot);
|
|
2376
|
-
|
|
2377
|
-
openModal('Manage capacity limits', body);
|
|
2378
|
-
}
|
|
2379
|
-
|
|
2380
|
-
function forecastCard() {
|
|
2381
|
-
const v = S.view;
|
|
2382
|
-
const f = v.forecast;
|
|
2383
|
-
const body = el('div');
|
|
2384
|
-
|
|
2385
|
-
if (!f || f.tomorrow === null) {
|
|
2386
|
-
body.appendChild(el('p', { class: 'hint', text: f?.reason || 'Not enough history yet.' }));
|
|
2387
|
-
return card('Forecast', 'A conservative linear trend over recent days — never a promise.', body);
|
|
2388
|
-
}
|
|
2389
|
-
|
|
2390
|
-
const kpis = el('div', { style: 'display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;padding:10px 14px 2px' });
|
|
2391
|
-
const kpiTile = (label, val, sub) => {
|
|
2392
|
-
const d = el('div', { style: 'background:var(--surface-2);border-radius:8px;padding:10px' });
|
|
2393
|
-
d.appendChild(el('div', { class: 'hint', text: label }));
|
|
2394
|
-
d.appendChild(el('div', { class: 'k-value str', text: val, style: 'font-size:20px' }));
|
|
2395
|
-
if (sub) d.appendChild(el('div', { class: 'hint', text: sub }));
|
|
2396
|
-
return d;
|
|
2397
|
-
};
|
|
2398
|
-
kpis.appendChild(kpiTile('Tomorrow (projected)', compact(f.tomorrow), f.tomorrowInterval ? `${compact(f.tomorrowInterval[0])} – ${compact(f.tomorrowInterval[1])}` : null));
|
|
2399
|
-
kpis.appendChild(kpiTile('Next 7 days', compact(f.next7days), f.next7daysCost != null ? usd(f.next7daysCost) : null));
|
|
2400
|
-
if (f.monthEnd !== null) {
|
|
2401
|
-
kpis.appendChild(kpiTile('Month-end', compact(f.monthEnd), `measured so far ${compact(f.monthEndActualToDate)}${f.monthEndCost !== null ? ` · ≈${usd(f.monthEndCost)} est.` : ''}`));
|
|
2402
|
-
}
|
|
2403
|
-
kpis.appendChild(kpiTile('Confidence', f.confidence, f.n ? `${f.n}-day trend` : null));
|
|
2404
|
-
body.appendChild(kpis);
|
|
2405
|
-
|
|
2406
|
-
// History + projection side by side: measured bars, then forecast bars in a
|
|
2407
|
-
// dashed-looking muted tone, clearly separated by an empty slot.
|
|
2408
|
-
const daily = v.daily.slice(-14);
|
|
2409
|
-
const data = daily.map((d) => ({
|
|
2410
|
-
label: shortDate(d.key),
|
|
2411
|
-
value: d.total,
|
|
2412
|
-
fmtXLong: d.key,
|
|
2413
|
-
color: 'var(--series-1)',
|
|
2414
|
-
}));
|
|
2415
|
-
if (f.tomorrow !== null) {
|
|
2416
|
-
data.push({ label: 'tomorrow*', value: f.tomorrow, color: 'var(--hairline)', extra: [{ name: 'Projected', value: compact(f.tomorrow) }] });
|
|
2417
|
-
}
|
|
2418
|
-
// The month-end projection deliberately stays OUT of the chart: a whole-
|
|
2419
|
-
// month total beside daily bars would flatten the history into unreadability.
|
|
2420
|
-
// It lives in the KPI tiles above, labelled as a projection.
|
|
2421
|
-
const wrapChart = el('div', { style: 'padding:6px 14px 12px' });
|
|
2422
|
-
requestAnimationFrame(() => observeWidth(wrapChart, (w) => {
|
|
2423
|
-
wrapChart.textContent = '';
|
|
2424
|
-
wrapChart.appendChild(columns({
|
|
2425
|
-
data, width: w, height: 200, fmtY: (x) => compact(x), valueLabel: 'Tokens',
|
|
2426
|
-
ariaLabel: 'Recent daily usage with projections appended',
|
|
2427
|
-
}));
|
|
2428
|
-
}));
|
|
2429
|
-
body.appendChild(wrapChart);
|
|
2430
|
-
body.appendChild(el('p', { class: 'hint', style: 'padding:0 14px 12px', text: '* Projected, not measured. The trend assumes the recent pattern continues; confidence is stated above and drops sharply on thin or volatile history.' }));
|
|
2431
|
-
|
|
2432
|
-
return card('Forecast', 'Measured history first; projections always labelled and kept apart.', body);
|
|
2433
|
-
}
|
|
2434
|
-
|
|
2435
|
-
function anomaliesCard() {
|
|
2436
|
-
const v = S.view;
|
|
2437
|
-
const body = el('div', { style: 'padding:6px 14px 14px' });
|
|
2438
|
-
const anomalies = v.anomalies || [];
|
|
2439
|
-
|
|
2440
|
-
if (!anomalies.length) {
|
|
2441
|
-
body.appendChild(el('p', { class: 'hint', text: 'No anomalies detected in the current dataset. Detection covers token/cost/request spikes, weekday gaps and sudden drops — each reported with its own arithmetic.' }));
|
|
2442
|
-
} else {
|
|
2443
|
-
for (const a of anomalies) {
|
|
2444
|
-
const sev = SEV[a.severity] || SEV.info;
|
|
2445
|
-
const row = el('div', { style: 'display:flex;gap:10px;align-items:baseline;padding:7px 0;border-top:1px solid var(--hairline)' });
|
|
2446
|
-
row.appendChild(el('span', { class: `badge ${sev.cls}`, text: sev.label }));
|
|
2447
|
-
row.appendChild(el('span', { class: 'mono muted', text: a.date, style: 'min-width:86px;font-size:11px' }));
|
|
2448
|
-
row.appendChild(el('span', { text: a.detail }));
|
|
2449
|
-
body.appendChild(row);
|
|
2450
|
-
}
|
|
2451
|
-
}
|
|
2452
|
-
|
|
2453
|
-
const fresh = [...(v.firstSeen?.models || []).map((m) => ({ kind: 'model', ...m })), ...(v.firstSeen?.providers || []).map((p) => ({ kind: 'provider', ...p }))];
|
|
2454
|
-
if (fresh.length) {
|
|
2455
|
-
const chips = el('div', { class: 'chips', style: 'padding-top:10px' });
|
|
2456
|
-
chips.appendChild(el('span', { class: 'muted', text: 'New this week: ' }));
|
|
2457
|
-
for (const x of fresh) {
|
|
2458
|
-
chips.appendChild(el('span', { class: 'chip', text: `${x.kind} ${x.entity} (${shortDate(x.firstSeen)})` }));
|
|
2459
|
-
}
|
|
2460
|
-
body.appendChild(chips);
|
|
2461
|
-
}
|
|
2462
|
-
return card('Anomalies & changes', 'Robust median/MAD detection — every alert shows observed vs expected so you can check it.', body);
|
|
2463
|
-
}
|
|
2464
|
-
|
|
2465
|
-
function viewLive() {
|
|
2466
|
-
ensureLiveLoop();
|
|
2467
|
-
const root = el('div', { class: 'grid' });
|
|
2468
|
-
if (!SNAPSHOT) root.appendChild(liveWatcherCard());
|
|
2469
|
-
root.appendChild(capacityCard());
|
|
2470
|
-
root.appendChild(forecastCard());
|
|
2471
|
-
root.appendChild(anomaliesCard());
|
|
2472
|
-
return root;
|
|
2473
|
-
}
|