@vimoxshah/tokenflow 1.1.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/CONTRIBUTING.md +84 -0
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/Refresh & Open Dashboard.command +22 -0
- package/SECURITY.md +42 -0
- package/bin/tokenflow.js +1342 -0
- package/docs/architecture.md +193 -0
- package/docs/cli.md +390 -0
- package/docs/configuration.md +281 -0
- package/docs/creating-provider.md +262 -0
- package/docs/data-model.md +213 -0
- package/docs/getting-started.md +266 -0
- package/docs/live-mode.md +199 -0
- package/docs/media/architecture-hero.svg +86 -0
- package/docs/media/cost-editorial-dark.png +0 -0
- package/docs/media/health-terminal-light.png +0 -0
- package/docs/media/menubar-dark.png +0 -0
- package/docs/media/menubar-light.png +0 -0
- package/docs/media/models-terminal-dark.png +0 -0
- package/docs/media/overview-aurora-dark.png +0 -0
- package/docs/media/time-aurora-light.png +0 -0
- package/docs/providers.md +309 -0
- package/docs/skill.md +64 -0
- package/docs/troubleshooting.md +207 -0
- package/examples/config.example.yaml +92 -0
- package/examples/demo-data/README.md +38 -0
- package/examples/demo-data/sample-usage.csv +11 -0
- package/package.json +74 -0
- package/scripts/build-dmg.sh +33 -0
- package/scripts/build-menubar-app.sh +67 -0
- package/scripts/lint.js +111 -0
- package/scripts/validate-install.js +140 -0
- package/skills/tokenflow/SKILL.md +392 -0
- package/skills/tokenflow/examples/config.yaml +92 -0
- package/skills/tokenflow/examples/generic-mapping.json +26 -0
- package/skills/tokenflow/examples/session-transcript.md +191 -0
- package/skills/tokenflow/providers/adapter-template.js +135 -0
- package/skills/tokenflow/providers/detection-matrix.md +142 -0
- package/skills/tokenflow/schemas/config.schema.json +107 -0
- package/skills/tokenflow/schemas/normalized-record.json +63 -0
- package/src/analytics/aggregate.js +247 -0
- package/src/analytics/anomalies.js +222 -0
- package/src/analytics/capacity.js +278 -0
- package/src/analytics/comparison.js +96 -0
- package/src/analytics/dimensions.js +230 -0
- package/src/analytics/efficiency.js +138 -0
- package/src/analytics/forecast.js +202 -0
- package/src/analytics/index.js +327 -0
- package/src/analytics/insights.js +283 -0
- package/src/analytics/milestones.js +91 -0
- package/src/analytics/peak.js +106 -0
- package/src/analytics/productivity.js +166 -0
- package/src/analytics/token-usage.js +267 -0
- package/src/commands/diagnostics.js +88 -0
- package/src/commands/digest.js +155 -0
- package/src/commands/models-compare.js +96 -0
- package/src/core/budget.js +142 -0
- package/src/core/bundle.js +191 -0
- package/src/core/config.js +202 -0
- package/src/core/delivery.js +109 -0
- package/src/core/geo.js +99 -0
- package/src/core/ingest.js +457 -0
- package/src/core/interface-map.js +55 -0
- package/src/core/jsonl.js +124 -0
- package/src/core/live-status.js +417 -0
- package/src/core/model-map.js +157 -0
- package/src/core/notify.js +83 -0
- package/src/core/pricing.js +288 -0
- package/src/core/prompt-analytics.js +127 -0
- package/src/core/registry.js +107 -0
- package/src/core/restore.js +261 -0
- package/src/core/schedule.js +120 -0
- package/src/core/schema.js +316 -0
- package/src/core/sqlite.js +96 -0
- package/src/core/store.js +493 -0
- package/src/core/sync.js +151 -0
- package/src/core/units.js +147 -0
- package/src/core/validate.js +123 -0
- package/src/core/watch.js +287 -0
- package/src/core/yaml.js +209 -0
- package/src/export/bundler.js +107 -0
- package/src/export/csv.js +100 -0
- package/src/export/html-snapshot.js +101 -0
- package/src/export/menubar.js +158 -0
- package/src/index.js +18 -0
- package/src/providers/anthropic/index.js +294 -0
- package/src/providers/cline/index.js +120 -0
- package/src/providers/cursor/index.js +143 -0
- package/src/providers/generic/index.js +268 -0
- package/src/providers/git/index.js +188 -0
- package/src/providers/headroom/index.js +114 -0
- package/src/providers/hermes/index.js +299 -0
- package/src/providers/mock/index.js +117 -0
- package/src/providers/openai/index.js +370 -0
- package/src/providers/opencode/index.js +245 -0
- package/src/sdk.js +46 -0
- package/src/server/server.js +264 -0
- package/src/ui/app.js +2473 -0
- package/src/ui/charts.js +925 -0
- package/src/ui/index.html +42 -0
- package/src/ui/styles.css +644 -0
package/src/ui/app.js
ADDED
|
@@ -0,0 +1,2473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard application.
|
|
3
|
+
*
|
|
4
|
+
* The browser loads the aggregate bundle exactly once and then does all
|
|
5
|
+
* filtering and aggregation locally by calling the SAME analytics modules the
|
|
6
|
+
* CLI uses. Changing a filter therefore costs zero API calls and cannot
|
|
7
|
+
* produce a number that disagrees with `tokenflow status`.
|
|
8
|
+
*/
|
|
9
|
+
import { computeView, resolveRange, QUICK_RANGES, EMPTY_FILTERS, addDays, daysBetween, previousPeriod } from '../analytics/index.js';
|
|
10
|
+
import { indexCube, filterCube } from '../analytics/aggregate.js';
|
|
11
|
+
import { calculateDimensionSeries } from '../analytics/dimensions.js';
|
|
12
|
+
import { compact, int, usd, pct, signedPct, shortDate, longDate, hourLabel, hourWindow, relativeTime, humanDuration, countdown, DOW } from '../core/units.js';
|
|
13
|
+
import { INTERFACE_ORDER } from '../core/schema.js';
|
|
14
|
+
import {
|
|
15
|
+
el, svg, timeSeries, columns, hbars, donut, compositionBar, calendarHeatmap,
|
|
16
|
+
matrix, scatter, sparkline, legend, table, miniBar, tooltip, observeWidth,
|
|
17
|
+
ColorScale, SERIES_VARS, OTHER_COLOR, scaleLegend,
|
|
18
|
+
} from './charts.js';
|
|
19
|
+
|
|
20
|
+
const SNAPSHOT = typeof window !== 'undefined' && !!window.__TOKENFLOW_BUNDLE__;
|
|
21
|
+
|
|
22
|
+
const S = {
|
|
23
|
+
bundle: null,
|
|
24
|
+
view: null,
|
|
25
|
+
tab: 'overview',
|
|
26
|
+
/** @type {'day'|'week'|'month'} */
|
|
27
|
+
granularity: 'day',
|
|
28
|
+
/** @type {'line'|'stacked'} */
|
|
29
|
+
seriesMode: 'stacked',
|
|
30
|
+
/** @type {'tokens'|'requests'|'cost'} metric shown by the provider daily chart */
|
|
31
|
+
providerMetric: 'tokens',
|
|
32
|
+
rangeId: 'all',
|
|
33
|
+
filters: { ...EMPTY_FILTERS },
|
|
34
|
+
hidden: new Set(),
|
|
35
|
+
drillDate: null,
|
|
36
|
+
compare: null,
|
|
37
|
+
tables: new Set(),
|
|
38
|
+
refreshing: false,
|
|
39
|
+
explorer: { page: 0, limit: 50, sort: 'ts', dir: 'desc', search: '', rows: [], total: 0, loading: false },
|
|
40
|
+
/** Latest /api/live snapshot (live mode only; null in a static snapshot). */
|
|
41
|
+
live: null,
|
|
42
|
+
colors: {
|
|
43
|
+
provider: new ColorScale(),
|
|
44
|
+
model: new ColorScale(),
|
|
45
|
+
iface: new ColorScale(),
|
|
46
|
+
client: new ColorScale(),
|
|
47
|
+
family: new ColorScale(),
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const COMP_COLORS = {
|
|
52
|
+
input: 'var(--series-1)',
|
|
53
|
+
output: 'var(--series-2)',
|
|
54
|
+
cacheRead: 'var(--series-3)',
|
|
55
|
+
cacheWrite: 'var(--series-4)',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const TABS = [
|
|
59
|
+
['overview', 'Overview'],
|
|
60
|
+
['live', 'Live'],
|
|
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'],
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Skins restyle the room; they never restyle the data. The categorical series
|
|
76
|
+
* steps live in the mode (dark/light) and were validated against every skin's
|
|
77
|
+
* chart surface, so switching skin cannot change what a colour means — a real
|
|
78
|
+
* risk with themeable dashboards, and the reason this is two axes and not six
|
|
79
|
+
* unrelated stylesheets.
|
|
80
|
+
*/
|
|
81
|
+
export const SKINS = [
|
|
82
|
+
{ id: 'aurora', name: 'Aurora', note: 'Indigo-slate, layered, luminous' },
|
|
83
|
+
{ id: 'terminal', name: 'Terminal', note: 'Near-black, hairlines, mono' },
|
|
84
|
+
{ id: 'editorial', name: 'Editorial', note: 'Warm charcoal, serif figures' },
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
// ============================================================ bootstrapping ==
|
|
88
|
+
|
|
89
|
+
boot().catch((err) => {
|
|
90
|
+
document.getElementById('view').appendChild(
|
|
91
|
+
el('div', { class: 'banner' }, [el('span', { text: 'Could not start: ' + err.message })]),
|
|
92
|
+
);
|
|
93
|
+
console.error(err);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
async function boot() {
|
|
97
|
+
const prefs = loadPrefs();
|
|
98
|
+
S.bundle = SNAPSHOT ? window.__TOKENFLOW_BUNDLE__ : await fetchJson('/api/bundle');
|
|
99
|
+
// Config supplies the default look; a choice made in the browser wins.
|
|
100
|
+
applyTheme(
|
|
101
|
+
prefs.skin || S.bundle.meta?.skin || SKINS[0].id,
|
|
102
|
+
prefs.mode || (prefs.theme === 'light' ? 'light' : null) || S.bundle.meta?.mode || 'dark',
|
|
103
|
+
);
|
|
104
|
+
if (prefs.filters) S.filters = { ...S.filters, ...prefs.filters };
|
|
105
|
+
S.rangeId = prefs.rangeId || S.bundle.meta.defaultRange || 'all';
|
|
106
|
+
if (prefs.granularity) S.granularity = prefs.granularity;
|
|
107
|
+
if (prefs.tab) S.tab = prefs.tab;
|
|
108
|
+
if (S.bundle.meta?.includeOverlayDefault) S.filters.includeOverlay = true;
|
|
109
|
+
applyRange(S.rangeId, { silent: true });
|
|
110
|
+
recompute();
|
|
111
|
+
renderShell();
|
|
112
|
+
render();
|
|
113
|
+
// Handed over from a saved snapshot's "Refresh & open live" button. The
|
|
114
|
+
// refresh runs here, same-origin, with this page's own token.
|
|
115
|
+
if (!SNAPSHOT && new URLSearchParams(location.search).get('refresh') === '1') {
|
|
116
|
+
history.replaceState(null, '', location.pathname);
|
|
117
|
+
doRefresh();
|
|
118
|
+
}
|
|
119
|
+
ensureLiveLoop();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ============================================================ live polling ==
|
|
123
|
+
|
|
124
|
+
let liveTimer = null;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Poll the live snapshot once a minute while the dashboard is open. This is
|
|
128
|
+
* what makes the header pill and the Live tab's watcher strip current without
|
|
129
|
+
* any user action. In a static snapshot there is no server: the loop never
|
|
130
|
+
* starts, and the Live tab renders purely from the bundle.
|
|
131
|
+
*/
|
|
132
|
+
function ensureLiveLoop() {
|
|
133
|
+
if (SNAPSHOT || liveTimer) return;
|
|
134
|
+
const tick = async () => {
|
|
135
|
+
try {
|
|
136
|
+
const r = await fetch('/api/live', { cache: 'no-store' });
|
|
137
|
+
if (r.ok) { S.live = await r.json(); updateLivePill(); }
|
|
138
|
+
} catch { /* server gone (dashboard closed): pill just stays absent */ }
|
|
139
|
+
};
|
|
140
|
+
tick();
|
|
141
|
+
liveTimer = setInterval(tick, 60000);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function updateLivePill() {
|
|
145
|
+
const host = document.getElementById('header-actions');
|
|
146
|
+
let pill = document.getElementById('live-pill');
|
|
147
|
+
const w = S.live?.watcher;
|
|
148
|
+
const fresh = S.live && !S.live.freshness?.stale;
|
|
149
|
+
if (!w) { if (pill) pill.remove(); return; }
|
|
150
|
+
if (!pill) {
|
|
151
|
+
pill = el('span', { class: 'live-pill', id: 'live-pill' });
|
|
152
|
+
host.appendChild(pill);
|
|
153
|
+
}
|
|
154
|
+
const age = S.live.freshness?.ageMs;
|
|
155
|
+
pill.textContent = `● live · ${age != null ? relativeTime(S.live.generatedAt).replace(' ago', '') : ''}`;
|
|
156
|
+
pill.title = `Watcher running (pid ${w.pid}, every ${w.intervalSeconds ?? '?'}s). Data ${fresh ? 'is fresh' : 'may be stale'}.`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param {string} id
|
|
161
|
+
* @param {{silent?:boolean}} [o]
|
|
162
|
+
*/
|
|
163
|
+
function applyRange(id, { silent } = {}) {
|
|
164
|
+
S.rangeId = id;
|
|
165
|
+
if (id !== 'custom') {
|
|
166
|
+
const cov = S.bundle.meta.coverage;
|
|
167
|
+
const today = S.bundle.meta.today && S.bundle.meta.today > cov.to ? S.bundle.meta.today : cov.to;
|
|
168
|
+
const r = resolveRange(id, cov, today);
|
|
169
|
+
const floor = S.bundle.meta.defaultFrom;
|
|
170
|
+
S.filters.from = floor && r.from && r.from < floor ? floor : r.from;
|
|
171
|
+
S.filters.to = r.to;
|
|
172
|
+
}
|
|
173
|
+
if (!silent) { recompute(); render(); }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function recompute() {
|
|
177
|
+
const t0 = performance.now();
|
|
178
|
+
S.view = computeView(S.bundle, {
|
|
179
|
+
...S.filters,
|
|
180
|
+
granularity: S.granularity,
|
|
181
|
+
drillDate: S.drillDate,
|
|
182
|
+
compare: S.compare,
|
|
183
|
+
});
|
|
184
|
+
S.computeMs = performance.now() - t0;
|
|
185
|
+
// Assign colours in a stable, data-driven order the first time we see them.
|
|
186
|
+
S.view.dimensions.providers.forEach((p) => S.colors.provider.get(p.key));
|
|
187
|
+
S.view.dimensions.models.forEach((m) => S.colors.model.get(m.key));
|
|
188
|
+
S.view.dimensions.interfaces.forEach((i) => S.colors.iface.get(i.key));
|
|
189
|
+
S.view.dimensions.clients.forEach((c) => S.colors.client.get(c.key));
|
|
190
|
+
S.view.dimensions.families.forEach((f) => S.colors.family.get(f.key));
|
|
191
|
+
savePrefs();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ==================================================================== theme ==
|
|
195
|
+
|
|
196
|
+
function applyTheme(skin, mode) {
|
|
197
|
+
const r = document.documentElement;
|
|
198
|
+
r.dataset.skin = SKINS.some((s) => s.id === skin) ? skin : 'aurora';
|
|
199
|
+
r.dataset.mode = mode === 'light' ? 'light' : 'dark';
|
|
200
|
+
// Kept for anything still reading the old single-axis attribute.
|
|
201
|
+
r.dataset.theme = r.dataset.mode;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function themePicker() {
|
|
205
|
+
const r = document.documentElement;
|
|
206
|
+
const wrap = el('div', { class: 'theme' });
|
|
207
|
+
const b = btn(`◑ ${SKINS.find((s) => s.id === r.dataset.skin)?.name || 'Theme'}`, (ev) => {
|
|
208
|
+
ev.stopPropagation();
|
|
209
|
+
wrap.classList.toggle('open');
|
|
210
|
+
}, 'ghost');
|
|
211
|
+
const pop = el('div', { class: 'theme-pop' });
|
|
212
|
+
pop.addEventListener('click', (ev) => ev.stopPropagation());
|
|
213
|
+
|
|
214
|
+
for (const sk of SKINS) {
|
|
215
|
+
const row = el('button', { class: 'theme-row', 'aria-pressed': String(r.dataset.skin === sk.id) }, [
|
|
216
|
+
el('span', { class: 'nm' }, [el('span', { text: sk.name }), el('small', { text: sk.note })]),
|
|
217
|
+
el('span', { class: 'swatches' }, ['1', '2', '3'].map((n) => {
|
|
218
|
+
const i = el('i');
|
|
219
|
+
i.style.background = `var(--series-${n})`;
|
|
220
|
+
return i;
|
|
221
|
+
})),
|
|
222
|
+
]);
|
|
223
|
+
row.addEventListener('click', () => {
|
|
224
|
+
applyTheme(sk.id, r.dataset.mode);
|
|
225
|
+
savePrefs();
|
|
226
|
+
renderShell();
|
|
227
|
+
render();
|
|
228
|
+
});
|
|
229
|
+
pop.appendChild(row);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const seg = el('div', { class: 'seg' });
|
|
233
|
+
for (const [id, label] of [['dark', '◐ Dark'], ['light', '◑ Light']]) {
|
|
234
|
+
const mb = el('button', { text: label, 'aria-pressed': String(r.dataset.mode === id) });
|
|
235
|
+
mb.addEventListener('click', () => {
|
|
236
|
+
applyTheme(r.dataset.skin, id);
|
|
237
|
+
savePrefs();
|
|
238
|
+
renderShell();
|
|
239
|
+
render();
|
|
240
|
+
});
|
|
241
|
+
seg.appendChild(mb);
|
|
242
|
+
}
|
|
243
|
+
pop.appendChild(seg);
|
|
244
|
+
pop.appendChild(el('div', { class: 'theme-note', text: 'Series colours are fixed per mode and validated for colour-blind separation, so a skin never changes what a colour means.' }));
|
|
245
|
+
|
|
246
|
+
wrap.appendChild(b);
|
|
247
|
+
wrap.appendChild(pop);
|
|
248
|
+
document.addEventListener('click', () => wrap.classList.remove('open'));
|
|
249
|
+
return wrap;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ==================================================================== shell ==
|
|
253
|
+
|
|
254
|
+
function renderShell() {
|
|
255
|
+
const acts = document.getElementById('header-actions');
|
|
256
|
+
acts.textContent = '';
|
|
257
|
+
if (!SNAPSHOT) {
|
|
258
|
+
acts.appendChild(btn('↻ Refresh data', () => doRefresh(), 'primary', 'refresh-btn'));
|
|
259
|
+
}
|
|
260
|
+
acts.appendChild(btn('Export CSV ▾', (ev) => exportMenu(ev), 'ghost'));
|
|
261
|
+
acts.appendChild(btn('Pricing', () => pricingModal(), 'ghost'));
|
|
262
|
+
acts.appendChild(themePicker());
|
|
263
|
+
|
|
264
|
+
const tabs = document.getElementById('tabs');
|
|
265
|
+
tabs.textContent = '';
|
|
266
|
+
for (const [id, label] of TABS) {
|
|
267
|
+
const b = el('button', { role: 'tab', text: label, 'aria-selected': String(S.tab === id) });
|
|
268
|
+
b.addEventListener('click', () => { S.tab = id; savePrefs(); renderShell(); render(); });
|
|
269
|
+
tabs.appendChild(b);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const foot = document.getElementById('footer');
|
|
273
|
+
foot.textContent = '';
|
|
274
|
+
const m = S.bundle.meta;
|
|
275
|
+
foot.appendChild(el('div', {
|
|
276
|
+
text: `Local-first: every number on this page was computed in this browser from ${m.dataHome}. Nothing is uploaded.`,
|
|
277
|
+
}));
|
|
278
|
+
foot.appendChild(el('div', {
|
|
279
|
+
text: `v${m.appVersion} · cube v${m.cubeVersion} · timezone ${m.timezone} · pricing table ${m.pricingTableVersion} · view computed in ${Math.round(S.computeMs || 0)} ms`,
|
|
280
|
+
}));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function render() {
|
|
284
|
+
renderHeaderMeta();
|
|
285
|
+
renderBanners();
|
|
286
|
+
renderFilters();
|
|
287
|
+
renderCrumbs();
|
|
288
|
+
const host = document.getElementById('view');
|
|
289
|
+
host.textContent = '';
|
|
290
|
+
const fn = {
|
|
291
|
+
overview: viewOverview,
|
|
292
|
+
live: viewLive,
|
|
293
|
+
providers: () => viewDimension('provider', 'Provider intelligence'),
|
|
294
|
+
models: viewModels,
|
|
295
|
+
interfaces: viewInterfaces,
|
|
296
|
+
time: viewTime,
|
|
297
|
+
peaks: viewPeaks,
|
|
298
|
+
efficiency: viewEfficiency,
|
|
299
|
+
cost: viewCost,
|
|
300
|
+
productivity: viewProductivity,
|
|
301
|
+
compare: viewCompare,
|
|
302
|
+
explorer: viewExplorer,
|
|
303
|
+
health: viewHealth,
|
|
304
|
+
}[S.tab] || viewOverview;
|
|
305
|
+
host.appendChild(fn());
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function renderHeaderMeta() {
|
|
309
|
+
const m = S.bundle.meta;
|
|
310
|
+
const h = S.bundle.health;
|
|
311
|
+
document.getElementById('coverage').textContent =
|
|
312
|
+
`${h.coverage.from ? longDate(h.coverage.from) : '—'} → ${h.coverage.to ? longDate(h.coverage.to) : '—'} · ${int(h.records)} records · refreshed ${relativeTime(m.lastRefresh)}`;
|
|
313
|
+
const dot = document.getElementById('health-dot');
|
|
314
|
+
dot.className = 'dot' + (S.refreshing ? ' busy' : h.grade === 'Excellent' ? '' : ' stale');
|
|
315
|
+
dot.title = `Data health: ${h.grade}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function renderBanners() {
|
|
319
|
+
const box = document.getElementById('banners');
|
|
320
|
+
box.textContent = '';
|
|
321
|
+
if (S.bundle.meta.demo) {
|
|
322
|
+
box.appendChild(el('div', { class: 'banner' }, [
|
|
323
|
+
el('span', { class: 'badge demo', text: 'DEMO DATA' }),
|
|
324
|
+
el('span', { text: 'This dataset contains synthetic records generated for demonstration. Run `tokenflow refresh --full` after removing the mock provider to see real usage.' }),
|
|
325
|
+
]));
|
|
326
|
+
}
|
|
327
|
+
if (!S.bundle.cube.rows.length) {
|
|
328
|
+
box.appendChild(el('div', { class: 'banner info' }, [
|
|
329
|
+
el('span', { text: 'No usage data yet. Run `tokenflow setup` then `tokenflow refresh`, or `npm run demo` to explore with synthetic data.' }),
|
|
330
|
+
]));
|
|
331
|
+
}
|
|
332
|
+
if (SNAPSHOT) box.appendChild(freshnessBar());
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* A saved snapshot is a file, and a file cannot re-read your logs — so instead
|
|
337
|
+
* of a dead ↻ button it states its own age and offers the two honest ways to
|
|
338
|
+
* get current data.
|
|
339
|
+
*
|
|
340
|
+
* It probes the loopback API for a running dashboard. If one answers, the
|
|
341
|
+
* button hands over to it with ?refresh=1 (a navigation, not a cross-origin
|
|
342
|
+
* POST — the live page then refreshes with its own token). If nothing answers,
|
|
343
|
+
* it shows the one command that starts everything.
|
|
344
|
+
*/
|
|
345
|
+
function freshnessBar() {
|
|
346
|
+
const snapAt = typeof window !== 'undefined' ? window.__TOKENFLOW_SNAPSHOT_AT__ : null;
|
|
347
|
+
const dataAt = S.bundle.meta?.builtAt || snapAt;
|
|
348
|
+
const ageDays = dataAt ? Math.floor((Date.now() - new Date(dataAt).getTime()) / 86400000) : null;
|
|
349
|
+
const stale = ageDays !== null && ageDays >= 2;
|
|
350
|
+
|
|
351
|
+
const bar = el('div', { class: 'freshness' + (stale ? ' warn' : '') });
|
|
352
|
+
bar.appendChild(el('span', { class: 'badge', text: 'SNAPSHOT' }));
|
|
353
|
+
bar.appendChild(el('span', {}, [
|
|
354
|
+
el('span', { class: 'age', text: ageDays === null ? 'Age unknown' : ageDays === 0 ? 'Data from today' : ageDays === 1 ? 'Data from yesterday' : `Data is ${ageDays} days old` }),
|
|
355
|
+
document.createTextNode(dataAt ? ` · captured ${new Date(dataAt).toLocaleString()}` : ''),
|
|
356
|
+
]));
|
|
357
|
+
bar.appendChild(el('span', { class: 'spacer' }));
|
|
358
|
+
const slot = el('span', { class: 'chips' }, [el('span', { class: 'k-sub', text: 'looking for a live dashboard…' })]);
|
|
359
|
+
bar.appendChild(slot);
|
|
360
|
+
|
|
361
|
+
findLiveServer().then((live) => {
|
|
362
|
+
slot.textContent = '';
|
|
363
|
+
if (live) {
|
|
364
|
+
slot.appendChild(el('span', { class: 'k-sub', text: `live dashboard on port ${live.port}` }));
|
|
365
|
+
const go = btn('↻ Refresh & open live', () => {
|
|
366
|
+
window.location.href = `${live.origin}/?refresh=1`;
|
|
367
|
+
}, 'primary sm');
|
|
368
|
+
slot.appendChild(go);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
slot.appendChild(el('span', { class: 'k-sub', text: 'no live dashboard running — start one:' }));
|
|
372
|
+
slot.appendChild(el('code', { text: 'npm start' }));
|
|
373
|
+
const copy = btn('Copy', async () => {
|
|
374
|
+
try { await navigator.clipboard.writeText('npm start'); copy.textContent = 'Copied'; } catch { copy.textContent = 'npm start'; }
|
|
375
|
+
}, 'ghost sm');
|
|
376
|
+
slot.appendChild(copy);
|
|
377
|
+
});
|
|
378
|
+
return bar;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Probe the usual loopback ports for a running dashboard. */
|
|
382
|
+
async function findLiveServer() {
|
|
383
|
+
const ports = (typeof window !== 'undefined' && window.__TOKENFLOW_PORTS__) || [7799, 7800, 8799];
|
|
384
|
+
const tryPort = async (port) => {
|
|
385
|
+
const origin = `http://127.0.0.1:${port}`;
|
|
386
|
+
const ctrl = new AbortController();
|
|
387
|
+
const t = setTimeout(() => ctrl.abort(), 900);
|
|
388
|
+
try {
|
|
389
|
+
const r = await fetch(`${origin}/api/ping`, { signal: ctrl.signal, cache: 'no-store' });
|
|
390
|
+
const j = await r.json();
|
|
391
|
+
if (j && j.app === 'tokenflow') return { ...j, origin, port };
|
|
392
|
+
} catch { /* nothing there, or blocked — treat as absent */ } finally { clearTimeout(t); }
|
|
393
|
+
return null;
|
|
394
|
+
};
|
|
395
|
+
const results = await Promise.all(ports.map(tryPort));
|
|
396
|
+
return results.find(Boolean) || null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ================================================================== filters ==
|
|
400
|
+
|
|
401
|
+
function renderFilters() {
|
|
402
|
+
const box = document.getElementById('filters');
|
|
403
|
+
box.textContent = '';
|
|
404
|
+
const cov = S.bundle.meta.coverage;
|
|
405
|
+
|
|
406
|
+
const quick = el('div', { class: 'chips' });
|
|
407
|
+
for (const r of QUICK_RANGES) {
|
|
408
|
+
if (r.id === 'custom') continue;
|
|
409
|
+
const allFrom = S.bundle.meta.defaultFrom && S.bundle.meta.defaultFrom > (cov.from || '') ? S.bundle.meta.defaultFrom : cov.from;
|
|
410
|
+
const c = el('button', { class: 'chip', text: r.id === 'all' ? `Since ${shortDate(allFrom || '')}` : r.label, 'aria-pressed': String(S.rangeId === r.id) });
|
|
411
|
+
c.addEventListener('click', () => applyRange(r.id));
|
|
412
|
+
quick.appendChild(c);
|
|
413
|
+
}
|
|
414
|
+
box.appendChild(el('div', { class: 'grp' }, [el('label', { class: 'fld' }, [el('span', { text: 'Quick range' }), quick])]));
|
|
415
|
+
|
|
416
|
+
box.appendChild(dateField('Date from', S.filters.from, (v) => { S.filters.from = v; S.rangeId = 'custom'; recompute(); render(); }));
|
|
417
|
+
box.appendChild(dateField('Date to', S.filters.to, (v) => { S.filters.to = v; S.rangeId = 'custom'; recompute(); render(); }));
|
|
418
|
+
box.appendChild(hourField('Hour from', S.filters.hourFrom, (v) => { S.filters.hourFrom = v; recompute(); render(); }));
|
|
419
|
+
box.appendChild(hourField('Hour to', S.filters.hourTo, (v) => { S.filters.hourTo = v; recompute(); render(); }));
|
|
420
|
+
|
|
421
|
+
const f = S.view.facets;
|
|
422
|
+
box.appendChild(multi('Provider', f.provider, S.filters.provider, (v) => { S.filters.provider = v; recompute(); render(); }));
|
|
423
|
+
box.appendChild(multi('Model', f.model, S.filters.model, (v) => { S.filters.model = v; recompute(); render(); }));
|
|
424
|
+
box.appendChild(multi('Client', f.client, S.filters.client, (v) => { S.filters.client = v; recompute(); render(); }));
|
|
425
|
+
box.appendChild(multi('Interface', f.interface, S.filters.interface, (v) => { S.filters.interface = v; recompute(); render(); }));
|
|
426
|
+
box.appendChild(multi('Project', f.project, S.filters.project, (v) => { S.filters.project = v; recompute(); render(); }));
|
|
427
|
+
box.appendChild(multi('Gateway', f.gateway, S.filters.gateway, (v) => { S.filters.gateway = v; recompute(); render(); }));
|
|
428
|
+
box.appendChild(multi('Service tier', f.service_tier, S.filters.service_tier, (v) => { S.filters.service_tier = v; recompute(); render(); }));
|
|
429
|
+
|
|
430
|
+
const toggles = el('div', { class: 'chips' });
|
|
431
|
+
toggles.appendChild(toggleChip('Include gateway overlay', S.filters.includeOverlay, (v) => {
|
|
432
|
+
S.filters.includeOverlay = v; recompute(); render();
|
|
433
|
+
}, 'Proxy/gateway logs describe traffic already counted by the client adapter. Including them double-counts tokens, but exposes measured cost.'));
|
|
434
|
+
toggles.appendChild(toggleChip('Include activity-only', S.filters.includeActivity, (v) => {
|
|
435
|
+
S.filters.includeActivity = v; recompute(); render();
|
|
436
|
+
}, 'Records from sources that report no token counts (Cline sessions, IDE edits, commits). They never add tokens, only activity.'));
|
|
437
|
+
box.appendChild(el('div', { class: 'grp' }, [el('label', { class: 'fld' }, [el('span', { text: 'Scope' }), toggles])]));
|
|
438
|
+
|
|
439
|
+
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'));
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function activeFilterCount() {
|
|
449
|
+
let n = 0;
|
|
450
|
+
for (const k of ['provider', 'model', 'model_family', 'client', 'interface', 'gateway', 'project', 'repository', 'service_tier']) {
|
|
451
|
+
if (S.filters[k] && S.filters[k].length) n++;
|
|
452
|
+
}
|
|
453
|
+
if (S.filters.hourFrom !== null || S.filters.hourTo !== null) n++;
|
|
454
|
+
if (S.drillDate) n++;
|
|
455
|
+
return n;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function dateField(label, value, onChange) {
|
|
459
|
+
const i = el('input', { type: 'date', value: value || '' });
|
|
460
|
+
i.min = S.bundle.meta.coverage.from || '';
|
|
461
|
+
i.addEventListener('change', () => onChange(i.value || null));
|
|
462
|
+
return el('label', { class: 'fld' }, [el('span', { text: label }), i]);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function hourField(label, value, onChange) {
|
|
466
|
+
const s = el('select');
|
|
467
|
+
s.appendChild(el('option', { value: '', text: 'any' }));
|
|
468
|
+
for (let h = 0; h < 24; h++) s.appendChild(el('option', { value: String(h), text: hourLabel(h) + ':00' }));
|
|
469
|
+
s.value = value === null || value === undefined ? '' : String(value);
|
|
470
|
+
s.addEventListener('change', () => onChange(s.value === '' ? null : Number(s.value)));
|
|
471
|
+
return el('label', { class: 'fld' }, [el('span', { text: label }), s]);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function toggleChip(label, on, onChange, title) {
|
|
475
|
+
const c = el('button', { class: 'chip', text: (on ? '✓ ' : '') + label, 'aria-pressed': String(!!on), title: title || '' });
|
|
476
|
+
c.addEventListener('click', () => onChange(!on));
|
|
477
|
+
return c;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function multi(label, options, selected, onChange) {
|
|
481
|
+
const sel = new Set(selected || []);
|
|
482
|
+
const wrap = el('div', { class: 'ms' });
|
|
483
|
+
const b = el('button', {
|
|
484
|
+
class: 'btn ms-btn',
|
|
485
|
+
text: sel.size ? `${label}: ${sel.size}` : label,
|
|
486
|
+
});
|
|
487
|
+
b.appendChild(el('span', { class: 'muted', text: '▾' }));
|
|
488
|
+
const pop = el('div', { class: 'ms-pop' });
|
|
489
|
+
const search = el('input', { type: 'text', placeholder: `Filter ${label.toLowerCase()}…` });
|
|
490
|
+
const list = el('div', { class: 'ms-list' });
|
|
491
|
+
const paint = () => {
|
|
492
|
+
list.textContent = '';
|
|
493
|
+
const q = search.value.toLowerCase();
|
|
494
|
+
for (const o of options) {
|
|
495
|
+
const name = String(o.value);
|
|
496
|
+
if (q && !name.toLowerCase().includes(q)) continue;
|
|
497
|
+
const row = el('div', { class: 'ms-row', role: 'option', 'aria-selected': String(sel.has(o.value)) }, [
|
|
498
|
+
el('span', { class: 'tick', text: sel.has(o.value) ? '✓' : '' }),
|
|
499
|
+
el('span', { class: 'nm', text: name, title: name }),
|
|
500
|
+
el('span', { class: 'ct', text: compact(o.total) }),
|
|
501
|
+
]);
|
|
502
|
+
row.addEventListener('click', () => {
|
|
503
|
+
if (sel.has(o.value)) sel.delete(o.value); else sel.add(o.value);
|
|
504
|
+
paint();
|
|
505
|
+
b.firstChild.textContent = sel.size ? `${label}: ${sel.size}` : label;
|
|
506
|
+
});
|
|
507
|
+
list.appendChild(row);
|
|
508
|
+
}
|
|
509
|
+
if (!list.children.length) list.appendChild(el('div', { class: 'empty', text: 'No matches' }));
|
|
510
|
+
};
|
|
511
|
+
search.addEventListener('input', paint);
|
|
512
|
+
paint();
|
|
513
|
+
pop.appendChild(search);
|
|
514
|
+
pop.appendChild(list);
|
|
515
|
+
pop.appendChild(el('div', { class: 'ms-foot' }, [
|
|
516
|
+
btn('Clear', () => { sel.clear(); paint(); onChange(null); wrap.classList.remove('open'); }, 'ghost sm'),
|
|
517
|
+
btn('Apply', () => { onChange([...sel]); wrap.classList.remove('open'); }, 'primary sm'),
|
|
518
|
+
]));
|
|
519
|
+
b.addEventListener('click', (ev) => {
|
|
520
|
+
ev.stopPropagation();
|
|
521
|
+
document.querySelectorAll('.ms.open').forEach((x) => { if (x !== wrap) x.classList.remove('open'); });
|
|
522
|
+
wrap.classList.toggle('open');
|
|
523
|
+
});
|
|
524
|
+
document.addEventListener('click', (ev) => { if (!wrap.contains(ev.target)) wrap.classList.remove('open'); });
|
|
525
|
+
wrap.appendChild(b);
|
|
526
|
+
wrap.appendChild(pop);
|
|
527
|
+
return el('label', { class: 'fld' }, [el('span', { text: label }), wrap]);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ============================================================== breadcrumbs ==
|
|
531
|
+
|
|
532
|
+
function renderCrumbs() {
|
|
533
|
+
const box = document.getElementById('crumbs');
|
|
534
|
+
box.textContent = '';
|
|
535
|
+
const parts = [{ label: 'All data', reset: () => { S.filters = { ...EMPTY_FILTERS, includeOverlay: S.filters.includeOverlay, includeActivity: S.filters.includeActivity }; S.drillDate = null; applyRange('all'); } }];
|
|
536
|
+
for (const [key, label] of [['provider', 'Provider'], ['model', 'Model'], ['client', 'Client'], ['interface', 'Interface'], ['project', 'Project'], ['gateway', 'Gateway'], ['service_tier', 'Tier']]) {
|
|
537
|
+
const v = S.filters[key];
|
|
538
|
+
if (v && v.length) {
|
|
539
|
+
parts.push({
|
|
540
|
+
label: `${label}: ${v.join(', ')}`,
|
|
541
|
+
reset: () => { S.filters[key] = null; recompute(); render(); },
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (S.filters.hourFrom !== null || S.filters.hourTo !== null) {
|
|
546
|
+
parts.push({
|
|
547
|
+
label: `Hours ${S.filters.hourFrom ?? 0}–${S.filters.hourTo ?? 23}`,
|
|
548
|
+
reset: () => { S.filters.hourFrom = null; S.filters.hourTo = null; recompute(); render(); },
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
if (S.drillDate) parts.push({ label: longDate(S.drillDate), reset: () => { S.drillDate = null; recompute(); render(); } });
|
|
552
|
+
|
|
553
|
+
parts.forEach((p, i) => {
|
|
554
|
+
if (i) box.appendChild(el('span', { class: 'sep', text: '→' }));
|
|
555
|
+
const c = el('span', { class: 'cr', text: p.label });
|
|
556
|
+
c.addEventListener('click', p.reset);
|
|
557
|
+
box.appendChild(c);
|
|
558
|
+
});
|
|
559
|
+
if (parts.length > 1) box.appendChild(el('span', { class: 'muted', text: ` · click a crumb to remove it` }));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ==================================================== card / chart plumbing ==
|
|
563
|
+
|
|
564
|
+
function card(title, hint, body, actions) {
|
|
565
|
+
const c = el('div', { class: 'card pad0' });
|
|
566
|
+
const head = el('div', { class: 'card-head' });
|
|
567
|
+
const tt = el('div', { style: 'min-width:0' });
|
|
568
|
+
tt.appendChild(el('h3', {}, [document.createTextNode(title)]));
|
|
569
|
+
if (hint) tt.appendChild(el('p', { class: 'hint', text: hint }));
|
|
570
|
+
head.appendChild(tt);
|
|
571
|
+
head.appendChild(el('div', { class: 'spacer' }));
|
|
572
|
+
if (actions) for (const a of [].concat(actions)) head.appendChild(a);
|
|
573
|
+
c.appendChild(head);
|
|
574
|
+
const b = el('div', { class: 'card-body' });
|
|
575
|
+
b.appendChild(body);
|
|
576
|
+
c.appendChild(b);
|
|
577
|
+
return c;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* A chart card with its mandatory table twin. The toggle is per card and the
|
|
582
|
+
* table is the WCAG-clean equivalent, so no value is hover-only.
|
|
583
|
+
*/
|
|
584
|
+
function chartCard(id, title, hint, renderChart, tableSpec, extraActions) {
|
|
585
|
+
const showTable = S.tables.has(id);
|
|
586
|
+
const body = el('div');
|
|
587
|
+
const toggle = btn(showTable ? '▤ Chart' : '▦ Table', () => {
|
|
588
|
+
if (showTable) S.tables.delete(id); else S.tables.add(id);
|
|
589
|
+
render();
|
|
590
|
+
}, 'ghost sm');
|
|
591
|
+
const actions = [].concat(extraActions || []).concat([toggle]);
|
|
592
|
+
if (showTable && tableSpec) {
|
|
593
|
+
body.appendChild(table(tableSpec.columns, tableSpec.rows, tableSpec));
|
|
594
|
+
if (tableSpec.rows.length) {
|
|
595
|
+
actions.unshift(btn('⇩ CSV', () => downloadCsv(`${id}.csv`, tableSpec.columns, tableSpec.rows), 'ghost sm'));
|
|
596
|
+
}
|
|
597
|
+
} else {
|
|
598
|
+
const host = el('div');
|
|
599
|
+
body.appendChild(host);
|
|
600
|
+
requestAnimationFrame(() => observeWidth(host, (w) => {
|
|
601
|
+
host.textContent = '';
|
|
602
|
+
const n = renderChart(w);
|
|
603
|
+
if (n) host.appendChild(n);
|
|
604
|
+
}));
|
|
605
|
+
}
|
|
606
|
+
return card(title, hint, body, actions);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function btn(label, onClick, cls = '', id = null) {
|
|
610
|
+
const b = el('button', { class: 'btn ' + cls, text: label });
|
|
611
|
+
if (id) b.id = id;
|
|
612
|
+
b.addEventListener('click', onClick);
|
|
613
|
+
return b;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function kpi(label, value, sub, opt = {}) {
|
|
617
|
+
const c = el('div', { class: 'card' + (opt.hero ? ' hero-card' : '') });
|
|
618
|
+
const k = el('div', { class: 'kpi' + (opt.onClick ? ' clickable' : '') });
|
|
619
|
+
k.appendChild(el('span', { class: 'k-label' }, [
|
|
620
|
+
document.createTextNode(label),
|
|
621
|
+
opt.badge ? el('span', { class: 'badge ' + (opt.badgeKind || ''), text: opt.badge, title: opt.badgeTitle || '' }) : null,
|
|
622
|
+
]));
|
|
623
|
+
k.appendChild(el('span', {
|
|
624
|
+
class: 'k-value' + (opt.hero ? ' hero' : '') + (opt.str ? ' str' : ''),
|
|
625
|
+
text: value,
|
|
626
|
+
}));
|
|
627
|
+
if (sub) k.appendChild(el('span', { class: 'k-sub' }, [typeof sub === 'string' ? document.createTextNode(sub) : sub]));
|
|
628
|
+
if (opt.spark && opt.spark.length) {
|
|
629
|
+
const s = el('div', { class: 'k-spark' });
|
|
630
|
+
s.appendChild(sparkline(opt.spark, { color: opt.sparkColor, width: 140, height: 26 }));
|
|
631
|
+
k.appendChild(s);
|
|
632
|
+
}
|
|
633
|
+
if (opt.onClick) k.addEventListener('click', opt.onClick);
|
|
634
|
+
if (opt.title) c.title = opt.title;
|
|
635
|
+
c.appendChild(k);
|
|
636
|
+
return c;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function deltaChip(change, { goodUp = true } = {}) {
|
|
640
|
+
if (change === null || change === undefined || !isFinite(change)) {
|
|
641
|
+
return el('span', { class: 'delta flat', text: 'no prior period' });
|
|
642
|
+
}
|
|
643
|
+
const dir = Math.abs(change) < 0.005 ? 'flat' : change > 0 === goodUp ? 'up' : 'down';
|
|
644
|
+
return el('span', { class: 'delta ' + dir, text: `${change > 0 ? '▲' : change < 0 ? '▼' : '■'} ${signedPct(change)}` });
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function sectionTitle(t) {
|
|
648
|
+
return el('div', { class: 'sec-title', text: t });
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function emptyCard(text, detail) {
|
|
652
|
+
const b = el('div', { class: 'empty' });
|
|
653
|
+
b.appendChild(el('strong', { text }));
|
|
654
|
+
if (detail) b.appendChild(el('span', { text: detail }));
|
|
655
|
+
return b;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ================================================================= overview ==
|
|
659
|
+
|
|
660
|
+
function viewOverview() {
|
|
661
|
+
const v = S.view;
|
|
662
|
+
const root = el('div', { class: 'grid' });
|
|
663
|
+
root.appendChild(kpiRow());
|
|
664
|
+
|
|
665
|
+
const gran = el('div', { class: 'chips' });
|
|
666
|
+
for (const g of /** @type {('day'|'week'|'month')[]} */ (['day', 'week', 'month'])) {
|
|
667
|
+
const c = el('button', { class: 'chip', text: g[0].toUpperCase() + g.slice(1) + 'ly', 'aria-pressed': String(S.granularity === g) });
|
|
668
|
+
c.addEventListener('click', () => { S.granularity = g; recompute(); render(); });
|
|
669
|
+
gran.appendChild(c);
|
|
670
|
+
}
|
|
671
|
+
for (const m of /** @type {[('stacked'|'line'), string][]} */ ([['stacked', 'Stacked'], ['line', 'Lines']])) {
|
|
672
|
+
const c = el('button', { class: 'chip', text: m[1], 'aria-pressed': String(S.seriesMode === m[0]) });
|
|
673
|
+
c.addEventListener('click', () => { S.seriesMode = m[0]; render(); });
|
|
674
|
+
gran.appendChild(c);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
root.appendChild(mainSeriesCard(gran));
|
|
678
|
+
root.appendChild(compositionCard());
|
|
679
|
+
root.appendChild(providerDailyCard());
|
|
680
|
+
|
|
681
|
+
const two = el('div', { class: 'grid', style: 'grid-template-columns:repeat(auto-fit,minmax(420px,1fr))' });
|
|
682
|
+
two.appendChild(shareCard('provider', 'Provider distribution', v.dimensions.providers, S.colors.provider, 'provider'));
|
|
683
|
+
two.appendChild(shareCard('model', 'Model distribution', v.dimensions.models, S.colors.model, 'model'));
|
|
684
|
+
two.appendChild(interfaceCard());
|
|
685
|
+
two.appendChild(topModelsCard());
|
|
686
|
+
root.appendChild(two);
|
|
687
|
+
|
|
688
|
+
root.appendChild(calendarCard());
|
|
689
|
+
root.appendChild(insightsCard());
|
|
690
|
+
return root;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function kpiRow() {
|
|
694
|
+
const v = S.view;
|
|
695
|
+
const k = v.kpis;
|
|
696
|
+
const daily = v.daily.map((d) => d.total);
|
|
697
|
+
const box = el('div', { class: 'cards' });
|
|
698
|
+
const prev = previousPeriod(v.range.from, v.range.to);
|
|
699
|
+
const prevView = computeView(S.bundle, { ...v.filters, from: prev.from, to: prev.to, granularity: 'day' });
|
|
700
|
+
const chg = (a, b) => (b > 0 ? (a - b) / b : null);
|
|
701
|
+
|
|
702
|
+
const totalCard = kpi('Total usage', compact(k.total.value), deltaChip(chg(k.total.value, prevView.totals.total)), { hero: true, spark: daily, title: int(k.total.value) + ' tokens' });
|
|
703
|
+
totalCard.classList.add('wide');
|
|
704
|
+
box.appendChild(totalCard);
|
|
705
|
+
box.appendChild(kpi('Input', compact(k.input.value), naSub(k.input.na, v.totals.req, pct(v.composition.shares.input)), { spark: v.daily.map((d) => d.in), sparkColor: COMP_COLORS.input }));
|
|
706
|
+
box.appendChild(kpi('Output', compact(k.output.value), naSub(k.output.na, v.totals.req, pct(v.composition.shares.output)), { spark: v.daily.map((d) => d.out), sparkColor: COMP_COLORS.output }));
|
|
707
|
+
box.appendChild(kpi('Cache', compact(k.cache.value), naSub(k.cache.na, v.totals.req * 2, pct(v.composition.shares.cache)), { spark: v.daily.map((d) => d.cr + d.cw), sparkColor: COMP_COLORS.cacheRead }));
|
|
708
|
+
box.appendChild(kpi('Avg / active day', compact(k.avgPerDay.value), `median ${compact(v.averages.medianActiveDay)}`));
|
|
709
|
+
box.appendChild(kpi('Peak day', compact(k.peak.value), k.peak.detail ? shortDate(k.peak.detail) : '—', {
|
|
710
|
+
onClick: k.peak.detail ? () => { S.drillDate = k.peak.detail; S.tab = 'peaks'; renderShell(); recompute(); render(); } : null,
|
|
711
|
+
}));
|
|
712
|
+
box.appendChild(kpi('Active days', int(k.activeDays.value),
|
|
713
|
+
`of ${v.daily.length} in range · streak ${v.streaks.longest}`
|
|
714
|
+
+ (v.averages.activityOnlyDays ? ` · +${v.averages.activityOnlyDays} activity-only` : ''),
|
|
715
|
+
{ title: 'Days with measured token usage. Days where only a no-token source (IDE edits, sessions without a usage block) was active are counted separately.' }));
|
|
716
|
+
box.appendChild(kpi('Avg sessions / day', k.sessionsPerDay.value === null ? '—' : k.sessionsPerDay.value.toFixed(1), `${int(k.sessions.value)} sessions`));
|
|
717
|
+
box.appendChild(kpi('Providers', int(k.providers.value), v.dimensions.providers.slice(0, 2).map((p) => p.key).join(', ')));
|
|
718
|
+
box.appendChild(kpi('Models', int(k.models.value), `${int(k.requests.value)} requests`));
|
|
719
|
+
return box;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function naSub(na, denom, share) {
|
|
723
|
+
if (na > 0 && denom > 0 && na / denom >= 0.005) {
|
|
724
|
+
const frag = el('span');
|
|
725
|
+
frag.appendChild(document.createTextNode(share + ' of total · '));
|
|
726
|
+
frag.appendChild(el('span', { class: 'badge na', text: `${pct(na / denom, 0)} n/a`, title: 'Records whose source did not report this field. Excluded from the total rather than counted as zero.' }));
|
|
727
|
+
return frag;
|
|
728
|
+
}
|
|
729
|
+
return share + ' of total';
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function mainSeriesCard(granChips) {
|
|
733
|
+
const v = S.view;
|
|
734
|
+
const keys = [
|
|
735
|
+
{ key: 'in', label: 'Input', color: COMP_COLORS.input },
|
|
736
|
+
{ key: 'out', label: 'Output', color: COMP_COLORS.output },
|
|
737
|
+
{ key: 'cr', label: 'Cache read', color: COMP_COLORS.cacheRead },
|
|
738
|
+
{ key: 'cw', label: 'Cache write', color: COMP_COLORS.cacheWrite },
|
|
739
|
+
].map((k) => ({ ...k, hidden: S.hidden.has(k.key) }));
|
|
740
|
+
|
|
741
|
+
const peakIdx = [];
|
|
742
|
+
if (v.peaks.peakDay && S.granularity === 'day') {
|
|
743
|
+
const i = v.series.findIndex((d) => d.key === v.peaks.peakDay.date);
|
|
744
|
+
if (i >= 0) peakIdx.push(i);
|
|
745
|
+
}
|
|
746
|
+
const overlays = S.granularity === 'day' && S.seriesMode === 'line'
|
|
747
|
+
? [
|
|
748
|
+
{ values: v.movingAverages.ma7, label: '7-day average', color: 'var(--series-7)' },
|
|
749
|
+
{ values: v.movingAverages.ma30, label: '30-day average', color: 'var(--series-8)' },
|
|
750
|
+
]
|
|
751
|
+
: [];
|
|
752
|
+
|
|
753
|
+
const trendLine = v.trend.change === null
|
|
754
|
+
? el('span', { class: 'muted', text: v.trend.reason || '' })
|
|
755
|
+
: el('span', {}, [
|
|
756
|
+
document.createTextNode(`Trend over the last ${v.trend.window} days: `),
|
|
757
|
+
deltaChip(v.trend.change),
|
|
758
|
+
document.createTextNode(` · daily avg ${compact(v.averages.perActiveDay)} · 7-day ${compact(lastNonNull(v.movingAverages.ma7))} · lowest active ${compact(v.peaks.lowestActiveDay?.total)}`),
|
|
759
|
+
]);
|
|
760
|
+
|
|
761
|
+
const body = el('div');
|
|
762
|
+
const host = el('div');
|
|
763
|
+
body.appendChild(host);
|
|
764
|
+
|
|
765
|
+
const tbl = {
|
|
766
|
+
columns: [
|
|
767
|
+
{ key: 'key', label: S.granularity === 'day' ? 'Date' : S.granularity === 'week' ? 'Week of' : 'Month', text: true },
|
|
768
|
+
{ key: 'total', label: 'Total', value: (r) => compact(r.total) },
|
|
769
|
+
{ key: 'in', label: 'Input', value: (r) => compact(r.in) },
|
|
770
|
+
{ key: 'out', label: 'Output', value: (r) => compact(r.out) },
|
|
771
|
+
{ key: 'cr', label: 'Cache read', value: (r) => compact(r.cr) },
|
|
772
|
+
{ key: 'cw', label: 'Cache write', value: (r) => compact(r.cw) },
|
|
773
|
+
{ key: 'req', label: 'Requests', value: (r) => int(r.req) },
|
|
774
|
+
],
|
|
775
|
+
rows: [...v.series].reverse(),
|
|
776
|
+
onRowClick: S.granularity === 'day' ? (r) => { S.drillDate = r.key; recompute(); render(); } : null,
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
const c = chartCard('main-series', 'Daily token usage', `${longDate(v.range.from)} → ${longDate(v.range.to)} · drag to zoom, click a point for the day`, (w) => {
|
|
780
|
+
const wrap = el('div');
|
|
781
|
+
wrap.appendChild(timeSeries({
|
|
782
|
+
data: v.series, keys: keys.filter((k) => !k.hidden), mode: S.seriesMode, width: w, height: 320,
|
|
783
|
+
overlays, peaks: peakIdx, fillArea: true, endLabel: true,
|
|
784
|
+
fmtY: (x) => compact(x), fmtX: (k) => (S.granularity === 'month' ? k : shortDate(k)),
|
|
785
|
+
fmtXLong: (k) => (S.granularity === 'day' ? longDate(k) : k),
|
|
786
|
+
ariaLabel: 'Token usage over time',
|
|
787
|
+
onBrush: (a, b) => {
|
|
788
|
+
S.filters.from = v.series[a].key.length === 10 ? v.series[a].key : S.filters.from;
|
|
789
|
+
S.filters.to = v.series[b].key.length === 10 ? v.series[b].key : S.filters.to;
|
|
790
|
+
S.rangeId = 'custom';
|
|
791
|
+
recompute(); render();
|
|
792
|
+
},
|
|
793
|
+
onClick: (i) => { if (S.granularity === 'day') { S.drillDate = v.series[i].key; recompute(); render(); } },
|
|
794
|
+
}));
|
|
795
|
+
wrap.appendChild(legend([...keys, ...overlays.map((o) => ({ label: o.label, color: o.color, line: true }))], {
|
|
796
|
+
onToggle: (it) => { if (it.key) { if (S.hidden.has(it.key)) S.hidden.delete(it.key); else S.hidden.add(it.key); render(); } },
|
|
797
|
+
}));
|
|
798
|
+
return wrap;
|
|
799
|
+
}, tbl, granChips);
|
|
800
|
+
c.querySelector('.card-body').appendChild(el('div', { class: 'hint', style: 'padding-top:8px' }, [trendLine]));
|
|
801
|
+
return c;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function lastNonNull(a) {
|
|
805
|
+
for (let i = a.length - 1; i >= 0; i--) if (a[i] !== null) return a[i];
|
|
806
|
+
return null;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function compositionCard() {
|
|
810
|
+
const c = S.view.composition;
|
|
811
|
+
const segs = [
|
|
812
|
+
{ label: 'Input', value: c.input, color: COMP_COLORS.input },
|
|
813
|
+
{ label: 'Output', value: c.output, color: COMP_COLORS.output },
|
|
814
|
+
{ label: 'Cache read', value: c.cacheRead, color: COMP_COLORS.cacheRead },
|
|
815
|
+
{ label: 'Cache write', value: c.cacheWrite, color: COMP_COLORS.cacheWrite },
|
|
816
|
+
];
|
|
817
|
+
const body = el('div');
|
|
818
|
+
body.appendChild(compositionBar(segs, { fmt: compact }));
|
|
819
|
+
const kv = el('dl', { class: 'kv', style: 'margin-top:14px' });
|
|
820
|
+
const add = (k, v, title) => {
|
|
821
|
+
kv.appendChild(el('dt', { text: k, title: title || '' }));
|
|
822
|
+
kv.appendChild(el('dd', { text: v }));
|
|
823
|
+
};
|
|
824
|
+
add('Output / input ratio', c.outputPerInput === null ? '—' : c.outputPerInput.toFixed(3), 'Generated tokens per FRESH prompt token (the literal output/input ratio)');
|
|
825
|
+
add('Output / all prompt tokens', c.outputPerPromptToken === null ? '—' : c.outputPerPromptToken.toFixed(4), 'Generated tokens per prompt token actually sent, including cache reads and writes — the honest picture for a cache-heavy agent');
|
|
826
|
+
add('Cache / total', pct(c.cacheRatio));
|
|
827
|
+
add('Cache hit rate', pct(c.cacheHitRate), 'Cache reads as a share of all prompt tokens (fresh input + cache read)');
|
|
828
|
+
add('Refresh share of cache writes', pct(c.refreshShareOfCacheWrite), 'Long-TTL cache writes as a share of all cache writes');
|
|
829
|
+
add('Reasoning share of output', pct(c.reasoningShareOfOutput), 'Thinking/reasoning tokens as a share of generated tokens');
|
|
830
|
+
body.appendChild(kv);
|
|
831
|
+
const verdict = c.shares.cache > 0.5 ? 'cache-heavy' : c.shares.output > 0.3 ? 'output-heavy' : 'prompt-heavy';
|
|
832
|
+
return card('Token composition', `This usage is ${verdict}. Cache read, cache write, fresh input and output are mutually exclusive and sum to the total.`, body);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function shareCard(id, title, rows, scale, filterKey) {
|
|
836
|
+
// Values present only through activity-only sources have no tokens to show.
|
|
837
|
+
// Listing them as zero-length bars reads as a bug; they stay in the table and
|
|
838
|
+
// are counted in a footnote instead.
|
|
839
|
+
const withTokens = rows.filter((r) => r.total > 0);
|
|
840
|
+
const zero = rows.filter((r) => r.total <= 0);
|
|
841
|
+
const top = withTokens.slice(0, 6);
|
|
842
|
+
const rest = withTokens.slice(6);
|
|
843
|
+
const segs = top.map((r) => ({ label: r.key, value: r.total, color: scale.get(r.key) }));
|
|
844
|
+
if (rest.length) segs.push({ label: `Other (${rest.length})`, value: rest.reduce((a, r) => a + r.total, 0), color: OTHER_COLOR });
|
|
845
|
+
|
|
846
|
+
const tbl = {
|
|
847
|
+
columns: [
|
|
848
|
+
{ key: 'key', label: title.split(' ')[0], text: true, onClick: (r) => drillTo(filterKey, r.key) },
|
|
849
|
+
{ key: 'total', label: 'Total', value: (r) => compact(r.total) },
|
|
850
|
+
{ key: 'input', label: 'Input', value: (r) => compact(r.input) },
|
|
851
|
+
{ key: 'output', label: 'Output', value: (r) => compact(r.output) },
|
|
852
|
+
{ key: 'cache', label: 'Cache', value: (r) => compact(r.cache) },
|
|
853
|
+
{ key: 'avgPerActiveDay', label: 'Avg/day', value: (r) => compact(r.avgPerActiveDay) },
|
|
854
|
+
{ key: 'share', label: '% share', value: (r) => pct(r.share) },
|
|
855
|
+
],
|
|
856
|
+
rows,
|
|
857
|
+
};
|
|
858
|
+
const hint = `${rows.length} distinct · click a segment to drill in`
|
|
859
|
+
+ (zero.length ? ` · ${zero.length} reported no tokens (activity-only sources) — see the table` : '');
|
|
860
|
+
return chartCard(id + '-share', title, hint, () => {
|
|
861
|
+
const wrap = el('div', { style: 'display:flex;gap:18px;align-items:center;flex-wrap:wrap' });
|
|
862
|
+
wrap.appendChild(donut(segs, {
|
|
863
|
+
fmt: compact, size: 176,
|
|
864
|
+
center: { value: compact(S.view.totals.total), label: 'tokens' },
|
|
865
|
+
onClick: (s) => { if (!s.label.startsWith('Other')) drillTo(filterKey, s.label); },
|
|
866
|
+
}));
|
|
867
|
+
wrap.appendChild(el('div', { style: 'flex:1;min-width:220px' }, [
|
|
868
|
+
hbars(segs.map((s) => ({ label: s.label, value: s.value, color: s.color })), {
|
|
869
|
+
fmt: compact,
|
|
870
|
+
onClick: (r) => { if (!r.label.startsWith('Other')) drillTo(filterKey, r.label); },
|
|
871
|
+
}),
|
|
872
|
+
]));
|
|
873
|
+
return wrap;
|
|
874
|
+
}, tbl);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function topModelsCard() {
|
|
878
|
+
const rows = S.view.dimensions.models.slice(0, 10);
|
|
879
|
+
return chartCard('top-models', 'Top models by usage', 'Horizontal bars, one colour per entity', () => hbars(
|
|
880
|
+
rows.map((r) => ({
|
|
881
|
+
label: r.key, value: r.total, color: S.colors.model.get(r.key),
|
|
882
|
+
rows: [
|
|
883
|
+
{ color: S.colors.model.get(r.key), name: 'Total', value: compact(r.total) },
|
|
884
|
+
{ color: null, name: 'Requests', value: int(r.requests) },
|
|
885
|
+
{ color: null, name: 'Avg/request', value: compact(r.avgPerRequest) },
|
|
886
|
+
],
|
|
887
|
+
})),
|
|
888
|
+
{ fmt: compact, onClick: (r) => drillTo('model', r.label) },
|
|
889
|
+
), {
|
|
890
|
+
columns: [
|
|
891
|
+
{ key: 'key', label: 'Model', text: true, onClick: (r) => drillTo('model', r.key) },
|
|
892
|
+
{ key: 'total', label: 'Total', value: (r) => compact(r.total) },
|
|
893
|
+
{ key: 'requests', label: 'Requests', value: (r) => int(r.requests) },
|
|
894
|
+
{ key: 'avgPerRequest', label: 'Avg/request', value: (r) => compact(r.avgPerRequest) },
|
|
895
|
+
],
|
|
896
|
+
rows,
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function interfaceCard() {
|
|
901
|
+
const rows = S.view.dimensions.interfaces;
|
|
902
|
+
const total = rows.reduce((a, r) => a + r.total, 0);
|
|
903
|
+
const ordered = INTERFACE_ORDER.map((k) => rows.find((r) => r.key === k)).filter(Boolean)
|
|
904
|
+
.concat(rows.filter((r) => !INTERFACE_ORDER.includes(r.key)));
|
|
905
|
+
const body = el('div');
|
|
906
|
+
body.appendChild(hbars(ordered.map((r) => ({
|
|
907
|
+
label: r.key, value: r.total, color: S.colors.iface.get(r.key),
|
|
908
|
+
rows: [
|
|
909
|
+
{ color: S.colors.iface.get(r.key), name: 'Tokens', value: compact(r.total) },
|
|
910
|
+
{ color: null, name: 'Share', value: pct(r.share) },
|
|
911
|
+
{ color: null, name: 'Sessions', value: r.sessions === null ? 'n/a' : int(r.sessions) },
|
|
912
|
+
],
|
|
913
|
+
})), { fmt: compact, onClick: (r) => drillTo('interface', r.label) }));
|
|
914
|
+
const unknown = rows.find((r) => r.key === 'Unknown');
|
|
915
|
+
if (unknown) {
|
|
916
|
+
body.appendChild(el('p', { class: 'hint', style: 'margin-top:10px', text: `${pct(unknown.share)} of tokens came from records with no surface field to classify. Interface is never inferred from the model, so these stay Unknown rather than being guessed into a bucket.` }));
|
|
917
|
+
}
|
|
918
|
+
return chartCard('iface', 'CLI vs Desktop vs Web vs API', 'Classified only from an explicit surface field in the source record', () => body, {
|
|
919
|
+
columns: [
|
|
920
|
+
{ key: 'key', label: 'Interface', text: true, onClick: (r) => drillTo('interface', r.key) },
|
|
921
|
+
{ key: 'total', label: 'Tokens', value: (r) => compact(r.total) },
|
|
922
|
+
{ key: 'share', label: 'Share', value: (r) => pct(r.share) },
|
|
923
|
+
{ key: 'requests', label: 'Requests', value: (r) => int(r.requests) },
|
|
924
|
+
{ key: 'sessions', label: 'Sessions', value: (r) => (r.sessions === null ? null : int(r.sessions)) },
|
|
925
|
+
{ key: 'activeDays', label: 'Active days', value: (r) => int(r.activeDays) },
|
|
926
|
+
],
|
|
927
|
+
rows: ordered,
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function calendarCard() {
|
|
932
|
+
const v = S.view;
|
|
933
|
+
const lv = v.calendar.levels;
|
|
934
|
+
return chartCard('calendar', 'Daily usage heatmap', `Intensity is percentile-based within this slice (median ${compact(lv.median)}, max ${compact(lv.max)}) — not fixed thresholds, so it reads correctly at any scale.`, () => {
|
|
935
|
+
const wrap = el('div');
|
|
936
|
+
wrap.appendChild(calendarHeatmap(v.calendar.days, {
|
|
937
|
+
levelOf: (t, a) => lv.levelOf(t, a),
|
|
938
|
+
fmt: compact, fmtDate: longDate, selected: S.drillDate,
|
|
939
|
+
onClick: (d) => { S.drillDate = S.drillDate === d ? null : d; recompute(); render(); },
|
|
940
|
+
note: `${v.averages.activeDays} active of ${v.calendar.days.length} days`,
|
|
941
|
+
}));
|
|
942
|
+
if (S.drillDate && v.drill) wrap.appendChild(dayDetailBox(v.drill));
|
|
943
|
+
else if (S.drillDate) wrap.appendChild(el('div', { class: 'empty', text: `No records on ${longDate(S.drillDate)} within the current filters.` }));
|
|
944
|
+
return wrap;
|
|
945
|
+
}, {
|
|
946
|
+
columns: [
|
|
947
|
+
{ key: 'date', label: 'Date', text: true },
|
|
948
|
+
{ key: 'total', label: 'Total', value: (r) => compact(r.total) },
|
|
949
|
+
{ key: 'in', label: 'Input', value: (r) => compact(r.in) },
|
|
950
|
+
{ key: 'out', label: 'Output', value: (r) => compact(r.out) },
|
|
951
|
+
{ key: 'cache', label: 'Cache', value: (r) => compact(r.cr + r.cw) },
|
|
952
|
+
{ key: 'req', label: 'Requests', value: (r) => int(r.req) },
|
|
953
|
+
{ key: 'active', label: 'Active', value: (r) => (r.active ? 'yes' : 'no'), text: true },
|
|
954
|
+
],
|
|
955
|
+
rows: [...v.calendar.days].reverse(),
|
|
956
|
+
onRowClick: (r) => { S.drillDate = r.date; recompute(); render(); },
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function dayDetailBox(d) {
|
|
961
|
+
const box = el('div', { class: 'card', style: 'margin-top:14px;background:var(--surface-2)' });
|
|
962
|
+
box.appendChild(el('h3', {}, [document.createTextNode(longDate(d.date))]));
|
|
963
|
+
const grid = el('div', { style: 'display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:16px;margin-top:8px' });
|
|
964
|
+
const kv = el('dl', { class: 'kv' });
|
|
965
|
+
const add = (k, val) => { kv.appendChild(el('dt', { text: k })); kv.appendChild(el('dd', { text: val })); };
|
|
966
|
+
add('Total', compact(d.total));
|
|
967
|
+
add('Input', compact(d.input));
|
|
968
|
+
add('Output', compact(d.output));
|
|
969
|
+
add('Cache read', compact(d.cacheRead));
|
|
970
|
+
add('Cache write', compact(d.cacheWrite));
|
|
971
|
+
add('Requests', int(d.requests));
|
|
972
|
+
add('Sessions', int(d.sessions));
|
|
973
|
+
if (d.cost !== null) add('Est. cost', usd(d.cost));
|
|
974
|
+
grid.appendChild(kv);
|
|
975
|
+
for (const [label, arr, scale, key] of [['Top providers', d.providers, S.colors.provider, 'provider'], ['Top models', d.models, S.colors.model, 'model'], ['Interfaces', d.interfaces, S.colors.iface, 'interface']]) {
|
|
976
|
+
const col = el('div');
|
|
977
|
+
col.appendChild(el('div', { class: 'hint', text: label }));
|
|
978
|
+
col.appendChild(hbars(arr.map((x) => ({ label: x.key, value: x.total, color: scale.get(x.key) })), {
|
|
979
|
+
fmt: compact, onClick: (r) => drillTo(key, r.label),
|
|
980
|
+
}));
|
|
981
|
+
grid.appendChild(col);
|
|
982
|
+
}
|
|
983
|
+
box.appendChild(grid);
|
|
984
|
+
const hostH = el('div', { style: 'margin-top:12px' });
|
|
985
|
+
box.appendChild(hostH);
|
|
986
|
+
requestAnimationFrame(() => observeWidth(hostH, (w) => {
|
|
987
|
+
hostH.textContent = '';
|
|
988
|
+
hostH.appendChild(el('div', { class: 'hint', text: 'Tokens by hour on this day' }));
|
|
989
|
+
hostH.appendChild(columns({
|
|
990
|
+
data: d.hours.map((v, h) => ({ label: hourLabel(h), value: v, color: 'var(--series-1)' })),
|
|
991
|
+
width: w, height: 150, fmtY: compact, valueLabel: 'Tokens',
|
|
992
|
+
fmtXLong: (x) => `${x.label}:00`,
|
|
993
|
+
}));
|
|
994
|
+
}));
|
|
995
|
+
return box;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function insightsCard() {
|
|
999
|
+
const body = el('div', { class: 'insights' });
|
|
1000
|
+
for (const i of S.view.insights) {
|
|
1001
|
+
body.appendChild(el('div', { class: 'ins ' + i.kind }, [
|
|
1002
|
+
el('span', { class: 'i-ico', text: i.icon }),
|
|
1003
|
+
el('span', { text: i.text }),
|
|
1004
|
+
]));
|
|
1005
|
+
}
|
|
1006
|
+
return card('AI activity insights', 'Generated from the current slice. An insight only appears when its own condition holds — the panel is deliberately allowed to be short.', body);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function drillTo(key, value) {
|
|
1010
|
+
S.filters[key] = [value];
|
|
1011
|
+
recompute();
|
|
1012
|
+
render();
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// ================================================== dimension detail views ==
|
|
1016
|
+
|
|
1017
|
+
function viewDimension(kind, title) {
|
|
1018
|
+
const v = S.view;
|
|
1019
|
+
const rows = kind === 'provider' ? v.dimensions.providers : v.dimensions.models;
|
|
1020
|
+
const scale = kind === 'provider' ? S.colors.provider : S.colors.model;
|
|
1021
|
+
const growth = kind === 'provider' ? v.growth.providers : v.growth.models;
|
|
1022
|
+
const stack = kind === 'provider' ? v.stacks.providerSeries : v.stacks.modelSeries;
|
|
1023
|
+
const filterKey = kind;
|
|
1024
|
+
|
|
1025
|
+
const root = el('div', { class: 'grid' });
|
|
1026
|
+
root.appendChild(sectionTitle(title));
|
|
1027
|
+
|
|
1028
|
+
const cards = el('div', { class: 'cards' });
|
|
1029
|
+
cards.appendChild(kpi(`${title.split(' ')[0]}s in slice`, int(rows.length), rows.slice(0, 3).map((r) => r.key).join(', ')));
|
|
1030
|
+
if (rows[0]) {
|
|
1031
|
+
cards.appendChild(kpi('Leader', rows[0].key, `${pct(rows[0].share)} of tokens`, { str: true }));
|
|
1032
|
+
cards.appendChild(kpi('Leader avg / active day', compact(rows[0].avgPerActiveDay), `${int(rows[0].activeDays)} active days`));
|
|
1033
|
+
}
|
|
1034
|
+
const newest = growth.rows.filter((r) => r.status === 'new');
|
|
1035
|
+
if (newest.length) cards.appendChild(kpi('New this period', int(newest.length), newest.slice(0, 2).map((r) => r.key).join(', ')));
|
|
1036
|
+
root.appendChild(cards);
|
|
1037
|
+
|
|
1038
|
+
root.appendChild(shareCard(kind + '-detail', `${title.split(' ')[0]} token share`, rows, scale, filterKey));
|
|
1039
|
+
root.appendChild(stackCard(kind + '-trend', `${title.split(' ')[0]} daily trend`, stack, scale));
|
|
1040
|
+
|
|
1041
|
+
root.appendChild(card(`${title.split(' ')[0]} comparison`, 'Full table — every measured field, plus the not-available counts behind each one.', table([
|
|
1042
|
+
{ key: 'key', label: title.split(' ')[0], text: true, onClick: (r) => drillTo(filterKey, r.key) },
|
|
1043
|
+
{ key: 'total', label: 'Total', value: (r) => compact(r.total) },
|
|
1044
|
+
{ key: 'input', label: 'Input', value: (r) => compact(r.input) },
|
|
1045
|
+
{ key: 'output', label: 'Output', value: (r) => compact(r.output) },
|
|
1046
|
+
{ key: 'cache', label: 'Cache', value: (r) => compact(r.cache) },
|
|
1047
|
+
{ key: 'requests', label: 'Requests', value: (r) => int(r.requests) },
|
|
1048
|
+
{ key: 'sessions', label: 'Sessions', value: (r) => (r.sessions === null ? null : int(r.sessions)) },
|
|
1049
|
+
{ key: 'activeDays', label: 'Active days', value: (r) => int(r.activeDays) },
|
|
1050
|
+
{ key: 'avgPerActiveDay', label: 'Avg/day', value: (r) => compact(r.avgPerActiveDay) },
|
|
1051
|
+
{ key: 'avgPerSession', label: 'Avg/session', value: (r) => compact(r.avgPerSession) },
|
|
1052
|
+
{ key: 'peakDay', label: 'Peak day', value: (r) => (r.peakDay ? `${shortDate(r.peakDay)} (${compact(r.peakDayTotal)})` : null), text: true },
|
|
1053
|
+
{ key: 'cost', label: 'Est. cost', value: (r) => (r.cost === null ? null : usd(r.cost)), na: 'no price' },
|
|
1054
|
+
{ key: 'share', label: '% share', value: (r) => pct(r.share) },
|
|
1055
|
+
], rows)));
|
|
1056
|
+
|
|
1057
|
+
root.appendChild(growthCard(`${title.split(' ')[0]} growth`, growth));
|
|
1058
|
+
return root;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* CodexBar-style multi-provider day-wise chart. One stacked bar per day, one
|
|
1063
|
+
* segment per provider, switchable between Tokens / Requests / Cost. The
|
|
1064
|
+
* series is recomputed for the chosen metric — the same cube rows, measured
|
|
1065
|
+
* differently — so switching can never show a number the data does not back.
|
|
1066
|
+
*/
|
|
1067
|
+
function providerDailyCard() {
|
|
1068
|
+
const v = S.view;
|
|
1069
|
+
const metric = S.providerMetric;
|
|
1070
|
+
|
|
1071
|
+
// Recompute the stack under the selected metric from the same filtered rows
|
|
1072
|
+
// computeView used; indexCube of the bundle gives us the accessor layout.
|
|
1073
|
+
const ix = indexCube(S.bundle.cube);
|
|
1074
|
+
const rows = filterCube(ix, { ...v.filters, from: v.range.from, to: v.range.to });
|
|
1075
|
+
const bucketOf = (d) => d;
|
|
1076
|
+
const buckets = v.series.map((s) => s.key);
|
|
1077
|
+
const stack = calculateDimensionSeries(rows, ix, 'p', buckets, { topN: 6, bucketOf, metric });
|
|
1078
|
+
|
|
1079
|
+
const scale = S.colors.provider;
|
|
1080
|
+
const keys = stack.keys.map((k) => ({ key: k, label: k, color: k === 'Other' ? OTHER_COLOR : scale.get(k) }));
|
|
1081
|
+
const fmtY = metric === 'cost' ? usd : metric === 'requests' ? int : compact;
|
|
1082
|
+
const unit = metric === 'cost' ? 'estimated cost' : metric;
|
|
1083
|
+
|
|
1084
|
+
const chips = el('div', { class: 'chips' });
|
|
1085
|
+
for (const m of /** @type {['tokens'|'requests'|'cost', string][]} */ ([['tokens', 'Tokens'], ['requests', 'Requests'], ['cost', 'Cost']])) {
|
|
1086
|
+
const c = el('button', { class: 'chip', text: m[1], 'aria-pressed': String(metric === m[0]) });
|
|
1087
|
+
c.addEventListener('click', () => { S.providerMetric = m[0]; render(); });
|
|
1088
|
+
chips.appendChild(c);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
return chartCard('provider-daily', 'Provider usage — daily', `Stacked ${unit} per provider per day. Top 6 by volume; the rest fold into Other.`, (w) => {
|
|
1092
|
+
const wrap = el('div');
|
|
1093
|
+
wrap.appendChild(chips);
|
|
1094
|
+
wrap.appendChild(timeSeries({
|
|
1095
|
+
data: stack.series, keys, mode: 'stacked', width: w, height: 260,
|
|
1096
|
+
fmtY, fmtX: (k) => (k.length === 10 ? shortDate(k) : k), fmtXLong: (k) => (k.length === 10 ? longDate(k) : k),
|
|
1097
|
+
ariaLabel: `Daily usage by provider in ${unit}`,
|
|
1098
|
+
}));
|
|
1099
|
+
wrap.appendChild(legend(keys));
|
|
1100
|
+
return wrap;
|
|
1101
|
+
}, {
|
|
1102
|
+
columns: [{ key: 'key', label: 'Date', text: true }, ...stack.keys.map((k) => ({ key: k, label: k, value: (r) => fmtY(r[k]) }))],
|
|
1103
|
+
rows: [...stack.series].reverse(),
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function stackCard(id, title, stack, scale) {
|
|
1108
|
+
const keys = stack.keys.map((k) => ({ key: k, label: k, color: k === 'Other' ? OTHER_COLOR : scale.get(k) }));
|
|
1109
|
+
return chartCard(id, title, 'Top 6 by volume; everything else folded into Other rather than given a generated colour.', (w) => {
|
|
1110
|
+
const wrap = el('div');
|
|
1111
|
+
wrap.appendChild(timeSeries({
|
|
1112
|
+
data: stack.series, keys, mode: 'stacked', width: w, height: 260,
|
|
1113
|
+
fmtY: compact, fmtX: (k) => (k.length === 10 ? shortDate(k) : k), fmtXLong: (k) => (k.length === 10 ? longDate(k) : k),
|
|
1114
|
+
ariaLabel: title,
|
|
1115
|
+
}));
|
|
1116
|
+
wrap.appendChild(legend(keys));
|
|
1117
|
+
return wrap;
|
|
1118
|
+
}, {
|
|
1119
|
+
columns: [{ key: 'key', label: 'Bucket', text: true }, ...stack.keys.map((k) => ({ key: k, label: k, value: (r) => compact(r[k]) }))],
|
|
1120
|
+
rows: [...stack.series].reverse(),
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function growthCard(title, growth) {
|
|
1125
|
+
const rows = growth.rows.filter((r) => r.current > 0 || r.previous > 0);
|
|
1126
|
+
return card(title, `Current window ${shortDate(growth.window.from)} → ${shortDate(growth.window.to)} vs the equally long window before it (${shortDate(growth.previousWindow.from)} → ${shortDate(growth.previousWindow.to)}).`, table([
|
|
1127
|
+
{ key: 'key', label: 'Key', text: true },
|
|
1128
|
+
{ key: 'previous', label: 'Previous', value: (r) => compact(r.previous) },
|
|
1129
|
+
{ key: 'current', label: 'Current', value: (r) => compact(r.current) },
|
|
1130
|
+
{ key: 'absolute', label: 'Change', value: (r) => (r.absolute >= 0 ? '+' : '') + compact(r.absolute) },
|
|
1131
|
+
{ key: 'change', label: '%', value: (r) => (r.change === null ? null : deltaChip(r.change)), na: 'new base' },
|
|
1132
|
+
{ key: 'status', label: 'Status', text: true },
|
|
1133
|
+
], rows, { emptyText: 'No comparable previous window inside the dataset.' }));
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function viewModels() {
|
|
1137
|
+
const v = S.view;
|
|
1138
|
+
const root = viewDimension('model', 'Model intelligence');
|
|
1139
|
+
// Model efficiency scatter: all-pairs colour separation caps groups at 3.
|
|
1140
|
+
const topProviders = v.dimensions.providers.slice(0, ColorScale.ALLPAIRS_LIMIT).map((p) => p.key);
|
|
1141
|
+
const pts = v.modelEfficiency
|
|
1142
|
+
.filter((m) => m.tokensPerSession !== null && m.sessionsPerDay !== null)
|
|
1143
|
+
.map((m) => ({
|
|
1144
|
+
x: m.tokensPerSession, y: m.sessionsPerDay, r: m.total,
|
|
1145
|
+
color: topProviders.includes(m.provider) ? SERIES_VARS[topProviders.indexOf(m.provider)] : OTHER_COLOR,
|
|
1146
|
+
label: m.model, short: m.model.length > 18 ? m.model.slice(0, 17) + '…' : m.model,
|
|
1147
|
+
rows: [
|
|
1148
|
+
{ color: null, name: 'Total', value: compact(m.total) },
|
|
1149
|
+
{ color: null, name: 'Tokens / session', value: compact(m.tokensPerSession) },
|
|
1150
|
+
{ color: null, name: 'Sessions / day', value: m.sessionsPerDay.toFixed(2) },
|
|
1151
|
+
{ color: null, name: 'Sessions', value: int(m.sessions) },
|
|
1152
|
+
{ color: null, name: 'Provider', value: m.provider },
|
|
1153
|
+
],
|
|
1154
|
+
}));
|
|
1155
|
+
root.appendChild(chartCard('model-eff', 'Model efficiency', 'x = tokens per session · y = sessions per active day · bubble = total tokens. Colour groups are capped at three: a scatter needs all-pairs colour separation, so the rest are grouped as Other and named in the tooltip and table.', (w) => {
|
|
1156
|
+
const wrap = el('div');
|
|
1157
|
+
if (!pts.length) return emptyCard('Not enough session data', 'Model efficiency needs sessions with token counts.');
|
|
1158
|
+
wrap.appendChild(scatter(pts, {
|
|
1159
|
+
width: w, height: 330, fmtX: compact, fmtY: (v2) => v2.toFixed(1),
|
|
1160
|
+
xLabel: 'Tokens per session', yLabel: 'Sessions per active day',
|
|
1161
|
+
onClick: (p) => drillTo('model', p.label),
|
|
1162
|
+
}));
|
|
1163
|
+
wrap.appendChild(legend(topProviders.map((p, i) => ({ label: p, color: SERIES_VARS[i] })).concat(v.dimensions.providers.length > ColorScale.ALLPAIRS_LIMIT ? [{ label: 'Other providers', color: OTHER_COLOR }] : [])));
|
|
1164
|
+
return wrap;
|
|
1165
|
+
}, {
|
|
1166
|
+
columns: [
|
|
1167
|
+
{ key: 'model', label: 'Model', text: true, onClick: (r) => drillTo('model', r.model) },
|
|
1168
|
+
{ key: 'provider', label: 'Provider', text: true },
|
|
1169
|
+
{ key: 'total', label: 'Total', value: (r) => compact(r.total) },
|
|
1170
|
+
{ key: 'sessions', label: 'Sessions', value: (r) => (r.sessions === null ? null : int(r.sessions)) },
|
|
1171
|
+
{ key: 'tokensPerSession', label: 'Tokens/session', value: (r) => compact(r.tokensPerSession) },
|
|
1172
|
+
{ key: 'sessionsPerDay', label: 'Sessions/day', value: (r) => (r.sessionsPerDay === null ? null : r.sessionsPerDay.toFixed(2)) },
|
|
1173
|
+
{ key: 'tokensPerRequest', label: 'Tokens/request', value: (r) => compact(r.tokensPerRequest) },
|
|
1174
|
+
{ key: 'medianSessionMs', label: 'Median session', value: (r) => (r.medianSessionMs === null ? null : humanDuration(r.medianSessionMs)) },
|
|
1175
|
+
],
|
|
1176
|
+
rows: v.modelEfficiency,
|
|
1177
|
+
}));
|
|
1178
|
+
|
|
1179
|
+
root.appendChild(shareCard('family', 'Model family share', v.dimensions.families, S.colors.family, 'model_family'));
|
|
1180
|
+
return root;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function viewInterfaces() {
|
|
1184
|
+
const v = S.view;
|
|
1185
|
+
const root = el('div', { class: 'grid' });
|
|
1186
|
+
root.appendChild(sectionTitle('Interface & client analysis'));
|
|
1187
|
+
|
|
1188
|
+
const cls = v.stacks.interfaceTrend;
|
|
1189
|
+
const clsColors = new ColorScale(['CLI / headless', 'IDE', 'Desktop / Web', 'API', 'Unknown']);
|
|
1190
|
+
const cards = el('div', { class: 'cards' });
|
|
1191
|
+
const last = cls.shares[cls.shares.length - 1] || {};
|
|
1192
|
+
for (const k of cls.keys) {
|
|
1193
|
+
const totalK = cls.series.reduce((a, r) => a + (r[k] || 0), 0);
|
|
1194
|
+
cards.appendChild(kpi(k, pct(v.totals.total ? totalK / v.totals.total : null), compact(totalK) + ' tokens'));
|
|
1195
|
+
}
|
|
1196
|
+
root.appendChild(cards);
|
|
1197
|
+
|
|
1198
|
+
root.appendChild(interfaceCard());
|
|
1199
|
+
root.appendChild(stackCard('iface-trend', 'CLI vs GUI trend', { keys: cls.keys, series: cls.series }, clsColors));
|
|
1200
|
+
root.appendChild(chartCard('iface-share-trend', 'Interface share over time', 'Share of tokens per bucket — the shape that makes a tooling shift legible.', (w) => {
|
|
1201
|
+
const keys = cls.keys.map((k) => ({ key: k, label: k, color: clsColors.get(k) }));
|
|
1202
|
+
const wrap = el('div');
|
|
1203
|
+
wrap.appendChild(timeSeries({
|
|
1204
|
+
data: cls.shares, keys, mode: 'stacked', width: w, height: 220,
|
|
1205
|
+
fmtY: (x) => (x * 100).toFixed(0) + '%', fmtX: (k) => (k.length === 10 ? shortDate(k) : k),
|
|
1206
|
+
fmtXLong: (k) => (k.length === 10 ? longDate(k) : k), ariaLabel: 'Interface share over time',
|
|
1207
|
+
}));
|
|
1208
|
+
wrap.appendChild(legend(keys));
|
|
1209
|
+
return wrap;
|
|
1210
|
+
}, {
|
|
1211
|
+
columns: [{ key: 'key', label: 'Bucket', text: true }, ...cls.keys.map((k) => ({ key: k, label: k, value: (r) => pct(r[k]) }))],
|
|
1212
|
+
rows: [...cls.shares].reverse(),
|
|
1213
|
+
}));
|
|
1214
|
+
|
|
1215
|
+
root.appendChild(shareCard('client', 'Client distribution', v.dimensions.clients, S.colors.client, 'client'));
|
|
1216
|
+
if (v.dimensions.gateways.length > 1) {
|
|
1217
|
+
root.appendChild(shareCard('gateway', 'Gateway / routing', v.dimensions.gateways, new ColorScale(), 'gateway'));
|
|
1218
|
+
}
|
|
1219
|
+
root.appendChild(card('Projects', 'Top projects by token usage — derived from each record\'s working directory.', table([
|
|
1220
|
+
{ key: 'key', label: 'Project', text: true, onClick: (r) => drillTo('project', r.key) },
|
|
1221
|
+
{ key: 'total', label: 'Total', value: (r) => compact(r.total) },
|
|
1222
|
+
{ key: 'requests', label: 'Requests', value: (r) => int(r.requests) },
|
|
1223
|
+
{ key: 'sessions', label: 'Sessions', value: (r) => (r.sessions === null ? null : int(r.sessions)) },
|
|
1224
|
+
{ key: 'activeDays', label: 'Active days', value: (r) => int(r.activeDays) },
|
|
1225
|
+
{ key: 'share', label: 'Share', value: (r) => pct(r.share) },
|
|
1226
|
+
], v.dimensions.projects)));
|
|
1227
|
+
return root;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// ============================================================ time patterns ==
|
|
1231
|
+
|
|
1232
|
+
function viewTime() {
|
|
1233
|
+
const v = S.view;
|
|
1234
|
+
const root = el('div', { class: 'grid' });
|
|
1235
|
+
root.appendChild(sectionTitle('When you use AI'));
|
|
1236
|
+
|
|
1237
|
+
const cards = el('div', { class: 'cards' });
|
|
1238
|
+
const pw = v.hourly.peakWindow;
|
|
1239
|
+
const sw = v.hourly.secondaryWindow;
|
|
1240
|
+
cards.appendChild(kpi('Peak usage window', pw ? hourWindow(pw.from, pw.to) : '—', pw ? `${pct(pw.share)} of tokens` : 'no timestamped data'));
|
|
1241
|
+
cards.appendChild(kpi('Secondary peak', sw ? hourWindow(sw.from, sw.to) : '—', sw ? `${pct(sw.share)} of tokens` : '—'));
|
|
1242
|
+
const busiestDow = [...v.dowUsage].sort((a, b) => b.total - a.total)[0];
|
|
1243
|
+
cards.appendChild(kpi('Busiest day of week', busiestDow ? DOW[busiestDow.dow] : '—', busiestDow ? compact(busiestDow.total) : '—'));
|
|
1244
|
+
cards.appendChild(kpi('Weekend share', pct(v.productivity.proxies.weekendShare), `${v.productivity.proxies.weekendActiveDays} weekend active days`));
|
|
1245
|
+
cards.appendChild(kpi('Longest active streak', int(v.streaks.longest) + ' days', v.streaks.longestEndedOn ? `ended ${shortDate(v.streaks.longestEndedOn)}` : ''));
|
|
1246
|
+
root.appendChild(cards);
|
|
1247
|
+
|
|
1248
|
+
root.appendChild(chartCard('hourly', 'Usage by hour of day', `24-hour profile in ${S.bundle.meta.timezone}. Click a bar to filter to that hour.`, (w) => columns({
|
|
1249
|
+
data: v.hourly.buckets.map((b) => ({ label: hourLabel(b.hour), value: b.total, color: 'var(--series-1)', hour: b.hour, extra: [{ color: null, name: 'Requests', value: int(b.req) }] })),
|
|
1250
|
+
width: w, height: 220, fmtY: compact, valueLabel: 'Tokens',
|
|
1251
|
+
fmtXLong: (d) => `${d.label}:00 – ${hourLabel((d.hour + 1) % 24)}:00`,
|
|
1252
|
+
onClick: (d) => { S.filters.hourFrom = d.hour; S.filters.hourTo = d.hour; recompute(); render(); },
|
|
1253
|
+
}), {
|
|
1254
|
+
columns: [
|
|
1255
|
+
{ key: 'hour', label: 'Hour', value: (r) => hourLabel(r.hour) + ':00', text: true },
|
|
1256
|
+
{ key: 'total', label: 'Total', value: (r) => compact(r.total) },
|
|
1257
|
+
{ key: 'in', label: 'Input', value: (r) => compact(r.in) },
|
|
1258
|
+
{ key: 'out', label: 'Output', value: (r) => compact(r.out) },
|
|
1259
|
+
{ key: 'req', label: 'Requests', value: (r) => int(r.req) },
|
|
1260
|
+
],
|
|
1261
|
+
rows: v.hourly.buckets,
|
|
1262
|
+
}));
|
|
1263
|
+
|
|
1264
|
+
root.appendChild(chartCard('dow', 'Usage by day of week', 'Monday first. Per-active-day averages remove the effect of how many of each weekday fall in the range.', (w) => {
|
|
1265
|
+
const wrap = el('div');
|
|
1266
|
+
wrap.appendChild(columns({
|
|
1267
|
+
data: v.dowUsage.map((b) => ({ label: DOW[b.dow], value: b.total, color: b.dow >= 5 ? 'var(--series-2)' : 'var(--series-1)', extra: [{ color: null, name: 'Avg / active day', value: compact(b.perActiveDay) }, { color: null, name: 'Active days', value: int(b.days) }] })),
|
|
1268
|
+
width: w, height: 200, fmtY: compact, valueLabel: 'Tokens',
|
|
1269
|
+
}));
|
|
1270
|
+
wrap.appendChild(legend([{ label: 'Weekday', color: 'var(--series-1)' }, { label: 'Weekend', color: 'var(--series-2)' }]));
|
|
1271
|
+
return wrap;
|
|
1272
|
+
}, {
|
|
1273
|
+
columns: [
|
|
1274
|
+
{ key: 'dow', label: 'Day', value: (r) => DOW[r.dow], text: true },
|
|
1275
|
+
{ key: 'total', label: 'Total', value: (r) => compact(r.total) },
|
|
1276
|
+
{ key: 'days', label: 'Active days', value: (r) => int(r.days) },
|
|
1277
|
+
{ key: 'perActiveDay', label: 'Avg / active day', value: (r) => compact(r.perActiveDay) },
|
|
1278
|
+
{ key: 'req', label: 'Requests', value: (r) => int(r.req) },
|
|
1279
|
+
],
|
|
1280
|
+
rows: v.dowUsage,
|
|
1281
|
+
}));
|
|
1282
|
+
|
|
1283
|
+
const cells = v.hourDow.cells.map((c) => ({
|
|
1284
|
+
row: c.dow, col: c.hour, value: c.total,
|
|
1285
|
+
label: `${DOW[c.dow]} ${hourLabel(c.hour)}:00`,
|
|
1286
|
+
extra: [{ color: null, name: 'Requests', value: int(c.req) }],
|
|
1287
|
+
dow: c.dow, hour: c.hour,
|
|
1288
|
+
}));
|
|
1289
|
+
root.appendChild(chartCard('hourdow', 'Hour × day-of-week heatmap', 'One hue, light→dark. Click a cell to filter to that hour and weekday.', (w) => {
|
|
1290
|
+
const wrap = el('div', { style: 'overflow:auto' });
|
|
1291
|
+
wrap.appendChild(matrix(cells, {
|
|
1292
|
+
rows: DOW, cols: Array.from({ length: 24 }, (_, h) => hourLabel(h)),
|
|
1293
|
+
max: v.hourDow.max, fmt: compact, cellW: Math.max(22, Math.min(46, (w - 40) / 24)), cellH: 22,
|
|
1294
|
+
ariaLabel: 'Hour by weekday heatmap',
|
|
1295
|
+
onClick: (c) => { S.filters.hourFrom = c.hour; S.filters.hourTo = c.hour; S.filters.dows = [c.dow]; recompute(); render(); },
|
|
1296
|
+
}));
|
|
1297
|
+
wrap.appendChild(scaleLegend(v.hourDow.max, compact));
|
|
1298
|
+
return wrap;
|
|
1299
|
+
}, {
|
|
1300
|
+
columns: [
|
|
1301
|
+
{ key: 'label', label: 'Slot', text: true },
|
|
1302
|
+
{ key: 'value', label: 'Tokens', value: (r) => compact(r.value) },
|
|
1303
|
+
],
|
|
1304
|
+
rows: [...cells].sort((a, b) => b.value - a.value).slice(0, 60),
|
|
1305
|
+
}));
|
|
1306
|
+
|
|
1307
|
+
root.appendChild(calendarCard());
|
|
1308
|
+
return root;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// ==================================================================== peaks ==
|
|
1312
|
+
|
|
1313
|
+
function viewPeaks() {
|
|
1314
|
+
const v = S.view;
|
|
1315
|
+
const p = v.peaks;
|
|
1316
|
+
const root = el('div', { class: 'grid' });
|
|
1317
|
+
root.appendChild(sectionTitle('Peak usage analysis'));
|
|
1318
|
+
|
|
1319
|
+
const cards = el('div', { class: 'cards' });
|
|
1320
|
+
const add = (label, obj, keyName, fmt = compact) => {
|
|
1321
|
+
if (!obj) { cards.appendChild(kpi(label, '—', 'no data')); return; }
|
|
1322
|
+
cards.appendChild(kpi(label, fmt(obj.total ?? obj.value), String(obj[keyName] ?? obj.key ?? ''), { str: false }));
|
|
1323
|
+
};
|
|
1324
|
+
cards.appendChild(kpi('Peak day', compact(p.peakDay?.total), p.peakDay ? longDate(p.peakDay.date) : '—', {
|
|
1325
|
+
hero: true, onClick: p.peakDay ? () => { S.drillDate = p.peakDay.date; recompute(); render(); } : null,
|
|
1326
|
+
}));
|
|
1327
|
+
add('Peak week', p.peakWeek && { total: p.peakWeek.total, key: 'week of ' + shortDate(p.peakWeek.weekStart) }, 'key');
|
|
1328
|
+
add('Peak month', p.peakMonth && { total: p.peakMonth.total, key: p.peakMonth.month }, 'key');
|
|
1329
|
+
cards.appendChild(kpi('Peak hour', p.peakHour ? hourLabel(p.peakHour.hour) + ':00' : '—', p.peakHour ? compact(p.peakHour.total) : '—'));
|
|
1330
|
+
add('Peak provider', p.peakProvider && { total: p.peakProvider.total, key: p.peakProvider.provider }, 'key');
|
|
1331
|
+
add('Peak model', p.peakModel && { total: p.peakModel.total, key: p.peakModel.model }, 'key');
|
|
1332
|
+
add('Peak interface', p.peakInterface && { total: p.peakInterface.total, key: p.peakInterface.interface }, 'key');
|
|
1333
|
+
add('Peak project', p.peakProject && { total: p.peakProject.total, key: p.peakProject.project }, 'key');
|
|
1334
|
+
cards.appendChild(kpi('Highest output day', compact(p.highestOutputDay?.value), p.highestOutputDay ? shortDate(p.highestOutputDay.key) : '—'));
|
|
1335
|
+
cards.appendChild(kpi('Highest input day', compact(p.highestInputDay?.value), p.highestInputDay ? shortDate(p.highestInputDay.key) : '—'));
|
|
1336
|
+
cards.appendChild(kpi('Highest cache day', compact(p.highestCacheDay?.value), p.highestCacheDay ? shortDate(p.highestCacheDay.key) : '—'));
|
|
1337
|
+
cards.appendChild(kpi('Lowest active day', compact(p.lowestActiveDay?.total), p.lowestActiveDay ? shortDate(p.lowestActiveDay.date) : '—', { title: 'Days with no usage are excluded — a calendar gap is not a low day.' }));
|
|
1338
|
+
root.appendChild(cards);
|
|
1339
|
+
|
|
1340
|
+
root.appendChild(chartCard('top-days', 'Top 10 peak days', 'Click a row to open that day.', () => hbars(p.topDays.map((d, i) => ({
|
|
1341
|
+
label: `${i + 1}. ${shortDate(d.date)}`, value: d.total, color: 'var(--series-1)',
|
|
1342
|
+
rows: [
|
|
1343
|
+
{ color: 'var(--series-1)', name: 'Total', value: compact(d.total) },
|
|
1344
|
+
{ color: COMP_COLORS.input, name: 'Input', value: compact(d.input) },
|
|
1345
|
+
{ color: COMP_COLORS.output, name: 'Output', value: compact(d.output) },
|
|
1346
|
+
{ color: COMP_COLORS.cacheRead, name: 'Cache', value: compact(d.cache) },
|
|
1347
|
+
],
|
|
1348
|
+
date: d.date,
|
|
1349
|
+
})), { fmt: compact, onClick: (r) => { S.drillDate = r.date; recompute(); render(); } }), {
|
|
1350
|
+
columns: [
|
|
1351
|
+
{ key: 'date', label: 'Date', value: (r) => longDate(r.date), text: true },
|
|
1352
|
+
{ key: 'total', label: 'Total', value: (r) => compact(r.total) },
|
|
1353
|
+
{ key: 'input', label: 'Input', value: (r) => compact(r.input) },
|
|
1354
|
+
{ key: 'output', label: 'Output', value: (r) => compact(r.output) },
|
|
1355
|
+
{ key: 'cache', label: 'Cache', value: (r) => compact(r.cache) },
|
|
1356
|
+
{ key: 'requests', label: 'Requests', value: (r) => int(r.requests) },
|
|
1357
|
+
],
|
|
1358
|
+
rows: p.topDays,
|
|
1359
|
+
onRowClick: (r) => { S.drillDate = r.date; recompute(); render(); },
|
|
1360
|
+
}));
|
|
1361
|
+
|
|
1362
|
+
if (p.peakSession) {
|
|
1363
|
+
const body = el('dl', { class: 'kv' });
|
|
1364
|
+
const add2 = (k, v2) => { body.appendChild(el('dt', { text: k })); body.appendChild(el('dd', { text: v2 })); };
|
|
1365
|
+
add2('Tokens', compact(p.peakSession.total));
|
|
1366
|
+
add2('Model', String(p.peakSession.model));
|
|
1367
|
+
add2('Project', String(p.peakSession.project));
|
|
1368
|
+
add2('Date', longDate(p.peakSession.date));
|
|
1369
|
+
add2('Requests', int(p.peakSession.requests));
|
|
1370
|
+
add2('Duration', humanDuration(p.peakSession.durationMs));
|
|
1371
|
+
root.appendChild(card('Largest single session', 'The heaviest individual session in this slice.', body));
|
|
1372
|
+
}
|
|
1373
|
+
root.appendChild(calendarCard());
|
|
1374
|
+
return root;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// =============================================================== efficiency ==
|
|
1378
|
+
|
|
1379
|
+
function viewEfficiency() {
|
|
1380
|
+
const v = S.view;
|
|
1381
|
+
const e = v.efficiency;
|
|
1382
|
+
const root = el('div', { class: 'grid' });
|
|
1383
|
+
root.appendChild(sectionTitle('Usage efficiency'));
|
|
1384
|
+
root.appendChild(el('div', { class: 'banner info' }, [el('span', {
|
|
1385
|
+
text: 'These are measurements, not a score. A high output/input ratio is not automatically better — long cached contexts are how agentic tools work, and a low ratio can be exactly right.',
|
|
1386
|
+
})]));
|
|
1387
|
+
|
|
1388
|
+
const cards = el('div', { class: 'cards' });
|
|
1389
|
+
cards.appendChild(kpi('Output / input', e.outputPerInput === null ? '—' : e.outputPerInput.toFixed(3), 'generated per FRESH prompt token'));
|
|
1390
|
+
cards.appendChild(kpi('Output / prompt sent', e.outputPerPromptToken === null ? '—' : e.outputPerPromptToken.toFixed(4), 'generated per prompt token actually sent (incl. cache)'));
|
|
1391
|
+
cards.appendChild(kpi('Cache / total', pct(e.cacheRatio), 'cache share of all token activity'));
|
|
1392
|
+
cards.appendChild(kpi('Cache hit rate', pct(e.cacheHitRate), 'cache reads / all prompt tokens'));
|
|
1393
|
+
cards.appendChild(kpi('Fresh per cached prompt', e.freshPerCachedPrompt === null ? '—' : e.freshPerCachedPrompt.toFixed(2), 'below 1 means cache is carrying the context'));
|
|
1394
|
+
cards.appendChild(kpi('Tokens / session', compact(e.tokensPerSession)));
|
|
1395
|
+
cards.appendChild(kpi('Output / session', compact(e.outputPerSession)));
|
|
1396
|
+
cards.appendChild(kpi('Requests / session', e.requestsPerSession === null ? '—' : e.requestsPerSession.toFixed(1)));
|
|
1397
|
+
cards.appendChild(kpi('Tokens / active day', compact(e.tokensPerActiveDay)));
|
|
1398
|
+
cards.appendChild(kpi('Tokens / request', compact(e.tokensPerRequest)));
|
|
1399
|
+
cards.appendChild(kpi('Output / request', compact(e.outputPerRequest)));
|
|
1400
|
+
cards.appendChild(kpi('Reasoning share of output', pct(e.reasoningShareOfOutput)));
|
|
1401
|
+
cards.appendChild(kpi('Refresh share of cache writes', pct(e.refreshShareOfCacheWrite)));
|
|
1402
|
+
root.appendChild(cards);
|
|
1403
|
+
|
|
1404
|
+
const sp = v.sessionProfile;
|
|
1405
|
+
root.appendChild(chartCard('session-profile', 'Session size distribution', `Buckets are percentiles of this dataset, not fixed sizes. Median session ${compact(sp.medianTokens)} tokens${sp.medianDurationMs ? `, ${humanDuration(sp.medianDurationMs)}` : ''}.`, (w) => columns({
|
|
1406
|
+
data: sp.buckets.map((b) => ({ label: b.label, value: b.tokens, color: 'var(--series-1)', extra: [{ color: null, name: 'Sessions', value: int(b.sessions) }] })),
|
|
1407
|
+
width: w, height: 210, fmtY: compact, valueLabel: 'Tokens',
|
|
1408
|
+
}), {
|
|
1409
|
+
columns: [
|
|
1410
|
+
{ key: 'label', label: 'Bucket', text: true },
|
|
1411
|
+
{ key: 'sessions', label: 'Sessions', value: (r) => int(r.sessions) },
|
|
1412
|
+
{ key: 'tokens', label: 'Tokens', value: (r) => compact(r.tokens) },
|
|
1413
|
+
{ key: 'share', label: 'Share of tokens', value: (r) => pct(r.share) },
|
|
1414
|
+
{ key: 'upperEdge', label: 'Upper edge', value: (r) => (r.upperEdge === null ? null : compact(r.upperEdge)) },
|
|
1415
|
+
],
|
|
1416
|
+
rows: sp.buckets,
|
|
1417
|
+
}));
|
|
1418
|
+
|
|
1419
|
+
root.appendChild(card('Per-model efficiency', 'Same measurements, per model.', table([
|
|
1420
|
+
{ key: 'key', label: 'Model', text: true, onClick: (r) => drillTo('model', r.key) },
|
|
1421
|
+
{ key: 'total', label: 'Total', value: (r) => compact(r.total) },
|
|
1422
|
+
{ key: 'outIn', label: 'Output/input', value: (r) => (r.input ? (r.output / r.input).toFixed(3) : null) },
|
|
1423
|
+
{ key: 'cacheRatio', label: 'Cache/total', value: (r) => (r.total ? pct(r.cache / r.total) : null) },
|
|
1424
|
+
{ key: 'avgPerRequest', label: 'Tokens/request', value: (r) => compact(r.avgPerRequest) },
|
|
1425
|
+
{ key: 'avgPerSession', label: 'Tokens/session', value: (r) => compact(r.avgPerSession) },
|
|
1426
|
+
{ key: 'requests', label: 'Requests', value: (r) => int(r.requests) },
|
|
1427
|
+
], v.dimensions.models)));
|
|
1428
|
+
return root;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// ===================================================================== cost ==
|
|
1432
|
+
|
|
1433
|
+
function viewCost() {
|
|
1434
|
+
const v = S.view;
|
|
1435
|
+
const c = v.cost;
|
|
1436
|
+
const root = el('div', { class: 'grid' });
|
|
1437
|
+
root.appendChild(sectionTitle('Cost analysis'));
|
|
1438
|
+
|
|
1439
|
+
if (c.estimated === null && c.measured === null) {
|
|
1440
|
+
root.appendChild(el('div', { class: 'banner warn' }, [
|
|
1441
|
+
el('span', { text: 'No cost is shown because no model in this slice has a configured price. Rather than invent a rate, the dashboard leaves cost blank.' }),
|
|
1442
|
+
btn('Configure pricing', () => pricingModal(), 'primary sm'),
|
|
1443
|
+
]));
|
|
1444
|
+
} else {
|
|
1445
|
+
root.appendChild(el('div', { class: 'banner info' }, [
|
|
1446
|
+
el('span', { class: 'badge est', text: 'ESTIMATE' }),
|
|
1447
|
+
el('span', { text: c.basisNote }),
|
|
1448
|
+
]));
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
const cards = el('div', { class: 'cards' });
|
|
1452
|
+
cards.appendChild(kpi('Estimated cost', usd(c.estimated), c.coverage === null ? '' : `${pct(c.coverage)} of requests priced`, { hero: true, badge: 'est.', badgeKind: 'est', badgeTitle: 'Computed from a published price table, not from a bill.' }));
|
|
1453
|
+
if (c.measured !== null) {
|
|
1454
|
+
cards.appendChild(kpi('Gateway-measured cost', usd(c.measured), 'from proxy billing logs', { badge: 'measured', badgeKind: 'meas', badgeTitle: c.measuredNote || 'Reported by a gateway that actually billed the request. Covers proxy-routed traffic only.' }));
|
|
1455
|
+
}
|
|
1456
|
+
cards.appendChild(kpi('Cost / active day', usd(c.perDay)));
|
|
1457
|
+
cards.appendChild(kpi('Cost / session', usd(c.perSession)));
|
|
1458
|
+
cards.appendChild(kpi('Cost / 1M tokens', usd(c.perMillionTokens)));
|
|
1459
|
+
cards.appendChild(kpi('Cost / 1M output', usd(c.perMillionOutput)));
|
|
1460
|
+
if (c.premiumTierShare) {
|
|
1461
|
+
cards.appendChild(kpi('At a premium tier', pct(c.premiumTierShare),
|
|
1462
|
+
`${compact(c.premiumTierTokens)} tokens · ${c.premiumTierNames.join(', ')}`,
|
|
1463
|
+
{ title: 'Requests billed above the standard rate. OpenAI\'s Fast mode (formerly "priority") is 4x standard; Anthropic\'s Batch API is 0.5x. The multiplier is applied per request.' }));
|
|
1464
|
+
}
|
|
1465
|
+
root.appendChild(cards);
|
|
1466
|
+
|
|
1467
|
+
if (c.measuredNote) {
|
|
1468
|
+
root.appendChild(el('div', { class: 'banner info' }, [
|
|
1469
|
+
el('span', { class: 'badge meas', text: 'MEASURED' }),
|
|
1470
|
+
el('span', { text: c.measuredNote }),
|
|
1471
|
+
]));
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
root.appendChild(el('div', { class: 'banner warn' }, [
|
|
1475
|
+
el('span', { text: c.underEstimateNote }),
|
|
1476
|
+
]));
|
|
1477
|
+
|
|
1478
|
+
// Service tier is a billing dimension, so it gets its own breakdown.
|
|
1479
|
+
const tiers = v.dimensions.tiers.filter((t) => t.total > 0);
|
|
1480
|
+
if (tiers.length > 1) {
|
|
1481
|
+
const multFor = (t) => {
|
|
1482
|
+
const tm = S.bundle.meta.tierMultipliers || {};
|
|
1483
|
+
const found = Object.values(tm).map((x) => x[t]).filter((x) => x !== undefined);
|
|
1484
|
+
return found.length ? Math.max(...found) : 1;
|
|
1485
|
+
};
|
|
1486
|
+
root.appendChild(chartCard('cost-tier', 'Cost by service tier',
|
|
1487
|
+
'A tier is a price multiplier, not a label. The estimate applies it per request.',
|
|
1488
|
+
() => hbars(tiers.map((t) => ({
|
|
1489
|
+
label: `${t.key}${multFor(t.key) !== 1 ? ` (${multFor(t.key)}x)` : ''}`,
|
|
1490
|
+
value: t.cost === null ? 0 : t.cost,
|
|
1491
|
+
color: multFor(t.key) > 1 ? 'var(--series-2)' : 'var(--series-1)',
|
|
1492
|
+
rows: [
|
|
1493
|
+
{ color: null, name: 'Est. cost', value: usd(t.cost) },
|
|
1494
|
+
{ color: null, name: 'Tokens', value: compact(t.total) },
|
|
1495
|
+
{ color: null, name: 'Requests', value: int(t.requests) },
|
|
1496
|
+
{ color: null, name: 'Multiplier', value: multFor(t.key) + 'x' },
|
|
1497
|
+
],
|
|
1498
|
+
tier: t.key,
|
|
1499
|
+
})), { fmt: usd, valueLabel: 'Est. cost', onClick: (r) => drillTo('service_tier', r.tier) }), {
|
|
1500
|
+
columns: [
|
|
1501
|
+
{ key: 'key', label: 'Tier', text: true, onClick: (r) => drillTo('service_tier', r.key) },
|
|
1502
|
+
{ key: 'mult', label: 'Multiplier', value: (r) => multFor(r.key) + 'x' },
|
|
1503
|
+
{ key: 'total', label: 'Tokens', value: (r) => compact(r.total) },
|
|
1504
|
+
{ key: 'requests', label: 'Requests', value: (r) => int(r.requests) },
|
|
1505
|
+
{ key: 'cost', label: 'Est. cost', value: (r) => (r.cost === null ? null : usd(r.cost)), na: 'no price' },
|
|
1506
|
+
{ key: 'share', label: 'Share of tokens', value: (r) => pct(r.share) },
|
|
1507
|
+
],
|
|
1508
|
+
rows: tiers,
|
|
1509
|
+
}));
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
const priced = v.dimensions.providers.filter((p) => p.cost !== null);
|
|
1513
|
+
if (priced.length) {
|
|
1514
|
+
root.appendChild(chartCard('cost-provider', 'Cost by provider', 'Estimated, from the configured price table.', () => hbars(priced.map((p) => ({
|
|
1515
|
+
label: p.key, value: p.cost, color: S.colors.provider.get(p.key),
|
|
1516
|
+
})), { fmt: usd, valueLabel: 'Est. cost', onClick: (r) => drillTo('provider', r.label) }), {
|
|
1517
|
+
columns: [
|
|
1518
|
+
{ key: 'key', label: 'Provider', text: true },
|
|
1519
|
+
{ key: 'cost', label: 'Est. cost', value: (r) => usd(r.cost) },
|
|
1520
|
+
{ key: 'total', label: 'Tokens', value: (r) => compact(r.total) },
|
|
1521
|
+
{ key: 'per1m', label: '$/1M tokens', value: (r) => (r.total ? usd(r.cost / (r.total / 1e6)) : null) },
|
|
1522
|
+
],
|
|
1523
|
+
rows: priced,
|
|
1524
|
+
}));
|
|
1525
|
+
root.appendChild(card('Cost by model', 'Estimated, from the configured price table.', table([
|
|
1526
|
+
{ key: 'key', label: 'Model', text: true, onClick: (r) => drillTo('model', r.key) },
|
|
1527
|
+
{ key: 'priceSource', label: 'Rate from', text: true, na: 'unpriced' },
|
|
1528
|
+
{ key: 'cost', label: 'Est. cost', value: (r) => (r.cost === null ? null : usd(r.cost)), na: 'no price' },
|
|
1529
|
+
{ key: 'costMeasured', label: 'Measured', value: (r) => (r.costMeasured === null ? null : usd(r.costMeasured)), na: '—' },
|
|
1530
|
+
{ key: 'total', label: 'Tokens', value: (r) => compact(r.total) },
|
|
1531
|
+
{ key: 'requests', label: 'Requests', value: (r) => int(r.requests) },
|
|
1532
|
+
{ key: 'per1m', label: '$/1M', value: (r) => (r.cost !== null && r.total ? usd(r.cost / (r.total / 1e6)) : null), na: '—' },
|
|
1533
|
+
], v.dimensions.models)));
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
const srcs = S.bundle.meta.pricingSources || {};
|
|
1537
|
+
if (Object.keys(srcs).length) {
|
|
1538
|
+
root.appendChild(card('Where these rates come from',
|
|
1539
|
+
`Built-in table ${S.bundle.meta.pricingTableVersion}. Your own overrides in the Pricing dialog always win.`,
|
|
1540
|
+
table([
|
|
1541
|
+
{ key: 'key', label: 'Source', text: true },
|
|
1542
|
+
{ key: 'confidence', label: 'Confidence', value: (r) => el('span', {
|
|
1543
|
+
class: 'badge ' + (r.confidence === 'official' ? 'meas' : r.confidence === 'third-party' ? 'est' : ''),
|
|
1544
|
+
text: r.confidence,
|
|
1545
|
+
}) },
|
|
1546
|
+
{ key: 'fetched', label: 'Fetched', text: true },
|
|
1547
|
+
{ key: 'url', label: 'Published at', value: (r) => el('a', { href: r.url, target: '_blank', rel: 'noreferrer noopener', text: shorten(r.url) }), text: true },
|
|
1548
|
+
{ key: 'note', label: 'Caveat', text: true, na: '—' },
|
|
1549
|
+
], Object.entries(srcs).map(([key, s2]) => ({ key, ...s2 })))));
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
if (c.unpriced.length) {
|
|
1553
|
+
root.appendChild(card('Models with no configured price', `${c.unpriced.length} model(s) covering ${compact(c.unpriced.reduce((a, u) => a + u.total, 0))} tokens. Add a price and every cost figure above updates.`, (() => {
|
|
1554
|
+
const box = el('div');
|
|
1555
|
+
box.appendChild(table([
|
|
1556
|
+
{ key: 'model', label: 'Model', text: true },
|
|
1557
|
+
{ key: 'provider', label: 'Provider', text: true },
|
|
1558
|
+
{ key: 'total', label: 'Tokens', value: (r) => compact(r.total) },
|
|
1559
|
+
{ key: 'requests', label: 'Requests', value: (r) => int(r.requests) },
|
|
1560
|
+
], c.unpriced.slice(0, 40)));
|
|
1561
|
+
box.appendChild(el('div', { style: 'padding-top:10px' }, [btn('Configure pricing', () => pricingModal(), 'primary sm')]));
|
|
1562
|
+
return box;
|
|
1563
|
+
})()));
|
|
1564
|
+
}
|
|
1565
|
+
return root;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// ============================================================= productivity ==
|
|
1569
|
+
|
|
1570
|
+
function viewProductivity() {
|
|
1571
|
+
const v = S.view;
|
|
1572
|
+
const pr = v.productivity;
|
|
1573
|
+
const root = el('div', { class: 'grid' });
|
|
1574
|
+
root.appendChild(sectionTitle('AI activity / productivity proxies'));
|
|
1575
|
+
root.appendChild(el('div', { class: 'banner info' }, [el('span', {
|
|
1576
|
+
text: 'Token usage is not a measure of productivity. Everything on this page is either a description of AI activity or a correlation with an independent work signal — never a claim that AI usage caused an outcome.',
|
|
1577
|
+
})]));
|
|
1578
|
+
|
|
1579
|
+
const cards = el('div', { class: 'cards' });
|
|
1580
|
+
const p = pr.proxies;
|
|
1581
|
+
cards.appendChild(kpi('AI sessions', int(p.sessions), `${p.sessionsPerActiveDay === null ? '—' : p.sessionsPerActiveDay.toFixed(1)} per active day`));
|
|
1582
|
+
cards.appendChild(kpi('AI-assisted days', int(p.activeDays), `${p.weekdayActiveDays} weekday · ${p.weekendActiveDays} weekend`));
|
|
1583
|
+
cards.appendChild(kpi('Tokens / session', compact(p.tokensPerSession)));
|
|
1584
|
+
cards.appendChild(kpi('Output / session', compact(p.outputPerSession)));
|
|
1585
|
+
cards.appendChild(kpi('Requests / session', p.requestsPerSession === null ? '—' : p.requestsPerSession.toFixed(1)));
|
|
1586
|
+
cards.appendChild(kpi('Projects touched', int(p.projects), `${int(p.repositories)} repositories`));
|
|
1587
|
+
cards.appendChild(kpi('Long sessions (>30m)', int(v.sessionProfile.longSessions), `${int(v.sessionProfile.shortSessions)} under 2 minutes`));
|
|
1588
|
+
cards.appendChild(kpi('Median sessions / day', p.medianSessionsPerDay === null ? '—' : int(p.medianSessionsPerDay)));
|
|
1589
|
+
root.appendChild(cards);
|
|
1590
|
+
|
|
1591
|
+
const corr = pr.correlations;
|
|
1592
|
+
if (!corr.available) {
|
|
1593
|
+
root.appendChild(card('Work correlation', 'Unavailable — and here is exactly why.', emptyCard('Not enough overlapping data', corr.reason || '')));
|
|
1594
|
+
} else {
|
|
1595
|
+
for (const m of corr.metrics.slice(0, 3)) {
|
|
1596
|
+
root.appendChild(chartCard('corr-' + m.metric, `AI usage vs ${metricLabel(m.metric)}`, `Pearson r = ${m.r.toFixed(2)} (${m.strength} ${m.direction}) over ${m.n} overlapping days. ${corr.note}`, (w) => {
|
|
1597
|
+
const pts = m.series.map((s) => ({
|
|
1598
|
+
x: s.usage, y: s.work, r: 1, color: 'var(--series-1)', label: longDate(s.date),
|
|
1599
|
+
rows: [{ color: null, name: 'AI tokens', value: compact(s.usage) }, { color: null, name: metricLabel(m.metric), value: int(s.work) }],
|
|
1600
|
+
}));
|
|
1601
|
+
return scatter(pts, {
|
|
1602
|
+
width: w, height: 300, fmtX: compact, fmtY: compact,
|
|
1603
|
+
xLabel: 'AI tokens that day', yLabel: metricLabel(m.metric),
|
|
1604
|
+
});
|
|
1605
|
+
}, {
|
|
1606
|
+
columns: [
|
|
1607
|
+
{ key: 'date', label: 'Date', value: (r) => longDate(r.date), text: true },
|
|
1608
|
+
{ key: 'usage', label: 'AI tokens', value: (r) => compact(r.usage) },
|
|
1609
|
+
{ key: 'work', label: metricLabel(m.metric), value: (r) => int(r.work) },
|
|
1610
|
+
],
|
|
1611
|
+
rows: [...m.series].reverse(),
|
|
1612
|
+
}));
|
|
1613
|
+
}
|
|
1614
|
+
if (pr.contrast && pr.contrast.difference !== null) {
|
|
1615
|
+
root.appendChild(card('Higher- vs lower-usage days', pr.contrast.note, (() => {
|
|
1616
|
+
const kv = el('dl', { class: 'kv' });
|
|
1617
|
+
const add = (k, val) => { kv.appendChild(el('dt', { text: k })); kv.appendChild(el('dd', { text: val })); };
|
|
1618
|
+
add('Days compared', int(pr.contrast.n));
|
|
1619
|
+
add(`Mean ${metricLabel(pr.contrast.metric)} — lower-usage half`, int(pr.contrast.lowUsageMean));
|
|
1620
|
+
add(`Mean ${metricLabel(pr.contrast.metric)} — higher-usage half`, int(pr.contrast.highUsageMean));
|
|
1621
|
+
add('Difference between groups', signedPct(pr.contrast.difference));
|
|
1622
|
+
return kv;
|
|
1623
|
+
})()));
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
if (pr.work.length) {
|
|
1628
|
+
root.appendChild(chartCard('work-series', 'Work activity over time', 'From the git / IDE activity adapters. These records carry no token counts and never enter a token total.', (w) => {
|
|
1629
|
+
const keys = [
|
|
1630
|
+
{ key: 'insertions', label: 'Lines added', color: 'var(--series-3)' },
|
|
1631
|
+
{ key: 'deletions', label: 'Lines removed', color: 'var(--series-8)' },
|
|
1632
|
+
];
|
|
1633
|
+
const wrap = el('div');
|
|
1634
|
+
wrap.appendChild(timeSeries({
|
|
1635
|
+
data: pr.work.map((wd) => ({ key: wd.date, ...wd })), keys, mode: 'line', width: w, height: 220,
|
|
1636
|
+
fmtY: int, fmtX: shortDate, fmtXLong: longDate, fillArea: true, ariaLabel: 'work activity',
|
|
1637
|
+
}));
|
|
1638
|
+
wrap.appendChild(legend(keys));
|
|
1639
|
+
return wrap;
|
|
1640
|
+
}, {
|
|
1641
|
+
columns: [
|
|
1642
|
+
{ key: 'date', label: 'Date', value: (r) => longDate(r.date), text: true },
|
|
1643
|
+
{ key: 'commits', label: 'Commits', value: (r) => int(r.commits) },
|
|
1644
|
+
{ key: 'files', label: 'Files', value: (r) => int(r.files) },
|
|
1645
|
+
{ key: 'insertions', label: 'Lines +', value: (r) => int(r.insertions) },
|
|
1646
|
+
{ key: 'deletions', label: 'Lines −', value: (r) => int(r.deletions) },
|
|
1647
|
+
{ key: 'aiLines', label: 'AI lines', value: (r) => int(r.aiLines) },
|
|
1648
|
+
{ key: 'edits', label: 'AI edits', value: (r) => int(r.edits) },
|
|
1649
|
+
],
|
|
1650
|
+
rows: [...pr.work].reverse(),
|
|
1651
|
+
}));
|
|
1652
|
+
}
|
|
1653
|
+
return root;
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
function shorten(url) {
|
|
1657
|
+
return String(url).replace(/^https?:\/\//, '').replace(/\/$/, '');
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
function metricLabel(k) {
|
|
1661
|
+
return { commits: 'git commits', insertions: 'lines added', files: 'files changed', aiLines: 'AI-authored lines', edits: 'AI edit events' }[k] || k;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// ================================================================== compare ==
|
|
1665
|
+
|
|
1666
|
+
function viewCompare() {
|
|
1667
|
+
const v = S.view;
|
|
1668
|
+
const root = el('div', { class: 'grid' });
|
|
1669
|
+
root.appendChild(sectionTitle('Comparison mode'));
|
|
1670
|
+
|
|
1671
|
+
const def = defaultCompare();
|
|
1672
|
+
const a = S.compare?.a || def.a;
|
|
1673
|
+
const b = S.compare?.b || def.b;
|
|
1674
|
+
|
|
1675
|
+
const bar = el('div', { class: 'filters', style: 'padding-top:0' });
|
|
1676
|
+
const mk = (label, obj, k) => dateFieldRaw(label, obj[k], (val) => {
|
|
1677
|
+
obj[k] = val;
|
|
1678
|
+
S.compare = { a, b };
|
|
1679
|
+
recompute();
|
|
1680
|
+
render();
|
|
1681
|
+
});
|
|
1682
|
+
bar.appendChild(mk('Period A from', a, 'from'));
|
|
1683
|
+
bar.appendChild(mk('Period A to', a, 'to'));
|
|
1684
|
+
bar.appendChild(mk('Period B from', b, 'from'));
|
|
1685
|
+
bar.appendChild(mk('Period B to', b, 'to'));
|
|
1686
|
+
bar.appendChild(btn('Previous vs current', () => {
|
|
1687
|
+
const prev = previousPeriod(v.range.from, v.range.to);
|
|
1688
|
+
S.compare = { a: { ...prev }, b: { from: v.range.from, to: v.range.to } };
|
|
1689
|
+
recompute(); render();
|
|
1690
|
+
}, 'ghost'));
|
|
1691
|
+
bar.appendChild(btn('Split range in half', () => {
|
|
1692
|
+
const mid = addDays(v.range.from, Math.floor((daysBetween(v.range.from, v.range.to)) / 2));
|
|
1693
|
+
S.compare = { a: { from: v.range.from, to: mid }, b: { from: addDays(mid, 1), to: v.range.to } };
|
|
1694
|
+
recompute(); render();
|
|
1695
|
+
}, 'ghost'));
|
|
1696
|
+
root.appendChild(bar);
|
|
1697
|
+
|
|
1698
|
+
if (!S.compare) { S.compare = { a, b }; recompute(); }
|
|
1699
|
+
const cmp = S.view.comparison;
|
|
1700
|
+
if (!cmp) return root;
|
|
1701
|
+
|
|
1702
|
+
const head = el('div', { class: 'cards' });
|
|
1703
|
+
head.appendChild(kpi('Period A', `${shortDate(cmp.a.period.from)} – ${shortDate(cmp.a.period.to)}`, `${cmp.a.activeDays} active days · ${compact(cmp.a.total)} tokens`));
|
|
1704
|
+
head.appendChild(kpi('Period B', `${shortDate(cmp.b.period.from)} – ${shortDate(cmp.b.period.to)}`, `${cmp.b.activeDays} active days · ${compact(cmp.b.total)} tokens`));
|
|
1705
|
+
root.appendChild(head);
|
|
1706
|
+
|
|
1707
|
+
root.appendChild(card('Metric comparison', 'B relative to A. A metric with no comparable base shows "no comparable period" rather than a fabricated percentage.', table([
|
|
1708
|
+
{ key: 'label', label: 'Metric', text: true },
|
|
1709
|
+
{ key: 'a', label: 'Period A', value: (r) => fmtByKind(r.a, r.kind) },
|
|
1710
|
+
{ key: 'b', label: 'Period B', value: (r) => fmtByKind(r.b, r.kind) },
|
|
1711
|
+
{ key: 'change', label: 'Change', value: (r) => deltaChip(r.change) },
|
|
1712
|
+
], cmp.deltas)));
|
|
1713
|
+
|
|
1714
|
+
for (const [title, rows, key] of [['Provider shift', cmp.providerShift, 'provider'], ['Model shift', cmp.modelShift, 'model'], ['Interface shift', cmp.interfaceShift, 'interface']]) {
|
|
1715
|
+
root.appendChild(card(title, 'Ordered by absolute change.', table([
|
|
1716
|
+
{ key: 'key', label: 'Key', text: true, onClick: (r) => drillTo(key, r.key) },
|
|
1717
|
+
{ key: 'a', label: 'Period A', value: (r) => compact(r.a) },
|
|
1718
|
+
{ key: 'b', label: 'Period B', value: (r) => compact(r.b) },
|
|
1719
|
+
{ key: 'absolute', label: 'Change', value: (r) => (r.absolute >= 0 ? '+' : '') + compact(r.absolute) },
|
|
1720
|
+
{ key: 'change', label: '%', value: (r) => deltaChip(r.change) },
|
|
1721
|
+
], rows.filter((r) => r.a || r.b).slice(0, 15))));
|
|
1722
|
+
}
|
|
1723
|
+
return root;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
function fmtByKind(v, kind) {
|
|
1727
|
+
if (v === null || v === undefined) return null;
|
|
1728
|
+
if (kind === 'share') return pct(v);
|
|
1729
|
+
if (kind === 'cost') return usd(v);
|
|
1730
|
+
return typeof v === 'number' && v > 9999 ? compact(v) : int(v);
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
function defaultCompare() {
|
|
1734
|
+
const v = S.view;
|
|
1735
|
+
const mid = addDays(v.range.from, Math.floor(daysBetween(v.range.from, v.range.to) / 2));
|
|
1736
|
+
return { a: { from: v.range.from, to: mid }, b: { from: addDays(mid, 1), to: v.range.to } };
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
function dateFieldRaw(label, value, onChange) {
|
|
1740
|
+
const i = el('input', { type: 'date', value: value || '' });
|
|
1741
|
+
i.addEventListener('change', () => onChange(i.value || null));
|
|
1742
|
+
return el('label', { class: 'fld' }, [el('span', { text: label }), i]);
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
// ================================================================= explorer ==
|
|
1746
|
+
|
|
1747
|
+
function viewExplorer() {
|
|
1748
|
+
const root = el('div', { class: 'grid' });
|
|
1749
|
+
root.appendChild(sectionTitle('Raw data explorer'));
|
|
1750
|
+
const ex = S.explorer;
|
|
1751
|
+
|
|
1752
|
+
const bar = el('div', { class: 'filters', style: 'padding-top:0' });
|
|
1753
|
+
const search = el('input', { type: 'text', placeholder: 'Search model, project, session, branch…', value: ex.search });
|
|
1754
|
+
search.style.minWidth = '280px';
|
|
1755
|
+
let t = null;
|
|
1756
|
+
search.addEventListener('input', () => {
|
|
1757
|
+
clearTimeout(t);
|
|
1758
|
+
t = setTimeout(() => { ex.search = search.value; ex.page = 0; loadExplorer(); }, 260);
|
|
1759
|
+
});
|
|
1760
|
+
bar.appendChild(el('label', { class: 'fld' }, [el('span', { text: 'Search' }), search]));
|
|
1761
|
+
const lim = el('select');
|
|
1762
|
+
for (const n of [25, 50, 100, 250, 500]) lim.appendChild(el('option', { value: String(n), text: `${n} / page` }));
|
|
1763
|
+
lim.value = String(ex.limit);
|
|
1764
|
+
lim.addEventListener('change', () => { ex.limit = Number(lim.value); ex.page = 0; loadExplorer(); });
|
|
1765
|
+
bar.appendChild(el('label', { class: 'fld' }, [el('span', { text: 'Page size' }), lim]));
|
|
1766
|
+
bar.appendChild(btn('⇩ Export current view', () => exportCsv('view'), 'ghost'));
|
|
1767
|
+
bar.appendChild(btn('⇩ Export all data', () => exportCsv('all'), 'ghost'));
|
|
1768
|
+
root.appendChild(bar);
|
|
1769
|
+
|
|
1770
|
+
const info = el('div', { class: 'hint' });
|
|
1771
|
+
info.textContent = ex.loading ? 'loading…' : `${int(ex.total)} matching records · showing ${ex.rows.length} · sorted by ${ex.sort} ${ex.dir}`;
|
|
1772
|
+
root.appendChild(info);
|
|
1773
|
+
|
|
1774
|
+
const cols = [
|
|
1775
|
+
{ key: 'ts', label: 'Timestamp', value: (r) => (r.ts || '').replace('T', ' ').slice(0, 19), text: true },
|
|
1776
|
+
{ key: 'p', label: 'Provider', text: true, onClick: (r) => drillTo('provider', r.p) },
|
|
1777
|
+
{ key: 'm', label: 'Model', text: true, onClick: (r) => drillTo('model', r.m) },
|
|
1778
|
+
{ key: 'c', label: 'Client', text: true },
|
|
1779
|
+
{ key: 'i', label: 'Interface', text: true },
|
|
1780
|
+
{ key: 'in', label: 'Input', value: (r) => (r.in === undefined ? null : int(r.in)) },
|
|
1781
|
+
{ key: 'ou', label: 'Output', value: (r) => (r.ou === undefined ? null : int(r.ou)) },
|
|
1782
|
+
{ key: 'cr', label: 'Cache R', value: (r) => (r.cr === undefined ? null : int(r.cr)) },
|
|
1783
|
+
{ key: 'cw', label: 'Cache W', value: (r) => (r.cw === undefined ? null : int(r.cw)) },
|
|
1784
|
+
{ key: 'tt', label: 'Total', value: (r) => (r.tt === undefined ? null : int(r.tt)) },
|
|
1785
|
+
{ key: 's', label: 'Session', value: (r) => (r.s ? String(r.s).slice(0, 8) : null), text: true },
|
|
1786
|
+
{ key: 'pj', label: 'Project', text: true },
|
|
1787
|
+
{ key: 'tr', label: 'Tier', text: true, onClick: (r) => (r.tr ? drillTo('service_tier', r.tr) : null) },
|
|
1788
|
+
{ key: 'co', label: 'Cost', value: (r) => (r.co === undefined ? null : usd(r.co)), na: 'no price' },
|
|
1789
|
+
{ key: 'ms', label: 'Kind', text: true },
|
|
1790
|
+
];
|
|
1791
|
+
root.appendChild(card('Normalized records', 'Streamed from disk, filtered server-side. An empty cell means the source did not report that field — it is never shown as 0.', (() => {
|
|
1792
|
+
const box = el('div');
|
|
1793
|
+
box.appendChild(table(cols, ex.rows, {
|
|
1794
|
+
onSort: (k) => { if (ex.sort === k) ex.dir = ex.dir === 'asc' ? 'desc' : 'asc'; else { ex.sort = k; ex.dir = 'desc'; } loadExplorer(); },
|
|
1795
|
+
sortKey: ex.sort, sortDir: ex.dir,
|
|
1796
|
+
onRowClick: (r) => recordModal(r),
|
|
1797
|
+
tall: true,
|
|
1798
|
+
emptyText: ex.loading ? 'loading…' : 'No records match the current filters.',
|
|
1799
|
+
}));
|
|
1800
|
+
const nav = el('div', { style: 'display:flex;gap:8px;align-items:center;padding-top:10px' });
|
|
1801
|
+
nav.appendChild(btn('← Previous', () => { if (ex.page > 0) { ex.page--; loadExplorer(); } }, 'ghost sm'));
|
|
1802
|
+
nav.appendChild(el('span', { class: 'muted', text: `page ${ex.page + 1} of ${Math.max(1, Math.ceil(ex.total / ex.limit))}` }));
|
|
1803
|
+
nav.appendChild(btn('Next →', () => { if ((ex.page + 1) * ex.limit < ex.total) { ex.page++; loadExplorer(); } }, 'ghost sm'));
|
|
1804
|
+
box.appendChild(nav);
|
|
1805
|
+
return box;
|
|
1806
|
+
})()));
|
|
1807
|
+
|
|
1808
|
+
if (!ex.rows.length && !ex.loading) loadExplorer();
|
|
1809
|
+
return root;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
async function loadExplorer() {
|
|
1813
|
+
const ex = S.explorer;
|
|
1814
|
+
ex.loading = true;
|
|
1815
|
+
render();
|
|
1816
|
+
try {
|
|
1817
|
+
if (SNAPSHOT) {
|
|
1818
|
+
ex.rows = (window.__TOKENFLOW_RECORDS__ || []).slice(ex.page * ex.limit, (ex.page + 1) * ex.limit);
|
|
1819
|
+
ex.total = (window.__TOKENFLOW_RECORDS__ || []).length;
|
|
1820
|
+
} else {
|
|
1821
|
+
const q = new URLSearchParams({
|
|
1822
|
+
offset: String(ex.page * ex.limit), limit: String(ex.limit),
|
|
1823
|
+
sort: ex.sort, dir: ex.dir, search: ex.search,
|
|
1824
|
+
from: S.filters.from || '', to: S.filters.to || '',
|
|
1825
|
+
});
|
|
1826
|
+
for (const [k, fk] of [['provider', 'provider'], ['model', 'model'], ['client', 'client'], ['interface', 'interface'], ['project', 'project']]) {
|
|
1827
|
+
if (S.filters[fk] && S.filters[fk].length) q.set(k, S.filters[fk].join(','));
|
|
1828
|
+
}
|
|
1829
|
+
const res = await fetchJson('/api/records?' + q.toString());
|
|
1830
|
+
ex.rows = res.rows;
|
|
1831
|
+
ex.total = res.total;
|
|
1832
|
+
}
|
|
1833
|
+
} catch (err) {
|
|
1834
|
+
ex.rows = [];
|
|
1835
|
+
ex.total = 0;
|
|
1836
|
+
console.error(err);
|
|
1837
|
+
}
|
|
1838
|
+
ex.loading = false;
|
|
1839
|
+
render();
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
function recordModal(r) {
|
|
1843
|
+
const body = el('div');
|
|
1844
|
+
const kv = el('dl', { class: 'kv' });
|
|
1845
|
+
const NAMES = {
|
|
1846
|
+
ts: 'Timestamp', d: 'Date', h: 'Hour', p: 'Provider', m: 'Model', mf: 'Family', g: 'Gateway', tr: 'Service tier',
|
|
1847
|
+
c: 'Client', ap: 'Application', i: 'Interface', in: 'Input tokens', ou: 'Output tokens',
|
|
1848
|
+
cr: 'Cache read', cw: 'Cache write', cf: 'Cache refresh', rs: 'Reasoning', tt: 'Total',
|
|
1849
|
+
s: 'Session', cv: 'Conversation', rq: 'Request id', pj: 'Project', rp: 'Repository',
|
|
1850
|
+
br: 'Branch', k: 'Category', co: 'Cost', cb: 'Cost basis', ms: 'Measurement', so: 'Source',
|
|
1851
|
+
du: 'Duration ms', u: 'User', mc: 'Machine',
|
|
1852
|
+
};
|
|
1853
|
+
for (const [k, label] of Object.entries(NAMES)) {
|
|
1854
|
+
kv.appendChild(el('dt', { text: label }));
|
|
1855
|
+
const v = r[k];
|
|
1856
|
+
kv.appendChild(el('dd', v === undefined || v === null
|
|
1857
|
+
? { class: 'na', text: 'not available', title: 'The source did not report this field' }
|
|
1858
|
+
: { text: String(v) }));
|
|
1859
|
+
}
|
|
1860
|
+
body.appendChild(kv);
|
|
1861
|
+
if (r.x) {
|
|
1862
|
+
body.appendChild(el('h3', { style: 'margin-top:16px', text: 'Source metadata' }));
|
|
1863
|
+
body.appendChild(el('pre', { class: 'mono', style: 'white-space:pre-wrap;background:var(--surface-2);padding:10px;border-radius:6px', text: JSON.stringify(r.x, null, 2) }));
|
|
1864
|
+
}
|
|
1865
|
+
openModal('Record ' + (r.id || ''), body);
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
// ============================================================== data health ==
|
|
1869
|
+
|
|
1870
|
+
function viewHealth() {
|
|
1871
|
+
const h = S.bundle.health;
|
|
1872
|
+
const m = S.bundle.meta;
|
|
1873
|
+
const root = el('div', { class: 'grid' });
|
|
1874
|
+
root.appendChild(sectionTitle('Data health'));
|
|
1875
|
+
|
|
1876
|
+
const cards = el('div', { class: 'cards' });
|
|
1877
|
+
cards.appendChild(kpi('Data health', h.grade, `${pct(h.missingTokenFieldRate)} of token fields not reported`, { hero: true }));
|
|
1878
|
+
cards.appendChild(kpi('Records', int(h.records), `${int(h.sourceFiles)} source files tracked`));
|
|
1879
|
+
cards.appendChild(kpi('Date coverage', h.coverage.from ? `${shortDate(h.coverage.from)} → ${shortDate(h.coverage.to)}` : '—', `${h.coverage.from ? daysBetween(h.coverage.from, h.coverage.to) + 1 : 0} days`));
|
|
1880
|
+
cards.appendChild(kpi('Providers', int(h.providers), `${int(h.models)} models · ${int(h.clients)} clients`));
|
|
1881
|
+
cards.appendChild(kpi('Sessions', int(h.sessions)));
|
|
1882
|
+
cards.appendChild(kpi('Duplicate records', int(h.duplicateRecords), 'structural dedup: bytes are never read twice', { title: 'Ingest resumes at a byte offset per source file, so a record cannot be ingested twice. Streaming duplicates within a source are collapsed by the adapter.' }));
|
|
1883
|
+
cards.appendChild(kpi('Malformed lines skipped', int(h.malformedLines)));
|
|
1884
|
+
cards.appendChild(kpi('Last refresh', relativeTime(m.lastRefresh), m.lastRefreshDurationMs ? `took ${humanDuration(m.lastRefreshDurationMs)}` : ''));
|
|
1885
|
+
root.appendChild(cards);
|
|
1886
|
+
|
|
1887
|
+
// ---- request geography: honest unavailability ------------------------------
|
|
1888
|
+
// No supported source exposes the network region a request was served to:
|
|
1889
|
+
// local session logs record tokens, models and timestamps, not IP egress.
|
|
1890
|
+
// Rather than infer geography from model names (wrong) or show zeros, this
|
|
1891
|
+
// panel states plainly what is and is not knowable from local logs.
|
|
1892
|
+
// ---- request geography: honest unavailability ------------------------------
|
|
1893
|
+
// No supported source exposes the network region a request was served to:
|
|
1894
|
+
// local session logs record tokens, models and timestamps, not IP egress.
|
|
1895
|
+
// Rather than infer geography from model names (wrong) or show zeros, this
|
|
1896
|
+
// panel states plainly what is and is not knowable from local logs.
|
|
1897
|
+
const geoBody = el('div');
|
|
1898
|
+
geoBody.appendChild(el('p', { class: 'muted', text: 'Region data: not provided by provider for all connected sources.' }));
|
|
1899
|
+
const geoDetail = el('p', { class: 'muted' });
|
|
1900
|
+
const geoStrong = document.createElement('strong');
|
|
1901
|
+
geoStrong.textContent = 'What is known instead: ';
|
|
1902
|
+
geoDetail.appendChild(geoStrong);
|
|
1903
|
+
geoDetail.appendChild(document.createTextNode('requests by provider, model, interface and client — all measured from your own logs on the Providers and Interfaces pages.'));
|
|
1904
|
+
geoBody.appendChild(geoDetail);
|
|
1905
|
+
root.appendChild(card('Request geography', 'Where requests are served is a property of vendor infrastructure, and none of the local sources report it. TokenFlow will surface per-region breakdowns the day a connected source exposes region data; until then every request is recorded with region not available, never guessed.', geoBody));
|
|
1906
|
+
|
|
1907
|
+
root.appendChild(card('Field availability', 'Per-field share of records where the source reported nothing. These gaps are excluded from totals, never counted as zero.', table([
|
|
1908
|
+
{ key: 'field', label: 'Field', text: true },
|
|
1909
|
+
{ key: 'missing', label: 'Not reported', value: (r) => pct(r.missing) },
|
|
1910
|
+
{ key: 'bar', label: '', value: (r) => miniBar(r.missing, r.missing > 0.3 ? 'var(--warning)' : 'var(--series-1)') },
|
|
1911
|
+
], Object.entries(h.missingByField).map(([field, missing]) => ({ field, missing })))));
|
|
1912
|
+
|
|
1913
|
+
root.appendChild(card('Sources', 'What each adapter contributed, and the window it actually covers — a source that only started logging in July does not cover the whole range.', table([
|
|
1914
|
+
{ key: 'id', label: 'Adapter', text: true },
|
|
1915
|
+
{ key: 'records', label: 'Records', value: (r) => int(r.records) },
|
|
1916
|
+
{ key: 'tokens', label: 'Tokens', value: (r) => (r.tokens ? compact(r.tokens) : null), na: 'none reported' },
|
|
1917
|
+
{ key: 'sessions', label: 'Sessions', value: (r) => int(r.sessions) },
|
|
1918
|
+
{ key: 'coverage', label: 'Covers', value: (r) => (r.coverage?.from ? `${shortDate(r.coverage.from)} → ${shortDate(r.coverage.to)}` : null), text: true },
|
|
1919
|
+
{ key: 'files', label: 'Files tracked', value: (r) => int(r.files) },
|
|
1920
|
+
{ key: 'lastRefresh', label: 'Last refresh', value: (r) => relativeTime(r.lastRefresh), text: true },
|
|
1921
|
+
], m.sources)));
|
|
1922
|
+
|
|
1923
|
+
root.appendChild(card('Measurement kinds', 'Why some records never contribute tokens.', (() => {
|
|
1924
|
+
const box = el('div');
|
|
1925
|
+
box.appendChild(el('p', { class: 'hint', text: 'primary — authoritative per-request usage from the model API; counted in every total.' }));
|
|
1926
|
+
box.appendChild(el('p', { class: 'hint', text: 'overlay — a gateway/proxy view of traffic already counted by a client adapter. Excluded from totals by default so tokens are not double counted; contributes measured cost.' }));
|
|
1927
|
+
box.appendChild(el('p', { class: 'hint', text: 'activity — AI activity with no token accounting (IDE edits, sessions without a usage block, commits). Contributes to activity and correlation only.' }));
|
|
1928
|
+
return box;
|
|
1929
|
+
})()));
|
|
1930
|
+
return root;
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
// ================================================================= refresh ===
|
|
1934
|
+
|
|
1935
|
+
async function doRefresh() {
|
|
1936
|
+
if (S.refreshing) return;
|
|
1937
|
+
S.refreshing = true;
|
|
1938
|
+
const b = /** @type {HTMLButtonElement|null} */ (document.getElementById('refresh-btn'));
|
|
1939
|
+
if (b) { b.disabled = true; b.textContent = '↻ Refreshing…'; }
|
|
1940
|
+
document.getElementById('view').classList.add('refreshing');
|
|
1941
|
+
renderHeaderMeta();
|
|
1942
|
+
const status = el('div', { class: 'banner info' }, [el('span', { text: 'Scanning sources…' })]);
|
|
1943
|
+
document.getElementById('banners').prepend(status);
|
|
1944
|
+
try {
|
|
1945
|
+
// Stream progress so a multi-gigabyte first scan shows life, and keep the
|
|
1946
|
+
// previous render on screen at reduced opacity — no skeleton, no jump.
|
|
1947
|
+
const res = await fetch('/api/refresh', { method: 'POST' });
|
|
1948
|
+
if (!res.ok) throw new Error(await res.text());
|
|
1949
|
+
const reader = res.body.getReader();
|
|
1950
|
+
const dec = new TextDecoder();
|
|
1951
|
+
let buf = '';
|
|
1952
|
+
let report = null;
|
|
1953
|
+
while (true) {
|
|
1954
|
+
const { value, done } = await reader.read();
|
|
1955
|
+
if (done) break;
|
|
1956
|
+
buf += dec.decode(value, { stream: true });
|
|
1957
|
+
const lines = buf.split('\n');
|
|
1958
|
+
buf = lines.pop();
|
|
1959
|
+
for (const line of lines) {
|
|
1960
|
+
if (!line.trim()) continue;
|
|
1961
|
+
let ev;
|
|
1962
|
+
try { ev = JSON.parse(line); } catch { continue; }
|
|
1963
|
+
if (ev.type === 'progress') status.firstChild.textContent = `${ev.provider}: ${int(ev.files)} files, ${int(ev.records)} new records…`;
|
|
1964
|
+
else if (ev.type === 'log') status.firstChild.textContent = ev.message;
|
|
1965
|
+
else if (ev.type === 'done') report = ev.report;
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
// Preserve filters across the reload — the whole point of a refresh button.
|
|
1969
|
+
const keep = { ...S.filters };
|
|
1970
|
+
const keepRange = S.rangeId;
|
|
1971
|
+
S.bundle = await fetchJson('/api/bundle');
|
|
1972
|
+
S.filters = keep;
|
|
1973
|
+
if (keepRange !== 'custom') applyRange(keepRange, { silent: true });
|
|
1974
|
+
recompute();
|
|
1975
|
+
status.textContent = '';
|
|
1976
|
+
status.appendChild(el('span', {
|
|
1977
|
+
text: report
|
|
1978
|
+
? `Refresh complete: ${int(report.newRecords)} new records from ${int(report.filesScanned)} changed files (${int(report.filesSkipped)} unchanged files skipped) in ${humanDuration(report.durationMs)}.${report.done ? '' : ' Budget reached — run refresh again to continue.'}`
|
|
1979
|
+
: 'Refresh complete.',
|
|
1980
|
+
}));
|
|
1981
|
+
if (report && !report.done) status.appendChild(btn('Continue', () => doRefresh(), 'primary sm'));
|
|
1982
|
+
setTimeout(() => status.remove(), 9000);
|
|
1983
|
+
} catch (err) {
|
|
1984
|
+
status.className = 'banner';
|
|
1985
|
+
status.textContent = 'Refresh failed: ' + err.message;
|
|
1986
|
+
} finally {
|
|
1987
|
+
S.refreshing = false;
|
|
1988
|
+
document.getElementById('view').classList.remove('refreshing');
|
|
1989
|
+
if (b) { b.disabled = false; b.textContent = '↻ Refresh data'; }
|
|
1990
|
+
render();
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
// ================================================================== exports ==
|
|
1995
|
+
|
|
1996
|
+
function exportMenu(ev) {
|
|
1997
|
+
const body = el('div');
|
|
1998
|
+
body.appendChild(el('p', { class: 'hint', text: 'Missing values export as empty cells, never as 0, so a spreadsheet cannot turn "not reported" into "zero".' }));
|
|
1999
|
+
const list = el('div', { style: 'display:grid;gap:8px' });
|
|
2000
|
+
list.appendChild(btn('Export current view (filtered records)', () => { exportCsv('view'); closeModal(); }, 'primary'));
|
|
2001
|
+
list.appendChild(btn('Export all data (every record)', () => { exportCsv('all'); closeModal(); }, 'ghost'));
|
|
2002
|
+
list.appendChild(btn('Export daily series', () => {
|
|
2003
|
+
downloadCsv('tokenflow-daily.csv', [
|
|
2004
|
+
{ key: 'key', label: 'date' }, { key: 'total', label: 'total_tokens' }, { key: 'in', label: 'input_tokens' },
|
|
2005
|
+
{ key: 'out', label: 'output_tokens' }, { key: 'cr', label: 'cache_read_tokens' }, { key: 'cw', label: 'cache_write_tokens' },
|
|
2006
|
+
{ key: 'cf', label: 'cache_refresh_tokens' }, { key: 'rs', label: 'reasoning_tokens' }, { key: 'req', label: 'requests' },
|
|
2007
|
+
{ key: 'active', label: 'active_day' },
|
|
2008
|
+
], S.view.daily);
|
|
2009
|
+
closeModal();
|
|
2010
|
+
}, 'ghost'));
|
|
2011
|
+
list.appendChild(btn('Export provider table', () => { downloadCsv('tokenflow-providers.csv', providerCsvCols(), S.view.dimensions.providers); closeModal(); }, 'ghost'));
|
|
2012
|
+
list.appendChild(btn('Export model table', () => { downloadCsv('tokenflow-models.csv', providerCsvCols(), S.view.dimensions.models); closeModal(); }, 'ghost'));
|
|
2013
|
+
body.appendChild(list);
|
|
2014
|
+
openModal('Export CSV', body);
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
function providerCsvCols() {
|
|
2018
|
+
return [
|
|
2019
|
+
{ key: 'key', label: 'key' }, { key: 'total', label: 'total_tokens' }, { key: 'input', label: 'input_tokens' },
|
|
2020
|
+
{ key: 'output', label: 'output_tokens' }, { key: 'cacheRead', label: 'cache_read_tokens' },
|
|
2021
|
+
{ key: 'cacheWrite', label: 'cache_write_tokens' }, { key: 'requests', label: 'requests' },
|
|
2022
|
+
{ key: 'sessions', label: 'sessions' }, { key: 'activeDays', label: 'active_days' },
|
|
2023
|
+
{ key: 'avgPerActiveDay', label: 'avg_per_active_day' }, { key: 'peakDay', label: 'peak_day' },
|
|
2024
|
+
{ key: 'cost', label: 'estimated_cost' }, { key: 'share', label: 'share' },
|
|
2025
|
+
];
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
function exportCsv(scope) {
|
|
2029
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
2030
|
+
if (SNAPSHOT) {
|
|
2031
|
+
const rows = window.__TOKENFLOW_RECORDS__ || [];
|
|
2032
|
+
downloadCsv(`tokenflow-usage-${today}.csv`, [
|
|
2033
|
+
{ key: 'ts', label: 'timestamp' }, { key: 'p', label: 'provider' }, { key: 'm', label: 'model' },
|
|
2034
|
+
{ key: 'c', label: 'client' }, { key: 'i', label: 'interface' }, { key: 'in', label: 'input_tokens' },
|
|
2035
|
+
{ key: 'ou', label: 'output_tokens' }, { key: 'cr', label: 'cache_read_tokens' }, { key: 'cw', label: 'cache_write_tokens' },
|
|
2036
|
+
{ key: 'tt', label: 'total_tokens' }, { key: 's', label: 'session_id' }, { key: 'pj', label: 'project' },
|
|
2037
|
+
{ key: 'co', label: 'estimated_cost' },
|
|
2038
|
+
], rows);
|
|
2039
|
+
return;
|
|
2040
|
+
}
|
|
2041
|
+
const q = new URLSearchParams({ scope });
|
|
2042
|
+
if (scope === 'view') {
|
|
2043
|
+
q.set('from', S.filters.from || '');
|
|
2044
|
+
q.set('to', S.filters.to || '');
|
|
2045
|
+
for (const k of ['provider', 'model', 'client', 'interface', 'project']) {
|
|
2046
|
+
if (S.filters[k] && S.filters[k].length) q.set(k, S.filters[k].join(','));
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
window.location.href = '/api/export.csv?' + q.toString();
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
function downloadCsv(name, cols, rows) {
|
|
2053
|
+
const cell = (v) => {
|
|
2054
|
+
if (v === null || v === undefined) return '';
|
|
2055
|
+
const s = String(v);
|
|
2056
|
+
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
|
2057
|
+
};
|
|
2058
|
+
let out = cols.map((c) => cell(c.label ?? c.key)).join(',') + '\n';
|
|
2059
|
+
for (const r of rows) {
|
|
2060
|
+
out += cols.map((c) => {
|
|
2061
|
+
const raw = c.raw ? c.raw(r) : r[c.key];
|
|
2062
|
+
return cell(raw instanceof Node ? '' : raw);
|
|
2063
|
+
}).join(',') + '\n';
|
|
2064
|
+
}
|
|
2065
|
+
const blob = new Blob([out], { type: 'text/csv;charset=utf-8' });
|
|
2066
|
+
const a = el('a', { href: URL.createObjectURL(blob), download: name });
|
|
2067
|
+
document.body.appendChild(a);
|
|
2068
|
+
a.click();
|
|
2069
|
+
a.remove();
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
// ================================================================== pricing ==
|
|
2073
|
+
|
|
2074
|
+
function pricingModal() {
|
|
2075
|
+
const body = el('div');
|
|
2076
|
+
body.appendChild(el('p', { class: 'hint', text: `Rates are USD per 1,000,000 tokens. Built-in table ${S.bundle.meta.pricingTableVersion}; anything you enter here overrides it. Left blank means unpriced — the dashboard shows "no price" rather than inventing a rate. Cache write columns: the first is the short-TTL (5-minute) rate, and the long-TTL (1-hour) subset falls back to each vendor's published multiple.` }));
|
|
2077
|
+
body.appendChild(el('p', { class: 'hint', text: 'Service-tier multipliers (OpenAI Fast mode 4x, Anthropic Batch 0.5x) are applied automatically per request from the recorded tier — do not bake them into these rates.' }));
|
|
2078
|
+
const models = S.view.dimensions.models;
|
|
2079
|
+
const existing = S.bundle.pricing?.models || {};
|
|
2080
|
+
const inputs = new Map();
|
|
2081
|
+
const rows = models.map((m) => {
|
|
2082
|
+
const cur = existing[m.key] || {};
|
|
2083
|
+
const mk = (k, ph) => {
|
|
2084
|
+
const i = el('input', { type: 'number', step: '0.0001', min: '0', placeholder: ph, value: cur[k] ?? '' });
|
|
2085
|
+
i.style.width = '92px';
|
|
2086
|
+
return i;
|
|
2087
|
+
};
|
|
2088
|
+
const inp = mk('in', 'input');
|
|
2089
|
+
const out = mk('out', 'output');
|
|
2090
|
+
const cr = mk('cacheRead', 'cache r');
|
|
2091
|
+
const cw = mk('cacheWrite', 'cache w');
|
|
2092
|
+
inputs.set(m.key, { in: inp, out, cacheRead: cr, cacheWrite: cw });
|
|
2093
|
+
return { model: m.key, tokens: m.total, priced: m.cost !== null, inp, out, cr, cw };
|
|
2094
|
+
});
|
|
2095
|
+
body.appendChild(table([
|
|
2096
|
+
{ key: 'model', label: 'Model', text: true },
|
|
2097
|
+
{ key: 'tokens', label: 'Tokens', value: (r) => compact(r.tokens) },
|
|
2098
|
+
{ key: 'priced', label: 'Status', value: (r) => el('span', { class: 'badge ' + (r.priced ? 'meas' : 'na'), text: r.priced ? 'priced' : 'no price' }) },
|
|
2099
|
+
{ key: 'in', label: 'Input $/1M', value: (r) => r.inp },
|
|
2100
|
+
{ key: 'out', label: 'Output $/1M', value: (r) => r.out },
|
|
2101
|
+
{ key: 'cr', label: 'Cache read', value: (r) => r.cr },
|
|
2102
|
+
{ key: 'cw', label: 'Cache write', value: (r) => r.cw },
|
|
2103
|
+
], rows));
|
|
2104
|
+
|
|
2105
|
+
const foot = [
|
|
2106
|
+
btn('Save & refresh totals', async () => {
|
|
2107
|
+
const models2 = {};
|
|
2108
|
+
for (const [key, fields] of inputs) {
|
|
2109
|
+
const o = {};
|
|
2110
|
+
for (const [k, i] of Object.entries(fields)) if (i.value !== '') o[k] = Number(i.value);
|
|
2111
|
+
if (Object.keys(o).length) models2[key] = o;
|
|
2112
|
+
}
|
|
2113
|
+
if (SNAPSHOT) {
|
|
2114
|
+
S.bundle.pricing = { models: models2 };
|
|
2115
|
+
recompute(); closeModal(); render();
|
|
2116
|
+
return;
|
|
2117
|
+
}
|
|
2118
|
+
await fetch('/api/pricing', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ models: models2 }) });
|
|
2119
|
+
closeModal();
|
|
2120
|
+
await doRefresh();
|
|
2121
|
+
}, 'primary'),
|
|
2122
|
+
];
|
|
2123
|
+
openModal(`Pricing — table ${S.bundle.meta.pricingTableVersion}`, body, foot);
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
// ==================================================================== modal ==
|
|
2127
|
+
|
|
2128
|
+
function openModal(title, body, foot) {
|
|
2129
|
+
const d = /** @type {HTMLDialogElement} */ (document.getElementById('modal'));
|
|
2130
|
+
document.getElementById('modal-title').textContent = title;
|
|
2131
|
+
const b = document.getElementById('modal-body');
|
|
2132
|
+
b.textContent = '';
|
|
2133
|
+
b.appendChild(body);
|
|
2134
|
+
const f = document.getElementById('modal-foot');
|
|
2135
|
+
f.textContent = '';
|
|
2136
|
+
for (const x of [].concat(foot || [])) f.appendChild(x);
|
|
2137
|
+
f.appendChild(btn('Close', () => closeModal(), 'ghost'));
|
|
2138
|
+
document.getElementById('modal-close').onclick = () => closeModal();
|
|
2139
|
+
d.showModal();
|
|
2140
|
+
}
|
|
2141
|
+
function closeModal() {
|
|
2142
|
+
/** @type {HTMLDialogElement} */ (document.getElementById('modal')).close();
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
// ===================================================================== util ==
|
|
2146
|
+
|
|
2147
|
+
async function fetchJson(url, opt) {
|
|
2148
|
+
const r = await fetch(url, opt);
|
|
2149
|
+
if (!r.ok) throw new Error(`${url} → ${r.status} ${await r.text()}`);
|
|
2150
|
+
return r.json();
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
function loadPrefs() {
|
|
2154
|
+
// Server-side preferences are the source of truth; localStorage is only an
|
|
2155
|
+
// offline fallback and may legitimately be unavailable, so never let it throw.
|
|
2156
|
+
try {
|
|
2157
|
+
const raw = localStorage.getItem('tokenflow-prefs');
|
|
2158
|
+
return raw ? JSON.parse(raw) : {};
|
|
2159
|
+
} catch {
|
|
2160
|
+
return {};
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
let saveTimer = null;
|
|
2165
|
+
function savePrefs() {
|
|
2166
|
+
const prefs = {
|
|
2167
|
+
theme: document.documentElement.dataset.mode,
|
|
2168
|
+
skin: document.documentElement.dataset.skin,
|
|
2169
|
+
mode: document.documentElement.dataset.mode,
|
|
2170
|
+
tab: S.tab,
|
|
2171
|
+
granularity: S.granularity,
|
|
2172
|
+
rangeId: S.rangeId,
|
|
2173
|
+
filters: S.filters,
|
|
2174
|
+
};
|
|
2175
|
+
try {
|
|
2176
|
+
localStorage.setItem('tokenflow-prefs', JSON.stringify(prefs));
|
|
2177
|
+
} catch { /* private mode / file:// — preferences simply don't persist */ }
|
|
2178
|
+
if (SNAPSHOT) return;
|
|
2179
|
+
clearTimeout(saveTimer);
|
|
2180
|
+
saveTimer = setTimeout(() => {
|
|
2181
|
+
fetch('/api/prefs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(prefs) }).catch(() => {});
|
|
2182
|
+
}, 800);
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
window.addEventListener('keydown', (ev) => {
|
|
2186
|
+
if (ev.key === 'r' && (ev.metaKey || ev.ctrlKey) === false && ev.target === document.body && !SNAPSHOT) doRefresh();
|
|
2187
|
+
if (ev.key === 'Escape') tooltip.hide();
|
|
2188
|
+
});
|
|
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
|
+
}
|