pi-cost 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +10 -1
- package/extensions/cost.ts +53 -83
- package/lib/app.js +113 -0
- package/lib/args.js +17 -0
- package/lib/cli.js +593 -0
- package/lib/cost-dir.js +62 -0
- package/lib/costs.js +799 -0
- package/lib/net-guard.js +178 -0
- package/lib/pi-parsers-cost.js +234 -66
- package/lib/pi-pricing.js +35 -8
- package/lib/themes.js +90 -0
- package/package.json +16 -7
- package/public/app.js +2232 -448
- package/public/fonts/OFL-IBM-Plex.txt +93 -0
- package/public/fonts/OFL-Playfair-Display.txt +93 -0
- package/public/fonts/fonts.css +244 -0
- package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
- package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
- package/public/fonts/playfair-display-latin-400.woff2 +0 -0
- package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
- package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
- package/public/index.html +68 -46
- package/public/style.css +319 -8
- package/public/sw.js +2 -2
- package/server.js +58 -660
- package/themes/ayu-dark.json +21 -0
- package/themes/ayu-light.json +21 -0
- package/themes/catppuccin-dark.json +21 -0
- package/themes/catppuccin-light.json +21 -0
- package/themes/dracula-dark.json +21 -0
- package/themes/dracula-light.json +21 -0
- package/themes/everforest-dark.json +21 -0
- package/themes/everforest-light.json +21 -0
- package/themes/github-dark.json +21 -0
- package/themes/github-light.json +21 -0
- package/themes/gruvbox-dark.json +21 -0
- package/themes/gruvbox-light.json +21 -0
- package/themes/kanagawa-dark.json +21 -0
- package/themes/kanagawa-light.json +21 -0
- package/themes/monokai-dark.json +21 -0
- package/themes/monokai-light.json +21 -0
- package/themes/night-owl-dark.json +21 -0
- package/themes/night-owl-light.json +21 -0
- package/themes/nord-dark.json +21 -0
- package/themes/nord-light.json +21 -0
- package/themes/one-dark-dark.json +21 -0
- package/themes/one-dark-light.json +21 -0
- package/themes/rose-pine-dark.json +21 -0
- package/themes/rose-pine-light.json +21 -0
- package/themes/solarized-dark.json +21 -0
- package/themes/solarized-light.json +21 -0
- package/themes/synthwave-dark.json +21 -0
- package/themes/synthwave-light.json +21 -0
- package/themes/tokyo-night-dark.json +21 -0
- package/themes/tokyo-night-light.json +21 -0
- package/themes/vitesse-dark.json +21 -0
- package/themes/vitesse-light.json +21 -0
package/public/app.js
CHANGED
|
@@ -2,25 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
let currentView = 'overview';
|
|
4
4
|
let overviewData = null;
|
|
5
|
-
let
|
|
5
|
+
let insightsData = null;
|
|
6
6
|
let sessionsData = null;
|
|
7
|
-
let
|
|
7
|
+
let sessionsCostSeries = null;
|
|
8
8
|
let sessionsModelDistribution = null;
|
|
9
|
-
let currentProjectInfo = null;
|
|
10
9
|
let sessionDetailData = null;
|
|
11
10
|
let currentProjectDir = null;
|
|
12
11
|
let currentProjectName = null;
|
|
13
12
|
let currentSessionId = null;
|
|
14
|
-
let parentView = 'projects';
|
|
15
13
|
let lastSelectedProject = null;
|
|
16
14
|
let lastSelectedSession = null;
|
|
17
15
|
const DEFAULT_SORT = {
|
|
18
16
|
overview: { field: 'totalCost', order: 'desc' },
|
|
19
|
-
projects: { field: 'lastActive', order: 'desc' },
|
|
20
17
|
sessions: { field: 'lastTimestamp', order: 'desc' },
|
|
21
18
|
};
|
|
22
19
|
const viewSort = structuredClone(DEFAULT_SORT);
|
|
23
|
-
|
|
20
|
+
// A tagged union: a rolling preset the server recomputes per request, or a concrete window of
|
|
21
|
+
// two local dates. Persisted as JSON under pi-cost:range (see loadDateRange).
|
|
22
|
+
let dateRange = { kind: 'preset', value: '3' };
|
|
24
23
|
const charts = {};
|
|
25
24
|
let lastRenderHash = {};
|
|
26
25
|
let navCounter = 0;
|
|
@@ -30,10 +29,46 @@ let selectedRowIdx = -1;
|
|
|
30
29
|
|
|
31
30
|
// #region UTILS
|
|
32
31
|
|
|
32
|
+
// The div.textContent -> innerHTML trick escapes only & < > because it serializes for text
|
|
33
|
+
// context, so a bare " breaks out of any attribute it lands in.
|
|
34
|
+
const HTML_ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '`': '`' };
|
|
35
|
+
|
|
33
36
|
function esc(str) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
if (str == null) return '';
|
|
38
|
+
return String(str).replace(/[&<>"'`]/g, (c) => HTML_ESCAPES[c]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// For a value inside a quoted JS string inside an HTML attribute: onclick="fn('${escAttrJs(x)}')".
|
|
42
|
+
// The browser HTML-decodes the attribute before the JS parser sees it, so the JS escape comes
|
|
43
|
+
// first. Windows paths need it: C:\Users\nikiforovall would lose \U \n as JS escapes.
|
|
44
|
+
function escAttrJs(value) {
|
|
45
|
+
const js = String(value == null ? '' : value)
|
|
46
|
+
.replace(/\\/g, '\\\\')
|
|
47
|
+
.replace(/'/g, "\\'")
|
|
48
|
+
.replace(/\r/g, '\\r')
|
|
49
|
+
.replace(/\n/g, '\\n');
|
|
50
|
+
return esc(js);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Storage access throws in a sandboxed frame or with site data blocked; the page must still work.
|
|
54
|
+
function storageGet(kind, key) {
|
|
55
|
+
try {
|
|
56
|
+
return window[kind].getItem(key);
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function storageSet(kind, key, value) {
|
|
63
|
+
try {
|
|
64
|
+
window[kind].setItem(key, value);
|
|
65
|
+
} catch {}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function storageRemove(kind, key) {
|
|
69
|
+
try {
|
|
70
|
+
window[kind].removeItem(key);
|
|
71
|
+
} catch {}
|
|
37
72
|
}
|
|
38
73
|
|
|
39
74
|
function basename(p) {
|
|
@@ -41,18 +76,6 @@ function basename(p) {
|
|
|
41
76
|
return s.split(/[\\/]/).filter(Boolean).pop() || s;
|
|
42
77
|
}
|
|
43
78
|
|
|
44
|
-
// Escape for use inside a single-quoted JS string embedded in an HTML attribute.
|
|
45
|
-
// Without this, Windows paths like C:\Users\nikiforovall lose \U \n \d as JS escapes.
|
|
46
|
-
function jsStr(s) {
|
|
47
|
-
return String(s ?? '')
|
|
48
|
-
.replace(/\\/g, '\\\\')
|
|
49
|
-
.replace(/'/g, "\\'")
|
|
50
|
-
.replace(/&/g, '&')
|
|
51
|
-
.replace(/"/g, '"')
|
|
52
|
-
.replace(/</g, '<')
|
|
53
|
-
.replace(/>/g, '>');
|
|
54
|
-
}
|
|
55
|
-
|
|
56
79
|
function formatCost(usd) {
|
|
57
80
|
if (usd == null || usd === 0) return '$0.00';
|
|
58
81
|
if (usd < 0.01) return '<$0.01';
|
|
@@ -67,6 +90,38 @@ function formatTokens(count) {
|
|
|
67
90
|
return count.toString();
|
|
68
91
|
}
|
|
69
92
|
|
|
93
|
+
function formatPct(part, total) {
|
|
94
|
+
return total > 0 ? ((part / total) * 100).toFixed(1) : '0.0';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Intl puts U+202F before AM/PM, which reads as a stray gap in the mono font.
|
|
98
|
+
function stripNarrowSpace(str) {
|
|
99
|
+
return str.replace(/\u202f/g, ' ');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function localTime(iso, opts) {
|
|
103
|
+
return stripNarrowSpace(new Date(iso).toLocaleTimeString(undefined, opts));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function meter(pct, { label = '', cls = '', ariaLabel = '' } = {}) {
|
|
107
|
+
const width = Math.min(100, Math.max(0, Number(pct) || 0)).toFixed(1);
|
|
108
|
+
const klass = esc(cls ? `meter ${cls}` : 'meter');
|
|
109
|
+
return `<div class="${klass}" role="img" aria-label="${esc(ariaLabel)}">
|
|
110
|
+
<div class="meter-track"><i style="width:${width}%"></i></div>
|
|
111
|
+
${label ? `<span class="meter-value">${esc(label)}</span>` : ''}
|
|
112
|
+
</div>`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function formatDuration(minutes) {
|
|
116
|
+
if (!minutes || minutes < 1) return '<1m';
|
|
117
|
+
minutes = Math.round(minutes);
|
|
118
|
+
if (minutes < 60) return `${minutes}m`;
|
|
119
|
+
if (minutes >= 1440) return `${Math.floor(minutes / 1440)}d ${Math.floor((minutes % 1440) / 60)}h`;
|
|
120
|
+
const h = Math.floor(minutes / 60);
|
|
121
|
+
const m = minutes % 60;
|
|
122
|
+
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
|
123
|
+
}
|
|
124
|
+
|
|
70
125
|
function timeAgo(ts) {
|
|
71
126
|
if (!ts) return '';
|
|
72
127
|
const diff = Date.now() - new Date(ts).getTime();
|
|
@@ -80,9 +135,50 @@ function timeAgo(ts) {
|
|
|
80
135
|
return new Date(ts).toLocaleDateString();
|
|
81
136
|
}
|
|
82
137
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
138
|
+
// Bucket keys arrive as YYYY-MM-DD (day) or YYYY-MM-DDTHH (hour), in the server's local time.
|
|
139
|
+
function bucketLabel(key, bucket) {
|
|
140
|
+
if (bucket === 'hour') return localTime(`${key}:00:00`, { hour: 'numeric' });
|
|
141
|
+
return new Date(`${key}T00:00:00`).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
145
|
+
|
|
146
|
+
function isoDate(d) {
|
|
147
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function todayIso() {
|
|
151
|
+
return isoDate(new Date());
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isoParts(iso) {
|
|
155
|
+
return iso.split('-').map(Number);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isoShift(iso, days) {
|
|
159
|
+
const [y, m, d] = isoParts(iso);
|
|
160
|
+
return isoDate(new Date(y, m - 1, d + days));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Inclusive, so a single day counts as 1. Midday avoids a DST shift landing on a boundary.
|
|
164
|
+
function isoDayCount(from, to) {
|
|
165
|
+
const [ay, am, ad] = isoParts(from);
|
|
166
|
+
const [by, bm, bd] = isoParts(to);
|
|
167
|
+
return Math.round((new Date(by, bm - 1, bd, 12) - new Date(ay, am - 1, ad, 12)) / 86400000) + 1;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// "AUG 12", with a two-digit year only outside the current one: "DEC 28 '25".
|
|
171
|
+
function isoDayLabel(iso) {
|
|
172
|
+
const [y] = isoParts(iso);
|
|
173
|
+
const yearTag = y === new Date().getFullYear() ? '' : ` '${String(y).slice(2)}`;
|
|
174
|
+
const dayLabel = new Date(`${iso}T00:00:00`)
|
|
175
|
+
.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
|
176
|
+
.toUpperCase();
|
|
177
|
+
return `${dayLabel}${yearTag}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function costSeriesTitle(series) {
|
|
181
|
+
return series?.bucket === 'hour' ? 'Hourly Cost' : 'Daily Cost';
|
|
86
182
|
}
|
|
87
183
|
|
|
88
184
|
function shortId(id) {
|
|
@@ -105,7 +201,8 @@ function srcBadge(src) {
|
|
|
105
201
|
}
|
|
106
202
|
|
|
107
203
|
function sortCompare(a, b, field, order) {
|
|
108
|
-
let va = a[field],
|
|
204
|
+
let va = a[field],
|
|
205
|
+
vb = b[field];
|
|
109
206
|
if (va == null) va = '';
|
|
110
207
|
if (vb == null) vb = '';
|
|
111
208
|
if (typeof va === 'string') va = va.toLowerCase();
|
|
@@ -115,34 +212,52 @@ function sortCompare(a, b, field, order) {
|
|
|
115
212
|
return 0;
|
|
116
213
|
}
|
|
117
214
|
|
|
118
|
-
function currentSort() {
|
|
119
|
-
|
|
120
|
-
function sortArrow(field) {
|
|
121
|
-
const s = currentSort();
|
|
122
|
-
if (!s || s.field !== field) return '';
|
|
123
|
-
return `<span class="sort-arrow">${s.order === 'asc' ? '▲' : '▼'}</span>`;
|
|
215
|
+
function currentSort() {
|
|
216
|
+
return viewSort[currentView];
|
|
124
217
|
}
|
|
125
218
|
|
|
126
|
-
function
|
|
219
|
+
function sortTh(field, label) {
|
|
127
220
|
const s = currentSort();
|
|
128
|
-
|
|
221
|
+
const on = s && s.field === field;
|
|
222
|
+
const arrow = on ? `<span class="sort-arrow">${s.order === 'asc' ? '▲' : '▼'}</span>` : '';
|
|
223
|
+
return `<th class="${on ? 'sorted' : ''}" onclick="sortBy('${escAttrJs(field)}')">${esc(label)} ${arrow}</th>`;
|
|
129
224
|
}
|
|
130
225
|
|
|
131
226
|
function parentBreadcrumb() {
|
|
132
|
-
|
|
133
|
-
|
|
227
|
+
return '<a class="parent-breadcrumb">Overview</a>';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function projectsTable(rows, sort) {
|
|
231
|
+
return `<table class="data-table">
|
|
232
|
+
<thead><tr>
|
|
233
|
+
${sortTh('name', 'Project')}
|
|
234
|
+
${sortTh('totalCost', 'Cost')}
|
|
235
|
+
${sortTh('sessions', 'Sessions')}
|
|
236
|
+
${sortTh('lastActive', 'Last Active')}
|
|
237
|
+
<th>Model</th>
|
|
238
|
+
</tr></thead>
|
|
239
|
+
<tbody>
|
|
240
|
+
${[...rows]
|
|
241
|
+
.sort((a, b) => sortCompare(a, b, sort.field, sort.order))
|
|
242
|
+
.map(
|
|
243
|
+
(p) => `
|
|
244
|
+
<tr data-clickable data-key="${esc(p.projectDir)}" onclick="navigateToSessions('${escAttrJs(p.projectDir)}','${escAttrJs(p.name)}')">
|
|
245
|
+
<td title="${esc(p.cwd)}">${esc(p.name)}</td>
|
|
246
|
+
<td class="cost-cell">${formatCost(p.totalCost)}</td>
|
|
247
|
+
<td>${p.sessions}</td>
|
|
248
|
+
<td class="muted">${timeAgo(p.lastActive)}</td>
|
|
249
|
+
<td><span class="model-badge">${esc(shortModel(p.primaryModel))}</span></td>
|
|
250
|
+
</tr>`,
|
|
251
|
+
)
|
|
252
|
+
.join('')}
|
|
253
|
+
</tbody>
|
|
254
|
+
</table>`;
|
|
134
255
|
}
|
|
135
256
|
|
|
136
257
|
function focusPreviousRow(view) {
|
|
137
|
-
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
selector = `tr[data-key="${cssAttrEscape(lastSelectedProject)}"]`;
|
|
141
|
-
} else if (view === 'sessions') {
|
|
142
|
-
if (!lastSelectedSession) return;
|
|
143
|
-
selector = `tr[data-key="${cssAttrEscape(lastSelectedSession)}"]`;
|
|
144
|
-
}
|
|
145
|
-
if (!selector) return;
|
|
258
|
+
const key = view === 'sessions' ? lastSelectedSession : view === 'overview' ? lastSelectedProject : null;
|
|
259
|
+
if (!key) return;
|
|
260
|
+
const selector = `tr[data-key="${CSS.escape(key)}"]`;
|
|
146
261
|
requestAnimationFrame(() => {
|
|
147
262
|
const rows = getVisibleRows();
|
|
148
263
|
const idx = rows.findIndex((r) => r.matches(selector));
|
|
@@ -150,18 +265,17 @@ function focusPreviousRow(view) {
|
|
|
150
265
|
});
|
|
151
266
|
}
|
|
152
267
|
|
|
153
|
-
function cssAttrEscape(s) {
|
|
154
|
-
return String(s).replace(/"/g, '\\"');
|
|
155
|
-
}
|
|
156
|
-
|
|
157
268
|
// #endregion
|
|
158
269
|
|
|
159
270
|
// #region URL_STATE
|
|
160
271
|
|
|
272
|
+
const TOP_VIEWS = ['overview', 'insights'];
|
|
273
|
+
const VIEWS = [...TOP_VIEWS, 'sessions', 'detail'];
|
|
274
|
+
|
|
161
275
|
function getUrlState() {
|
|
162
276
|
let p = new URLSearchParams(window.location.search);
|
|
163
277
|
if (!p.has('view') && !p.has('session') && !p.has('project')) {
|
|
164
|
-
const saved =
|
|
278
|
+
const saved = storageGet('sessionStorage', 'pi-cost:nav');
|
|
165
279
|
if (saved) p = new URLSearchParams(saved);
|
|
166
280
|
}
|
|
167
281
|
return {
|
|
@@ -169,36 +283,135 @@ function getUrlState() {
|
|
|
169
283
|
project: p.get('project'),
|
|
170
284
|
projectName: p.get('projectName'),
|
|
171
285
|
session: p.get('session'),
|
|
172
|
-
parentView: p.get('parentView') || 'projects',
|
|
173
286
|
};
|
|
174
287
|
}
|
|
175
288
|
|
|
289
|
+
const DEFAULT_RANGE_PRESET = '3';
|
|
290
|
+
|
|
291
|
+
function presetRange(value) {
|
|
292
|
+
return { kind: 'preset', value: String(value) };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function presetDef(value) {
|
|
296
|
+
return RANGE_PRESETS.find((p) => p.value === String(value));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function isPresetValue(v) {
|
|
300
|
+
return !!presetDef(v);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function isCustomRange(r) {
|
|
304
|
+
return r?.kind === 'custom';
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// The old '1 day' preset meant a rolling day, which is now '24h'.
|
|
308
|
+
function migrateStoredPreset(value) {
|
|
309
|
+
const s = String(value);
|
|
310
|
+
return s === '1' ? '24h' : s;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Reads the JSON tagged union and the legacy bare "3" / "today" scalar that predates it.
|
|
314
|
+
function parseStoredRange(raw) {
|
|
315
|
+
if (!raw) return null;
|
|
316
|
+
let v;
|
|
317
|
+
try {
|
|
318
|
+
v = JSON.parse(raw);
|
|
319
|
+
} catch {
|
|
320
|
+
v = raw;
|
|
321
|
+
}
|
|
322
|
+
if (v?.kind === 'custom') {
|
|
323
|
+
return ISO_DATE_RE.test(v.from) && ISO_DATE_RE.test(v.to) && v.from <= v.to
|
|
324
|
+
? { kind: 'custom', from: v.from, to: v.to }
|
|
325
|
+
: null;
|
|
326
|
+
}
|
|
327
|
+
const s = migrateStoredPreset(v?.kind === 'preset' ? v.value : v);
|
|
328
|
+
return isPresetValue(s) ? presetRange(s) : null;
|
|
329
|
+
}
|
|
330
|
+
|
|
176
331
|
function loadDateRange() {
|
|
177
|
-
|
|
178
|
-
if (!v) return 3;
|
|
179
|
-
if (v === 'today') return 'today';
|
|
180
|
-
return parseInt(v, 10) || 3;
|
|
332
|
+
return parseStoredRange(storageGet('localStorage', 'pi-cost:range')) || presetRange(DEFAULT_RANGE_PRESET);
|
|
181
333
|
}
|
|
182
334
|
|
|
183
|
-
function saveDateRange(val) {
|
|
335
|
+
function saveDateRange(val) {
|
|
336
|
+
storageSet('localStorage', 'pi-cost:range', JSON.stringify(val));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// The range reaches the labels either as the client's tagged union or as the shape the server
|
|
340
|
+
// echoes on the session detail payload: 'today', '24h', N days, null for all time, or { from, to }.
|
|
341
|
+
function normalizeRange(r) {
|
|
342
|
+
if (r == null) return null;
|
|
343
|
+
if (typeof r === 'object') {
|
|
344
|
+
if (r.kind === 'preset' && isPresetValue(r.value)) return presetRange(r.value);
|
|
345
|
+
if (r.from && r.to) return { kind: 'custom', from: r.from, to: r.to };
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
if (isPresetValue(r)) return presetRange(r);
|
|
349
|
+
if (String(r) === '1') return presetRange('today');
|
|
350
|
+
const n = Number(r);
|
|
351
|
+
return Number.isFinite(n) && n > 0 ? presetRange(n) : null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function rangeSpanLabel(r) {
|
|
355
|
+
return r.from === r.to ? isoDayLabel(r.to) : `${isoDayLabel(r.from)} - ${isoDayLabel(r.to)}`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// One classification (all time, custom window, today, rolling preset) with one phrase per class.
|
|
359
|
+
function rangeText(r, prefix = {}) {
|
|
360
|
+
const n = normalizeRange(r);
|
|
361
|
+
const kind = !n ? 'all' : n.kind === 'custom' ? 'custom' : n.value === 'today' ? 'today' : 'rolling';
|
|
362
|
+
const base =
|
|
363
|
+
kind === 'all'
|
|
364
|
+
? 'all time'
|
|
365
|
+
: kind === 'custom'
|
|
366
|
+
? rangeSpanLabel(n)
|
|
367
|
+
: kind === 'today'
|
|
368
|
+
? 'today'
|
|
369
|
+
: n.value === '24h'
|
|
370
|
+
? '24 hours'
|
|
371
|
+
: `${n.value} days`;
|
|
372
|
+
return (prefix[kind] || '') + base;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const rangeLabel = (r) => rangeText(r);
|
|
376
|
+
const rangeScopeLabel = (r) => rangeText(r, { rolling: 'last ' });
|
|
377
|
+
const rangeInLabel = (r) => rangeText(r, { all: 'in ', custom: 'in ', rolling: 'in the last ' });
|
|
378
|
+
|
|
379
|
+
const rangeTriggerLabel = (r) => {
|
|
380
|
+
const n = normalizeRange(r) || presetRange(DEFAULT_RANGE_PRESET);
|
|
381
|
+
if (n.kind === 'custom') return rangeSpanLabel(n);
|
|
382
|
+
const def = presetDef(n.value);
|
|
383
|
+
return (def ? def.label : rangeText(n)).toUpperCase();
|
|
384
|
+
};
|
|
184
385
|
|
|
185
|
-
|
|
386
|
+
const rangeCardLabel = (r) => {
|
|
387
|
+
const n = normalizeRange(r);
|
|
388
|
+
if (!n) return 'All Time';
|
|
389
|
+
if (n.kind === 'custom') return rangeSpanLabel(n);
|
|
390
|
+
const def = presetDef(n.value);
|
|
391
|
+
return def ? def.cardLabel || def.label : `${n.value} Days`;
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
// The window card would duplicate the Today card, so it is dropped.
|
|
395
|
+
function isTodayOnlyRange(r) {
|
|
396
|
+
const n = normalizeRange(r);
|
|
397
|
+
if (!n) return false;
|
|
398
|
+
return n.kind === 'custom' ? n.from === n.to && n.to === todayIso() : n.value === 'today';
|
|
399
|
+
}
|
|
186
400
|
|
|
187
401
|
function loadSort() {
|
|
188
402
|
for (const view of Object.keys(DEFAULT_SORT)) {
|
|
189
|
-
const raw = localStorage
|
|
190
|
-
if (raw)
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
403
|
+
const raw = storageGet('localStorage', `pi-cost:sort:${view}`);
|
|
404
|
+
if (!raw) continue;
|
|
405
|
+
try {
|
|
406
|
+
const { field, order } = JSON.parse(raw);
|
|
407
|
+
if (field) viewSort[view].field = field;
|
|
408
|
+
if (order) viewSort[view].order = order;
|
|
409
|
+
} catch {}
|
|
197
410
|
}
|
|
198
411
|
}
|
|
199
412
|
|
|
200
413
|
function saveSort() {
|
|
201
|
-
localStorage
|
|
414
|
+
storageSet('localStorage', `pi-cost:sort:${currentView}`, JSON.stringify(currentSort()));
|
|
202
415
|
}
|
|
203
416
|
|
|
204
417
|
function updateUrl(mode = 'push') {
|
|
@@ -207,46 +420,101 @@ function updateUrl(mode = 'push') {
|
|
|
207
420
|
if (currentProjectDir) p.set('project', currentProjectDir);
|
|
208
421
|
if (currentProjectName) p.set('projectName', currentProjectName);
|
|
209
422
|
if (currentSessionId) p.set('session', currentSessionId);
|
|
210
|
-
if (
|
|
423
|
+
if (EMBED) p.set('embed', '1');
|
|
211
424
|
const qs = p.toString();
|
|
212
425
|
const url = qs ? `?${qs}` : '/';
|
|
213
426
|
const sameAsCurrent = (location.search || '') === (qs ? `?${qs}` : '');
|
|
214
427
|
const method = mode === 'push' && !sameAsCurrent ? 'pushState' : 'replaceState';
|
|
215
428
|
history[method](null, '', url);
|
|
216
|
-
|
|
429
|
+
storageSet('sessionStorage', 'pi-cost:nav', qs);
|
|
217
430
|
}
|
|
218
431
|
|
|
219
432
|
// #endregion
|
|
220
433
|
|
|
221
434
|
// #region FETCH
|
|
222
435
|
|
|
436
|
+
const BROWSER_CACHE_TTL = 60 * 1000;
|
|
437
|
+
const MAX_CACHE_ENTRIES = 20;
|
|
223
438
|
let forceRefresh = false;
|
|
439
|
+
// Age of the data on screen, for the auto-refresh check. A cache hit carries its own timestamp.
|
|
440
|
+
let dataFetchedAt = 0;
|
|
441
|
+
|
|
442
|
+
// In memory on purpose: a restarted server may read another agent dir, and a cache that outlives
|
|
443
|
+
// the page would keep showing the old dir's data. The server's 30 s cache absorbs reload cost.
|
|
444
|
+
const dataCache = new Map();
|
|
445
|
+
const pendingFetches = new Map();
|
|
446
|
+
|
|
447
|
+
function getCached(key) {
|
|
448
|
+
if (forceRefresh) return null;
|
|
449
|
+
const hit = dataCache.get(key);
|
|
450
|
+
if (!hit) return null;
|
|
451
|
+
if (Date.now() - hit.ts > BROWSER_CACHE_TTL) {
|
|
452
|
+
dataCache.delete(key);
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
dataFetchedAt = Math.max(dataFetchedAt, hit.ts);
|
|
456
|
+
return hit.data;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function cacheData(key, data) {
|
|
460
|
+
dataCache.delete(key);
|
|
461
|
+
dataCache.set(key, { data, ts: Date.now() });
|
|
462
|
+
if (dataCache.size > MAX_CACHE_ENTRIES) dataCache.delete(dataCache.keys().next().value);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// A re-render can ask for a URL whose first request is still in flight (the board's theme message
|
|
466
|
+
// arrives during the first load), so it joins that request.
|
|
467
|
+
async function fetchJSON(url, skipCache) {
|
|
468
|
+
if (skipCache) return requestJSON(url);
|
|
469
|
+
const cached = getCached(url);
|
|
470
|
+
if (cached) return cached;
|
|
471
|
+
const pending = pendingFetches.get(url);
|
|
472
|
+
if (pending) return pending;
|
|
473
|
+
const request = requestJSON(url);
|
|
474
|
+
pendingFetches.set(url, request);
|
|
475
|
+
try {
|
|
476
|
+
const data = await request;
|
|
477
|
+
cacheData(url, data);
|
|
478
|
+
return data;
|
|
479
|
+
} finally {
|
|
480
|
+
pendingFetches.delete(url);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
224
483
|
|
|
225
|
-
async function
|
|
484
|
+
async function requestJSON(url) {
|
|
226
485
|
const res = await fetch(url, forceRefresh ? { cache: 'reload' } : undefined);
|
|
227
|
-
if (!res.ok)
|
|
228
|
-
|
|
486
|
+
if (!res.ok) {
|
|
487
|
+
const err = new Error(`HTTP ${res.status}`);
|
|
488
|
+
err.status = res.status;
|
|
489
|
+
throw err;
|
|
490
|
+
}
|
|
491
|
+
const data = await res.json();
|
|
492
|
+
dataFetchedAt = Date.now();
|
|
493
|
+
return data;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// A custom window sends its two local dates; presets stay on the rolling `range` param.
|
|
497
|
+
function rangeParams(r = dateRange) {
|
|
498
|
+
return isCustomRange(r) ? `from=${r.from}&to=${r.to}` : `range=${encodeURIComponent(r.value)}`;
|
|
229
499
|
}
|
|
230
500
|
|
|
231
501
|
async function fetchOverview() {
|
|
232
|
-
overviewData = await fetchJSON(`/api/overview
|
|
502
|
+
overviewData = await fetchJSON(`/api/overview?${rangeParams()}`);
|
|
233
503
|
}
|
|
234
504
|
|
|
235
|
-
async function
|
|
236
|
-
|
|
237
|
-
projectsData = data.projects;
|
|
505
|
+
async function fetchInsights() {
|
|
506
|
+
insightsData = await fetchJSON(`/api/insights?${rangeParams()}`);
|
|
238
507
|
}
|
|
239
508
|
|
|
240
509
|
async function fetchSessions(projectDir) {
|
|
241
|
-
const data = await fetchJSON(`/api/projects/${encodeURIComponent(projectDir)}/sessions
|
|
510
|
+
const data = await fetchJSON(`/api/projects/${encodeURIComponent(projectDir)}/sessions?${rangeParams()}`, true);
|
|
242
511
|
sessionsData = data.sessions || [];
|
|
243
|
-
|
|
512
|
+
sessionsCostSeries = data.costSeries || null;
|
|
244
513
|
sessionsModelDistribution = data.modelDistribution || [];
|
|
245
|
-
currentProjectInfo = data.project || null;
|
|
246
514
|
}
|
|
247
515
|
|
|
248
516
|
async function fetchSessionDetail(sessionId) {
|
|
249
|
-
sessionDetailData = await fetchJSON(`/api/sessions/${encodeURIComponent(sessionId)}
|
|
517
|
+
sessionDetailData = await fetchJSON(`/api/sessions/${encodeURIComponent(sessionId)}?${rangeParams()}`, true);
|
|
250
518
|
if (sessionDetailData && !currentProjectDir) {
|
|
251
519
|
currentProjectDir = sessionDetailData.projectDir;
|
|
252
520
|
currentProjectName = sessionDetailData.cwd;
|
|
@@ -255,13 +523,36 @@ async function fetchSessionDetail(sessionId) {
|
|
|
255
523
|
|
|
256
524
|
// #endregion
|
|
257
525
|
|
|
526
|
+
// #region LOADING
|
|
527
|
+
|
|
528
|
+
let booted = false;
|
|
529
|
+
const SKEL_BARS = [35, 60, 45, 80, 55, 70, 40, 90, 65, 50, 75, 30];
|
|
530
|
+
|
|
531
|
+
function skeletonHtml(view) {
|
|
532
|
+
const line = '<span class="skel"></span>';
|
|
533
|
+
const cards = `<div class="cards-row">${'<div class="stat-card"><span class="skel card-label"></span><span class="skel card-value"></span><span class="skel card-sub"></span></div>'.repeat(4)}</div>`;
|
|
534
|
+
const plot = SKEL_BARS.map((h) => `<span class="skel" style="--h:${h}%"></span>`).join('');
|
|
535
|
+
const chartsRow = `<div class="charts-row">
|
|
536
|
+
<div class="chart-box"><span class="skel chart-title"></span><div class="skel-plot">${plot}</div></div>
|
|
537
|
+
<div class="chart-box"><span class="skel chart-title"></span><div class="skel-donut"></div></div>
|
|
538
|
+
</div>`;
|
|
539
|
+
const table = `<div class="skel-table">${`<div>${line.repeat(4)}</div>`.repeat(6)}</div>`;
|
|
540
|
+
const head = TOP_VIEWS.includes(view) ? '' : `<div class="breadcrumb">${line}</div>`;
|
|
541
|
+
const body = view === 'sessions' ? chartsRow + table : cards + chartsRow + table;
|
|
542
|
+
// The first JS skeleton replaces the static one from index.html; fading it in again would blink.
|
|
543
|
+
const fade = booted ? ' skeleton-fade' : '';
|
|
544
|
+
return `<div class="dashboard-content skeleton${esc(fade)}" aria-busy="true" aria-label="Loading">${head}${body}</div>`;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// #endregion
|
|
548
|
+
|
|
258
549
|
// #region RENDER_OVERVIEW
|
|
259
550
|
|
|
260
551
|
function renderOverview() {
|
|
261
552
|
const el = document.getElementById('overview-view');
|
|
262
553
|
if (!el) return;
|
|
263
554
|
if (!overviewData) {
|
|
264
|
-
el.innerHTML = '
|
|
555
|
+
el.innerHTML = skeletonHtml('overview');
|
|
265
556
|
return;
|
|
266
557
|
}
|
|
267
558
|
|
|
@@ -270,26 +561,38 @@ function renderOverview() {
|
|
|
270
561
|
if (lastRenderHash.overview === h) return;
|
|
271
562
|
lastRenderHash.overview = h;
|
|
272
563
|
|
|
273
|
-
const
|
|
564
|
+
const costSeries = overviewData.costSeries;
|
|
274
565
|
const models = overviewData.modelDistribution || [];
|
|
275
566
|
const projects = overviewData.projects || [];
|
|
276
567
|
const meta = overviewData.pricingMeta || {};
|
|
277
568
|
const sc = s.sourceCounts || {};
|
|
278
569
|
const totalRows = (sc.jsonl || 0) + (sc.pricing || 0) + (sc.none || 0);
|
|
279
570
|
const pctEst = totalRows ? Math.round(((sc.pricing || 0) / totalRows) * 100) : 0;
|
|
571
|
+
// null, not 0, when nothing cacheable ran: an empty range shows no meter at all.
|
|
572
|
+
const cachePct = s.totalCacheRead + s.totalCacheWrite > 0 ? s.cacheEfficiency * 100 : null;
|
|
280
573
|
|
|
281
574
|
el.innerHTML = `
|
|
282
575
|
<div class="dashboard-content">
|
|
283
576
|
<div class="cards-row">
|
|
577
|
+
${
|
|
578
|
+
// null when the window ends before today: a zero would read as "nothing spent today".
|
|
579
|
+
s.todayCost == null
|
|
580
|
+
? ''
|
|
581
|
+
: `
|
|
284
582
|
<div class="stat-card">
|
|
285
583
|
<div class="card-label">Today</div>
|
|
286
584
|
<div class="card-value cost">${formatCost(s.todayCost)}</div>
|
|
287
|
-
</div
|
|
288
|
-
|
|
585
|
+
</div>`
|
|
586
|
+
}
|
|
587
|
+
${
|
|
588
|
+
isTodayOnlyRange(dateRange)
|
|
589
|
+
? ''
|
|
590
|
+
: `
|
|
289
591
|
<div class="stat-card">
|
|
290
|
-
<div class="card-label">${dateRange}
|
|
592
|
+
<div class="card-label">${esc(rangeCardLabel(dateRange))}</div>
|
|
291
593
|
<div class="card-value cost">${formatCost(s.totalCost)}</div>
|
|
292
|
-
</div>`
|
|
594
|
+
</div>`
|
|
595
|
+
}
|
|
293
596
|
<div class="stat-card">
|
|
294
597
|
<div class="card-label">Sessions</div>
|
|
295
598
|
<div class="card-value">${s.totalSessions}</div>
|
|
@@ -301,8 +604,9 @@ function renderOverview() {
|
|
|
301
604
|
</div>
|
|
302
605
|
<div class="stat-card">
|
|
303
606
|
<div class="card-label">Cache</div>
|
|
304
|
-
<div class="card-value">${
|
|
305
|
-
|
|
607
|
+
<div class="card-value">${cachePct == null ? '0.0' : cachePct.toFixed(1)}%</div>
|
|
608
|
+
${cachePct == null ? '' : meter(cachePct, { ariaLabel: `${cachePct.toFixed(1)}% of all input tokens were served from cache` })}
|
|
609
|
+
<div class="card-sub">Read: <strong>${formatTokens(s.totalCacheRead)}</strong> / Write: ${formatTokens(s.totalCacheWrite)}</div>
|
|
306
610
|
</div>
|
|
307
611
|
<div class="stat-card">
|
|
308
612
|
<div class="card-label">Cost Source</div>
|
|
@@ -316,8 +620,8 @@ function renderOverview() {
|
|
|
316
620
|
|
|
317
621
|
<div class="charts-row">
|
|
318
622
|
<div class="chart-box">
|
|
319
|
-
<div class="chart-title"
|
|
320
|
-
<canvas id="
|
|
623
|
+
<div class="chart-title">${costSeriesTitle(costSeries)}</div>
|
|
624
|
+
<canvas id="costChart"></canvas>
|
|
321
625
|
</div>
|
|
322
626
|
<div class="chart-box">
|
|
323
627
|
<div class="chart-title">Cost by Model</div>
|
|
@@ -325,132 +629,268 @@ function renderOverview() {
|
|
|
325
629
|
</div>
|
|
326
630
|
</div>
|
|
327
631
|
|
|
328
|
-
${
|
|
632
|
+
${
|
|
633
|
+
projects.length
|
|
634
|
+
? `
|
|
329
635
|
<div class="section-title">Projects</div>
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
<th class="${thClass('totalCost')}" onclick="sortBy('totalCost')">Cost ${sortArrow('totalCost')}</th>
|
|
334
|
-
<th class="${thClass('sessions')}" onclick="sortBy('sessions')">Sessions ${sortArrow('sessions')}</th>
|
|
335
|
-
<th class="${thClass('lastActive')}" onclick="sortBy('lastActive')">Last Active ${sortArrow('lastActive')}</th>
|
|
336
|
-
<th>Model</th>
|
|
337
|
-
</tr></thead>
|
|
338
|
-
<tbody>
|
|
339
|
-
${[...projects]
|
|
340
|
-
.sort((a, b) => sortCompare(a, b, viewSort.overview.field, viewSort.overview.order))
|
|
341
|
-
.map((p) => `
|
|
342
|
-
<tr data-clickable data-key="${esc(p.projectDir)}" onclick="navigateToSessions('${jsStr(p.projectDir)}','${jsStr(p.name)}')">
|
|
343
|
-
<td title="${esc(p.cwd)}">${esc(p.name)}</td>
|
|
344
|
-
<td class="cost-cell">${formatCost(p.totalCost)}</td>
|
|
345
|
-
<td>${p.sessions}</td>
|
|
346
|
-
<td class="muted">${timeAgo(p.lastActive)}</td>
|
|
347
|
-
<td><span class="model-badge">${esc(shortModel(p.primaryModel))}</span></td>
|
|
348
|
-
</tr>`).join('')}
|
|
349
|
-
</tbody>
|
|
350
|
-
</table>` : ''}
|
|
636
|
+
${projectsTable(projects, viewSort.overview)}`
|
|
637
|
+
: `<div class="empty-state"><div>No usage ${esc(rangeInLabel(dateRange))}</div></div>`
|
|
638
|
+
}
|
|
351
639
|
</div>`;
|
|
352
640
|
|
|
353
641
|
requestAnimationFrame(() => {
|
|
354
|
-
|
|
642
|
+
renderCostChart(costSeries, 'costChart', 'cost', models);
|
|
355
643
|
renderModelChart(models);
|
|
356
644
|
});
|
|
357
645
|
}
|
|
358
646
|
|
|
359
647
|
// #endregion
|
|
360
648
|
|
|
361
|
-
// #region
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}
|
|
649
|
+
// #region RENDER_INSIGHTS
|
|
650
|
+
|
|
651
|
+
const HEATMAP_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
652
|
+
// Columns start at 6 AM so the workday sits left of center instead of the dead early hours.
|
|
653
|
+
const HEATMAP_START_HOUR = 6;
|
|
654
|
+
|
|
655
|
+
function heatmapDayLabel(dateStr) {
|
|
656
|
+
const [y, m, d] = isoParts(dateStr);
|
|
657
|
+
return `${HEATMAP_DAYS[new Date(y, m - 1, d).getDay()]} ${d}`;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function ampm(h) {
|
|
661
|
+
if (h === 0) return '12am';
|
|
662
|
+
if (h === 12) return '12pm';
|
|
663
|
+
return h < 12 ? `${h}am` : `${h - 12}pm`;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Post-midnight columns sit at each row's tail and read from the next calendar day: the night
|
|
667
|
+
// belongs to the evening it continues.
|
|
668
|
+
function buildHeatmap(hm, days, today) {
|
|
669
|
+
const rowCosts = hm.map((row, d) => [
|
|
670
|
+
...row.slice(HEATMAP_START_HOUR),
|
|
671
|
+
...(hm[d + 1] ? hm[d + 1].slice(0, HEATMAP_START_HOUR) : new Array(HEATMAP_START_HOUR).fill(0)),
|
|
672
|
+
]);
|
|
673
|
+
const max = rowCosts.reduce((m, row) => Math.max(m, ...row), 0);
|
|
674
|
+
if (max === 0) return '<div class="empty-state"><div>No activity in this range</div></div>';
|
|
675
|
+
const labels = days.map(heatmapDayLabel);
|
|
676
|
+
const hourAt = (col) => (col + HEATMAP_START_HOUR) % 24;
|
|
677
|
+
const hours = Array.from(
|
|
678
|
+
{ length: 24 },
|
|
679
|
+
(_, col) => `<span class="hm-hour">${hourAt(col) % 3 === 0 ? ampm(hourAt(col)) : ''}</span>`,
|
|
680
|
+
).join('');
|
|
681
|
+
const rows = hm
|
|
682
|
+
.map((_, d) => {
|
|
683
|
+
const label = labels[d];
|
|
684
|
+
return `
|
|
685
|
+
<div class="hm-row${days[d] === today ? ' hm-today' : ''}">
|
|
686
|
+
<span class="hm-day">${esc(label)}</span>
|
|
687
|
+
${rowCosts[d]
|
|
688
|
+
.map((cost, col) => {
|
|
689
|
+
const h = hourAt(col);
|
|
690
|
+
const day = h < HEATMAP_START_HOUR ? (labels[d + 1] ?? label) : label;
|
|
691
|
+
return `
|
|
692
|
+
<span class="hm-cell" data-hm="${esc(`${day}|${h}|${cost}`)}">
|
|
693
|
+
<i style="opacity:${cost > 0 ? Math.max(0.12, cost / max).toFixed(2) : 0}"></i>
|
|
694
|
+
</span>`;
|
|
695
|
+
})
|
|
696
|
+
.join('')}
|
|
697
|
+
</div>`;
|
|
698
|
+
})
|
|
699
|
+
.join('');
|
|
700
|
+
return `<div class="heatmap">${rows}<div class="hm-row"><span class="hm-day"></span>${hours}</div></div>`;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
let hmTipEl = null;
|
|
704
|
+
function initHeatmapTooltip() {
|
|
705
|
+
if (hmTipEl) return;
|
|
706
|
+
hmTipEl = document.createElement('div');
|
|
707
|
+
hmTipEl.className = 'hm-tip';
|
|
708
|
+
document.body.appendChild(hmTipEl);
|
|
709
|
+
document.addEventListener('mouseover', (e) => {
|
|
710
|
+
const cell = e.target.closest('.hm-cell[data-hm]');
|
|
711
|
+
if (!cell) {
|
|
712
|
+
hmTipEl.classList.remove('on');
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
const [day, h, cost] = cell.dataset.hm.split('|');
|
|
716
|
+
hmTipEl.innerHTML = `<b>${formatCost(+cost)}</b><span>${esc(day)} ${ampm(+h)}–${ampm((+h + 1) % 24)}</span>`;
|
|
717
|
+
hmTipEl.classList.add('on');
|
|
718
|
+
const r = cell.getBoundingClientRect();
|
|
719
|
+
const tr = hmTipEl.getBoundingClientRect();
|
|
720
|
+
const left = Math.max(8, Math.min(r.left + r.width / 2 - tr.width / 2, window.innerWidth - tr.width - 8));
|
|
721
|
+
const top = r.top - tr.height - 6;
|
|
722
|
+
hmTipEl.style.left = `${left}px`;
|
|
723
|
+
hmTipEl.style.top = `${top < 4 ? r.bottom + 6 : top}px`;
|
|
724
|
+
});
|
|
725
|
+
}
|
|
370
726
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
727
|
+
function blockSpanLabel(b) {
|
|
728
|
+
const opts = { month: 'short', day: 'numeric', hour: 'numeric' };
|
|
729
|
+
return `${stripNarrowSpace(new Date(b.start).toLocaleString(undefined, opts))} – ${localTime(b.end, { hour: 'numeric' })}`;
|
|
730
|
+
}
|
|
374
731
|
|
|
375
|
-
|
|
732
|
+
// pi records no plan limits, so a block is only open (its 5 h window has not ended) or ended.
|
|
733
|
+
function blockStatus(b) {
|
|
734
|
+
const left = (new Date(b.end) - Date.now()) / 60000;
|
|
735
|
+
return left > 0
|
|
736
|
+
? `<span class="block-badge active">open · ${formatDuration(left)} left</span>`
|
|
737
|
+
: `<span class="block-badge">ended ${timeAgo(b.lastActivity)}</span>`;
|
|
738
|
+
}
|
|
376
739
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
</div></div>`;
|
|
740
|
+
function renderInsights() {
|
|
741
|
+
const el = document.getElementById('insights-view');
|
|
742
|
+
if (!el) return;
|
|
743
|
+
if (!insightsData) {
|
|
744
|
+
el.innerHTML = skeletonHtml('insights');
|
|
383
745
|
return;
|
|
384
746
|
}
|
|
385
747
|
|
|
748
|
+
const d = insightsData;
|
|
749
|
+
const h = JSON.stringify(d);
|
|
750
|
+
if (lastRenderHash.insights === h) return;
|
|
751
|
+
lastRenderHash.insights = h;
|
|
752
|
+
|
|
753
|
+
const sub = d.subagents;
|
|
754
|
+
const agentTotal = sub.mainCost + sub.subagentCost;
|
|
755
|
+
const subPct = formatPct(sub.subagentCost, agentTotal);
|
|
756
|
+
const cacheHitPct = d.tokens.cacheRead + d.tokens.cacheWrite > 0 ? Math.round(d.cacheEfficiency * 100) : null;
|
|
757
|
+
const comp = d.compactions || { count: 0, totalCost: 0, tokensBefore: 0 };
|
|
758
|
+
const showWeekly = d.weekly.length >= 3;
|
|
759
|
+
const heatSpan = d.heatmapDays.length
|
|
760
|
+
? `${isoDayLabel(d.heatmapDays[0])} → ${isoDayLabel(d.heatmapDays[d.heatmapDays.length - 1])}`
|
|
761
|
+
: '';
|
|
762
|
+
|
|
386
763
|
el.innerHTML = `
|
|
387
764
|
<div class="dashboard-content">
|
|
388
|
-
<div class="
|
|
765
|
+
<div class="cards-row">
|
|
766
|
+
<div class="stat-card">
|
|
767
|
+
<div class="card-label">Monthly Run-Rate</div>
|
|
768
|
+
<div class="card-value cost">${formatCost(d.runRate.projectedMonthly)}</div>
|
|
769
|
+
<div class="card-sub"><strong>${formatCost(d.runRate.dailyAvg)}/day</strong> over ${d.runRate.days} ${d.runRate.days === 1 ? 'day' : 'days'}</div>
|
|
770
|
+
</div>
|
|
771
|
+
<div class="stat-card">
|
|
772
|
+
<div class="card-label">Cache Savings</div>
|
|
773
|
+
<div class="card-value cost">${formatCost(d.cacheSavings)}</div>
|
|
774
|
+
${cacheHitPct == null ? '' : meter(cacheHitPct, { label: `${cacheHitPct}% hit`, ariaLabel: `${cacheHitPct}% of input was served from cache` })}
|
|
775
|
+
<div class="card-sub">vs. uncached input · <strong>${formatTokens(d.tokens.cacheRead)}</strong> reads</div>
|
|
776
|
+
</div>
|
|
777
|
+
<div class="stat-card">
|
|
778
|
+
<div class="card-label">Subagent Share</div>
|
|
779
|
+
<div class="card-value">${subPct}%</div>
|
|
780
|
+
${agentTotal > 0 ? meter(subPct, { ariaLabel: `subagents are ${subPct}% of ${formatCost(agentTotal)}` }) : ''}
|
|
781
|
+
<div class="card-sub"><strong>${formatCost(sub.subagentCost)}</strong> of ${formatCost(agentTotal)} · ${sub.runs} ${sub.runs === 1 ? 'run' : 'runs'}</div>
|
|
782
|
+
</div>
|
|
783
|
+
<div class="stat-card">
|
|
784
|
+
<div class="card-label">Compactions</div>
|
|
785
|
+
<div class="card-value">${comp.count}</div>
|
|
786
|
+
<div class="card-sub">summarizer <strong>${formatCost(comp.totalCost)}</strong> · ${formatTokens(comp.tokensBefore)} tokens replaced</div>
|
|
787
|
+
</div>
|
|
788
|
+
</div>
|
|
789
|
+
|
|
790
|
+
<div class="charts-row">
|
|
791
|
+
<div class="chart-box">
|
|
792
|
+
<div class="chart-title">Token Composition <span class="chart-title-note">log scale</span></div>
|
|
793
|
+
<canvas id="tokenSplitChart"></canvas>
|
|
794
|
+
</div>
|
|
795
|
+
<div class="chart-box">
|
|
796
|
+
<div class="chart-title">Top Tools</div>
|
|
797
|
+
${d.tools.length ? '<canvas id="toolsChart"></canvas>' : '<div class="card-sub">No tool calls in this range</div>'}
|
|
798
|
+
</div>
|
|
799
|
+
</div>
|
|
800
|
+
|
|
801
|
+
<div class="charts-row${showWeekly ? '' : ' single'}">
|
|
802
|
+
<div class="chart-box">
|
|
803
|
+
<div class="chart-title">Activity Heatmap (cost by hour) <span class="chart-title-note">${heatSpan}</span></div>
|
|
804
|
+
${buildHeatmap(d.heatmap, d.heatmapDays, d.heatmapToday)}
|
|
805
|
+
</div>
|
|
806
|
+
${
|
|
807
|
+
showWeekly
|
|
808
|
+
? `
|
|
809
|
+
<div class="chart-box">
|
|
810
|
+
<div class="chart-title">Weekly Cost</div>
|
|
811
|
+
<canvas id="weeklyChart"></canvas>
|
|
812
|
+
</div>`
|
|
813
|
+
: ''
|
|
814
|
+
}
|
|
815
|
+
</div>
|
|
816
|
+
|
|
817
|
+
${
|
|
818
|
+
d.blocks.length
|
|
819
|
+
? `
|
|
820
|
+
<div class="section-title">5-Hour Blocks</div>
|
|
389
821
|
<table class="data-table">
|
|
390
822
|
<thead><tr>
|
|
391
|
-
<th
|
|
392
|
-
<th class="${thClass('totalCost')}" onclick="sortBy('totalCost')">Cost ${sortArrow('totalCost')}</th>
|
|
393
|
-
<th class="${thClass('sessions')}" onclick="sortBy('sessions')">Sessions ${sortArrow('sessions')}</th>
|
|
394
|
-
<th class="${thClass('totalTokens')}" onclick="sortBy('totalTokens')">Tokens ${sortArrow('totalTokens')}</th>
|
|
395
|
-
<th class="${thClass('lastActive')}" onclick="sortBy('lastActive')">Last Active ${sortArrow('lastActive')}</th>
|
|
396
|
-
<th>Model</th>
|
|
823
|
+
<th>Block</th><th>Status</th><th>Messages</th><th>Tokens</th><th>Cost</th><th>Models</th>
|
|
397
824
|
</tr></thead>
|
|
398
825
|
<tbody>
|
|
399
|
-
${
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
<td>${
|
|
404
|
-
<td>${
|
|
405
|
-
<td
|
|
406
|
-
<td
|
|
407
|
-
|
|
826
|
+
${d.blocks
|
|
827
|
+
.map(
|
|
828
|
+
(b) => `
|
|
829
|
+
<tr>
|
|
830
|
+
<td>${blockSpanLabel(b)}</td>
|
|
831
|
+
<td>${blockStatus(b)}</td>
|
|
832
|
+
<td>${b.messageCount}</td>
|
|
833
|
+
<td>${formatTokens(b.tokens)}</td>
|
|
834
|
+
<td class="cost-cell">${formatCost(b.cost)}</td>
|
|
835
|
+
<td>${b.models.map((m) => `<span class="model-badge">${esc(shortModel(m))}</span>`).join(' ')}</td>
|
|
836
|
+
</tr>`,
|
|
837
|
+
)
|
|
838
|
+
.join('')}
|
|
408
839
|
</tbody>
|
|
409
|
-
</table
|
|
840
|
+
</table>`
|
|
841
|
+
: ''
|
|
842
|
+
}
|
|
410
843
|
</div>`;
|
|
844
|
+
|
|
845
|
+
initHeatmapTooltip();
|
|
846
|
+
requestAnimationFrame(() => {
|
|
847
|
+
renderTokenSplitChart(d.tokens);
|
|
848
|
+
if (d.tools.length) renderToolsChart(d.tools);
|
|
849
|
+
if (showWeekly) renderWeeklyChart(d.weekly);
|
|
850
|
+
});
|
|
411
851
|
}
|
|
412
852
|
|
|
413
853
|
// #endregion
|
|
414
854
|
|
|
415
855
|
// #region RENDER_SESSIONS
|
|
416
856
|
|
|
857
|
+
function sessionBreadcrumb() {
|
|
858
|
+
return `<div class="breadcrumb">
|
|
859
|
+
${parentBreadcrumb()}
|
|
860
|
+
<span class="sep">/</span>
|
|
861
|
+
<span class="current" title="${esc(currentProjectName || '')}">${esc(basename(currentProjectName) || 'Project')}</span>
|
|
862
|
+
</div>`;
|
|
863
|
+
}
|
|
864
|
+
|
|
417
865
|
function renderSessions() {
|
|
418
866
|
const el = document.getElementById('sessions-view');
|
|
419
867
|
if (!el) return;
|
|
420
868
|
if (!sessionsData) {
|
|
421
|
-
el.innerHTML = '
|
|
869
|
+
el.innerHTML = skeletonHtml('sessions');
|
|
422
870
|
return;
|
|
423
871
|
}
|
|
424
872
|
|
|
425
|
-
const h = JSON.stringify({ sessionsData,
|
|
873
|
+
const h = JSON.stringify({ sessionsData, sessionsCostSeries, sessionsModelDistribution, sort: viewSort.sessions });
|
|
426
874
|
if (lastRenderHash.sessions === h) return;
|
|
427
875
|
lastRenderHash.sessions = h;
|
|
428
876
|
|
|
429
|
-
const total = sessionsData.reduce((s, x) => s + (x.totalCost || 0), 0);
|
|
430
|
-
|
|
431
877
|
if (sessionsData.length === 0) {
|
|
432
878
|
el.innerHTML = `<div class="dashboard-content">
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
<span class="sep">/</span>
|
|
436
|
-
<span class="current" title="${esc(currentProjectName || '')}">${esc(basename(currentProjectName) || 'Project')}</span>
|
|
437
|
-
</div>
|
|
438
|
-
<div class="empty-state"><div>No sessions found for this project</div></div>
|
|
879
|
+
${sessionBreadcrumb()}
|
|
880
|
+
<div class="empty-state"><div>No sessions for this project ${esc(rangeInLabel(dateRange))}</div></div>
|
|
439
881
|
</div>`;
|
|
440
882
|
return;
|
|
441
883
|
}
|
|
442
884
|
|
|
885
|
+
const total = sessionsData.reduce((s, x) => s + (x.totalCost || 0), 0);
|
|
886
|
+
|
|
443
887
|
el.innerHTML = `
|
|
444
888
|
<div class="dashboard-content">
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
<span class="sep">/</span>
|
|
448
|
-
<span class="current" title="${esc(currentProjectName || '')}">${esc(basename(currentProjectName) || 'Project')}</span>
|
|
449
|
-
</div>
|
|
450
|
-
<div class="charts-row" style="grid-template-columns:3fr 2fr auto">
|
|
889
|
+
${sessionBreadcrumb()}
|
|
890
|
+
<div class="charts-row with-aside">
|
|
451
891
|
<div class="chart-box">
|
|
452
|
-
<div class="chart-title"
|
|
453
|
-
<canvas id="
|
|
892
|
+
<div class="chart-title">${costSeriesTitle(sessionsCostSeries)}</div>
|
|
893
|
+
<canvas id="sessionsCostChart"></canvas>
|
|
454
894
|
</div>
|
|
455
895
|
<div class="chart-box">
|
|
456
896
|
<div class="chart-title">Cost by Model</div>
|
|
@@ -465,41 +905,57 @@ function renderSessions() {
|
|
|
465
905
|
|
|
466
906
|
<table class="data-table">
|
|
467
907
|
<thead><tr>
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
908
|
+
${sortTh('customTitle', 'Session')}
|
|
909
|
+
${sortTh('totalCost', 'Cost')}
|
|
910
|
+
${sortTh('totalTokens', 'Tokens')}
|
|
911
|
+
${sortTh('messageCount', 'Msgs')}
|
|
912
|
+
${sortTh('durationMinutes', 'Duration')}
|
|
472
913
|
<th>Provider</th>
|
|
473
914
|
<th>Model</th>
|
|
474
|
-
|
|
915
|
+
${sortTh('lastTimestamp', 'Last Active')}
|
|
475
916
|
</tr></thead>
|
|
476
917
|
<tbody>
|
|
477
918
|
${[...sessionsData]
|
|
478
919
|
.sort((a, b) => sortCompare(a, b, viewSort.sessions.field, viewSort.sessions.order))
|
|
479
|
-
.map(
|
|
480
|
-
|
|
481
|
-
const preview = s.firstUserText ? s.firstUserText.slice(0, 60) : '';
|
|
482
|
-
return `
|
|
483
|
-
<tr data-clickable data-key="${esc(s.id)}" onclick="navigateToDetail('${esc(s.id)}')">
|
|
484
|
-
<td class="truncate" title="${esc(s.id)}${preview ? ` — ${esc(s.firstUserText || '')}` : ''}"><code class="session-id">${esc(label)}</code>${preview ? ` <span class="muted">${esc(preview)}${s.firstUserText && s.firstUserText.length > 60 ? '…' : ''}</span>` : ''}${s.parentSession ? ' <span class="subagent-tag">subagent</span>' : ''}${s.subagentCount ? ` <span class="subagent-tag" title="${s.subagentCount} spawned subagent(s) totaling ${formatCost(s.subagentCost)}">+${s.subagentCount}</span>` : ''}</td>
|
|
485
|
-
<td class="cost-cell">${formatCost(s.totalCost)}</td>
|
|
486
|
-
<td>${formatTokens(s.totalTokens)}</td>
|
|
487
|
-
<td>${s.messageCount}</td>
|
|
488
|
-
<td class="muted">${esc(s.provider || '')}</td>
|
|
489
|
-
<td><span class="model-badge">${esc(shortModel(s.model))}</span></td>
|
|
490
|
-
<td class="muted">${timeAgo(s.lastTimestamp)}</td>
|
|
491
|
-
</tr>`;
|
|
492
|
-
}).join('')}
|
|
920
|
+
.map(sessionRow)
|
|
921
|
+
.join('')}
|
|
493
922
|
</tbody>
|
|
494
923
|
</table>
|
|
495
924
|
</div>`;
|
|
496
925
|
|
|
497
926
|
requestAnimationFrame(() => {
|
|
498
|
-
|
|
499
|
-
renderModelChart(sessionsModelDistribution
|
|
927
|
+
renderCostChart(sessionsCostSeries, 'sessionsCostChart', 'sessionsCost', sessionsModelDistribution);
|
|
928
|
+
renderModelChart(sessionsModelDistribution, 'sessionsModelChart', 'sessionsModel');
|
|
500
929
|
});
|
|
501
930
|
}
|
|
502
931
|
|
|
932
|
+
function sessionRow(s) {
|
|
933
|
+
const label = s.customTitle || shortId(s.id);
|
|
934
|
+
const preview = s.firstUserText ? s.firstUserText.slice(0, 60) : '';
|
|
935
|
+
const more = s.firstUserText && s.firstUserText.length > 60 ? '…' : '';
|
|
936
|
+
const title = preview ? `${s.id} — ${s.firstUserText}` : s.id;
|
|
937
|
+
const c = s.compactions;
|
|
938
|
+
const compactTag = c?.count
|
|
939
|
+
? ` <span class="compaction-tag" title="${esc(`${c.count} compaction(s), ${formatCost(c.totalCost)}`)}">${c.count}× compact</span>`
|
|
940
|
+
: '';
|
|
941
|
+
// Nested subagent runs never reach the lists, so a listed row with a parent is a fork.
|
|
942
|
+
const subTag = s.parentSession ? ' <span class="subagent-tag" title="Forked from another session">fork</span>' : '';
|
|
943
|
+
const childTag = s.subagentCount
|
|
944
|
+
? ` <span class="subagent-tag" title="${esc(`${s.subagentCount} spawned subagent(s) totaling ${formatCost(s.subagentCost)}`)}">+${s.subagentCount}</span>`
|
|
945
|
+
: '';
|
|
946
|
+
return `
|
|
947
|
+
<tr data-clickable data-key="${esc(s.id)}" onclick="navigateToDetail('${escAttrJs(s.id)}')">
|
|
948
|
+
<td class="truncate" title="${esc(title)}"><code class="session-id">${esc(label)}</code>${preview ? ` <span class="muted">${esc(preview)}${more}</span>` : ''}${subTag}${childTag}</td>
|
|
949
|
+
<td class="cost-cell">${formatCost(s.totalCost)}</td>
|
|
950
|
+
<td>${formatTokens(s.totalTokens)}</td>
|
|
951
|
+
<td>${s.messageCount}${compactTag}</td>
|
|
952
|
+
<td class="muted">${formatDuration(s.durationMinutes)}</td>
|
|
953
|
+
<td class="muted">${esc(s.provider || '')}</td>
|
|
954
|
+
<td><span class="model-badge">${esc(shortModel(s.primaryModel || s.model))}</span></td>
|
|
955
|
+
<td class="muted">${timeAgo(s.lastTimestamp)}</td>
|
|
956
|
+
</tr>`;
|
|
957
|
+
}
|
|
958
|
+
|
|
503
959
|
// #endregion
|
|
504
960
|
|
|
505
961
|
// #region RENDER_DETAIL
|
|
@@ -508,7 +964,7 @@ function renderDetail() {
|
|
|
508
964
|
const el = document.getElementById('detail-view');
|
|
509
965
|
if (!el) return;
|
|
510
966
|
if (!sessionDetailData) {
|
|
511
|
-
el.innerHTML = '
|
|
967
|
+
el.innerHTML = skeletonHtml('detail');
|
|
512
968
|
return;
|
|
513
969
|
}
|
|
514
970
|
|
|
@@ -521,48 +977,52 @@ function renderDetail() {
|
|
|
521
977
|
const projectCwd = d.cwd || currentProjectName || projectDir;
|
|
522
978
|
const projectName = basename(projectCwd);
|
|
523
979
|
const sc = d.sourceCounts || {};
|
|
980
|
+
const hasSubagents = d.messages.some((m) => m.subagent);
|
|
981
|
+
const parentLink = d.parent
|
|
982
|
+
? `<a class="truncate" onclick="navigateToDetail('${escAttrJs(d.parent.id)}')" title="${esc(d.parent.title || d.parent.id)}">${esc(d.parent.title || shortId(d.parent.id))}</a>
|
|
983
|
+
<span class="sep">/</span>`
|
|
984
|
+
: '';
|
|
524
985
|
|
|
525
986
|
el.innerHTML = `
|
|
526
987
|
<div class="dashboard-content">
|
|
527
988
|
<div class="breadcrumb">
|
|
528
989
|
${parentBreadcrumb()}
|
|
529
990
|
<span class="sep">/</span>
|
|
530
|
-
<a onclick="navigateToSessions('${
|
|
991
|
+
<a onclick="navigateToSessions('${escAttrJs(projectDir)}','${escAttrJs(projectName)}')" title="${esc(projectCwd)}">${esc(projectName)}</a>
|
|
531
992
|
<span class="sep">/</span>
|
|
993
|
+
${parentLink}
|
|
532
994
|
<span class="current" title="${esc(d.id || '')}"><code class="session-id">${esc(d.customTitle || shortId(d.id))}</code></span>
|
|
533
995
|
</div>
|
|
534
996
|
|
|
535
|
-
${d.firstUserText ? `<div style="color:var(--text-tertiary);font-size:12px;margin-bottom:16px;font-style:italic">"${esc(d.firstUserText.slice(0, 240))}${d.firstUserText.length > 240 ? '...' : ''}"</div>` : ''}
|
|
536
|
-
|
|
537
997
|
<div class="detail-header">
|
|
538
998
|
<div class="detail-stat">
|
|
539
999
|
<div class="detail-label">Total Cost</div>
|
|
540
|
-
<div class="detail-value cost">${formatCost(d.totalCost)}</div>
|
|
541
|
-
${d.subagentCount ? `<div class="detail-sub"
|
|
1000
|
+
<div class="detail-value cost">${formatCost(d.totalCost)}<span class="detail-label detail-scope">all time</span></div>
|
|
1001
|
+
${d.subagentCount ? `<div class="detail-sub">incl. <strong>${formatCost(d.subagentCost)}</strong> in ${d.subagentCount} subagent ${d.subagentCount === 1 ? 'run' : 'runs'}</div>` : ''}
|
|
542
1002
|
</div>
|
|
543
1003
|
<div class="detail-stat">
|
|
544
1004
|
<div class="detail-label">Input</div>
|
|
545
|
-
<div class="detail-value">${formatTokens(d.
|
|
1005
|
+
<div class="detail-value">${formatTokens(d.input)}</div>
|
|
546
1006
|
</div>
|
|
547
1007
|
<div class="detail-stat">
|
|
548
1008
|
<div class="detail-label">Output</div>
|
|
549
|
-
<div class="detail-value">${formatTokens(d.
|
|
1009
|
+
<div class="detail-value">${formatTokens(d.output)}</div>
|
|
550
1010
|
</div>
|
|
551
1011
|
<div class="detail-stat">
|
|
552
1012
|
<div class="detail-label">Cache Write</div>
|
|
553
|
-
<div class="detail-value">${formatTokens(d.
|
|
1013
|
+
<div class="detail-value">${formatTokens(d.cacheWrite)}</div>
|
|
554
1014
|
</div>
|
|
555
1015
|
<div class="detail-stat">
|
|
556
1016
|
<div class="detail-label">Cache Read</div>
|
|
557
|
-
<div class="detail-value">${formatTokens(d.
|
|
1017
|
+
<div class="detail-value">${formatTokens(d.cacheRead)}</div>
|
|
558
1018
|
</div>
|
|
559
1019
|
<div class="detail-stat">
|
|
560
1020
|
<div class="detail-label">Messages</div>
|
|
561
|
-
<div class="detail-value">${d.messages.length}</div>
|
|
1021
|
+
<div class="detail-value">${d.messages.length}<span class="detail-label detail-scope">all time</span></div>
|
|
562
1022
|
</div>
|
|
563
1023
|
<div class="detail-stat">
|
|
564
1024
|
<div class="detail-label">Models</div>
|
|
565
|
-
<div class="detail-value">${(d.models || []).map(shortModel).filter(Boolean).join(', ') || '—'}</div>
|
|
1025
|
+
<div class="detail-value">${esc((d.models || []).map(shortModel).filter(Boolean).join(', ') || '—')}</div>
|
|
566
1026
|
</div>
|
|
567
1027
|
<div class="detail-stat">
|
|
568
1028
|
<div class="detail-label">Source</div>
|
|
@@ -571,9 +1031,13 @@ function renderDetail() {
|
|
|
571
1031
|
${srcBadge('pricing')} ${sc.pricing || 0}
|
|
572
1032
|
</div>
|
|
573
1033
|
</div>
|
|
1034
|
+
${compactionStat(d)}
|
|
574
1035
|
</div>
|
|
575
1036
|
|
|
576
|
-
|
|
1037
|
+
${detailRangeNote(d)}
|
|
1038
|
+
${compactionNote(d)}
|
|
1039
|
+
|
|
1040
|
+
<div class="charts-row${hasSubagents ? ' with-share' : ''}" style="margin-bottom:20px">
|
|
577
1041
|
<div class="chart-box">
|
|
578
1042
|
<div class="chart-title">Cumulative Cost</div>
|
|
579
1043
|
<canvas id="cumulativeChart"></canvas>
|
|
@@ -582,29 +1046,24 @@ function renderDetail() {
|
|
|
582
1046
|
<div class="chart-title">Token Breakdown per Message</div>
|
|
583
1047
|
<canvas id="tokenBreakdownChart"></canvas>
|
|
584
1048
|
</div>
|
|
1049
|
+
${
|
|
1050
|
+
hasSubagents
|
|
1051
|
+
? `<div class="chart-box">
|
|
1052
|
+
<div class="chart-title">Subagent Share</div>
|
|
1053
|
+
<div class="share-body">
|
|
1054
|
+
<div class="share-pie"><canvas id="subagentShareChart"></canvas></div>
|
|
1055
|
+
<ul class="share-legend" id="subagentShareLegend"></ul>
|
|
1056
|
+
</div>
|
|
1057
|
+
</div>`
|
|
1058
|
+
: ''
|
|
1059
|
+
}
|
|
585
1060
|
</div>
|
|
586
1061
|
|
|
587
|
-
${d
|
|
588
|
-
<div class="section-title">Spawned sessions (${d.childSessions.length})</div>
|
|
589
|
-
<table class="data-table" style="margin-bottom:20px">
|
|
590
|
-
<thead><tr><th>Session</th><th>Cost</th><th>Tokens</th><th>Msgs</th><th>Model</th><th>When</th></tr></thead>
|
|
591
|
-
<tbody>
|
|
592
|
-
${d.childSessions.map((sa) => {
|
|
593
|
-
const label = sa.customTitle || shortId(sa.id);
|
|
594
|
-
const preview = sa.firstUserText ? sa.firstUserText.slice(0, 120) : '';
|
|
595
|
-
return `<tr data-clickable onclick="navigateToDetail('${esc(sa.id)}')">
|
|
596
|
-
<td class="truncate" title="${esc(sa.id)}${preview ? ` — ${esc(preview)}` : ''}"><code class="session-id">${esc(label)}</code>${preview ? ` <span class="muted">${esc(preview)}</span>` : ''}</td>
|
|
597
|
-
<td class="cost-cell">${formatCost(sa.totalCost)}</td>
|
|
598
|
-
<td>${formatTokens(sa.totalTokens)}</td>
|
|
599
|
-
<td>${sa.messageCount}</td>
|
|
600
|
-
<td><span class="model-badge">${esc(shortModel(sa.model))}</span></td>
|
|
601
|
-
<td class="muted">${timeAgo(sa.lastTimestamp)}</td>
|
|
602
|
-
</tr>`;
|
|
603
|
-
}).join('')}
|
|
604
|
-
</tbody>
|
|
605
|
-
</table>` : ''}
|
|
1062
|
+
${childSessionsSection(d)}
|
|
606
1063
|
|
|
607
|
-
|
|
1064
|
+
${rangeBrushHtml(d.messages.length)}
|
|
1065
|
+
|
|
1066
|
+
<div class="section-title" id="messagesTitle"></div>
|
|
608
1067
|
<table class="messages-table">
|
|
609
1068
|
<thead><tr>
|
|
610
1069
|
<th>#</th>
|
|
@@ -618,75 +1077,229 @@ function renderDetail() {
|
|
|
618
1077
|
<th>Src</th>
|
|
619
1078
|
<th>Cumulative</th>
|
|
620
1079
|
</tr></thead>
|
|
621
|
-
<tbody>
|
|
622
|
-
${[...d.messages].reverse().map((m, idxRev) => {
|
|
623
|
-
const idx = d.messages.length - idxRev;
|
|
624
|
-
const isSub = !!m.agent;
|
|
625
|
-
const sid = m.subagentSessionId;
|
|
626
|
-
const clickable = isSub && sid;
|
|
627
|
-
const rowAttrs = isSub
|
|
628
|
-
? ` class="subagent-row${clickable ? ' clickable' : ''}" title="${esc(m.task || '')}${clickable ? ' — click to open subagent session' : ''}"${clickable ? ` onclick="navigateToDetail('${esc(sid)}')"` : ''}`
|
|
629
|
-
: '';
|
|
630
|
-
return `<tr${rowAttrs}>
|
|
631
|
-
<td class="muted">${idx}</td>
|
|
632
|
-
<td class="muted">${m.ts ? new Date(m.ts).toLocaleTimeString() : ''}</td>
|
|
633
|
-
<td>${isSub ? `<span class="subagent-tag">${esc(m.agent)}</span> ` : ''}<span class="model-badge">${esc(shortModel(m.model))}</span></td>
|
|
634
|
-
<td>${formatTokens(m.input)}</td>
|
|
635
|
-
<td>${formatTokens(m.output)}</td>
|
|
636
|
-
<td>${formatTokens(m.cacheWrite)}</td>
|
|
637
|
-
<td>${formatTokens(m.cacheRead)}</td>
|
|
638
|
-
<td class="cost-cell">${formatCost(m.cost)}</td>
|
|
639
|
-
<td>${srcBadge(m.costSource)}</td>
|
|
640
|
-
<td class="cumulative">${formatCost(m.cumulativeCost)}</td>
|
|
641
|
-
</tr>`;
|
|
642
|
-
}).join('')}
|
|
643
|
-
</tbody>
|
|
1080
|
+
<tbody id="messagesBody"></tbody>
|
|
644
1081
|
</table>
|
|
645
1082
|
</div>`;
|
|
646
1083
|
|
|
647
1084
|
requestAnimationFrame(() => {
|
|
648
|
-
|
|
649
|
-
|
|
1085
|
+
destroyChart('cumulative');
|
|
1086
|
+
destroyChart('tokenBreakdown');
|
|
1087
|
+
destroyChart('subagentShare');
|
|
1088
|
+
const range = detailRangeFor(d);
|
|
1089
|
+
applyDetailRange(d.messages, range);
|
|
1090
|
+
initRangeBrush(d.messages, range);
|
|
650
1091
|
});
|
|
651
1092
|
}
|
|
652
1093
|
|
|
1094
|
+
// The session row that links here is scoped to the active range, so a session older than the
|
|
1095
|
+
// range would otherwise show larger numbers here with no explanation.
|
|
1096
|
+
function detailRangeNote(d) {
|
|
1097
|
+
const all = d.messages.length;
|
|
1098
|
+
const slice = (label, s) =>
|
|
1099
|
+
`<span class="scope-label">${esc(label)}</span> <strong>${formatCost(s.totalCost)}</strong> / ${s.messageCount} msg`;
|
|
1100
|
+
const worthShowing = (s) => s && s.messageCount > 0 && s.messageCount < all;
|
|
1101
|
+
const parts = [];
|
|
1102
|
+
if (worthShowing(d.inRange)) parts.push(slice(rangeScopeLabel(d.range), d.inRange));
|
|
1103
|
+
if (worthShowing(d.today) && d.today.messageCount !== d.inRange?.messageCount) parts.push(slice('today', d.today));
|
|
1104
|
+
if (!parts.length) return '';
|
|
1105
|
+
return `<div class="detail-range-note">Totals above are all-time · ${parts.join(' · ')}</div>`;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function compactionStat(d) {
|
|
1109
|
+
const n = d.compactionEvents?.length || 0;
|
|
1110
|
+
if (!n) return '';
|
|
1111
|
+
return `<div class="detail-stat">
|
|
1112
|
+
<div class="detail-label">Compactions</div>
|
|
1113
|
+
<div class="detail-value">${n}<span class="detail-label detail-scope">${formatCost(d.compactions?.totalCost)}</span></div>
|
|
1114
|
+
</div>`;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// pi records the summarizer call's usage on the compaction entry when it has one, so that cost is
|
|
1118
|
+
// real and already in the total. pi records no post-compaction size and no auto/manual trigger.
|
|
1119
|
+
function compactionNote(d) {
|
|
1120
|
+
const events = d.compactionEvents || [];
|
|
1121
|
+
if (!events.length) return '';
|
|
1122
|
+
const tokensBefore = events.reduce((s, e) => s + (e.tokensBefore || 0), 0);
|
|
1123
|
+
const billed = d.compactions?.count || 0;
|
|
1124
|
+
const hook = events.filter((e) => e.fromHook).length;
|
|
1125
|
+
return `<div class="detail-range-note compaction-note">
|
|
1126
|
+
<span class="scope-label">compaction</span> ${events.length} ${events.length === 1 ? 'event' : 'events'} ·
|
|
1127
|
+
replaced <strong>${formatTokens(tokensBefore)}</strong> tokens ·
|
|
1128
|
+
summarizer <strong>${formatCost(d.compactions?.totalCost)}</strong> (in total${billed < events.length ? `, recorded for ${billed} of ${events.length}` : ''})${hook ? ` · ${hook} from a hook` : ''}
|
|
1129
|
+
</div>`;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function childSessionsSection(d) {
|
|
1133
|
+
if (!d.childSessions?.length) return '';
|
|
1134
|
+
return `
|
|
1135
|
+
<div class="section-title">Spawned sessions (${d.childSessions.length})</div>
|
|
1136
|
+
<table class="data-table" style="margin-bottom:20px">
|
|
1137
|
+
<thead><tr><th>Session</th><th>Cost</th><th>Tokens</th><th>Msgs</th><th>Model</th><th>When</th></tr></thead>
|
|
1138
|
+
<tbody>
|
|
1139
|
+
${d.childSessions
|
|
1140
|
+
.map((sa) => {
|
|
1141
|
+
const label = sa.customTitle || shortId(sa.id);
|
|
1142
|
+
const preview = sa.firstUserText ? sa.firstUserText.slice(0, 120) : '';
|
|
1143
|
+
return `<tr data-clickable onclick="navigateToDetail('${escAttrJs(sa.id)}')">
|
|
1144
|
+
<td class="truncate" title="${esc(preview ? `${sa.id} — ${preview}` : sa.id)}"><code class="session-id">${esc(label)}</code>${preview ? ` <span class="muted">${esc(preview)}</span>` : ''}</td>
|
|
1145
|
+
<td class="cost-cell">${formatCost(sa.totalCost)}</td>
|
|
1146
|
+
<td>${formatTokens(sa.totalTokens)}</td>
|
|
1147
|
+
<td>${sa.messageCount}</td>
|
|
1148
|
+
<td><span class="model-badge">${esc(shortModel(sa.primaryModel || sa.model))}</span></td>
|
|
1149
|
+
<td class="muted">${timeAgo(sa.lastTimestamp)}</td>
|
|
1150
|
+
</tr>`;
|
|
1151
|
+
})
|
|
1152
|
+
.join('')}
|
|
1153
|
+
</tbody>
|
|
1154
|
+
</table>`;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function renderMessageRows(slice, total) {
|
|
1158
|
+
const body = document.getElementById('messagesBody');
|
|
1159
|
+
if (!body) return;
|
|
1160
|
+
document.getElementById('messagesTitle').textContent =
|
|
1161
|
+
slice.length === total ? `Messages (${total})` : `Messages (${slice.length} of ${total})`;
|
|
1162
|
+
const rows = [];
|
|
1163
|
+
for (let i = slice.length - 1; i >= 0; i--) rows.push(buildMessageRow(slice[i]));
|
|
1164
|
+
body.innerHTML = rows.join('');
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function buildMessageRow(m) {
|
|
1168
|
+
const sa = m.subagent;
|
|
1169
|
+
const model = `<span class="model-badge">${esc(shortModel(m.model))}</span>`;
|
|
1170
|
+
let modelCol;
|
|
1171
|
+
let rowAttrs = '';
|
|
1172
|
+
if (sa) {
|
|
1173
|
+
const task = sa.task ? String(sa.task) : '';
|
|
1174
|
+
modelCol = `${model} <span class="subagent-tag">${esc(sa.agent || 'subagent')}</span>${task ? ` <span class="subagent-desc">${esc(task.slice(0, 80))}</span>` : ''}`;
|
|
1175
|
+
const tip = `${task || sa.agent || 'subagent'}${sa.turns ? ` · ${sa.turns} turns` : ''}${sa.sessionId ? ' · open subagent session' : ''}`;
|
|
1176
|
+
rowAttrs = sa.sessionId
|
|
1177
|
+
? ` class="subagent-row clickable" data-clickable onclick="navigateToDetail('${escAttrJs(sa.sessionId)}')" title="${esc(tip)}"`
|
|
1178
|
+
: ` class="subagent-row" title="${esc(tip)}"`;
|
|
1179
|
+
} else if (m.compaction) {
|
|
1180
|
+
const tip = `compaction · replaced ${formatTokens(m.compaction.tokensBefore)} tokens${m.compaction.fromHook ? ' · from a hook' : ''}`;
|
|
1181
|
+
modelCol = `${model} <span class="compaction-tag" title="${esc(tip)}">compact</span>`;
|
|
1182
|
+
rowAttrs = ' class="compaction-row"';
|
|
1183
|
+
} else {
|
|
1184
|
+
const tools = m.tools?.length ? ` ${m.tools.map((t) => `<span class="tool-tag">${esc(t)}</span>`).join(' ')}` : '';
|
|
1185
|
+
modelCol = model + tools;
|
|
1186
|
+
}
|
|
1187
|
+
return `<tr${rowAttrs}>
|
|
1188
|
+
<td class="muted">${m.index}</td>
|
|
1189
|
+
<td class="muted">${m.ts ? localTime(m.ts) : ''}</td>
|
|
1190
|
+
<td>${modelCol}</td>
|
|
1191
|
+
<td>${formatTokens(m.input)}</td>
|
|
1192
|
+
<td>${formatTokens(m.output)}</td>
|
|
1193
|
+
<td>${formatTokens(m.cacheWrite)}</td>
|
|
1194
|
+
<td>${formatTokens(m.cacheRead)}</td>
|
|
1195
|
+
<td class="cost-cell">${formatCost(m.cost)}</td>
|
|
1196
|
+
<td>${srcBadge(m.costSource)}</td>
|
|
1197
|
+
<td class="cumulative">${formatCost(m.cumulativeCost)}</td>
|
|
1198
|
+
</tr>`;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
653
1201
|
// #endregion
|
|
654
1202
|
|
|
655
1203
|
// #region CHARTS
|
|
656
1204
|
|
|
1205
|
+
let _chartColors = null;
|
|
1206
|
+
|
|
657
1207
|
function getChartColors() {
|
|
1208
|
+
return (_chartColors ||= readChartColors());
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function readChartColors() {
|
|
658
1212
|
const style = getComputedStyle(document.body);
|
|
1213
|
+
const v = (name, fallback) => style.getPropertyValue(name).trim() || fallback;
|
|
659
1214
|
return {
|
|
660
|
-
accent:
|
|
661
|
-
chartFill:
|
|
662
|
-
text:
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
1215
|
+
accent: v('--accent', '#e86f33'),
|
|
1216
|
+
chartFill: v('--chart-fill', 'rgba(232,111,51,0.6)'),
|
|
1217
|
+
text: v('--text-secondary', '#c2c4c9'),
|
|
1218
|
+
textPrimary: v('--text-primary', '#f0f1f3'),
|
|
1219
|
+
textMuted: v('--text-muted', '#7d808a'),
|
|
1220
|
+
mono: v('--mono', "'IBM Plex Mono', monospace"),
|
|
1221
|
+
border: v('--border', '#363840'),
|
|
1222
|
+
bg: v('--bg-elevated', '#1e2025'),
|
|
1223
|
+
chart2: v('--chart-2', '#60a5fa'),
|
|
1224
|
+
chart3: v('--chart-3', '#3ecf8e'),
|
|
1225
|
+
chart4: v('--chart-4', '#f0b429'),
|
|
1226
|
+
chart5: v('--chart-5', '#b784f5'),
|
|
1227
|
+
chart6: v('--chart-6', '#fb7185'),
|
|
669
1228
|
};
|
|
670
1229
|
}
|
|
671
1230
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
const hue = Math.abs(h) % 360;
|
|
677
|
-
const sat = isLight ? 55 : 60;
|
|
678
|
-
const light = isLight ? 50 : 62;
|
|
679
|
-
return `hsl(${hue} ${sat}% ${light}%)`;
|
|
1231
|
+
// One palette so per-model colors match across charts. It leads with the theme accent, so a
|
|
1232
|
+
// single-model chart takes the theme's color.
|
|
1233
|
+
function chartPalette(c) {
|
|
1234
|
+
return [c.accent, c.chart2, c.chart3, c.chart4, c.chart5, c.chart6];
|
|
680
1235
|
}
|
|
681
1236
|
|
|
682
|
-
|
|
683
|
-
|
|
1237
|
+
let colorProbe = null;
|
|
1238
|
+
|
|
1239
|
+
// Theme colors come from JSON files and from the board, so they can be hex, rgb() or any CSS
|
|
1240
|
+
// color. Anything else is resolved through the browser once.
|
|
1241
|
+
function parseColor(color) {
|
|
1242
|
+
const s = String(color || '').trim();
|
|
1243
|
+
const hex = /^#([\da-f]{3}|[\da-f]{6})$/i.exec(s);
|
|
1244
|
+
if (hex) {
|
|
1245
|
+
const h = hex[1].length === 3 ? [...hex[1]].map((ch) => ch + ch).join('') : hex[1];
|
|
1246
|
+
return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
|
|
1247
|
+
}
|
|
1248
|
+
const rgb = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(s);
|
|
1249
|
+
if (rgb) return rgb.slice(1, 4).map(Number);
|
|
1250
|
+
if (!s || !CSS.supports('color', s)) return null;
|
|
1251
|
+
if (!colorProbe) {
|
|
1252
|
+
colorProbe = document.createElement('span');
|
|
1253
|
+
colorProbe.style.display = 'none';
|
|
1254
|
+
document.body.appendChild(colorProbe);
|
|
1255
|
+
}
|
|
1256
|
+
colorProbe.style.color = s;
|
|
1257
|
+
const resolved = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(getComputedStyle(colorProbe).color);
|
|
1258
|
+
return resolved ? resolved.slice(1, 4).map(Number) : null;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function withAlpha(color, alpha) {
|
|
1262
|
+
const rgb = parseColor(color);
|
|
1263
|
+
return rgb ? `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})` : color;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function messageTooltipTitle(messages, items) {
|
|
1267
|
+
const m = messages[items[0]?.dataIndex];
|
|
1268
|
+
if (!m) return '';
|
|
1269
|
+
return `#${m.index}${m.subagent ? ` · ${m.subagent.agent}` : m.compaction ? ' · compaction' : ''}`;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
function tokenSeries(c) {
|
|
1273
|
+
return [
|
|
1274
|
+
['Input', 'input', c.accent],
|
|
1275
|
+
['Output', 'output', c.chart2],
|
|
1276
|
+
['Cache Write', 'cacheWrite', c.chart3],
|
|
1277
|
+
['Cache Read', 'cacheRead', c.chart4],
|
|
1278
|
+
];
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
function chartDefaults(c = getChartColors()) {
|
|
684
1282
|
return {
|
|
685
1283
|
responsive: true,
|
|
686
1284
|
maintainAspectRatio: false,
|
|
687
1285
|
plugins: {
|
|
688
1286
|
legend: { display: false },
|
|
689
|
-
|
|
1287
|
+
// Mirrors .hm-tip so a chart hover and a heatmap hover read as the same tooltip.
|
|
1288
|
+
tooltip: {
|
|
1289
|
+
backgroundColor: c.bg,
|
|
1290
|
+
titleColor: c.textMuted,
|
|
1291
|
+
bodyColor: c.textPrimary,
|
|
1292
|
+
borderColor: c.border,
|
|
1293
|
+
borderWidth: 1,
|
|
1294
|
+
cornerRadius: 4,
|
|
1295
|
+
padding: { top: 5, bottom: 5, left: 8, right: 8 },
|
|
1296
|
+
titleFont: { family: c.mono, size: 10, weight: 'normal' },
|
|
1297
|
+
bodyFont: { family: c.mono, size: 13, weight: '600' },
|
|
1298
|
+
titleMarginBottom: 2,
|
|
1299
|
+
boxWidth: 8,
|
|
1300
|
+
boxHeight: 8,
|
|
1301
|
+
boxPadding: 4,
|
|
1302
|
+
},
|
|
690
1303
|
},
|
|
691
1304
|
scales: {
|
|
692
1305
|
x: { ticks: { color: c.text, font: { size: 10 } }, grid: { color: c.border, drawBorder: false } },
|
|
@@ -695,33 +1308,122 @@ function chartDefaults() {
|
|
|
695
1308
|
};
|
|
696
1309
|
}
|
|
697
1310
|
|
|
698
|
-
|
|
699
|
-
|
|
1311
|
+
// Log axes tick at every 2..9 within a decade, which reads as noise. Keep powers of 10 only.
|
|
1312
|
+
function isDecade(v) {
|
|
1313
|
+
const l = Math.log10(v);
|
|
1314
|
+
return Math.abs(l - Math.round(l)) < 1e-9;
|
|
700
1315
|
}
|
|
701
1316
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
1317
|
+
// maxValue caps the axis at the largest bar and labels it.
|
|
1318
|
+
function logAxis(xDefaults, maxValue) {
|
|
1319
|
+
const labeled = (v) => isDecade(v) || v === maxValue;
|
|
1320
|
+
return {
|
|
1321
|
+
...xDefaults,
|
|
1322
|
+
type: 'logarithmic',
|
|
1323
|
+
min: 1,
|
|
1324
|
+
...(maxValue > 1 ? { max: maxValue } : {}),
|
|
1325
|
+
afterBuildTicks: (scale) => {
|
|
1326
|
+
if (maxValue <= 1) return;
|
|
1327
|
+
scale.ticks = scale.ticks.filter(
|
|
1328
|
+
(t) => t.value !== maxValue && !(isDecade(t.value) && Math.abs(Math.log10(maxValue / t.value)) < 0.25),
|
|
1329
|
+
);
|
|
1330
|
+
scale.ticks.push({ value: maxValue });
|
|
1331
|
+
},
|
|
1332
|
+
grid: {
|
|
1333
|
+
...xDefaults.grid,
|
|
1334
|
+
color: (ctx) => (ctx.tick && labeled(ctx.tick.value) ? xDefaults.grid.color : 'transparent'),
|
|
1335
|
+
},
|
|
1336
|
+
ticks: {
|
|
1337
|
+
...xDefaults.ticks,
|
|
1338
|
+
autoSkip: false,
|
|
1339
|
+
callback: (v) => (labeled(v) ? formatTokens(v) : ''),
|
|
1340
|
+
},
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
function barDataset(data, color) {
|
|
1345
|
+
return {
|
|
1346
|
+
data,
|
|
1347
|
+
backgroundColor: Array.isArray(color) ? color.map((x) => withAlpha(x, 0.5)) : withAlpha(color, 0.5),
|
|
1348
|
+
borderColor: color,
|
|
1349
|
+
borderWidth: 1.5,
|
|
1350
|
+
borderRadius: 3,
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
function withTooltip(defaults, callbacks) {
|
|
1355
|
+
return { ...defaults.plugins, tooltip: { ...defaults.plugins.tooltip, callbacks } };
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// Two significant digits below a cent, so a sub-cent cost does not read as $0.00.
|
|
1359
|
+
function usd(v) {
|
|
1360
|
+
const a = Math.abs(v);
|
|
1361
|
+
return `$${v.toFixed(a >= 0.01 || a === 0 ? 2 : Math.min(6, Math.ceil(-Math.log10(a)) + 1))}`;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// Ticks share the decimals their step needs, so neighbouring labels stay distinct.
|
|
1365
|
+
function usdTick(v, _i, ticks) {
|
|
1366
|
+
const step = ticks.length > 1 ? Math.abs(ticks[1].value - ticks[0].value) : 0;
|
|
1367
|
+
let d = 2;
|
|
1368
|
+
while (d < 6 && step > 0 && Math.abs(step * 10 ** d - Math.round(step * 10 ** d)) > 1e-6) d++;
|
|
1369
|
+
return `$${v.toFixed(d)}`;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function destroyChart(id) {
|
|
1373
|
+
if (charts[id]) {
|
|
1374
|
+
charts[id].destroy();
|
|
1375
|
+
delete charts[id];
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function renderCostChart(series, canvasId, chartKey, distribution = []) {
|
|
1380
|
+
const points = series?.points;
|
|
1381
|
+
const canvas = document.getElementById(canvasId);
|
|
1382
|
+
if (!canvas || !points?.length) return;
|
|
1383
|
+
destroyChart(chartKey);
|
|
1384
|
+
|
|
1385
|
+
const c = getChartColors();
|
|
1386
|
+
const defaults = chartDefaults(c);
|
|
1387
|
+
|
|
1388
|
+
// Stack order follows modelDistribution so segment colors match the Cost by Model chart.
|
|
1389
|
+
const present = new Set();
|
|
1390
|
+
for (const p of points) {
|
|
1391
|
+
for (const m in p.byModel) if (p.byModel[m] > 0) present.add(m);
|
|
1392
|
+
}
|
|
1393
|
+
const models = distribution.filter((d) => present.has(d.model)).map((d) => d.model);
|
|
1394
|
+
for (const m of present) if (!models.includes(m)) models.push(m);
|
|
1395
|
+
const palette = chartPalette(c);
|
|
1396
|
+
|
|
1397
|
+
charts[chartKey] = new Chart(canvas, {
|
|
1398
|
+
type: 'bar',
|
|
1399
|
+
data: {
|
|
1400
|
+
labels: points.map((p) => bucketLabel(p.key, series.bucket)),
|
|
1401
|
+
datasets: models.map((m, i) => ({
|
|
1402
|
+
label: shortModel(m),
|
|
1403
|
+
data: points.map((p) => p.byModel?.[m] || 0),
|
|
1404
|
+
backgroundColor: withAlpha(palette[i % palette.length], 0.5),
|
|
1405
|
+
borderColor: palette[i % palette.length],
|
|
1406
|
+
borderWidth: 1.5,
|
|
1407
|
+
borderRadius: 2,
|
|
1408
|
+
stack: 'cost',
|
|
1409
|
+
})),
|
|
720
1410
|
},
|
|
721
1411
|
options: {
|
|
722
1412
|
...defaults,
|
|
723
|
-
|
|
724
|
-
|
|
1413
|
+
interaction: { mode: 'nearest', intersect: true },
|
|
1414
|
+
plugins: {
|
|
1415
|
+
...withTooltip(defaults, { label: (ctx) => `${ctx.dataset.label} ${usd(ctx.parsed.y)}` }),
|
|
1416
|
+
legend: {
|
|
1417
|
+
display: models.length > 1,
|
|
1418
|
+
position: 'bottom',
|
|
1419
|
+
labels: { color: c.text, font: { size: 9 }, boxWidth: 8, boxHeight: 8 },
|
|
1420
|
+
},
|
|
1421
|
+
},
|
|
1422
|
+
scales: {
|
|
1423
|
+
...defaults.scales,
|
|
1424
|
+
x: { ...defaults.scales.x, stacked: true },
|
|
1425
|
+
y: { ...defaults.scales.y, stacked: true, ticks: { ...defaults.scales.y.ticks, callback: usdTick } },
|
|
1426
|
+
},
|
|
725
1427
|
},
|
|
726
1428
|
});
|
|
727
1429
|
}
|
|
@@ -730,88 +1432,158 @@ function renderModelChart(models, canvasId = 'modelChart', chartKey = 'model') {
|
|
|
730
1432
|
const canvas = document.getElementById(canvasId);
|
|
731
1433
|
if (!canvas || !models?.length) return;
|
|
732
1434
|
destroyChart(chartKey);
|
|
733
|
-
|
|
1435
|
+
|
|
1436
|
+
const c = getChartColors();
|
|
1437
|
+
const palette = chartPalette(c);
|
|
1438
|
+
const defaults = chartDefaults(c);
|
|
734
1439
|
const total = models.reduce((s, m) => s + m.cost, 0);
|
|
1440
|
+
|
|
735
1441
|
charts[chartKey] = new Chart(canvas, {
|
|
736
1442
|
type: 'bar',
|
|
737
1443
|
data: {
|
|
738
1444
|
labels: models.map((m) => shortModel(m.model)),
|
|
739
|
-
datasets: [
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
1445
|
+
datasets: [
|
|
1446
|
+
barDataset(
|
|
1447
|
+
models.map((m) => m.cost),
|
|
1448
|
+
models.map((_, i) => palette[i % palette.length]),
|
|
1449
|
+
),
|
|
1450
|
+
],
|
|
745
1451
|
},
|
|
746
1452
|
options: {
|
|
747
1453
|
...defaults,
|
|
748
1454
|
indexAxis: 'y',
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
1455
|
+
interaction: { mode: 'nearest', intersect: true },
|
|
1456
|
+
plugins: withTooltip(defaults, { label: (ctx) => `${usd(ctx.parsed.x)} (${formatPct(ctx.parsed.x, total)}%)` }),
|
|
1457
|
+
scales: {
|
|
1458
|
+
...defaults.scales,
|
|
1459
|
+
x: { ...defaults.scales.x, ticks: { ...defaults.scales.x.ticks, callback: usdTick } },
|
|
752
1460
|
},
|
|
753
|
-
scales: { ...defaults.scales, x: { ...defaults.scales.x, ticks: { ...defaults.scales.x.ticks, callback: (v) => `$${v}` } } },
|
|
754
1461
|
},
|
|
755
1462
|
});
|
|
756
1463
|
}
|
|
757
1464
|
|
|
758
|
-
function
|
|
759
|
-
const
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
charts.cumulative = new Chart(canvas, {
|
|
765
|
-
type: 'line',
|
|
766
|
-
data: {
|
|
767
|
-
labels: messages.map((_, i) => i + 1),
|
|
768
|
-
datasets: [{
|
|
1465
|
+
function cumulativeChartData(messages, c) {
|
|
1466
|
+
const small = messages.length <= 50;
|
|
1467
|
+
return {
|
|
1468
|
+
labels: messages.map((m) => m.index),
|
|
1469
|
+
datasets: [
|
|
1470
|
+
{
|
|
769
1471
|
label: 'Cumulative Cost',
|
|
770
1472
|
data: messages.map((m) => m.cumulativeCost),
|
|
771
1473
|
borderColor: c.accent,
|
|
772
1474
|
backgroundColor: c.chartFill,
|
|
773
1475
|
fill: true,
|
|
774
1476
|
borderWidth: 2,
|
|
775
|
-
pointRadius:
|
|
776
|
-
|
|
1477
|
+
pointRadius: small ? 3 : 0,
|
|
1478
|
+
// Subagent points render hollow so they read as folded-in cost, not main-loop turns.
|
|
1479
|
+
pointBackgroundColor: small ? messages.map((m) => (m.subagent ? c.bg : c.accent)) : c.accent,
|
|
1480
|
+
pointBorderColor: c.accent,
|
|
777
1481
|
tension: 0.2,
|
|
778
|
-
}
|
|
779
|
-
|
|
1482
|
+
},
|
|
1483
|
+
],
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
function tokenBreakdownChartData(messages, c) {
|
|
1488
|
+
const barColors = (color) => {
|
|
1489
|
+
const faded = withAlpha(color, 0.45);
|
|
1490
|
+
return messages.map((m) => (m.subagent ? faded : color));
|
|
1491
|
+
};
|
|
1492
|
+
return {
|
|
1493
|
+
labels: messages.map((m) => m.index),
|
|
1494
|
+
datasets: tokenSeries(c).map(([label, key, color]) => ({
|
|
1495
|
+
label,
|
|
1496
|
+
data: messages.map((m) => m[key]),
|
|
1497
|
+
backgroundColor: barColors(color),
|
|
1498
|
+
borderRadius: 2,
|
|
1499
|
+
})),
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
// A dashed vertical line before each compaction row. Reads chart.$compactions, set by
|
|
1504
|
+
// markCompactions, so a draw costs no style lookup or scan.
|
|
1505
|
+
const compactionMarkers = {
|
|
1506
|
+
id: 'compactionMarkers',
|
|
1507
|
+
afterDatasetsDraw(chart) {
|
|
1508
|
+
const marks = chart.$compactions;
|
|
1509
|
+
if (!marks?.indices.length) return;
|
|
1510
|
+
const { ctx, chartArea, scales } = chart;
|
|
1511
|
+
ctx.save();
|
|
1512
|
+
ctx.strokeStyle = marks.color;
|
|
1513
|
+
ctx.lineWidth = 1;
|
|
1514
|
+
ctx.setLineDash([4, 3]);
|
|
1515
|
+
ctx.font = `9px ${marks.font}`;
|
|
1516
|
+
ctx.fillStyle = marks.color;
|
|
1517
|
+
ctx.textAlign = 'center';
|
|
1518
|
+
for (const i of marks.indices) {
|
|
1519
|
+
const p = scales.x.getPixelForValue(i);
|
|
1520
|
+
const x = i > 0 ? (p + scales.x.getPixelForValue(i - 1)) / 2 : p;
|
|
1521
|
+
ctx.beginPath();
|
|
1522
|
+
ctx.moveTo(x, chartArea.top);
|
|
1523
|
+
ctx.lineTo(x, chartArea.bottom);
|
|
1524
|
+
ctx.stroke();
|
|
1525
|
+
ctx.fillText('compact', x, chartArea.top - 2);
|
|
1526
|
+
}
|
|
1527
|
+
ctx.restore();
|
|
1528
|
+
},
|
|
1529
|
+
};
|
|
1530
|
+
|
|
1531
|
+
function markCompactions(chart, messages, c) {
|
|
1532
|
+
chart.$compactions = {
|
|
1533
|
+
indices: messages.flatMap((m, i) => (m.compaction ? [i] : [])),
|
|
1534
|
+
color: c.chart5,
|
|
1535
|
+
font: c.mono,
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
function renderCumulativeChart(messages) {
|
|
1540
|
+
const canvas = document.getElementById('cumulativeChart');
|
|
1541
|
+
if (!canvas || !messages?.length) return;
|
|
1542
|
+
destroyChart('cumulative');
|
|
1543
|
+
|
|
1544
|
+
const c = getChartColors();
|
|
1545
|
+
const defaults = chartDefaults(c);
|
|
1546
|
+
|
|
1547
|
+
charts.cumulative = new Chart(canvas, {
|
|
1548
|
+
type: 'line',
|
|
1549
|
+
data: cumulativeChartData(messages, c),
|
|
1550
|
+
plugins: [compactionMarkers],
|
|
780
1551
|
options: {
|
|
781
1552
|
...defaults,
|
|
1553
|
+
plugins: withTooltip(defaults, {
|
|
1554
|
+
title: (items) => messageTooltipTitle(detailSlice, items),
|
|
1555
|
+
label: (ctx) => usd(ctx.parsed.y),
|
|
1556
|
+
}),
|
|
782
1557
|
scales: {
|
|
783
1558
|
...defaults.scales,
|
|
784
1559
|
x: { ...defaults.scales.x, title: { display: true, text: 'Message #', color: c.text, font: { size: 10 } } },
|
|
785
|
-
y: { ...defaults.scales.y, ticks: { ...defaults.scales.y.ticks, callback:
|
|
1560
|
+
y: { ...defaults.scales.y, ticks: { ...defaults.scales.y.ticks, callback: usdTick } },
|
|
786
1561
|
},
|
|
787
1562
|
},
|
|
788
1563
|
});
|
|
1564
|
+
markCompactions(charts.cumulative, messages, c);
|
|
789
1565
|
}
|
|
790
1566
|
|
|
791
1567
|
function renderTokenBreakdownChart(messages) {
|
|
792
1568
|
const canvas = document.getElementById('tokenBreakdownChart');
|
|
793
1569
|
if (!canvas || !messages?.length) return;
|
|
794
1570
|
destroyChart('tokenBreakdown');
|
|
1571
|
+
|
|
795
1572
|
const c = getChartColors();
|
|
796
|
-
const defaults = chartDefaults();
|
|
1573
|
+
const defaults = chartDefaults(c);
|
|
1574
|
+
|
|
797
1575
|
charts.tokenBreakdown = new Chart(canvas, {
|
|
798
1576
|
type: 'bar',
|
|
799
|
-
data:
|
|
800
|
-
|
|
801
|
-
datasets: [
|
|
802
|
-
{ label: 'Input', data: messages.map((m) => m.input), backgroundColor: c.chart1, borderRadius: 2 },
|
|
803
|
-
{ label: 'Output', data: messages.map((m) => m.output), backgroundColor: c.chart2, borderRadius: 2 },
|
|
804
|
-
{ label: 'Cache Write', data: messages.map((m) => m.cacheWrite), backgroundColor: c.chart3, borderRadius: 2 },
|
|
805
|
-
{ label: 'Cache Read', data: messages.map((m) => m.cacheRead), backgroundColor: c.chart4, borderRadius: 2 },
|
|
806
|
-
],
|
|
807
|
-
},
|
|
1577
|
+
data: tokenBreakdownChartData(messages, c),
|
|
1578
|
+
plugins: [compactionMarkers],
|
|
808
1579
|
options: {
|
|
809
1580
|
...defaults,
|
|
810
1581
|
plugins: {
|
|
811
|
-
...defaults
|
|
1582
|
+
...withTooltip(defaults, { title: (items) => messageTooltipTitle(detailSlice, items) }),
|
|
812
1583
|
legend: {
|
|
813
|
-
display: true,
|
|
814
|
-
|
|
1584
|
+
display: true,
|
|
1585
|
+
position: 'bottom',
|
|
1586
|
+
labels: { color: c.text, font: { size: 10, family: c.mono }, padding: 8, boxWidth: 12, boxHeight: 12 },
|
|
815
1587
|
},
|
|
816
1588
|
},
|
|
817
1589
|
scales: {
|
|
@@ -821,6 +1593,481 @@ function renderTokenBreakdownChart(messages) {
|
|
|
821
1593
|
},
|
|
822
1594
|
},
|
|
823
1595
|
});
|
|
1596
|
+
markCompactions(charts.tokenBreakdown, messages, c);
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
function syncSubagentShare(messages, c) {
|
|
1600
|
+
const chart = charts.subagentShare;
|
|
1601
|
+
if (!chart) return renderSubagentShareChart(messages);
|
|
1602
|
+
chart.data = subagentShareChartData(messages, c);
|
|
1603
|
+
chart.update('none');
|
|
1604
|
+
renderSubagentShareLegend(chart.data.slices);
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
// Grouped by agent name. A group links to its run only when it has exactly one session to open.
|
|
1608
|
+
function subagentShareChartData(messages, c) {
|
|
1609
|
+
const groups = new Map();
|
|
1610
|
+
let main = 0;
|
|
1611
|
+
for (const m of messages) {
|
|
1612
|
+
const sa = m.subagent;
|
|
1613
|
+
if (!sa) {
|
|
1614
|
+
main += m.cost;
|
|
1615
|
+
continue;
|
|
1616
|
+
}
|
|
1617
|
+
const name = sa.agent || 'subagent';
|
|
1618
|
+
let group = groups.get(name);
|
|
1619
|
+
if (!group) {
|
|
1620
|
+
group = { name, cost: 0, sessions: new Set() };
|
|
1621
|
+
groups.set(name, group);
|
|
1622
|
+
}
|
|
1623
|
+
group.cost += m.cost;
|
|
1624
|
+
if (sa.sessionId) group.sessions.add(sa.sessionId);
|
|
1625
|
+
}
|
|
1626
|
+
const palette = chartPalette(c);
|
|
1627
|
+
const agents = [...groups.values()]
|
|
1628
|
+
.sort((a, b) => b.cost - a.cost)
|
|
1629
|
+
.map(({ sessions, ...g }, i) => ({
|
|
1630
|
+
...g,
|
|
1631
|
+
sessionId: sessions.size === 1 ? [...sessions][0] : null,
|
|
1632
|
+
color: palette[1 + (i % (palette.length - 1))],
|
|
1633
|
+
}));
|
|
1634
|
+
// The main loop keeps the accent wherever it ranks.
|
|
1635
|
+
const slices = [{ name: 'main', cost: main, color: palette[0] }, ...agents].sort((a, b) => b.cost - a.cost);
|
|
1636
|
+
return {
|
|
1637
|
+
labels: slices.map((s) => s.name),
|
|
1638
|
+
slices,
|
|
1639
|
+
datasets: [
|
|
1640
|
+
{
|
|
1641
|
+
...barDataset(
|
|
1642
|
+
slices.map((s) => s.cost),
|
|
1643
|
+
slices.map((s) => s.color),
|
|
1644
|
+
),
|
|
1645
|
+
borderRadius: 0,
|
|
1646
|
+
},
|
|
1647
|
+
],
|
|
1648
|
+
};
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
function renderSubagentShareChart(messages) {
|
|
1652
|
+
const canvas = document.getElementById('subagentShareChart');
|
|
1653
|
+
if (!canvas || !messages?.length) return;
|
|
1654
|
+
destroyChart('subagentShare');
|
|
1655
|
+
|
|
1656
|
+
const c = getChartColors();
|
|
1657
|
+
const { scales: _scales, ...defaults } = chartDefaults(c);
|
|
1658
|
+
|
|
1659
|
+
charts.subagentShare = new Chart(canvas, {
|
|
1660
|
+
type: 'doughnut',
|
|
1661
|
+
data: subagentShareChartData(messages, c),
|
|
1662
|
+
options: {
|
|
1663
|
+
...defaults,
|
|
1664
|
+
cutout: '55%',
|
|
1665
|
+
onClick: (_e, els, chart) => {
|
|
1666
|
+
const id = chart.data.slices[els[0]?.index]?.sessionId;
|
|
1667
|
+
if (id) navigateToDetail(id);
|
|
1668
|
+
},
|
|
1669
|
+
onHover: (e, els, chart) => {
|
|
1670
|
+
e.native.target.style.cursor = chart.data.slices[els[0]?.index]?.sessionId ? 'pointer' : '';
|
|
1671
|
+
},
|
|
1672
|
+
plugins: withTooltip(defaults, {
|
|
1673
|
+
title: (items) => items[0]?.chart.data.slices[items[0].dataIndex]?.name || '',
|
|
1674
|
+
label: (ctx) => {
|
|
1675
|
+
const total = ctx.dataset.data.reduce((s, v) => s + v, 0);
|
|
1676
|
+
return `${usd(ctx.parsed)} (${formatPct(ctx.parsed, total)}%)`;
|
|
1677
|
+
},
|
|
1678
|
+
}),
|
|
1679
|
+
},
|
|
1680
|
+
});
|
|
1681
|
+
renderSubagentShareLegend(charts.subagentShare.data.slices);
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
// An HTML list, not the Chart.js legend: a canvas legend that runs out of room drops entries.
|
|
1685
|
+
function renderSubagentShareLegend(slices) {
|
|
1686
|
+
const el = document.getElementById('subagentShareLegend');
|
|
1687
|
+
if (!el) return;
|
|
1688
|
+
const total = slices.reduce((s, x) => s + x.cost, 0);
|
|
1689
|
+
el.innerHTML = slices
|
|
1690
|
+
.map((s) => {
|
|
1691
|
+
const attrs = s.sessionId ? ` class="share-agent" onclick="navigateToDetail('${escAttrJs(s.sessionId)}')"` : '';
|
|
1692
|
+
return `<li${attrs} title="${esc(s.name)}">
|
|
1693
|
+
<span class="share-swatch" style="background:${esc(withAlpha(s.color, 0.5))};border-color:${esc(s.color)}"></span>
|
|
1694
|
+
<span class="share-name">${esc(s.name)}</span>
|
|
1695
|
+
<span class="share-cost">${formatCost(s.cost)}</span>
|
|
1696
|
+
<span class="share-pct">${formatPct(s.cost, total)}%</span>
|
|
1697
|
+
</li>`;
|
|
1698
|
+
})
|
|
1699
|
+
.join('');
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
function renderTokenSplitChart(t) {
|
|
1703
|
+
const canvas = document.getElementById('tokenSplitChart');
|
|
1704
|
+
if (!canvas) return;
|
|
1705
|
+
destroyChart('tokenSplit');
|
|
1706
|
+
|
|
1707
|
+
const c = getChartColors();
|
|
1708
|
+
const defaults = chartDefaults(c);
|
|
1709
|
+
const rows = tokenSeries(c).map(([label, key, color]) => [label, t[key], color]);
|
|
1710
|
+
const total = rows.reduce((s, r) => s + r[1], 0);
|
|
1711
|
+
|
|
1712
|
+
charts.tokenSplit = new Chart(canvas, {
|
|
1713
|
+
type: 'bar',
|
|
1714
|
+
data: {
|
|
1715
|
+
labels: rows.map((r) => r[0]),
|
|
1716
|
+
datasets: [
|
|
1717
|
+
barDataset(
|
|
1718
|
+
rows.map((r) => r[1]),
|
|
1719
|
+
rows.map((r) => r[2]),
|
|
1720
|
+
),
|
|
1721
|
+
],
|
|
1722
|
+
},
|
|
1723
|
+
options: {
|
|
1724
|
+
...defaults,
|
|
1725
|
+
indexAxis: 'y',
|
|
1726
|
+
interaction: { mode: 'index', axis: 'y', intersect: false },
|
|
1727
|
+
plugins: withTooltip(defaults, {
|
|
1728
|
+
label: (ctx) => `${formatTokens(ctx.parsed.x)} (${formatPct(ctx.parsed.x, total)}%)`,
|
|
1729
|
+
}),
|
|
1730
|
+
// Log scale: cache reads dwarf fresh input by orders of magnitude, so a linear axis hides
|
|
1731
|
+
// every other bar. The tooltip carries exact values.
|
|
1732
|
+
scales: { ...defaults.scales, x: logAxis(defaults.scales.x, Math.max(...rows.map((r) => r[1]))) },
|
|
1733
|
+
},
|
|
1734
|
+
});
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
function renderToolsChart(tools) {
|
|
1738
|
+
const canvas = document.getElementById('toolsChart');
|
|
1739
|
+
if (!canvas || !tools?.length) return;
|
|
1740
|
+
destroyChart('tools');
|
|
1741
|
+
|
|
1742
|
+
const c = getChartColors();
|
|
1743
|
+
const defaults = chartDefaults(c);
|
|
1744
|
+
const top = tools.slice(0, 8);
|
|
1745
|
+
// MCP tool names ("mcp__server__tool") are too long for axis labels, so keep the tail.
|
|
1746
|
+
const shortName = (n) => (n.includes('__') ? n.split('__').pop() : n);
|
|
1747
|
+
|
|
1748
|
+
charts.tools = new Chart(canvas, {
|
|
1749
|
+
type: 'bar',
|
|
1750
|
+
data: {
|
|
1751
|
+
labels: top.map((t) => shortName(t.name)),
|
|
1752
|
+
datasets: [
|
|
1753
|
+
barDataset(
|
|
1754
|
+
top.map((t) => t.count),
|
|
1755
|
+
c.chart2,
|
|
1756
|
+
),
|
|
1757
|
+
],
|
|
1758
|
+
},
|
|
1759
|
+
options: {
|
|
1760
|
+
...defaults,
|
|
1761
|
+
indexAxis: 'y',
|
|
1762
|
+
interaction: { mode: 'index', axis: 'y', intersect: false },
|
|
1763
|
+
plugins: withTooltip(defaults, {
|
|
1764
|
+
title: (items) => top[items[0].dataIndex]?.name || '',
|
|
1765
|
+
label: (ctx) => `${ctx.parsed.x} calls`,
|
|
1766
|
+
}),
|
|
1767
|
+
scales: {
|
|
1768
|
+
x: { ...defaults.scales.x, ticks: { ...defaults.scales.x.ticks, precision: 0 } },
|
|
1769
|
+
y: { ...defaults.scales.y, ticks: { ...defaults.scales.y.ticks, autoSkip: false } },
|
|
1770
|
+
},
|
|
1771
|
+
},
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
function renderWeeklyChart(weekly) {
|
|
1776
|
+
const canvas = document.getElementById('weeklyChart');
|
|
1777
|
+
if (!canvas || !weekly?.length) return;
|
|
1778
|
+
destroyChart('weekly');
|
|
1779
|
+
|
|
1780
|
+
const c = getChartColors();
|
|
1781
|
+
const defaults = chartDefaults(c);
|
|
1782
|
+
|
|
1783
|
+
charts.weekly = new Chart(canvas, {
|
|
1784
|
+
type: 'bar',
|
|
1785
|
+
data: {
|
|
1786
|
+
labels: weekly.map((w) => isoDayLabel(w.weekStart)),
|
|
1787
|
+
datasets: [
|
|
1788
|
+
barDataset(
|
|
1789
|
+
weekly.map((w) => w.cost),
|
|
1790
|
+
c.accent,
|
|
1791
|
+
),
|
|
1792
|
+
],
|
|
1793
|
+
},
|
|
1794
|
+
options: {
|
|
1795
|
+
...defaults,
|
|
1796
|
+
plugins: withTooltip(defaults, { label: (ctx) => usd(ctx.parsed.y) }),
|
|
1797
|
+
scales: { ...defaults.scales, y: { ...defaults.scales.y, ticks: { ...defaults.scales.y.ticks, callback: usdTick } } },
|
|
1798
|
+
},
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
// #endregion
|
|
1803
|
+
|
|
1804
|
+
// #region RANGE_BRUSH
|
|
1805
|
+
|
|
1806
|
+
// Long sessions squash both per-message charts into noise, so the detail view shows a window of
|
|
1807
|
+
// the last DETAIL_WINDOW messages and lets the user drag it across an overview of the session.
|
|
1808
|
+
// State lives outside the DOM because renderDetail rebuilds its innerHTML on every refresh; a
|
|
1809
|
+
// window at the tail stays there so new messages scroll into view.
|
|
1810
|
+
const DETAIL_WINDOW_PRESETS = [25, 50, 100, 200];
|
|
1811
|
+
const DETAIL_WINDOW = DETAIL_WINDOW_PRESETS[0];
|
|
1812
|
+
const DETAIL_WINDOW_MIN = 10;
|
|
1813
|
+
const detailRanges = new Map();
|
|
1814
|
+
let detailSlice = [];
|
|
1815
|
+
|
|
1816
|
+
function median(sorted) {
|
|
1817
|
+
return sorted[Math.floor(sorted.length / 2)];
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
// A cache burst is a main-loop turn that wrote far more cache than the session normally does: the
|
|
1821
|
+
// prompt prefix was invalidated and re-cached. Relative to the session median, with an absolute
|
|
1822
|
+
// floor that drops noise in tiny sessions.
|
|
1823
|
+
const CACHE_BURST_RATIO = 4;
|
|
1824
|
+
const CACHE_BURST_MIN_TOKENS = 20000;
|
|
1825
|
+
const SUBAGENT_COSTLY_USD = 1;
|
|
1826
|
+
const MARKER_KINDS = {
|
|
1827
|
+
burst: { cls: '', label: 'cache burst' },
|
|
1828
|
+
compaction: { cls: 'compact', label: 'compaction' },
|
|
1829
|
+
agent: { cls: 'agent', label: 'subagent' },
|
|
1830
|
+
costlyAgent: { cls: 'agent costly', label: 'subagent', suffix: ` ≥ ${formatCost(SUBAGENT_COSTLY_USD)}` },
|
|
1831
|
+
};
|
|
1832
|
+
|
|
1833
|
+
function notableEvents(messages) {
|
|
1834
|
+
const writes = messages
|
|
1835
|
+
.filter((m) => !m.subagent && m.cacheWrite > 0)
|
|
1836
|
+
.map((m) => m.cacheWrite)
|
|
1837
|
+
.sort((a, b) => a - b);
|
|
1838
|
+
const threshold = writes.length < 5 ? Infinity : Math.max(CACHE_BURST_MIN_TOKENS, median(writes) * CACHE_BURST_RATIO);
|
|
1839
|
+
return messages.flatMap((m, i) => {
|
|
1840
|
+
if (m.compaction) {
|
|
1841
|
+
const tip = `#${m.index} · compaction · replaced ${formatTokens(m.compaction.tokensBefore)} tokens · ${formatCost(m.cost)}`;
|
|
1842
|
+
return [{ i, kind: 'compaction', tip }];
|
|
1843
|
+
}
|
|
1844
|
+
const sa = m.subagent;
|
|
1845
|
+
if (sa) {
|
|
1846
|
+
const tip = `#${m.index} · ${sa.agent || 'subagent'}${sa.task ? ` · ${String(sa.task).slice(0, 80)}` : ''} · ${formatCost(m.cost)}`;
|
|
1847
|
+
return [{ i, kind: m.cost >= SUBAGENT_COSTLY_USD ? 'costlyAgent' : 'agent', tip }];
|
|
1848
|
+
}
|
|
1849
|
+
if (m.cacheWrite >= threshold) {
|
|
1850
|
+
return [{ i, kind: 'burst', tip: `#${m.index} · cache write ${formatTokens(m.cacheWrite)}` }];
|
|
1851
|
+
}
|
|
1852
|
+
return [];
|
|
1853
|
+
});
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
function clampRange(start, end, total) {
|
|
1857
|
+
const s = Math.max(0, Math.min(start, total - DETAIL_WINDOW_MIN));
|
|
1858
|
+
return { start: s, end: Math.max(s + DETAIL_WINDOW_MIN, Math.min(end, total)) };
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
function detailRangeFor(d) {
|
|
1862
|
+
const total = d.messages.length;
|
|
1863
|
+
const prev = detailRanges.get(d.id) || null;
|
|
1864
|
+
const size = prev ? prev.end - prev.start : DETAIL_WINDOW;
|
|
1865
|
+
const atTail = !prev || prev.end === prev.total;
|
|
1866
|
+
const start = atTail ? total - size : prev.start;
|
|
1867
|
+
const range = { total, ...clampRange(start, start + size, total) };
|
|
1868
|
+
detailRanges.set(d.id, range);
|
|
1869
|
+
return range;
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
function applyDetailRange(messages, range) {
|
|
1873
|
+
detailSlice = messages.slice(range.start, range.end);
|
|
1874
|
+
renderMessageRows(detailSlice, messages.length);
|
|
1875
|
+
const c = getChartColors();
|
|
1876
|
+
if (charts.cumulative && charts.tokenBreakdown) {
|
|
1877
|
+
charts.cumulative.data = cumulativeChartData(detailSlice, c);
|
|
1878
|
+
charts.tokenBreakdown.data = tokenBreakdownChartData(detailSlice, c);
|
|
1879
|
+
for (const chart of [charts.cumulative, charts.tokenBreakdown]) {
|
|
1880
|
+
markCompactions(chart, detailSlice, c);
|
|
1881
|
+
chart.update('none');
|
|
1882
|
+
}
|
|
1883
|
+
} else {
|
|
1884
|
+
renderCumulativeChart(detailSlice);
|
|
1885
|
+
renderTokenBreakdownChart(detailSlice);
|
|
1886
|
+
}
|
|
1887
|
+
syncSubagentShare(detailSlice, c);
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// The overview shares the brush's percentage coordinates, so it stretches with the track.
|
|
1891
|
+
const RANGE_OVERVIEW_SVG =
|
|
1892
|
+
'<svg class="rb-overview" viewBox="0 0 1000 100" preserveAspectRatio="none" aria-hidden="true"><polygon /><polyline /></svg>';
|
|
1893
|
+
|
|
1894
|
+
function rangeBrushHtml(total) {
|
|
1895
|
+
if (total <= DETAIL_WINDOW) return '';
|
|
1896
|
+
const presets = DETAIL_WINDOW_PRESETS.filter((n) => n < total)
|
|
1897
|
+
.map((n) => `<button type="button" class="rb-preset" data-size="${n}">${n}</button>`)
|
|
1898
|
+
.join('');
|
|
1899
|
+
return `
|
|
1900
|
+
<div class="range-brush" id="rangeBrush">
|
|
1901
|
+
<div class="rb-head">
|
|
1902
|
+
<span class="rb-label"><span class="rb-scope"></span><span class="rb-burst-count" hidden><i></i></span></span>
|
|
1903
|
+
<div class="rb-presets" role="group" aria-label="Window size">
|
|
1904
|
+
${presets}
|
|
1905
|
+
<button type="button" class="rb-preset" data-size="all">All</button>
|
|
1906
|
+
</div>
|
|
1907
|
+
</div>
|
|
1908
|
+
<div class="rb-track" tabindex="0" role="slider" aria-label="Visible message range"
|
|
1909
|
+
aria-valuemin="1" aria-valuemax="${esc(total)}">
|
|
1910
|
+
${RANGE_OVERVIEW_SVG}
|
|
1911
|
+
<div class="rb-bursts"></div>
|
|
1912
|
+
<div class="rb-window">
|
|
1913
|
+
<div class="rb-handle rb-handle-left" data-edge="start"></div>
|
|
1914
|
+
<div class="rb-handle rb-handle-right" data-edge="end"></div>
|
|
1915
|
+
</div>
|
|
1916
|
+
</div>
|
|
1917
|
+
</div>`;
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
function initRangeBrush(messages, range) {
|
|
1921
|
+
const root = document.getElementById('rangeBrush');
|
|
1922
|
+
if (!root) return;
|
|
1923
|
+
const total = messages.length;
|
|
1924
|
+
const q = (sel) => root.querySelector(sel);
|
|
1925
|
+
const track = q('.rb-track');
|
|
1926
|
+
const win = q('.rb-window');
|
|
1927
|
+
const scope = q('.rb-scope');
|
|
1928
|
+
const burstCount = q('.rb-burst-count');
|
|
1929
|
+
const presets = [...root.querySelectorAll('.rb-preset')].map((el) => ({
|
|
1930
|
+
el,
|
|
1931
|
+
size: el.dataset.size === 'all' ? total : Number(el.dataset.size),
|
|
1932
|
+
}));
|
|
1933
|
+
|
|
1934
|
+
drawRangeOverview(q('.rb-overview'), messages);
|
|
1935
|
+
|
|
1936
|
+
const markers = notableEvents(messages);
|
|
1937
|
+
const burstsEl = q('.rb-bursts');
|
|
1938
|
+
burstsEl.innerHTML = markers
|
|
1939
|
+
.map(
|
|
1940
|
+
(k) =>
|
|
1941
|
+
`<button type="button" class="rb-burst ${esc(MARKER_KINDS[k.kind].cls)}" data-i="${esc(String(k.i))}" style="left:${((k.i + 0.5) / total) * 100}%" title="${esc(k.tip)}" aria-label="${esc(k.tip)}"></button>`,
|
|
1942
|
+
)
|
|
1943
|
+
.join('');
|
|
1944
|
+
const burstEls = [...burstsEl.children].map((el, k) => ({ el, i: markers[k].i }));
|
|
1945
|
+
const tally = {};
|
|
1946
|
+
for (const k of markers) tally[k.kind] = (tally[k.kind] || 0) + 1;
|
|
1947
|
+
const counts = Object.entries(MARKER_KINDS)
|
|
1948
|
+
.filter(([kind]) => tally[kind])
|
|
1949
|
+
.map(([kind, { cls, label, suffix = '' }]) => {
|
|
1950
|
+
const n = tally[kind];
|
|
1951
|
+
return `<span class="${esc(cls)}"><i></i>${n} ${esc(label)}${n === 1 ? '' : 's'}${esc(suffix)}</span>`;
|
|
1952
|
+
});
|
|
1953
|
+
if (counts.length) {
|
|
1954
|
+
burstCount.hidden = false;
|
|
1955
|
+
burstCount.innerHTML = counts.join('<span class="rb-sep">·</span>');
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
const layout = () => {
|
|
1959
|
+
const size = range.end - range.start;
|
|
1960
|
+
win.style.left = `${(range.start / total) * 100}%`;
|
|
1961
|
+
win.style.width = `${(size / total) * 100}%`;
|
|
1962
|
+
const text =
|
|
1963
|
+
size === total
|
|
1964
|
+
? `All ${total} messages`
|
|
1965
|
+
: `Messages #${messages[range.start].index} – #${messages[range.end - 1].index} · ${size} of ${total}`;
|
|
1966
|
+
scope.textContent = text;
|
|
1967
|
+
track.setAttribute('aria-valuenow', String(range.start + 1));
|
|
1968
|
+
track.setAttribute('aria-valuetext', text);
|
|
1969
|
+
for (const p of presets) p.el.classList.toggle('on', p.size === size);
|
|
1970
|
+
for (const b of burstEls) b.el.classList.toggle('in', b.i >= range.start && b.i < range.end);
|
|
1971
|
+
};
|
|
1972
|
+
|
|
1973
|
+
// Pointer and wheel events outrun the frame rate, so range writes coalesce into one paint.
|
|
1974
|
+
let frame = 0;
|
|
1975
|
+
const setRange = (from, to) => {
|
|
1976
|
+
const next = clampRange(from, to, total);
|
|
1977
|
+
if (next.start === range.start && next.end === range.end) return;
|
|
1978
|
+
Object.assign(range, next);
|
|
1979
|
+
if (frame) return;
|
|
1980
|
+
frame = requestAnimationFrame(() => {
|
|
1981
|
+
frame = 0;
|
|
1982
|
+
layout();
|
|
1983
|
+
applyDetailRange(messages, range);
|
|
1984
|
+
});
|
|
1985
|
+
};
|
|
1986
|
+
const size = () => range.end - range.start;
|
|
1987
|
+
const step = () => Math.max(1, Math.round(size() / 10));
|
|
1988
|
+
const pan = (delta) => setRange(range.start + delta, range.end + delta);
|
|
1989
|
+
const centerOn = (i) => {
|
|
1990
|
+
const start = Math.max(0, Math.min(i - Math.floor(size() / 2), total - size()));
|
|
1991
|
+
setRange(start, start + size());
|
|
1992
|
+
};
|
|
1993
|
+
const resize = (n) => {
|
|
1994
|
+
const end = Math.min(total, Math.max(range.end, n));
|
|
1995
|
+
setRange(end - n, end);
|
|
1996
|
+
};
|
|
1997
|
+
|
|
1998
|
+
// Pointer capture keeps a fast drag alive after the cursor leaves the thin track. The rect is
|
|
1999
|
+
// read once per drag: reading it per move right after writing the window's style would thrash.
|
|
2000
|
+
track.addEventListener('pointerdown', (e) => {
|
|
2001
|
+
if (e.button !== 0) return;
|
|
2002
|
+
e.preventDefault();
|
|
2003
|
+
track.focus({ preventScroll: true });
|
|
2004
|
+
const rect = track.getBoundingClientRect();
|
|
2005
|
+
const perPx = total / rect.width;
|
|
2006
|
+
const edge = e.target.dataset?.edge;
|
|
2007
|
+
if (!edge && e.target !== win) centerOn(Math.round((e.clientX - rect.left) * perPx));
|
|
2008
|
+
const origin = { x: e.clientX, start: range.start, end: range.end };
|
|
2009
|
+
track.classList.add('dragging');
|
|
2010
|
+
track.setPointerCapture(e.pointerId);
|
|
2011
|
+
const move = (ev) => {
|
|
2012
|
+
const delta = Math.round((ev.clientX - origin.x) * perPx);
|
|
2013
|
+
if (edge === 'start') setRange(origin.start + delta, origin.end);
|
|
2014
|
+
else if (edge === 'end') setRange(origin.start, origin.end + delta);
|
|
2015
|
+
else {
|
|
2016
|
+
const n = origin.end - origin.start;
|
|
2017
|
+
const start = Math.max(0, Math.min(origin.start + delta, total - n));
|
|
2018
|
+
setRange(start, start + n);
|
|
2019
|
+
}
|
|
2020
|
+
};
|
|
2021
|
+
const up = () => {
|
|
2022
|
+
track.classList.remove('dragging');
|
|
2023
|
+
track.removeEventListener('pointermove', move);
|
|
2024
|
+
track.removeEventListener('pointerup', up);
|
|
2025
|
+
track.removeEventListener('pointercancel', up);
|
|
2026
|
+
};
|
|
2027
|
+
track.addEventListener('pointermove', move);
|
|
2028
|
+
track.addEventListener('pointerup', up);
|
|
2029
|
+
track.addEventListener('pointercancel', up);
|
|
2030
|
+
});
|
|
2031
|
+
|
|
2032
|
+
track.addEventListener(
|
|
2033
|
+
'wheel',
|
|
2034
|
+
(e) => {
|
|
2035
|
+
e.preventDefault();
|
|
2036
|
+
pan((e.deltaY || e.deltaX) > 0 ? step() : -step());
|
|
2037
|
+
},
|
|
2038
|
+
{ passive: false },
|
|
2039
|
+
);
|
|
2040
|
+
|
|
2041
|
+
track.addEventListener('keydown', (e) => {
|
|
2042
|
+
const n = e.shiftKey ? size() : step();
|
|
2043
|
+
if (e.key === 'ArrowLeft') pan(-n);
|
|
2044
|
+
else if (e.key === 'ArrowRight') pan(n);
|
|
2045
|
+
else if (e.key === 'Home') setRange(0, size());
|
|
2046
|
+
else if (e.key === 'End') setRange(total - size(), total);
|
|
2047
|
+
else return;
|
|
2048
|
+
e.preventDefault();
|
|
2049
|
+
e.stopPropagation();
|
|
2050
|
+
});
|
|
2051
|
+
|
|
2052
|
+
for (const p of presets) p.el.addEventListener('click', () => resize(p.size));
|
|
2053
|
+
|
|
2054
|
+
burstsEl.addEventListener('pointerdown', (e) => {
|
|
2055
|
+
const b = e.target.closest('.rb-burst');
|
|
2056
|
+
if (!b) return;
|
|
2057
|
+
e.stopPropagation();
|
|
2058
|
+
e.preventDefault();
|
|
2059
|
+
centerOn(Number(b.dataset.i));
|
|
2060
|
+
});
|
|
2061
|
+
|
|
2062
|
+
layout();
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
function drawRangeOverview(svg, messages) {
|
|
2066
|
+
const n = messages.length;
|
|
2067
|
+
const max = messages[n - 1]?.cumulativeCost || 1;
|
|
2068
|
+
const pts = messages.map((m, i) => `${(i / Math.max(1, n - 1)) * 1000},${100 - (m.cumulativeCost / max) * 96 - 2}`);
|
|
2069
|
+
svg.querySelector('polyline').setAttribute('points', pts.join(' '));
|
|
2070
|
+
svg.querySelector('polygon').setAttribute('points', `0,100 ${pts.join(' ')} 1000,100`);
|
|
824
2071
|
}
|
|
825
2072
|
|
|
826
2073
|
// #endregion
|
|
@@ -828,25 +2075,63 @@ function renderTokenBreakdownChart(messages) {
|
|
|
828
2075
|
// #region THEME
|
|
829
2076
|
|
|
830
2077
|
const THEME_COLOR_TO_VAR = {
|
|
831
|
-
bgDeep: '--bg-deep',
|
|
2078
|
+
bgDeep: '--bg-deep',
|
|
2079
|
+
bgSurface: '--bg-surface',
|
|
2080
|
+
bgElevated: '--bg-elevated',
|
|
2081
|
+
bgHover: '--bg-hover',
|
|
832
2082
|
border: '--border',
|
|
833
|
-
textPrimary: '--text-primary',
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
2083
|
+
textPrimary: '--text-primary',
|
|
2084
|
+
textSecondary: '--text-secondary',
|
|
2085
|
+
textTertiary: '--text-tertiary',
|
|
2086
|
+
textMuted: '--text-muted',
|
|
2087
|
+
accent: '--accent',
|
|
2088
|
+
accentText: '--accent-text',
|
|
2089
|
+
success: '--success',
|
|
2090
|
+
warning: '--warning',
|
|
2091
|
+
error: '--error',
|
|
837
2092
|
};
|
|
838
2093
|
|
|
839
2094
|
let _themeCache = { light: null, dark: null };
|
|
2095
|
+
let _themeDefault = { light: null, dark: null };
|
|
2096
|
+
let _themeFamilies = [];
|
|
2097
|
+
const COLOR_THEME_KEY = 'pi-cost:color-theme';
|
|
2098
|
+
|
|
2099
|
+
// Shown in a pi-kanban pane (?embed=1), pi-cost takes the board's colors over postMessage
|
|
2100
|
+
// (pi-companion:theme), saves no theme choice, and hides its own theme controls (body.embed).
|
|
2101
|
+
const EMBED = new URLSearchParams(location.search).get('embed') === '1' && window.parent !== window;
|
|
2102
|
+
let _embedTheme = null;
|
|
2103
|
+
|
|
2104
|
+
function onEmbedMessage(e) {
|
|
2105
|
+
if (e.source !== window.parent || e.data?.type !== 'pi-companion:theme') return;
|
|
2106
|
+
const colors = {};
|
|
2107
|
+
for (const k of Object.keys(THEME_COLOR_TO_VAR)) {
|
|
2108
|
+
const v = e.data.colors?.[k];
|
|
2109
|
+
if (typeof v === 'string' && CSS.supports('color', v)) colors[k] = v;
|
|
2110
|
+
}
|
|
2111
|
+
const light = e.data.mode === 'light';
|
|
2112
|
+
_embedTheme = { mode: light ? 'light' : 'dark', colors };
|
|
2113
|
+
setThemeMode(light);
|
|
2114
|
+
repaint();
|
|
2115
|
+
}
|
|
840
2116
|
|
|
841
|
-
function
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
2117
|
+
function startEmbed() {
|
|
2118
|
+
document.body.classList.add('embed');
|
|
2119
|
+
window.addEventListener('message', onEmbedMessage);
|
|
2120
|
+
window.parent.postMessage({ type: 'pi-companion:ready' }, '*');
|
|
845
2121
|
}
|
|
846
2122
|
|
|
847
|
-
function
|
|
848
|
-
|
|
849
|
-
|
|
2123
|
+
function setThemeMode(light) {
|
|
2124
|
+
document.body.classList.toggle('light', light);
|
|
2125
|
+
document.body.classList.toggle('dark-forced', !light);
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
function repaint() {
|
|
2129
|
+
_chartColors = null;
|
|
2130
|
+
applyCurrentThemeColors();
|
|
2131
|
+
updateThemeIcon();
|
|
2132
|
+
renderThemeMenu();
|
|
2133
|
+
lastRenderHash = {};
|
|
2134
|
+
renderCurrentView();
|
|
850
2135
|
}
|
|
851
2136
|
|
|
852
2137
|
function isLightTheme() {
|
|
@@ -858,28 +2143,25 @@ function isLightTheme() {
|
|
|
858
2143
|
function applyTheme(theme) {
|
|
859
2144
|
if (!theme || !theme.colors) return;
|
|
860
2145
|
const root = document.body.style;
|
|
2146
|
+
const c = {};
|
|
861
2147
|
for (const [k, cssVar] of Object.entries(THEME_COLOR_TO_VAR)) {
|
|
862
|
-
|
|
2148
|
+
const v = theme.colors[k];
|
|
2149
|
+
if (typeof v === 'string' && CSS.supports('color', v)) c[k] = v;
|
|
2150
|
+
if (c[k]) root.setProperty(cssVar, c[k]);
|
|
2151
|
+
else root.removeProperty(cssVar);
|
|
863
2152
|
}
|
|
864
|
-
const c = theme.colors;
|
|
865
2153
|
const isLight = theme.mode === 'light';
|
|
866
2154
|
const dimAlpha = isLight ? 0.15 : 0.18;
|
|
867
|
-
const
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
set('--
|
|
875
|
-
set('--
|
|
876
|
-
set('--
|
|
877
|
-
set('--accent-soft', _rgba(c.accent, accentSoftAlpha));
|
|
878
|
-
set('--accent-tag', _rgba(c.accent, accentTagAlpha));
|
|
879
|
-
set('--chart-fill', _rgba(c.accent, chartFillAlpha));
|
|
880
|
-
set('--success-dim', _rgba(c.success, dimAlpha));
|
|
881
|
-
set('--warning-dim', _rgba(c.warning, dimAlpha));
|
|
882
|
-
set('--error-dim', _rgba(c.error, dimAlpha));
|
|
2155
|
+
const set = (name, color, alpha) => (color ? root.setProperty(name, withAlpha(color, alpha)) : root.removeProperty(name));
|
|
2156
|
+
set('--accent-dim', c.accent, isLight ? 0.18 : 0.22);
|
|
2157
|
+
set('--accent-glow', c.accent, isLight ? 0.5 : 0.55);
|
|
2158
|
+
set('--accent-faint', c.accent, isLight ? 0.06 : 0.05);
|
|
2159
|
+
set('--accent-soft', c.accent, 0.12);
|
|
2160
|
+
set('--accent-tag', c.accent, 0.15);
|
|
2161
|
+
set('--chart-fill', c.accent, isLight ? 0.5 : 0.6);
|
|
2162
|
+
set('--success-dim', c.success, dimAlpha);
|
|
2163
|
+
set('--warning-dim', c.warning, dimAlpha);
|
|
2164
|
+
set('--error-dim', c.error, dimAlpha);
|
|
883
2165
|
}
|
|
884
2166
|
|
|
885
2167
|
async function loadActiveThemes() {
|
|
@@ -887,13 +2169,75 @@ async function loadActiveThemes() {
|
|
|
887
2169
|
const res = await fetch('/api/themes');
|
|
888
2170
|
if (res.ok) {
|
|
889
2171
|
const data = await res.json();
|
|
890
|
-
|
|
2172
|
+
_themeDefault = { light: data.light, dark: data.dark };
|
|
2173
|
+
_themeFamilies = data.families || [];
|
|
2174
|
+
const stored = EMBED ? null : storageGet('localStorage', COLOR_THEME_KEY);
|
|
2175
|
+
if (stored && !selectColorTheme(stored)) storageRemove('localStorage', COLOR_THEME_KEY);
|
|
2176
|
+
if (!stored) selectColorTheme(null);
|
|
891
2177
|
}
|
|
892
2178
|
} catch {}
|
|
893
2179
|
}
|
|
894
2180
|
|
|
2181
|
+
let _colorThemeId = '';
|
|
2182
|
+
|
|
2183
|
+
function selectColorTheme(id) {
|
|
2184
|
+
const fam = id && _themeFamilies.find((f) => f.id === id);
|
|
2185
|
+
_themeCache = fam ? { light: fam.light, dark: fam.dark } : _themeDefault;
|
|
2186
|
+
_colorThemeId = fam ? fam.id : '';
|
|
2187
|
+
return _colorThemeId;
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
function setColorTheme(id) {
|
|
2191
|
+
if (EMBED) return;
|
|
2192
|
+
if (selectColorTheme(id)) storageSet('localStorage', COLOR_THEME_KEY, _colorThemeId);
|
|
2193
|
+
else storageRemove('localStorage', COLOR_THEME_KEY);
|
|
2194
|
+
closeThemeMenu();
|
|
2195
|
+
repaint();
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
function swatchStyle(t) {
|
|
2199
|
+
const c = t?.colors || {};
|
|
2200
|
+
const vars = { '--sw-bg': c.bgDeep, '--sw-accent': c.accent, '--sw-ink': c.textPrimary, '--sw-border': c.border };
|
|
2201
|
+
return Object.entries(vars)
|
|
2202
|
+
.filter(([, v]) => typeof v === 'string' && CSS.supports('color', v))
|
|
2203
|
+
.map(([k, v]) => `${k}:${v}`)
|
|
2204
|
+
.join(';');
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
function renderThemeMenu() {
|
|
2208
|
+
const menu = document.getElementById('themeMenu');
|
|
2209
|
+
if (!menu || EMBED) return;
|
|
2210
|
+
const mode = isLightTheme() ? 'light' : 'dark';
|
|
2211
|
+
const items = [{ id: '', displayName: 'Default', light: _themeDefault.light, dark: _themeDefault.dark }, ..._themeFamilies];
|
|
2212
|
+
menu.innerHTML = items
|
|
2213
|
+
.map((f) => {
|
|
2214
|
+
const on = f.id === _colorThemeId;
|
|
2215
|
+
return `<button class="theme-menu-item${on ? ' on' : ''}" role="menuitemradio" aria-checked="${on ? 'true' : 'false'}" style="${esc(swatchStyle(f[mode]))}" onclick="event.stopPropagation();setColorTheme('${escAttrJs(f.id)}')" title="${esc(f[mode]?.displayName || f.displayName)}">
|
|
2216
|
+
<span class="theme-swatch"><i class="sw-bg"></i><i class="sw-accent"></i><i class="sw-ink"></i></span>${esc(f.displayName)}
|
|
2217
|
+
</button>`;
|
|
2218
|
+
})
|
|
2219
|
+
.join('');
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
function isThemeMenuOpen() {
|
|
2223
|
+
return !!document.getElementById('themeMenu')?.classList.contains('open');
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
function closeThemeMenu() {
|
|
2227
|
+
document.getElementById('themeMenu')?.classList.remove('open');
|
|
2228
|
+
document.removeEventListener('click', closeThemeMenu);
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
function toggleThemeMenu(e) {
|
|
2232
|
+
if (e?.target.closest?.('.theme-menu')) return;
|
|
2233
|
+
e?.stopPropagation();
|
|
2234
|
+
if (isThemeMenuOpen()) return closeThemeMenu();
|
|
2235
|
+
document.getElementById('themeMenu')?.classList.add('open');
|
|
2236
|
+
document.addEventListener('click', closeThemeMenu);
|
|
2237
|
+
}
|
|
2238
|
+
|
|
895
2239
|
function applyCurrentThemeColors() {
|
|
896
|
-
const t = isLightTheme() ? _themeCache.light : _themeCache.dark;
|
|
2240
|
+
const t = _embedTheme || (isLightTheme() ? _themeCache.light : _themeCache.dark);
|
|
897
2241
|
if (t) applyTheme(t);
|
|
898
2242
|
}
|
|
899
2243
|
|
|
@@ -906,38 +2250,313 @@ function updateThemeIcon() {
|
|
|
906
2250
|
}
|
|
907
2251
|
|
|
908
2252
|
function toggleTheme() {
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
2253
|
+
if (EMBED) return;
|
|
2254
|
+
const nextLight = !isLightTheme();
|
|
2255
|
+
setThemeMode(nextLight);
|
|
2256
|
+
storageSet('localStorage', 'theme', nextLight ? 'light' : 'dark');
|
|
2257
|
+
repaint();
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
function loadTheme() {
|
|
2261
|
+
const saved = EMBED ? null : storageGet('localStorage', 'theme');
|
|
2262
|
+
if (saved === 'light' || saved === 'dark') setThemeMode(saved === 'light');
|
|
2263
|
+
updateThemeIcon();
|
|
2264
|
+
loadActiveThemes().then(repaint);
|
|
2265
|
+
window.matchMedia?.('(prefers-color-scheme: light)').addEventListener?.('change', repaint);
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
// #endregion
|
|
2269
|
+
|
|
2270
|
+
// #region DATE_PICKER
|
|
2271
|
+
|
|
2272
|
+
// Presets are sticky: the preset is stored, and the server recomputes its window per request.
|
|
2273
|
+
const RANGE_PRESETS = [
|
|
2274
|
+
{ value: 'today', label: 'Today' },
|
|
2275
|
+
{ value: '24h', label: 'Last 24h' },
|
|
2276
|
+
{ value: '3', label: '3 Days' },
|
|
2277
|
+
{ value: '7', label: '7 Days' },
|
|
2278
|
+
{ value: '30', label: '30 Days' },
|
|
2279
|
+
{ value: '90', label: '90 Days' },
|
|
2280
|
+
{ value: '365', label: '1 Year', cardLabel: '365 Days' },
|
|
2281
|
+
];
|
|
2282
|
+
const RANGE_DOW = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
|
|
2283
|
+
// Kept in step with the same breakpoint in style.css. One live MediaQueryList, so the listener
|
|
2284
|
+
// added on open is the one removed on close.
|
|
2285
|
+
const oneMonthMql = window.matchMedia('(max-width: 760px)');
|
|
2286
|
+
const RANGE_ARROWS = {
|
|
2287
|
+
prev: '<path d="M6.5 1L2.5 5l4 4"/>',
|
|
2288
|
+
next: '<path d="M3.5 1l4 4-4 4"/>',
|
|
2289
|
+
};
|
|
2290
|
+
|
|
2291
|
+
// Non-null only while the popover is open: it is both the draft and the open flag.
|
|
2292
|
+
let rangeDraft = null;
|
|
2293
|
+
|
|
2294
|
+
function isRangePickerOpen() {
|
|
2295
|
+
return !!rangeDraft;
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
function oneMonthMode() {
|
|
2299
|
+
return oneMonthMql.matches;
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
// What a preset covers, for highlighting only. '24h' straddles the two calendar days it touches.
|
|
2303
|
+
function rangePresetSpan(value) {
|
|
2304
|
+
const today = todayIso();
|
|
2305
|
+
if (value === '24h') return { from: isoShift(today, -1), to: today };
|
|
2306
|
+
const days = Number(value) || 1;
|
|
2307
|
+
return { from: days <= 1 ? today : isoShift(today, -(days - 1)), to: today };
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
function buildRangePresets() {
|
|
2311
|
+
const host = document.getElementById('rangePresets');
|
|
2312
|
+
if (!host) return;
|
|
2313
|
+
for (const p of RANGE_PRESETS) {
|
|
2314
|
+
const b = document.createElement('button');
|
|
2315
|
+
b.type = 'button';
|
|
2316
|
+
b.className = 'range-preset';
|
|
2317
|
+
b.dataset.value = p.value;
|
|
2318
|
+
b.textContent = p.label;
|
|
2319
|
+
b.addEventListener('click', () => {
|
|
2320
|
+
closeRangePicker();
|
|
2321
|
+
applyRange(presetRange(p.value));
|
|
2322
|
+
});
|
|
2323
|
+
host.appendChild(b);
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
function renderRangeTrigger() {
|
|
2328
|
+
const el = document.getElementById('rangeTriggerLabel');
|
|
2329
|
+
if (el) el.textContent = rangeTriggerLabel(dateRange);
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
function toggleRangePicker(e) {
|
|
2333
|
+
e?.stopPropagation();
|
|
2334
|
+
if (isRangePickerOpen()) closeRangePicker();
|
|
2335
|
+
else openRangePicker();
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
function openRangePicker() {
|
|
2339
|
+
const applied = normalizeRange(dateRange) || presetRange(DEFAULT_RANGE_PRESET);
|
|
2340
|
+
const span = applied.kind === 'custom' ? { from: applied.from, to: applied.to } : rangePresetSpan(applied.value);
|
|
2341
|
+
// The left pane shows the month before the window's end, so today sits in the right one.
|
|
2342
|
+
const [ay, am] = isoParts(span.to);
|
|
2343
|
+
const first = new Date(ay, am - 1 - (oneMonthMode() ? 0 : 1), 1);
|
|
2344
|
+
rangeDraft = {
|
|
2345
|
+
from: span.from,
|
|
2346
|
+
to: span.to,
|
|
2347
|
+
preset: applied.kind === 'custom' ? null : applied.value,
|
|
2348
|
+
picking: 'from',
|
|
2349
|
+
hover: null,
|
|
2350
|
+
month: { y: first.getFullYear(), m: first.getMonth() },
|
|
2351
|
+
};
|
|
2352
|
+
document.getElementById('rangePop').classList.add('open');
|
|
2353
|
+
document.getElementById('rangeTrigger').classList.add('on');
|
|
2354
|
+
// Capture phase: a day click re-renders the grid and detaches its own button, so by the bubble
|
|
2355
|
+
// phase the target is no longer inside #rangePicker and would read as an outside click.
|
|
2356
|
+
document.addEventListener('click', onRangePickerOutsideClick, true);
|
|
2357
|
+
const months = document.getElementById('rangeMonths');
|
|
2358
|
+
months.addEventListener('click', onRangeDayClick);
|
|
2359
|
+
months.addEventListener('mouseover', onRangeDayHover);
|
|
2360
|
+
oneMonthMql.addEventListener('change', renderRangePicker);
|
|
2361
|
+
renderRangePicker();
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
function closeRangePicker() {
|
|
2365
|
+
if (!rangeDraft) return;
|
|
2366
|
+
rangeDraft = null;
|
|
2367
|
+
document.getElementById('rangePop').classList.remove('open');
|
|
2368
|
+
document.getElementById('rangeTrigger').classList.remove('on');
|
|
2369
|
+
document.removeEventListener('click', onRangePickerOutsideClick, true);
|
|
2370
|
+
const months = document.getElementById('rangeMonths');
|
|
2371
|
+
months.removeEventListener('click', onRangeDayClick);
|
|
2372
|
+
months.removeEventListener('mouseover', onRangeDayHover);
|
|
2373
|
+
oneMonthMql.removeEventListener('change', renderRangePicker);
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
function onRangeDayClick(e) {
|
|
2377
|
+
const iso = e.target.dataset?.iso;
|
|
2378
|
+
if (iso) pickRangeDay(iso);
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
function onRangeDayHover(e) {
|
|
2382
|
+
const iso = e.target.dataset?.iso;
|
|
2383
|
+
if (iso && rangeDraft?.from && !rangeDraft.to && rangeDraft.hover !== iso) {
|
|
2384
|
+
rangeDraft.hover = iso;
|
|
2385
|
+
paintRangeSpan();
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
function onRangePickerOutsideClick(e) {
|
|
2390
|
+
if (!document.getElementById('rangePicker').contains(e.target)) closeRangePicker();
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
function aimRangePicker(which) {
|
|
2394
|
+
if (!rangeDraft) return;
|
|
2395
|
+
rangeDraft.picking = which;
|
|
2396
|
+
renderRangePicker();
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
function applyRangeDraft() {
|
|
2400
|
+
const d = rangeDraft;
|
|
2401
|
+
if (!d?.from || !d.to) return;
|
|
2402
|
+
const next = { kind: 'custom', from: d.from, to: d.to };
|
|
2403
|
+
closeRangePicker();
|
|
2404
|
+
applyRange(next);
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
function pickRangeDay(iso) {
|
|
2408
|
+
const d = rangeDraft;
|
|
2409
|
+
d.preset = null;
|
|
2410
|
+
if (d.picking === 'from' || !d.from) {
|
|
2411
|
+
d.from = iso;
|
|
2412
|
+
d.to = null;
|
|
2413
|
+
d.picking = 'to';
|
|
2414
|
+
} else if (iso < d.from) {
|
|
2415
|
+
d.to = d.from;
|
|
2416
|
+
d.from = iso;
|
|
2417
|
+
d.picking = 'from';
|
|
914
2418
|
} else {
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
2419
|
+
d.to = iso;
|
|
2420
|
+
d.picking = 'from';
|
|
2421
|
+
}
|
|
2422
|
+
d.hover = null;
|
|
2423
|
+
renderRangePicker();
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
function rangeMonthStep(step) {
|
|
2427
|
+
const c = new Date(rangeDraft.month.y, rangeDraft.month.m + step, 1);
|
|
2428
|
+
rangeDraft.month = { y: c.getFullYear(), m: c.getMonth() };
|
|
2429
|
+
renderRangePicker();
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
// There is no cost data ahead of today, so the calendar stops at the current month.
|
|
2433
|
+
function rangeNextDisabled() {
|
|
2434
|
+
const last = new Date(rangeDraft.month.y, rangeDraft.month.m + (oneMonthMode() ? 0 : 1), 1);
|
|
2435
|
+
const now = new Date();
|
|
2436
|
+
return last >= new Date(now.getFullYear(), now.getMonth(), 1);
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
function rangeDraftSpan() {
|
|
2440
|
+
const d = rangeDraft;
|
|
2441
|
+
if (!d?.from) return [null, null];
|
|
2442
|
+
if (d.to) return [d.from, d.to];
|
|
2443
|
+
if (!d.hover) return [d.from, d.from];
|
|
2444
|
+
return d.hover < d.from ? [d.hover, d.from] : [d.from, d.hover];
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
// Sole owner of the four span classes, so a hover repaint toggles them in place instead of
|
|
2448
|
+
// rebuilding both grids.
|
|
2449
|
+
function paintRangeSpan() {
|
|
2450
|
+
const [a, b] = rangeDraftSpan();
|
|
2451
|
+
for (const btn of document.querySelectorAll('#rangeMonths .range-day[data-iso]')) {
|
|
2452
|
+
const iso = btn.dataset.iso;
|
|
2453
|
+
const inSpan = !!(a && b && iso >= a && iso <= b);
|
|
2454
|
+
const edge = inSpan && (iso === a || iso === b);
|
|
2455
|
+
btn.classList.toggle('edge', edge);
|
|
2456
|
+
btn.classList.toggle('in', inSpan && !edge);
|
|
2457
|
+
btn.classList.toggle('start', inSpan && !edge && isoShift(iso, -1) === a);
|
|
2458
|
+
btn.classList.toggle('end', inSpan && !edge && isoShift(iso, 1) === b);
|
|
918
2459
|
}
|
|
919
|
-
applyCurrentThemeColors();
|
|
920
|
-
updateThemeIcon();
|
|
921
|
-
lastRenderHash = {};
|
|
922
|
-
renderCurrentView();
|
|
923
2460
|
}
|
|
924
2461
|
|
|
925
|
-
function
|
|
926
|
-
const
|
|
927
|
-
if (
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
document.body.classList.remove('light');
|
|
932
|
-
document.body.classList.add('dark-forced');
|
|
2462
|
+
function renderRangePicker() {
|
|
2463
|
+
const d = rangeDraft;
|
|
2464
|
+
if (!d) return;
|
|
2465
|
+
|
|
2466
|
+
for (const b of document.querySelectorAll('#rangePresets .range-preset')) {
|
|
2467
|
+
b.classList.toggle('on', b.dataset.value === d.preset);
|
|
933
2468
|
}
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
2469
|
+
|
|
2470
|
+
const fromBox = document.getElementById('rangeFromBox');
|
|
2471
|
+
const toBox = document.getElementById('rangeToBox');
|
|
2472
|
+
fromBox.textContent = d.from ? isoDayLabel(d.from) : '—';
|
|
2473
|
+
toBox.textContent = d.to ? isoDayLabel(d.to) : '—';
|
|
2474
|
+
fromBox.classList.toggle('empty', !d.from);
|
|
2475
|
+
toBox.classList.toggle('empty', !d.to);
|
|
2476
|
+
fromBox.classList.toggle('active', d.picking === 'from');
|
|
2477
|
+
toBox.classList.toggle('active', d.picking === 'to');
|
|
2478
|
+
|
|
2479
|
+
const [a, b] = rangeDraftSpan();
|
|
2480
|
+
const days = d.from && d.to ? isoDayCount(a, b) : 0;
|
|
2481
|
+
document.getElementById('rangeSummary').textContent = days ? `${days} ${days === 1 ? 'day' : 'days'}` : 'pick an end date';
|
|
2482
|
+
document.getElementById('rangeApply').disabled = !(d.from && d.to);
|
|
2483
|
+
|
|
2484
|
+
const host = document.getElementById('rangeMonths');
|
|
2485
|
+
host.innerHTML = '';
|
|
2486
|
+
const single = oneMonthMode();
|
|
2487
|
+
const first = new Date(d.month.y, d.month.m, 1);
|
|
2488
|
+
host.appendChild(buildRangeMonth(first.getFullYear(), first.getMonth(), { prev: true, next: single }));
|
|
2489
|
+
if (!single) {
|
|
2490
|
+
const second = new Date(d.month.y, d.month.m + 1, 1);
|
|
2491
|
+
host.appendChild(buildRangeMonth(second.getFullYear(), second.getMonth(), { prev: false, next: true }));
|
|
2492
|
+
}
|
|
2493
|
+
paintRangeSpan();
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
function rangeNavButton(dir) {
|
|
2497
|
+
const step = dir === 'next' ? 1 : -1;
|
|
2498
|
+
const btn = document.createElement('button');
|
|
2499
|
+
btn.type = 'button';
|
|
2500
|
+
btn.className = 'range-nav';
|
|
2501
|
+
btn.innerHTML = `<svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">${RANGE_ARROWS[dir]}</svg>`;
|
|
2502
|
+
if (step > 0) btn.disabled = rangeNextDisabled();
|
|
2503
|
+
btn.addEventListener('click', () => rangeMonthStep(step));
|
|
2504
|
+
return btn;
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
function rangeNavGap() {
|
|
2508
|
+
const gap = document.createElement('span');
|
|
2509
|
+
gap.className = 'range-nav-gap';
|
|
2510
|
+
return gap;
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
function buildRangeMonth(y, m, nav) {
|
|
2514
|
+
const wrap = document.createElement('div');
|
|
2515
|
+
wrap.className = 'range-cal';
|
|
2516
|
+
|
|
2517
|
+
const head = document.createElement('div');
|
|
2518
|
+
head.className = 'range-cal-head';
|
|
2519
|
+
head.appendChild(nav.prev ? rangeNavButton('prev') : rangeNavGap());
|
|
2520
|
+
const titleEl = document.createElement('span');
|
|
2521
|
+
titleEl.className = 'range-cal-title';
|
|
2522
|
+
titleEl.textContent = `${new Date(y, m, 1).toLocaleDateString(undefined, { month: 'short' }).toUpperCase()} ${y}`;
|
|
2523
|
+
head.appendChild(titleEl);
|
|
2524
|
+
head.appendChild(nav.next ? rangeNavButton('next') : rangeNavGap());
|
|
2525
|
+
wrap.appendChild(head);
|
|
2526
|
+
|
|
2527
|
+
const dow = document.createElement('div');
|
|
2528
|
+
dow.className = 'range-dow';
|
|
2529
|
+
dow.innerHTML = RANGE_DOW.map((d) => `<span>${d}</span>`).join('');
|
|
2530
|
+
wrap.appendChild(dow);
|
|
2531
|
+
|
|
2532
|
+
const grid = document.createElement('div');
|
|
2533
|
+
grid.className = 'range-grid';
|
|
2534
|
+
const lead = (new Date(y, m, 1).getDay() + 6) % 7;
|
|
2535
|
+
const dayCount = new Date(y, m + 1, 0).getDate();
|
|
2536
|
+
const today = todayIso();
|
|
2537
|
+
|
|
2538
|
+
for (let i = 0; i < lead; i++) {
|
|
2539
|
+
const pad = document.createElement('span');
|
|
2540
|
+
pad.className = 'range-day pad';
|
|
2541
|
+
grid.appendChild(pad);
|
|
2542
|
+
}
|
|
2543
|
+
for (let day = 1; day <= dayCount; day++) {
|
|
2544
|
+
const iso = isoDate(new Date(y, m, day));
|
|
2545
|
+
const btn = document.createElement('button');
|
|
2546
|
+
btn.type = 'button';
|
|
2547
|
+
btn.className = 'range-day';
|
|
2548
|
+
btn.textContent = day;
|
|
2549
|
+
if (iso === today) btn.classList.add('today');
|
|
2550
|
+
if (iso > today) {
|
|
2551
|
+
btn.classList.add('off');
|
|
2552
|
+
btn.disabled = true;
|
|
2553
|
+
} else {
|
|
2554
|
+
btn.dataset.iso = iso;
|
|
2555
|
+
}
|
|
2556
|
+
grid.appendChild(btn);
|
|
2557
|
+
}
|
|
2558
|
+
wrap.appendChild(grid);
|
|
2559
|
+
return wrap;
|
|
941
2560
|
}
|
|
942
2561
|
|
|
943
2562
|
// #endregion
|
|
@@ -962,24 +2581,20 @@ async function navigate(view, params, urlMode = 'push') {
|
|
|
962
2581
|
if (params?.projectName) currentProjectName = params.projectName;
|
|
963
2582
|
if (params?.session) currentSessionId = params.session;
|
|
964
2583
|
|
|
965
|
-
if (view
|
|
2584
|
+
if (TOP_VIEWS.includes(view)) {
|
|
966
2585
|
currentProjectDir = null;
|
|
967
2586
|
currentProjectName = null;
|
|
968
2587
|
currentSessionId = null;
|
|
969
2588
|
}
|
|
970
|
-
if (view === 'sessions')
|
|
971
|
-
currentSessionId = null;
|
|
972
|
-
}
|
|
2589
|
+
if (view === 'sessions') currentSessionId = null;
|
|
973
2590
|
|
|
974
|
-
|
|
975
|
-
setActiveNav(navView);
|
|
2591
|
+
setActiveNav(TOP_VIEWS.includes(view) ? view : 'overview');
|
|
976
2592
|
updateUrl(urlMode);
|
|
977
2593
|
await loadAndRender(view);
|
|
978
2594
|
if (view !== 'detail') selectRow(0);
|
|
979
2595
|
}
|
|
980
2596
|
|
|
981
2597
|
async function navigateToSessions(projectDir, name) {
|
|
982
|
-
if (currentView === 'overview' || currentView === 'projects') parentView = currentView;
|
|
983
2598
|
lastSelectedProject = projectDir;
|
|
984
2599
|
currentProjectDir = projectDir;
|
|
985
2600
|
currentProjectName = name;
|
|
@@ -996,20 +2611,26 @@ async function navigateToDetail(sessionId) {
|
|
|
996
2611
|
|
|
997
2612
|
function sortBy(field) {
|
|
998
2613
|
const s = currentSort();
|
|
2614
|
+
if (!s) return;
|
|
999
2615
|
if (s.field === field) s.order = s.order === 'desc' ? 'asc' : 'desc';
|
|
1000
|
-
else {
|
|
2616
|
+
else {
|
|
2617
|
+
s.field = field;
|
|
2618
|
+
s.order = 'desc';
|
|
2619
|
+
}
|
|
1001
2620
|
lastRenderHash[currentView] = null;
|
|
1002
2621
|
saveSort();
|
|
1003
2622
|
updateUrl('replace');
|
|
1004
2623
|
if (currentView === 'overview') renderOverview();
|
|
1005
2624
|
else if (currentView === 'sessions') renderSessions();
|
|
1006
|
-
else renderProjects();
|
|
1007
2625
|
}
|
|
1008
2626
|
|
|
1009
|
-
|
|
1010
|
-
|
|
2627
|
+
// The one entry point for a range change. The renderers are hash-gated, so the hash reset is what
|
|
2628
|
+
// makes them repaint the new window.
|
|
2629
|
+
async function applyRange(next) {
|
|
2630
|
+
dateRange = next;
|
|
1011
2631
|
saveDateRange(dateRange);
|
|
1012
2632
|
lastRenderHash = {};
|
|
2633
|
+
renderRangeTrigger();
|
|
1013
2634
|
updateUrl('replace');
|
|
1014
2635
|
const t = showToast(`Recalculating for ${rangeLabel(dateRange)}...`, true);
|
|
1015
2636
|
await loadAndRender(currentView);
|
|
@@ -1024,14 +2645,14 @@ async function refreshData() {
|
|
|
1024
2645
|
const minWait = new Promise((r) => setTimeout(r, 250));
|
|
1025
2646
|
try {
|
|
1026
2647
|
await fetch('/api/refresh', { method: 'POST' });
|
|
2648
|
+
dataCache.clear();
|
|
1027
2649
|
forceRefresh = true;
|
|
1028
2650
|
lastRenderHash = {};
|
|
1029
|
-
overviewData = projectsData = sessionsData = sessionDetailData = null;
|
|
1030
2651
|
await loadAndRender(currentView);
|
|
1031
|
-
forceRefresh = false;
|
|
1032
2652
|
await minWait;
|
|
1033
2653
|
showToast('Data refreshed', false, 'success');
|
|
1034
2654
|
} finally {
|
|
2655
|
+
forceRefresh = false;
|
|
1035
2656
|
dismissToast(t);
|
|
1036
2657
|
btn.classList.remove('loading');
|
|
1037
2658
|
btn.disabled = false;
|
|
@@ -1045,9 +2666,7 @@ async function loadAndRender(view) {
|
|
|
1045
2666
|
showView(`${view}-view`);
|
|
1046
2667
|
|
|
1047
2668
|
const viewEl = document.getElementById(`${view}-view`);
|
|
1048
|
-
if (viewEl && !viewEl.querySelector('.dashboard-content'))
|
|
1049
|
-
viewEl.innerHTML = '<div class="loading-state"><div class="loading-spinner"></div><span>Loading...</span></div>';
|
|
1050
|
-
}
|
|
2669
|
+
if (viewEl && !viewEl.querySelector('.dashboard-content')) viewEl.innerHTML = skeletonHtml(view);
|
|
1051
2670
|
|
|
1052
2671
|
try {
|
|
1053
2672
|
switch (view) {
|
|
@@ -1056,13 +2675,17 @@ async function loadAndRender(view) {
|
|
|
1056
2675
|
if (myNav !== navCounter) return;
|
|
1057
2676
|
renderOverview();
|
|
1058
2677
|
break;
|
|
1059
|
-
case '
|
|
1060
|
-
await
|
|
2678
|
+
case 'insights':
|
|
2679
|
+
await fetchInsights();
|
|
1061
2680
|
if (myNav !== navCounter) return;
|
|
1062
|
-
|
|
2681
|
+
renderInsights();
|
|
1063
2682
|
break;
|
|
1064
2683
|
case 'sessions':
|
|
1065
2684
|
if (currentProjectDir) {
|
|
2685
|
+
if (!sessionsData) {
|
|
2686
|
+
lastRenderHash.sessions = null;
|
|
2687
|
+
renderSessions();
|
|
2688
|
+
}
|
|
1066
2689
|
await fetchSessions(currentProjectDir);
|
|
1067
2690
|
if (myNav !== navCounter) return;
|
|
1068
2691
|
renderSessions();
|
|
@@ -1070,6 +2693,10 @@ async function loadAndRender(view) {
|
|
|
1070
2693
|
break;
|
|
1071
2694
|
case 'detail':
|
|
1072
2695
|
if (currentSessionId) {
|
|
2696
|
+
if (!sessionDetailData) {
|
|
2697
|
+
lastRenderHash.detail = null;
|
|
2698
|
+
renderDetail();
|
|
2699
|
+
}
|
|
1073
2700
|
await fetchSessionDetail(currentSessionId);
|
|
1074
2701
|
if (myNav !== navCounter) return;
|
|
1075
2702
|
renderDetail();
|
|
@@ -1080,17 +2707,29 @@ async function loadAndRender(view) {
|
|
|
1080
2707
|
} catch (err) {
|
|
1081
2708
|
if (myNav !== navCounter) return;
|
|
1082
2709
|
console.error(`Failed to load ${view}:`, err);
|
|
2710
|
+
// The cursor comes back from sessionStorage, so it can point at a session another agent dir
|
|
2711
|
+
// never had. A 404 there is a stale cursor: drop it and land on the overview.
|
|
2712
|
+
if (err.status === 404 && view === 'detail') {
|
|
2713
|
+
sessionDetailData = null;
|
|
2714
|
+
showToast('Session not found — showing overview');
|
|
2715
|
+
await navigate('overview', undefined, 'replace');
|
|
2716
|
+
return;
|
|
2717
|
+
}
|
|
1083
2718
|
showToast(`Error: ${err.message}`);
|
|
1084
2719
|
if (viewEl) viewEl.innerHTML = `<div class="loading-state"><span>Failed to load: ${esc(err.message)}</span></div>`;
|
|
2720
|
+
} finally {
|
|
2721
|
+
if (myNav === navCounter) booted = true;
|
|
1085
2722
|
}
|
|
1086
2723
|
}
|
|
1087
2724
|
|
|
1088
|
-
function renderCurrentView() {
|
|
2725
|
+
function renderCurrentView() {
|
|
2726
|
+
loadAndRender(currentView);
|
|
2727
|
+
}
|
|
1089
2728
|
|
|
1090
2729
|
function ensureViewElements() {
|
|
1091
2730
|
const app = document.getElementById('app');
|
|
1092
2731
|
if (!app) return;
|
|
1093
|
-
for (const v of
|
|
2732
|
+
for (const v of VIEWS) {
|
|
1094
2733
|
if (!document.getElementById(`${v}-view`)) {
|
|
1095
2734
|
const div = document.createElement('div');
|
|
1096
2735
|
div.id = `${v}-view`;
|
|
@@ -1106,7 +2745,57 @@ function ensureViewElements() {
|
|
|
1106
2745
|
|
|
1107
2746
|
// #region MODAL
|
|
1108
2747
|
|
|
2748
|
+
const SHORTCUT_GROUPS = [
|
|
2749
|
+
{
|
|
2750
|
+
title: 'Navigation',
|
|
2751
|
+
rows: [
|
|
2752
|
+
{ keys: ['1'], label: 'Overview' },
|
|
2753
|
+
{ keys: ['2'], label: 'Insights' },
|
|
2754
|
+
{ keys: ['Backspace'], label: 'Go back' },
|
|
2755
|
+
{ keys: ['Esc'], label: 'Close / back' },
|
|
2756
|
+
],
|
|
2757
|
+
},
|
|
2758
|
+
{
|
|
2759
|
+
title: 'Table',
|
|
2760
|
+
rows: [
|
|
2761
|
+
{ keys: ['J', '↓'], label: 'Next row' },
|
|
2762
|
+
{ keys: ['K', '↑'], label: 'Previous row' },
|
|
2763
|
+
{ keys: ['Enter', 'Space'], label: 'Open' },
|
|
2764
|
+
],
|
|
2765
|
+
},
|
|
2766
|
+
{
|
|
2767
|
+
title: 'Timeline range',
|
|
2768
|
+
rows: [
|
|
2769
|
+
{ keys: ['←', '→'], label: 'Pan one step' },
|
|
2770
|
+
{ keys: ['Shift', '←/→'], combo: true, label: 'Pan a full window' },
|
|
2771
|
+
{ keys: ['Home', 'End'], label: 'Jump to start / end' },
|
|
2772
|
+
],
|
|
2773
|
+
},
|
|
2774
|
+
{
|
|
2775
|
+
title: 'Actions',
|
|
2776
|
+
rows: [
|
|
2777
|
+
{ keys: ['R'], label: 'Refresh' },
|
|
2778
|
+
{ keys: ['T'], label: 'Toggle theme' },
|
|
2779
|
+
{ keys: ['?'], label: 'This help' },
|
|
2780
|
+
],
|
|
2781
|
+
},
|
|
2782
|
+
];
|
|
2783
|
+
|
|
2784
|
+
function buildHelpShortcuts() {
|
|
2785
|
+
return SHORTCUT_GROUPS.map((g) => {
|
|
2786
|
+
const rows = g.rows
|
|
2787
|
+
.map((r) => {
|
|
2788
|
+
const sep = r.combo ? ' + ' : ' / ';
|
|
2789
|
+
return `<tr><td>${r.keys.map((k) => `<kbd>${esc(k)}</kbd>`).join(sep)}</td><td>${esc(r.label)}</td></tr>`;
|
|
2790
|
+
})
|
|
2791
|
+
.join('');
|
|
2792
|
+
return `<div><h4 class="shortcut-section">${esc(g.title)}</h4><table class="shortcut-table">${rows}</table></div>`;
|
|
2793
|
+
}).join('');
|
|
2794
|
+
}
|
|
2795
|
+
|
|
1109
2796
|
function toggleHelpModal() {
|
|
2797
|
+
const list = document.getElementById('helpShortcuts');
|
|
2798
|
+
if (list && !list.childElementCount) list.innerHTML = buildHelpShortcuts();
|
|
1110
2799
|
document.getElementById('helpModal').classList.toggle('visible');
|
|
1111
2800
|
}
|
|
1112
2801
|
|
|
@@ -1144,36 +2833,75 @@ async function goBack() {
|
|
|
1144
2833
|
target = 'sessions';
|
|
1145
2834
|
await navigate('sessions', { project: currentProjectDir, projectName: currentProjectName });
|
|
1146
2835
|
} else {
|
|
1147
|
-
target =
|
|
1148
|
-
await navigate(
|
|
2836
|
+
target = 'overview';
|
|
2837
|
+
await navigate('overview');
|
|
1149
2838
|
}
|
|
1150
|
-
} else if (currentView === 'sessions') {
|
|
1151
|
-
target = parentView;
|
|
1152
|
-
await navigate(parentView);
|
|
1153
|
-
} else if (currentView === 'projects') {
|
|
2839
|
+
} else if (currentView === 'sessions' || currentView === 'insights') {
|
|
1154
2840
|
target = 'overview';
|
|
1155
2841
|
await navigate('overview');
|
|
1156
2842
|
}
|
|
1157
2843
|
if (target) focusPreviousRow(target);
|
|
1158
2844
|
}
|
|
1159
2845
|
|
|
2846
|
+
const KEY_VIEWS = { 1: 'overview', 2: 'insights' };
|
|
2847
|
+
|
|
1160
2848
|
document.addEventListener('keydown', (e) => {
|
|
1161
2849
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
|
|
1162
2850
|
|
|
1163
2851
|
const anyModal = document.querySelector('.modal-overlay.visible');
|
|
1164
2852
|
if (anyModal) {
|
|
1165
|
-
if (e.key === 'Escape'
|
|
2853
|
+
if (e.key === 'Escape' || (e.key === '?' && anyModal.id === 'helpModal')) {
|
|
2854
|
+
anyModal.classList.remove('visible');
|
|
2855
|
+
e.preventDefault();
|
|
2856
|
+
}
|
|
2857
|
+
return;
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
// The popover is a div, so the INPUT guard does not cover it, and r/t/j/k would fire through it.
|
|
2861
|
+
if (isRangePickerOpen()) {
|
|
2862
|
+
if (e.key === 'Escape') {
|
|
2863
|
+
closeRangePicker();
|
|
2864
|
+
e.preventDefault();
|
|
2865
|
+
}
|
|
1166
2866
|
return;
|
|
1167
2867
|
}
|
|
1168
|
-
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
|
1169
2868
|
|
|
1170
|
-
if (
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
2869
|
+
if (isThemeMenuOpen()) {
|
|
2870
|
+
if (e.key === 'Escape') {
|
|
2871
|
+
closeThemeMenu();
|
|
2872
|
+
document.getElementById('themePickerBtn')?.focus();
|
|
2873
|
+
e.preventDefault();
|
|
2874
|
+
}
|
|
2875
|
+
return;
|
|
2876
|
+
}
|
|
1176
2877
|
|
|
2878
|
+
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
|
2879
|
+
|
|
2880
|
+
if (e.key === '?' || (e.key === '/' && e.shiftKey)) {
|
|
2881
|
+
e.preventDefault();
|
|
2882
|
+
toggleHelpModal();
|
|
2883
|
+
return;
|
|
2884
|
+
}
|
|
2885
|
+
if (KEY_VIEWS[e.key]) {
|
|
2886
|
+
e.preventDefault();
|
|
2887
|
+
navigate(KEY_VIEWS[e.key]);
|
|
2888
|
+
return;
|
|
2889
|
+
}
|
|
2890
|
+
if (e.key === 'r') {
|
|
2891
|
+
e.preventDefault();
|
|
2892
|
+
refreshData();
|
|
2893
|
+
return;
|
|
2894
|
+
}
|
|
2895
|
+
if (e.key === 't') {
|
|
2896
|
+
e.preventDefault();
|
|
2897
|
+
toggleTheme();
|
|
2898
|
+
return;
|
|
2899
|
+
}
|
|
2900
|
+
if (e.key === 'Escape' || e.key === 'Backspace') {
|
|
2901
|
+
e.preventDefault();
|
|
2902
|
+
goBack();
|
|
2903
|
+
return;
|
|
2904
|
+
}
|
|
1177
2905
|
if (e.key === 'j' || e.key === 'ArrowDown') {
|
|
1178
2906
|
e.preventDefault();
|
|
1179
2907
|
const rows = getVisibleRows();
|
|
@@ -1186,7 +2914,10 @@ document.addEventListener('keydown', (e) => {
|
|
|
1186
2914
|
if (rows.length) selectRow(Math.max(selectedRowIdx - 1, 0));
|
|
1187
2915
|
return;
|
|
1188
2916
|
}
|
|
1189
|
-
if (e.key === 'Enter' || e.key === ' ') {
|
|
2917
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
2918
|
+
e.preventDefault();
|
|
2919
|
+
activateSelectedRow();
|
|
2920
|
+
}
|
|
1190
2921
|
});
|
|
1191
2922
|
|
|
1192
2923
|
// #endregion
|
|
@@ -1204,7 +2935,35 @@ function showToast(msg, persistent, type) {
|
|
|
1204
2935
|
return el;
|
|
1205
2936
|
}
|
|
1206
2937
|
|
|
1207
|
-
function dismissToast(el) {
|
|
2938
|
+
function dismissToast(el) {
|
|
2939
|
+
if (el) el.remove();
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
// #endregion
|
|
2943
|
+
|
|
2944
|
+
// #region AUTO_REFRESH
|
|
2945
|
+
|
|
2946
|
+
const STALE_CHECK_MS = 30_000;
|
|
2947
|
+
// The interval and visibilitychange share refreshIfStale, so a second trigger must not clear
|
|
2948
|
+
// forceRefresh under the first.
|
|
2949
|
+
let refreshing = false;
|
|
2950
|
+
|
|
2951
|
+
async function refreshIfStale() {
|
|
2952
|
+
if (refreshing || document.hidden) return;
|
|
2953
|
+
if (Date.now() - dataFetchedAt < BROWSER_CACHE_TTL) return;
|
|
2954
|
+
refreshing = true;
|
|
2955
|
+
forceRefresh = true;
|
|
2956
|
+
try {
|
|
2957
|
+
await loadAndRender(currentView);
|
|
2958
|
+
} catch {
|
|
2959
|
+
} finally {
|
|
2960
|
+
forceRefresh = false;
|
|
2961
|
+
refreshing = false;
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
setInterval(refreshIfStale, STALE_CHECK_MS);
|
|
2966
|
+
document.addEventListener('visibilitychange', refreshIfStale);
|
|
1208
2967
|
|
|
1209
2968
|
// #endregion
|
|
1210
2969
|
|
|
@@ -1212,8 +2971,7 @@ function dismissToast(el) { if (el) el.remove(); }
|
|
|
1212
2971
|
|
|
1213
2972
|
(async function initPi() {
|
|
1214
2973
|
try {
|
|
1215
|
-
|
|
1216
|
-
window.__PI__ = cfg;
|
|
2974
|
+
window.__PI__ = await fetch('/pi-config').then((r) => r.json());
|
|
1217
2975
|
} catch {}
|
|
1218
2976
|
})();
|
|
1219
2977
|
|
|
@@ -1222,7 +2980,29 @@ function dismissToast(el) { if (el) el.remove(); }
|
|
|
1222
2980
|
// #region PWA
|
|
1223
2981
|
|
|
1224
2982
|
if ('serviceWorker' in navigator) {
|
|
1225
|
-
navigator.serviceWorker.register('/sw.js');
|
|
2983
|
+
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
let deferredInstallPrompt = null;
|
|
2987
|
+
|
|
2988
|
+
function setInstallPrompt(e) {
|
|
2989
|
+
deferredInstallPrompt = e;
|
|
2990
|
+
const btn = document.getElementById('installBtn');
|
|
2991
|
+
if (btn) btn.hidden = !e;
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
window.addEventListener('beforeinstallprompt', (e) => {
|
|
2995
|
+
e.preventDefault();
|
|
2996
|
+
setInstallPrompt(e);
|
|
2997
|
+
});
|
|
2998
|
+
|
|
2999
|
+
window.addEventListener('appinstalled', () => setInstallPrompt(null));
|
|
3000
|
+
|
|
3001
|
+
async function promptInstall() {
|
|
3002
|
+
const e = deferredInstallPrompt;
|
|
3003
|
+
if (!e) return;
|
|
3004
|
+
setInstallPrompt(null);
|
|
3005
|
+
await e.prompt();
|
|
1226
3006
|
}
|
|
1227
3007
|
|
|
1228
3008
|
// #endregion
|
|
@@ -1230,61 +3010,65 @@ if ('serviceWorker' in navigator) {
|
|
|
1230
3010
|
// #region INIT
|
|
1231
3011
|
|
|
1232
3012
|
loadTheme();
|
|
3013
|
+
if (EMBED) startEmbed();
|
|
1233
3014
|
loadSort();
|
|
1234
3015
|
|
|
1235
3016
|
document.addEventListener('click', async (e) => {
|
|
1236
3017
|
if (e.target.matches('.parent-breadcrumb')) {
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
focusPreviousRow(target);
|
|
3018
|
+
await navigate('overview');
|
|
3019
|
+
focusPreviousRow('overview');
|
|
1240
3020
|
}
|
|
1241
3021
|
});
|
|
1242
3022
|
|
|
1243
3023
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
1244
3024
|
const state = getUrlState();
|
|
1245
3025
|
dateRange = loadDateRange();
|
|
1246
|
-
|
|
1247
|
-
|
|
3026
|
+
buildRangePresets();
|
|
3027
|
+
renderRangeTrigger();
|
|
1248
3028
|
|
|
1249
3029
|
if (state.session) {
|
|
1250
3030
|
currentSessionId = state.session;
|
|
1251
|
-
parentView = state.parentView;
|
|
1252
3031
|
currentProjectDir = state.project;
|
|
1253
3032
|
currentProjectName = state.projectName;
|
|
1254
3033
|
await navigate('detail', { session: state.session }, 'replace');
|
|
1255
3034
|
} else if (state.project) {
|
|
1256
|
-
parentView = state.parentView;
|
|
1257
3035
|
currentProjectDir = state.project;
|
|
1258
3036
|
currentProjectName = state.projectName;
|
|
1259
3037
|
await navigate('sessions', { project: state.project, projectName: state.projectName }, 'replace');
|
|
1260
|
-
} else if (state.view === 'projects') {
|
|
1261
|
-
await navigate('projects', undefined, 'replace');
|
|
1262
3038
|
} else {
|
|
1263
|
-
await navigate('overview', undefined, 'replace');
|
|
3039
|
+
await navigate(TOP_VIEWS.includes(state.view) ? state.view : 'overview', undefined, 'replace');
|
|
1264
3040
|
}
|
|
1265
3041
|
});
|
|
1266
3042
|
|
|
1267
3043
|
window.addEventListener('popstate', () => {
|
|
1268
3044
|
const p = new URLSearchParams(window.location.search);
|
|
1269
|
-
currentView = p.get('view')
|
|
1270
|
-
parentView = p.get('parentView') || 'projects';
|
|
3045
|
+
currentView = VIEWS.includes(p.get('view')) ? p.get('view') : 'overview';
|
|
1271
3046
|
currentProjectDir = p.get('project');
|
|
1272
3047
|
currentProjectName = p.get('projectName');
|
|
1273
3048
|
currentSessionId = p.get('session');
|
|
3049
|
+
if (p.has('session') && currentView === 'overview') currentView = 'detail';
|
|
1274
3050
|
lastRenderHash = {};
|
|
1275
|
-
|
|
1276
|
-
setActiveNav(currentView
|
|
3051
|
+
storageSet('sessionStorage', 'pi-cost:nav', p.toString());
|
|
3052
|
+
setActiveNav(TOP_VIEWS.includes(currentView) ? currentView : 'overview');
|
|
1277
3053
|
loadAndRender(currentView);
|
|
1278
3054
|
});
|
|
1279
3055
|
|
|
1280
|
-
//
|
|
1281
|
-
window
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
3056
|
+
// Exposed for the inline handlers in markup.
|
|
3057
|
+
Object.assign(window, {
|
|
3058
|
+
navigate,
|
|
3059
|
+
navigateToSessions,
|
|
3060
|
+
navigateToDetail,
|
|
3061
|
+
sortBy,
|
|
3062
|
+
refreshData,
|
|
3063
|
+
toggleTheme,
|
|
3064
|
+
toggleThemeMenu,
|
|
3065
|
+
setColorTheme,
|
|
3066
|
+
toggleHelpModal,
|
|
3067
|
+
toggleRangePicker,
|
|
3068
|
+
closeRangePicker,
|
|
3069
|
+
aimRangePicker,
|
|
3070
|
+
applyRangeDraft,
|
|
3071
|
+
promptInstall,
|
|
3072
|
+
});
|
|
1289
3073
|
|
|
1290
3074
|
// #endregion
|