@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
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Live tab: what is running right now, today's receipts, the guard, the
|
|
3
|
+
* last 24 hours by source, then watcher status, declared capacity limits,
|
|
4
|
+
* forecast and anomalies.
|
|
5
|
+
*
|
|
6
|
+
* This is the exemplar registered view. Everything it uses from the host comes
|
|
7
|
+
* in as a ViewContext, so it imports no application state and app.js never
|
|
8
|
+
* imports it back. Chart primitives are imported directly from ../charts.js and
|
|
9
|
+
* the pure shaping from ../../analytics/live-view.js: a view may import
|
|
10
|
+
* anything that does not lead back to app.js.
|
|
11
|
+
*
|
|
12
|
+
* The header live pill's own /api/live loop stays in app.js — it runs whichever
|
|
13
|
+
* tab is open. This view keeps a second, faster poll of the same endpoint for
|
|
14
|
+
* the four "right now" sections, and holds the result in module state so a
|
|
15
|
+
* filter change repaints them without refetching.
|
|
16
|
+
*
|
|
17
|
+
* Nothing here animates toward a number it was not given. Every value on this
|
|
18
|
+
* tab is the last thing `data/status.json` said, and it changes when that file
|
|
19
|
+
* changes and at no other moment.
|
|
20
|
+
*/
|
|
21
|
+
import { el, miniBar, columns, observeWidth, sparkline, SERIES_VARS, OTHER_COLOR } from '../charts.js';
|
|
22
|
+
import {
|
|
23
|
+
asOfLabel, capRows, contextGauge, costLabel, countLabel, guardChip,
|
|
24
|
+
orderSparkSources, sessionPlace, turnsLabel,
|
|
25
|
+
} from '../../analytics/live-view.js';
|
|
26
|
+
|
|
27
|
+
/** @typedef {import('./index.js').ViewContext} ViewContext */
|
|
28
|
+
|
|
29
|
+
export const id = 'live';
|
|
30
|
+
export const label = 'Live';
|
|
31
|
+
export const order = 30;
|
|
32
|
+
export const css = './styles/live.css';
|
|
33
|
+
|
|
34
|
+
/** How often the four "right now" sections refetch /api/live, in ms. */
|
|
35
|
+
const POLL_MS = 30000;
|
|
36
|
+
/** Session cards shown at once. The producer already caps its list here too. */
|
|
37
|
+
const MAX_SESSION_CARDS = 8;
|
|
38
|
+
/** Sparkline charts before the remaining sources fold into one "other". */
|
|
39
|
+
const MAX_SPARK_SERIES = 5;
|
|
40
|
+
/** Receipt rows on this card. The producer caps its list at the same number. */
|
|
41
|
+
const MAX_RECEIPT_ROWS = 3;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The last /api/live payload, or null before the first one arrives.
|
|
45
|
+
*
|
|
46
|
+
* Module state rather than ctx.S: `view()` runs on every filter and theme
|
|
47
|
+
* change, and refetching there would make the numbers flicker for reasons that
|
|
48
|
+
* have nothing to do with the store.
|
|
49
|
+
* @type {any}
|
|
50
|
+
*/
|
|
51
|
+
let status = null;
|
|
52
|
+
/** True between onEnter and onLeave: a late fetch must not repaint a left tab. */
|
|
53
|
+
let active = false;
|
|
54
|
+
|
|
55
|
+
const SEV = {
|
|
56
|
+
high: { label: 'high', cls: 'sev-high' },
|
|
57
|
+
warn: { label: 'watch', cls: 'sev-warn' },
|
|
58
|
+
info: { label: 'info', cls: 'sev-info' },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The Live tab body.
|
|
63
|
+
* @param {ViewContext} ctx
|
|
64
|
+
* @returns {HTMLElement}
|
|
65
|
+
*/
|
|
66
|
+
export function view(ctx) {
|
|
67
|
+
const root = el('div', { class: 'grid' });
|
|
68
|
+
// The four "right now" sections read data/status.json, which only a running
|
|
69
|
+
// watcher writes. A saved file has neither, so offline this tab stays exactly
|
|
70
|
+
// what it was: capacity, forecast and anomalies out of the embedded bundle.
|
|
71
|
+
if (!ctx.snapshot && status) {
|
|
72
|
+
for (const node of recentSections(ctx, status)) root.appendChild(node);
|
|
73
|
+
}
|
|
74
|
+
if (!ctx.snapshot) root.appendChild(liveWatcherCard(ctx));
|
|
75
|
+
root.appendChild(capacityCard(ctx));
|
|
76
|
+
root.appendChild(forecastCard(ctx));
|
|
77
|
+
root.appendChild(anomaliesCard(ctx));
|
|
78
|
+
return root;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Fetch the live status, then repaint. Called once on entry and every 30s.
|
|
83
|
+
* @param {ViewContext} ctx
|
|
84
|
+
*/
|
|
85
|
+
async function refreshStatus(ctx) {
|
|
86
|
+
let next = null;
|
|
87
|
+
try {
|
|
88
|
+
next = await ctx.fetchJson('/api/live');
|
|
89
|
+
} catch {
|
|
90
|
+
// The server is gone or answered an error. Keep the last status: blanking
|
|
91
|
+
// the sections would be a change in what this tab claims, and the status
|
|
92
|
+
// file never said anything of the kind.
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (!active || !next) return;
|
|
96
|
+
status = next;
|
|
97
|
+
ctx.rerender({ recompute: false });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Start the poll. Registered synchronously, before any await, because app.js
|
|
102
|
+
* only files a timer under the tab's lifetime while `onEnter` is on the stack —
|
|
103
|
+
* one registered after an await would be cleared by the very next render.
|
|
104
|
+
* @param {ViewContext} ctx
|
|
105
|
+
*/
|
|
106
|
+
export function onEnter(ctx) {
|
|
107
|
+
if (ctx.snapshot) return;
|
|
108
|
+
active = true;
|
|
109
|
+
ctx.schedule(() => { void refreshStatus(ctx); }, POLL_MS);
|
|
110
|
+
void refreshStatus(ctx);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Stop accepting an in-flight fetch. app.js clears the interval itself. */
|
|
114
|
+
export function onLeave() {
|
|
115
|
+
active = false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ------------------------------------------------------- right now sections --
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Live sessions, today's receipts, the guard and the last 24 hours by source.
|
|
122
|
+
* @param {ViewContext} ctx
|
|
123
|
+
* @param {any} st the /api/live payload
|
|
124
|
+
* @returns {Node[]}
|
|
125
|
+
*/
|
|
126
|
+
function recentSections(ctx, st) {
|
|
127
|
+
const out = [];
|
|
128
|
+
const asOf = st.liveSessions?.asOf ?? st.receiptsToday?.asOf ?? st.freshness?.lastRefresh ?? null;
|
|
129
|
+
if (st.liveSessions) out.push(...liveSessionsSection(ctx, st));
|
|
130
|
+
if (st.receiptsToday) out.push(receiptsTodayCard(ctx, st.receiptsToday, asOf));
|
|
131
|
+
if (st.guard) out.push(guardCard(ctx, st.guard));
|
|
132
|
+
if (st.sparklines) out.push(...sparklinesSection(ctx, st.sparklines, asOf));
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* One card per live session, under a header that dates itself.
|
|
138
|
+
*
|
|
139
|
+
* The header carries the relative asOf because this is a file read on a cycle,
|
|
140
|
+
* not a stream: "as of 3 min ago" is the honest claim, and "Live sessions" on
|
|
141
|
+
* its own would not be.
|
|
142
|
+
* @param {ViewContext} ctx
|
|
143
|
+
* @param {any} st
|
|
144
|
+
* @returns {Node[]}
|
|
145
|
+
*/
|
|
146
|
+
function liveSessionsSection(ctx, st) {
|
|
147
|
+
const ls = st.liveSessions;
|
|
148
|
+
const policy = st.guard?.policy ?? null;
|
|
149
|
+
const sessions = Array.isArray(ls.sessions) ? ls.sessions.slice(0, MAX_SESSION_CARDS) : [];
|
|
150
|
+
const head = ctx.sectionTitle(`Live sessions, as of ${asOfLabel(ls.asOf)}`);
|
|
151
|
+
if (!sessions.length) {
|
|
152
|
+
const minutes = Number.isFinite(ls.windowMinutes) ? ls.windowMinutes : null;
|
|
153
|
+
// The section title above is the heading, so the empty state gets the card
|
|
154
|
+
// surface without a second one.
|
|
155
|
+
return [head, el('div', { class: 'card' }, [ctx.emptyCard(
|
|
156
|
+
minutes === null ? 'No session active in the last few minutes.' : `No session active in the last ${minutes} minutes.`,
|
|
157
|
+
'A session appears here while its log is still being written.',
|
|
158
|
+
)])];
|
|
159
|
+
}
|
|
160
|
+
const grid = el('div', { class: 'live-sessions' });
|
|
161
|
+
for (const s of sessions) grid.appendChild(sessionCard(ctx, s, policy));
|
|
162
|
+
return [head, grid];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {ViewContext} ctx
|
|
167
|
+
* @param {any} s one entry of liveSessions.sessions
|
|
168
|
+
* @param {any} policy status.guard.policy, for the context cap
|
|
169
|
+
*/
|
|
170
|
+
function sessionCard(ctx, s, policy) {
|
|
171
|
+
const place = sessionPlace(s);
|
|
172
|
+
const chip = guardChip(s.guard);
|
|
173
|
+
const cost = costLabel({ costUsd: s.costUsd, coverage: s.coverage });
|
|
174
|
+
const gauge = contextGauge({ contextTokens: s.contextTokens, policy });
|
|
175
|
+
|
|
176
|
+
const card = el('div', { class: 'card live-session' });
|
|
177
|
+
|
|
178
|
+
const head = el('div', { class: 'live-session-head' });
|
|
179
|
+
const where = el('div', { class: 'live-session-where' });
|
|
180
|
+
where.appendChild(el('div', { class: 'live-session-project', text: place.where }));
|
|
181
|
+
where.appendChild(el('div', { class: 'hint live-session-branch', text: place.branch }));
|
|
182
|
+
head.appendChild(where);
|
|
183
|
+
head.appendChild(el('span', {
|
|
184
|
+
class: `badge live-guard live-guard-${chip.level}`,
|
|
185
|
+
text: chip.text,
|
|
186
|
+
title: chip.title,
|
|
187
|
+
}));
|
|
188
|
+
card.appendChild(head);
|
|
189
|
+
|
|
190
|
+
card.appendChild(el('div', { class: 'hint live-session-meta', text: `${s.model || 'n/a'} · ${s.source || 'n/a'}` }));
|
|
191
|
+
|
|
192
|
+
card.appendChild(statRow('turns', el('span', { text: turnsLabel(s) })));
|
|
193
|
+
|
|
194
|
+
const spend = el('span', {}, [
|
|
195
|
+
document.createTextNode(cost.text),
|
|
196
|
+
cost.priced ? el('span', { class: 'badge est live-est', text: 'est.', title: 'Estimated from the price book, not billed by the vendor.' }) : null,
|
|
197
|
+
]);
|
|
198
|
+
card.appendChild(statRow('spend', spend));
|
|
199
|
+
if (cost.coverageText) card.appendChild(el('div', { class: 'hint live-session-note', text: cost.coverageText }));
|
|
200
|
+
|
|
201
|
+
const gaugeBox = el('div', { class: 'live-gauge' });
|
|
202
|
+
const gaugeTop = el('div', { class: 'live-gauge-top' }, [
|
|
203
|
+
el('span', { class: 'live-k', text: 'context' }),
|
|
204
|
+
el('span', { class: 'live-gauge-value', text: gauge.text }),
|
|
205
|
+
]);
|
|
206
|
+
gaugeBox.appendChild(gaugeTop);
|
|
207
|
+
const track = el('div', {
|
|
208
|
+
class: 'live-gauge-track',
|
|
209
|
+
role: 'img',
|
|
210
|
+
'aria-label': `Context window ${gauge.pctText} used, ${gauge.text}`,
|
|
211
|
+
});
|
|
212
|
+
const fill = el('div', { class: 'live-gauge-fill' });
|
|
213
|
+
// No transition on this width: the fill may only ever show a value the status
|
|
214
|
+
// file actually reported, never a sweep toward one.
|
|
215
|
+
fill.style.width = `${(gauge.ratio ?? 0) * 100}%`;
|
|
216
|
+
track.appendChild(fill);
|
|
217
|
+
gaugeBox.appendChild(track);
|
|
218
|
+
gaugeBox.appendChild(el('div', { class: 'hint live-gauge-foot' }, [
|
|
219
|
+
document.createTextNode(gauge.pctText),
|
|
220
|
+
el('span', {
|
|
221
|
+
class: 'live-gauge-scale',
|
|
222
|
+
text: gauge.declared ? 'declared cap' : 'assumed 200K window',
|
|
223
|
+
title: gauge.declared
|
|
224
|
+
? 'The cap you declared with tokenflow guard --set maxContextTokens=<n>.'
|
|
225
|
+
: 'No context cap is declared, so the gauge draws against a 200K window. It is a scale, not a limit.',
|
|
226
|
+
}),
|
|
227
|
+
]));
|
|
228
|
+
card.appendChild(gaugeBox);
|
|
229
|
+
|
|
230
|
+
card.appendChild(el('div', {
|
|
231
|
+
class: 'hint live-session-note',
|
|
232
|
+
text: `started ${asOfLabel(s.startedAt)} · last turn ${asOfLabel(s.lastActivityAt)}`,
|
|
233
|
+
}));
|
|
234
|
+
return card;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** A `label value` line inside a session card. */
|
|
238
|
+
function statRow(k, valueNode) {
|
|
239
|
+
return el('div', { class: 'live-stat' }, [
|
|
240
|
+
el('span', { class: 'live-k', text: k }),
|
|
241
|
+
el('span', { class: 'live-v' }, [valueNode]),
|
|
242
|
+
]);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Today's receipts: what each repository and branch cost so far today.
|
|
247
|
+
* @param {ViewContext} ctx
|
|
248
|
+
* @param {any} r status.receiptsToday
|
|
249
|
+
* @param {string|null} asOf
|
|
250
|
+
*/
|
|
251
|
+
function receiptsTodayCard(ctx, r, asOf) {
|
|
252
|
+
const { usd } = ctx.fmt;
|
|
253
|
+
// Three rows is this card's own contract. The producer already caps its list
|
|
254
|
+
// at three, but a view that trusts that would grow silently if it stopped.
|
|
255
|
+
const items = (Array.isArray(r.items) ? r.items : []).slice(0, MAX_RECEIPT_ROWS);
|
|
256
|
+
const hint = `Cost by repository and branch since local midnight, as of ${asOfLabel(r.asOf ?? asOf)}.`;
|
|
257
|
+
if (!items.length) {
|
|
258
|
+
return ctx.card("Today's receipts", hint, ctx.emptyCard(
|
|
259
|
+
'Nothing recorded today yet.',
|
|
260
|
+
'A row appears here for each repository and branch as soon as a turn lands in the store.',
|
|
261
|
+
));
|
|
262
|
+
}
|
|
263
|
+
const body = el('div', { class: 'live-receipts' });
|
|
264
|
+
for (const it of items) {
|
|
265
|
+
const row = el('div', { class: 'live-receipt' });
|
|
266
|
+
const where = el('div', { class: 'live-receipt-where' });
|
|
267
|
+
where.appendChild(el('div', { class: 'live-receipt-repo', text: it.repo || 'n/a' }));
|
|
268
|
+
where.appendChild(el('div', { class: 'hint', text: it.branch || 'n/a' }));
|
|
269
|
+
row.appendChild(where);
|
|
270
|
+
row.appendChild(el('div', { class: 'live-receipt-cost' }, [
|
|
271
|
+
document.createTextNode(it.costUsd === null || it.costUsd === undefined ? 'n/a' : usd(it.costUsd, 'n/a')),
|
|
272
|
+
]));
|
|
273
|
+
row.appendChild(el('div', { class: 'hint live-receipt-n', text: countLabel(it.turns, 'turn') }));
|
|
274
|
+
row.appendChild(el('div', { class: 'hint live-receipt-n', text: countLabel(it.sessions, 'session') }));
|
|
275
|
+
body.appendChild(row);
|
|
276
|
+
}
|
|
277
|
+
const total = el('div', { class: 'live-receipt live-receipt-total' });
|
|
278
|
+
total.appendChild(el('div', { class: 'live-receipt-where' }, [el('div', { text: 'Day total' })]));
|
|
279
|
+
const totalPriced = typeof r.totalCostUsd === 'number' && Number.isFinite(r.totalCostUsd);
|
|
280
|
+
total.appendChild(el('div', { class: 'live-receipt-cost' }, [
|
|
281
|
+
document.createTextNode(totalPriced ? usd(r.totalCostUsd, 'n/a') : 'n/a'),
|
|
282
|
+
// "est." qualifies a figure. With no figure there is nothing to qualify.
|
|
283
|
+
totalPriced ? el('span', { class: 'badge est live-est', text: 'est.', title: 'Estimated from the price book, not billed by the vendor.' }) : null,
|
|
284
|
+
]));
|
|
285
|
+
// Two empty cells so the total lines up with the rows above it.
|
|
286
|
+
total.appendChild(el('div', { class: 'live-receipt-n' }));
|
|
287
|
+
total.appendChild(el('div', { class: 'live-receipt-n' }));
|
|
288
|
+
body.appendChild(total);
|
|
289
|
+
return ctx.card("Today's receipts", hint, body);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The guard: the caps you declared, the last verdict, and the one command that
|
|
294
|
+
* changes them.
|
|
295
|
+
*
|
|
296
|
+
* There is no form here on purpose. The guard runs as a Claude Code hook out of
|
|
297
|
+
* config.yaml, so a cap edited anywhere but config would let the hook and this
|
|
298
|
+
* page disagree about what is allowed.
|
|
299
|
+
* @param {ViewContext} ctx
|
|
300
|
+
* @param {any} g status.guard
|
|
301
|
+
*/
|
|
302
|
+
function guardCard(ctx, g) {
|
|
303
|
+
const caps = capRows(g.policy);
|
|
304
|
+
const body = el('div', { class: 'live-guard-body' });
|
|
305
|
+
|
|
306
|
+
body.appendChild(el('div', { class: 'live-sub', text: 'Declared caps' }));
|
|
307
|
+
if (!caps.length) {
|
|
308
|
+
body.appendChild(el('p', { class: 'hint live-none', text: 'No caps set. Nothing can trip until you declare one.' }));
|
|
309
|
+
} else {
|
|
310
|
+
const list = el('div', { class: 'live-caps' });
|
|
311
|
+
for (const c of caps) {
|
|
312
|
+
list.appendChild(el('div', { class: 'live-cap' }, [
|
|
313
|
+
el('span', { class: 'live-k', text: c.label }),
|
|
314
|
+
el('span', { class: 'live-v', text: c.text }),
|
|
315
|
+
]));
|
|
316
|
+
}
|
|
317
|
+
body.appendChild(list);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
body.appendChild(el('div', { class: 'live-sub', text: 'Last verdict' }));
|
|
321
|
+
const v = g.lastVerdict;
|
|
322
|
+
if (!v) {
|
|
323
|
+
body.appendChild(el('p', { class: 'hint live-none', text: 'No verdict yet.' }));
|
|
324
|
+
} else {
|
|
325
|
+
const chip = guardChip(v);
|
|
326
|
+
const line = el('div', { class: 'live-verdict' });
|
|
327
|
+
line.appendChild(el('span', { class: `badge live-guard live-guard-${chip.level}`, text: chip.text, title: chip.title }));
|
|
328
|
+
line.appendChild(el('span', { class: 'mono live-verdict-id', text: v.sessionId || 'n/a' }));
|
|
329
|
+
line.appendChild(el('span', { class: 'hint', text: asOfLabel(v.at) }));
|
|
330
|
+
body.appendChild(line);
|
|
331
|
+
if (chip.title) body.appendChild(el('p', { class: 'hint live-reason', text: chip.title }));
|
|
332
|
+
body.appendChild(el('p', {
|
|
333
|
+
class: 'hint live-source',
|
|
334
|
+
text: v.source === 'derived'
|
|
335
|
+
? 'Source: derived, which means computed from the live sessions above.'
|
|
336
|
+
: `Source: ${v.source || 'n/a'}.`,
|
|
337
|
+
}));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
body.appendChild(el('div', { class: 'live-sub', text: 'Declare a cap' }));
|
|
341
|
+
body.appendChild(el('pre', { class: 'mono live-cmd', text: 'tokenflow guard --set maxCostUsd=50' }));
|
|
342
|
+
body.appendChild(el('p', { class: 'hint', text: 'Caps are declared in config so the hook and this page agree about what is allowed.' }));
|
|
343
|
+
|
|
344
|
+
return ctx.card('Guard', 'The in-session circuit breaker: what it may stop, and what it last decided.', body);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* One small chart per source over the last 24 hourly buckets.
|
|
349
|
+
*
|
|
350
|
+
* Sources past the fifth fold into a summed "other" rather than disappearing,
|
|
351
|
+
* and every colour comes from the source's alphabetical position, so a quiet
|
|
352
|
+
* hour cannot repaint the chart above it.
|
|
353
|
+
* @param {ViewContext} ctx
|
|
354
|
+
* @param {any} sp status.sparklines
|
|
355
|
+
* @param {string|null} asOf
|
|
356
|
+
* @returns {Node[]}
|
|
357
|
+
*/
|
|
358
|
+
function sparklinesSection(ctx, sp, asOf) {
|
|
359
|
+
const { compact, hourLabel } = ctx.fmt;
|
|
360
|
+
const hours = Array.isArray(sp.hours) ? sp.hours : [];
|
|
361
|
+
const { series, folded } = orderSparkSources({ bySource: sp.bySource, max: MAX_SPARK_SERIES });
|
|
362
|
+
const head = ctx.sectionTitle('Tokens per hour, by source');
|
|
363
|
+
const caption = `tokens per hour, last 24 h, as of ${asOfLabel(asOf)}`;
|
|
364
|
+
if (!series.length) {
|
|
365
|
+
return [head, el('div', { class: 'card' }, [ctx.emptyCard('No tokens recorded in the last 24 hours.', caption)])];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const hourText = (iso) => {
|
|
369
|
+
const t = new Date(iso);
|
|
370
|
+
return Number.isNaN(t.getTime()) ? 'n/a' : `${hourLabel(t.getHours())}:00`;
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const grid = el('div', { class: 'live-sparks' });
|
|
374
|
+
for (const s of series) {
|
|
375
|
+
// Wrap rather than fall through to the muted colour: past eight sources a
|
|
376
|
+
// ninth would otherwise be indistinguishable from the folded "other". Each
|
|
377
|
+
// source owns its own chart, so a repeated hue costs nothing, and the
|
|
378
|
+
// modulo keeps the assignment as stable as the rank it comes from.
|
|
379
|
+
const color = s.colorIndex === null ? OTHER_COLOR : SERIES_VARS[s.colorIndex % SERIES_VARS.length];
|
|
380
|
+
const title = s.id === 'other' ? `other (${folded.length} sources)` : s.id;
|
|
381
|
+
const peak = s.values.length ? Math.max(...s.values.map((n) => (Number.isFinite(n) ? n : 0))) : null;
|
|
382
|
+
const rows = s.values.map((n, i) => ({
|
|
383
|
+
hour: i < hours.length ? hourText(hours[i]) : 'n/a',
|
|
384
|
+
tokens: compact(n, { na: 'n/a' }),
|
|
385
|
+
}));
|
|
386
|
+
grid.appendChild(ctx.chartCard(
|
|
387
|
+
`live-spark-${s.id}`,
|
|
388
|
+
title,
|
|
389
|
+
caption,
|
|
390
|
+
(w) => {
|
|
391
|
+
const wrap = el('div', { class: 'live-spark' });
|
|
392
|
+
wrap.appendChild(sparkline(s.values, { color, width: w, height: 48 }));
|
|
393
|
+
wrap.appendChild(el('div', {
|
|
394
|
+
class: 'hint live-spark-foot',
|
|
395
|
+
text: `peak ${compact(peak, { na: 'n/a' })} in an hour · ${compact(s.total, { na: 'n/a' })} in 24 h`,
|
|
396
|
+
}));
|
|
397
|
+
return wrap;
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
columns: [
|
|
401
|
+
{ key: 'hour', label: 'Hour', text: true, na: 'n/a' },
|
|
402
|
+
{ key: 'tokens', label: 'Tokens', na: 'n/a' },
|
|
403
|
+
],
|
|
404
|
+
rows,
|
|
405
|
+
emptyText: 'The status file carried no hourly buckets.',
|
|
406
|
+
},
|
|
407
|
+
));
|
|
408
|
+
}
|
|
409
|
+
return [head, grid];
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function liveWatcherCard(ctx) {
|
|
413
|
+
const { int, relativeTime } = ctx.fmt;
|
|
414
|
+
const body = el('div');
|
|
415
|
+
const w = ctx.S.live?.watcher;
|
|
416
|
+
if (w) {
|
|
417
|
+
const age = ctx.S.live.freshness?.ageMs;
|
|
418
|
+
body.appendChild(el('div', { class: 'chips', style: 'padding:10px 14px' }, [
|
|
419
|
+
el('span', { class: 'badge ok', text: `● watcher running · pid ${w.pid}` }),
|
|
420
|
+
el('span', { class: 'muted', text: `every ${w.intervalSeconds ?? '?'}s · ${int(w.cycles)} cycles` + (age != null ? ` · snapshot ${relativeTime(ctx.S.live.generatedAt)}` : '') }),
|
|
421
|
+
]));
|
|
422
|
+
} else {
|
|
423
|
+
const c = el('code', { text: 'tokenflow watch', style: 'font-size:12px' });
|
|
424
|
+
body.appendChild(el('div', { class: 'chips', style: 'padding:10px 14px;gap:8px;flex-wrap:wrap' }, [
|
|
425
|
+
el('span', { class: 'badge stale', text: '○ watcher not running' }),
|
|
426
|
+
el('span', { class: 'muted', text: 'run ' }),
|
|
427
|
+
c,
|
|
428
|
+
el('span', { class: 'muted', text: ' to keep the status file, menu bar and alerts current' }),
|
|
429
|
+
]));
|
|
430
|
+
}
|
|
431
|
+
return ctx.card('Real-time engine', 'The watcher refreshes incrementally and rewrites data/status.json after every cycle.', body);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function limitRow(ctx, s) {
|
|
435
|
+
const { compact, countdown } = ctx.fmt;
|
|
436
|
+
// Past ~10× a cap, percentages stop communicating; multiples do.
|
|
437
|
+
const pctText = s.pctUsed == null ? '—'
|
|
438
|
+
: s.pctUsed >= 10 ? `${Math.round(s.pctUsed)}×`
|
|
439
|
+
: `${(s.pctUsed * 100).toFixed(1)}%`;
|
|
440
|
+
const color = s.status === 'exceeded' ? 'var(--critical)' : s.status === 'warn' ? 'var(--warning)' : 'var(--series-1)';
|
|
441
|
+
const row = el('div', { style: 'display:flex;align-items:center;gap:12px;padding:8px 0;border-top:1px solid var(--hairline)' });
|
|
442
|
+
const glyph = s.status === 'exceeded' ? '✗' : s.status === 'warn' ? '⚠' : '✓';
|
|
443
|
+
const left = el('div', { style: 'min-width:220px' });
|
|
444
|
+
left.appendChild(el('div', {}, [document.createTextNode(`${glyph} ${s.label}`), s.provider ? el('span', { class: 'muted', text: ` [${s.provider}]` }) : null]));
|
|
445
|
+
left.appendChild(el('div', { class: 'hint', text: `${s.scope} · ${s.metric}` }));
|
|
446
|
+
row.appendChild(left);
|
|
447
|
+
const barWrap = el('div', { style: 'flex:1;min-width:120px' });
|
|
448
|
+
barWrap.appendChild(miniBar(Math.max(0, Math.min(1, s.pctUsed ?? 0)), color));
|
|
449
|
+
row.appendChild(barWrap);
|
|
450
|
+
const right = el('div', { style: 'text-align:right;min-width:190px' });
|
|
451
|
+
right.appendChild(el('div', { text: `${pctText} of ${compact(s.cap)}` }));
|
|
452
|
+
const sub = [];
|
|
453
|
+
if (s.status !== 'exceeded' && s.etaHours != null) sub.push(`ETA ${countdown(s.etaHours * 3600000)}`);
|
|
454
|
+
if (s.resetsInMs > 0) sub.push(`resets in ${countdown(s.resetsInMs)}`);
|
|
455
|
+
if (sub.length) right.appendChild(el('div', { class: 'hint', text: sub.join(' · ') }));
|
|
456
|
+
row.appendChild(right);
|
|
457
|
+
return row;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function capacityCard(ctx) {
|
|
461
|
+
const { compact, countdown } = ctx.fmt;
|
|
462
|
+
const cap = ctx.view.capacity || { states: [], invalid: [], summary: {} };
|
|
463
|
+
const body = el('div', { style: 'padding:6px 14px 14px' });
|
|
464
|
+
|
|
465
|
+
if (!cap.states.length) {
|
|
466
|
+
const yaml = [
|
|
467
|
+
'# ~/.tokenflow/config.yaml',
|
|
468
|
+
'limits:',
|
|
469
|
+
' - id: anthropic-monthly',
|
|
470
|
+
' provider: anthropic # optional: provider | model | project',
|
|
471
|
+
' scope: month # day | week | month',
|
|
472
|
+
' metric: tokens # tokens | input | output | requests | cost',
|
|
473
|
+
' cap: 120000000 # tokens (or $ for metric: cost)',
|
|
474
|
+
' warnAt: 0.8 # optional warn threshold',
|
|
475
|
+
].join('\n');
|
|
476
|
+
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:' }));
|
|
477
|
+
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' });
|
|
478
|
+
body.appendChild(pre);
|
|
479
|
+
const actions = ctx.btn('⧉ Copy YAML', () => {
|
|
480
|
+
navigator.clipboard.writeText(yaml).then(() => { actions.textContent = '✓ Copied'; setTimeout(() => { actions.textContent = '⧉ Copy YAML'; }, 1500); }).catch(() => {});
|
|
481
|
+
}, 'ghost sm');
|
|
482
|
+
return ctx.card('Capacity & budgets', 'Burn rate, exhaustion ETA and reset countdowns for your declared limits.', body, actions);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const sum = cap.summary || {};
|
|
486
|
+
if (sum.counts && (sum.counts.exceeded || sum.counts.warn)) {
|
|
487
|
+
body.appendChild(el('div', { class: 'chips', style: 'padding:2px 0 8px' }, [
|
|
488
|
+
sum.counts.exceeded ? el('span', { class: 'badge demo', text: `${sum.counts.exceeded} exceeded` }) : null,
|
|
489
|
+
sum.counts.warn ? el('span', { class: 'badge warn', text: `${sum.counts.warn} approaching` }) : null,
|
|
490
|
+
sum.firstToHit ? el('span', { class: 'muted', text: `first projected hit: ${sum.firstToHit.label} in ${countdown(sum.firstToHit.etaHours * 3600000)}` }) : null,
|
|
491
|
+
].filter(Boolean)));
|
|
492
|
+
}
|
|
493
|
+
for (const s of cap.states) body.appendChild(limitRow(ctx, s));
|
|
494
|
+
if (cap.invalid?.length) {
|
|
495
|
+
body.appendChild(el('p', { class: 'hint', text: `${cap.invalid.length} invalid limit definition(s) in config were ignored — check \`tokenflow capacity\`.` }));
|
|
496
|
+
}
|
|
497
|
+
// Editing limits writes to the local API, which a saved file does not have.
|
|
498
|
+
const manage = ctx.snapshot
|
|
499
|
+
? null
|
|
500
|
+
: ctx.btn('⚙ Manage limits', () => openLimitEditor(ctx), 'ghost sm');
|
|
501
|
+
return ctx.card('Capacity & budgets', 'Evaluated against all primary usage regardless of dashboard filters — quota windows are facts about your accounts, not filter states.', body, manage);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function openLimitEditor(ctx) {
|
|
505
|
+
const { compact } = ctx.fmt;
|
|
506
|
+
const cur = (ctx.bundle.limits || []).map((l) => ({ ...l }));
|
|
507
|
+
const body = el('div');
|
|
508
|
+
|
|
509
|
+
// A simple editable list is clearer than a grid here.
|
|
510
|
+
const rows = el('div');
|
|
511
|
+
const renderRows = () => {
|
|
512
|
+
rows.textContent = '';
|
|
513
|
+
for (const l of cur) {
|
|
514
|
+
const r = el('div', { style: 'display:flex;gap:8px;align-items:center;padding:4px 0' });
|
|
515
|
+
r.appendChild(el('span', { class: 'mono', text: `${l.id}`, style: 'min-width:140px' }));
|
|
516
|
+
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)}` }));
|
|
517
|
+
const spacer = el('div', { style: 'flex:1' });
|
|
518
|
+
r.appendChild(spacer);
|
|
519
|
+
r.appendChild(ctx.btn('Remove', () => { cur.splice(cur.indexOf(l), 1); renderRows(); }, 'ghost sm'));
|
|
520
|
+
rows.appendChild(r);
|
|
521
|
+
}
|
|
522
|
+
if (!cur.length) rows.appendChild(el('p', { class: 'hint', text: 'No limits yet — add one below.' }));
|
|
523
|
+
};
|
|
524
|
+
renderRows();
|
|
525
|
+
body.appendChild(rows);
|
|
526
|
+
|
|
527
|
+
const f = {};
|
|
528
|
+
const field = (key, placeholder, type = 'text') => {
|
|
529
|
+
const input = el('input', { placeholder, type, 'aria-label': key });
|
|
530
|
+
input.style.cssText = 'flex:1;min-width:90px';
|
|
531
|
+
f[key] = input;
|
|
532
|
+
return input;
|
|
533
|
+
};
|
|
534
|
+
const scopeSel = el('select', { 'aria-label': 'scope' });
|
|
535
|
+
for (const o of ['day', 'week', 'month']) scopeSel.appendChild(el('option', { value: o, text: o }));
|
|
536
|
+
const metricSel = el('select', { 'aria-label': 'metric' });
|
|
537
|
+
for (const o of ['tokens', 'input', 'output', 'requests', 'cost']) metricSel.appendChild(el('option', { value: o, text: o }));
|
|
538
|
+
|
|
539
|
+
const form = el('div', { style: 'display:flex;gap:6px;flex-wrap:wrap;margin-top:10px' }, [
|
|
540
|
+
field('id', 'id (required)'),
|
|
541
|
+
field('provider', 'provider (optional)'),
|
|
542
|
+
field('model', 'model (optional)'),
|
|
543
|
+
scopeSel, metricSel,
|
|
544
|
+
field('cap', 'cap', 'number'),
|
|
545
|
+
field('warnAt', 'warnAt 0–1', 'number'),
|
|
546
|
+
]);
|
|
547
|
+
for (const c of form.children) c.style.flexGrow = '0';
|
|
548
|
+
body.appendChild(form);
|
|
549
|
+
|
|
550
|
+
const errBox = el('p', { class: 'hint', style: 'color:var(--critical)' });
|
|
551
|
+
body.appendChild(errBox);
|
|
552
|
+
|
|
553
|
+
const foot = el('div', { style: 'display:flex;gap:8px;justify-content:flex-end;width:100%' });
|
|
554
|
+
foot.appendChild(ctx.btn('Cancel', () => ctx.closeModal(), 'ghost sm'));
|
|
555
|
+
foot.appendChild(ctx.btn('Save limits', async () => {
|
|
556
|
+
errBox.textContent = '';
|
|
557
|
+
// The form is only part of the save when the user actually named a new
|
|
558
|
+
// limit. Removal-only saves must not inject an empty draft — that bug
|
|
559
|
+
// made every "remove" also POST a junk row and fail validation.
|
|
560
|
+
const wantsAdd = f.id.value.trim() !== '' || f.cap.value !== '';
|
|
561
|
+
if (wantsAdd && f.id.value.trim() === '') {
|
|
562
|
+
errBox.textContent = 'New limit needs an id (or clear the form to save removals only).';
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
const def = {
|
|
566
|
+
id: f.id.value.trim(),
|
|
567
|
+
provider: f.provider.value.trim() || undefined,
|
|
568
|
+
model: f.model.value.trim() || undefined,
|
|
569
|
+
scope: scopeSel.value,
|
|
570
|
+
metric: metricSel.value,
|
|
571
|
+
cap: Number(f.cap.value),
|
|
572
|
+
...(f.warnAt.value !== '' ? { warnAt: Number(f.warnAt.value) } : {}),
|
|
573
|
+
};
|
|
574
|
+
const next = wantsAdd ? [...cur, def] : [...cur];
|
|
575
|
+
try {
|
|
576
|
+
const res = await fetch('/api/config', {
|
|
577
|
+
method: 'POST',
|
|
578
|
+
headers: { 'content-type': 'application/json' },
|
|
579
|
+
body: JSON.stringify({ limits: next }),
|
|
580
|
+
});
|
|
581
|
+
const out = await res.json();
|
|
582
|
+
if (!res.ok || !out.ok) {
|
|
583
|
+
errBox.textContent = `Invalid: ${(out.invalid || []).map((x) => `${x.id ? x.id + ': ' : ''}${x.errors.join('; ')}`).join(' | ')}`;
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
ctx.bundle.limits = out.limits;
|
|
587
|
+
ctx.rerender();
|
|
588
|
+
ctx.closeModal();
|
|
589
|
+
} catch (e) {
|
|
590
|
+
errBox.textContent = `Save failed: ${e.message}`;
|
|
591
|
+
}
|
|
592
|
+
}, 'sm'));
|
|
593
|
+
body.appendChild(foot);
|
|
594
|
+
|
|
595
|
+
ctx.openModal('Manage capacity limits', body);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function forecastCard(ctx) {
|
|
599
|
+
const { compact, usd, shortDate } = ctx.fmt;
|
|
600
|
+
const v = ctx.view;
|
|
601
|
+
const f = v.forecast;
|
|
602
|
+
const body = el('div');
|
|
603
|
+
|
|
604
|
+
if (!f || f.tomorrow === null) {
|
|
605
|
+
body.appendChild(el('p', { class: 'hint', text: f?.reason || 'Not enough history yet.' }));
|
|
606
|
+
return ctx.card('Forecast', 'A conservative linear trend over recent days — never a promise.', body);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const kpis = el('div', { style: 'display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;padding:10px 14px 2px' });
|
|
610
|
+
const kpiTile = (tileLabel, val, sub) => {
|
|
611
|
+
const d = el('div', { style: 'background:var(--surface-2);border-radius:8px;padding:10px' });
|
|
612
|
+
d.appendChild(el('div', { class: 'hint', text: tileLabel }));
|
|
613
|
+
d.appendChild(el('div', { class: 'k-value str', text: val, style: 'font-size:20px' }));
|
|
614
|
+
if (sub) d.appendChild(el('div', { class: 'hint', text: sub }));
|
|
615
|
+
return d;
|
|
616
|
+
};
|
|
617
|
+
kpis.appendChild(kpiTile('Tomorrow (projected)', compact(f.tomorrow), f.tomorrowInterval ? `${compact(f.tomorrowInterval[0])} – ${compact(f.tomorrowInterval[1])}` : null));
|
|
618
|
+
kpis.appendChild(kpiTile('Next 7 days', compact(f.next7days), f.next7daysCost != null ? usd(f.next7daysCost) : null));
|
|
619
|
+
if (f.monthEnd !== null) {
|
|
620
|
+
kpis.appendChild(kpiTile('Month-end', compact(f.monthEnd), `measured so far ${compact(f.monthEndActualToDate)}${f.monthEndCost !== null ? ` · ≈${usd(f.monthEndCost)} est.` : ''}`));
|
|
621
|
+
}
|
|
622
|
+
kpis.appendChild(kpiTile('Confidence', f.confidence, f.n ? `${f.n}-day trend` : null));
|
|
623
|
+
body.appendChild(kpis);
|
|
624
|
+
|
|
625
|
+
// History + projection side by side: measured bars, then forecast bars in a
|
|
626
|
+
// dashed-looking muted tone, clearly separated by an empty slot.
|
|
627
|
+
const daily = v.daily.slice(-14);
|
|
628
|
+
const data = daily.map((d) => ({
|
|
629
|
+
label: shortDate(d.key),
|
|
630
|
+
value: d.total,
|
|
631
|
+
fmtXLong: d.key,
|
|
632
|
+
color: 'var(--series-1)',
|
|
633
|
+
}));
|
|
634
|
+
if (f.tomorrow !== null) {
|
|
635
|
+
data.push({ label: 'tomorrow*', value: f.tomorrow, color: 'var(--hairline)', extra: [{ name: 'Projected', value: compact(f.tomorrow) }] });
|
|
636
|
+
}
|
|
637
|
+
// The month-end projection deliberately stays OUT of the chart: a whole-
|
|
638
|
+
// month total beside daily bars would flatten the history into unreadability.
|
|
639
|
+
// It lives in the KPI tiles above, labelled as a projection.
|
|
640
|
+
const wrapChart = el('div', { style: 'padding:6px 14px 12px' });
|
|
641
|
+
requestAnimationFrame(() => observeWidth(wrapChart, (w) => {
|
|
642
|
+
wrapChart.textContent = '';
|
|
643
|
+
wrapChart.appendChild(columns({
|
|
644
|
+
data, width: w, height: 200, fmtY: (x) => compact(x), valueLabel: 'Tokens',
|
|
645
|
+
ariaLabel: 'Recent daily usage with projections appended',
|
|
646
|
+
}));
|
|
647
|
+
}));
|
|
648
|
+
body.appendChild(wrapChart);
|
|
649
|
+
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.' }));
|
|
650
|
+
|
|
651
|
+
return ctx.card('Forecast', 'Measured history first; projections always labelled and kept apart.', body);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function anomaliesCard(ctx) {
|
|
655
|
+
const { shortDate } = ctx.fmt;
|
|
656
|
+
const v = ctx.view;
|
|
657
|
+
const body = el('div', { style: 'padding:6px 14px 14px' });
|
|
658
|
+
const anomalies = v.anomalies || [];
|
|
659
|
+
|
|
660
|
+
if (!anomalies.length) {
|
|
661
|
+
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.' }));
|
|
662
|
+
} else {
|
|
663
|
+
for (const a of anomalies) {
|
|
664
|
+
const sev = SEV[a.severity] || SEV.info;
|
|
665
|
+
const row = el('div', { style: 'display:flex;gap:10px;align-items:baseline;padding:7px 0;border-top:1px solid var(--hairline)' });
|
|
666
|
+
row.appendChild(el('span', { class: `badge ${sev.cls}`, text: sev.label }));
|
|
667
|
+
row.appendChild(el('span', { class: 'mono muted', text: a.date, style: 'min-width:86px;font-size:11px' }));
|
|
668
|
+
row.appendChild(el('span', { text: a.detail }));
|
|
669
|
+
body.appendChild(row);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const fresh = [...(v.firstSeen?.models || []).map((m) => ({ kind: 'model', ...m })), ...(v.firstSeen?.providers || []).map((p) => ({ kind: 'provider', ...p }))];
|
|
674
|
+
if (fresh.length) {
|
|
675
|
+
const chips = el('div', { class: 'chips', style: 'padding-top:10px' });
|
|
676
|
+
chips.appendChild(el('span', { class: 'muted', text: 'New this week: ' }));
|
|
677
|
+
for (const x of fresh) {
|
|
678
|
+
chips.appendChild(el('span', { class: 'chip', text: `${x.kind} ${x.entity} (${shortDate(x.firstSeen)})` }));
|
|
679
|
+
}
|
|
680
|
+
body.appendChild(chips);
|
|
681
|
+
}
|
|
682
|
+
return ctx.card('Anomalies & changes', 'Robust median/MAD detection — every alert shows observed vs expected so you can check it.', body);
|
|
683
|
+
}
|