@oharness/server 0.7.0 → 0.8.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.
Files changed (61) hide show
  1. package/dist/browser-pane.d.ts +48 -0
  2. package/dist/browser-pane.js +185 -0
  3. package/dist/browser-pane.js.map +1 -0
  4. package/dist/i18n.js +144 -0
  5. package/dist/i18n.js.map +1 -1
  6. package/dist/main.js +20 -0
  7. package/dist/main.js.map +1 -1
  8. package/dist/page/client/account.d.ts +8 -0
  9. package/dist/page/client/account.js +1895 -0
  10. package/dist/page/client/account.js.map +1 -0
  11. package/dist/page/client/artifacts.d.ts +7 -0
  12. package/dist/page/client/artifacts.js +767 -0
  13. package/dist/page/client/artifacts.js.map +1 -0
  14. package/dist/page/client/base.d.ts +8 -0
  15. package/dist/page/client/base.js +400 -0
  16. package/dist/page/client/base.js.map +1 -0
  17. package/dist/page/client/browser.d.ts +21 -0
  18. package/dist/page/client/browser.js +904 -0
  19. package/dist/page/client/browser.js.map +1 -0
  20. package/dist/page/client/composer.d.ts +8 -0
  21. package/dist/page/client/composer.js +1694 -0
  22. package/dist/page/client/composer.js.map +1 -0
  23. package/dist/page/client/dialogs.d.ts +7 -0
  24. package/dist/page/client/dialogs.js +724 -0
  25. package/dist/page/client/dialogs.js.map +1 -0
  26. package/dist/page/client/events.d.ts +7 -0
  27. package/dist/page/client/events.js +288 -0
  28. package/dist/page/client/events.js.map +1 -0
  29. package/dist/page/client/index.d.ts +16 -0
  30. package/dist/page/client/index.js +46 -0
  31. package/dist/page/client/index.js.map +1 -0
  32. package/dist/page/client/panels.d.ts +7 -0
  33. package/dist/page/client/panels.js +659 -0
  34. package/dist/page/client/panels.js.map +1 -0
  35. package/dist/page/client/pickers.d.ts +7 -0
  36. package/dist/page/client/pickers.js +624 -0
  37. package/dist/page/client/pickers.js.map +1 -0
  38. package/dist/page/client/routines.d.ts +6 -0
  39. package/dist/page/client/routines.js +910 -0
  40. package/dist/page/client/routines.js.map +1 -0
  41. package/dist/page/client/shell.d.ts +6 -0
  42. package/dist/page/client/shell.js +690 -0
  43. package/dist/page/client/shell.js.map +1 -0
  44. package/dist/page/client/state.d.ts +7 -0
  45. package/dist/page/client/state.js +1275 -0
  46. package/dist/page/client/state.js.map +1 -0
  47. package/dist/page/client/transcript.d.ts +8 -0
  48. package/dist/page/client/transcript.js +699 -0
  49. package/dist/page/client/transcript.js.map +1 -0
  50. package/dist/page/client/usage.d.ts +7 -0
  51. package/dist/page/client/usage.js +954 -0
  52. package/dist/page/client/usage.js.map +1 -0
  53. package/dist/page/style.d.ts +1 -0
  54. package/dist/page/style.js +2793 -0
  55. package/dist/page/style.js.map +1 -0
  56. package/dist/page.js +70 -13583
  57. package/dist/page.js.map +1 -1
  58. package/dist/server.d.ts +24 -0
  59. package/dist/server.js +511 -25
  60. package/dist/server.js.map +1 -1
  61. package/package.json +2 -2
@@ -0,0 +1,954 @@
1
+ /**
2
+ * What was spent and what was decided: the audit trail, the date range
3
+ * picker both pages share, and the usage chart.
4
+ *
5
+ * One part of the client script; see ./index.ts for how the parts join.
6
+ */
7
+ export const USAGE = String.raw `
8
+ const AUDIT_PREVIEW = 12;
9
+
10
+ /** How many lines the page draws at once, and adds each time it is asked for more. */
11
+ const AUDIT_PAGE = 200;
12
+
13
+ /**
14
+ * Decisions newest first, each carrying the answer that closed it.
15
+ *
16
+ * A decision and its answer are one event to anybody reading this, and were
17
+ * two lines on disk because that is when each was known. Keyed by session as
18
+ * well as id: ids are unique within the file they were written in, and the
19
+ * page reads every file at once.
20
+ */
21
+ function auditRows(entries) {
22
+ const list = entries || [];
23
+ const key = (entry) => (entry.session || "") + "/" + entry.id;
24
+ const answers = new Map();
25
+ for (const entry of list) if (entry.kind === "answer") answers.set(key(entry), entry);
26
+ return list
27
+ .filter((entry) => entry.kind === "decision")
28
+ .map((entry) => ({ entry, answer: answers.get(key(entry)) || null }))
29
+ .sort((a, b) => b.entry.at - a.entry.at);
30
+ }
31
+
32
+ /**
33
+ * How a decision ended.
34
+ *
35
+ * The answer replaces the behaviour it answered rather than following it:
36
+ * "ask → allowed" is one fact, and printing both reads as two.
37
+ */
38
+ function auditOutcome(row) {
39
+ return row.answer ? row.answer.outcome : row.entry.behavior;
40
+ }
41
+
42
+ /** Which of the page's filters a row belongs to. */
43
+ function auditKind(row) {
44
+ if (row.entry.behavior === "ask" && !row.answer) return "unanswered";
45
+ const outcome = auditOutcome(row);
46
+ if (outcome === "denied" || outcome === "deny" || outcome === "unavailable") return "denied";
47
+ return "allowed";
48
+ }
49
+
50
+ /**
51
+ * The trail as lines, newest first.
52
+ *
53
+ * withDate, because the two readers ask over different spans: within one
54
+ * session the time of day places an entry, and across every session on disk
55
+ * it does not place anything at all.
56
+ */
57
+ function auditList(rows, withDate) {
58
+ const list = el("div", "audit-list");
59
+ let day = "";
60
+ for (const row of rows) {
61
+ const entry = row.entry;
62
+ const at = new Date(entry.at);
63
+ // Across sessions the date is a heading rather than a column: the same
64
+ // "Sep 15" forty times over is noise that pushes the tool off the line.
65
+ if (withDate) {
66
+ const label = at.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
67
+ if (label !== day) {
68
+ day = label;
69
+ list.appendChild(el("div", "audit-day", label));
70
+ }
71
+ }
72
+ const kind = auditKind(row);
73
+ const line = el("div", "trail-line audit-row " + kind);
74
+ line.appendChild(el("span", "audit-time", at.toLocaleTimeString()));
75
+ line.appendChild(el("span", "audit-pill " + kind, auditOutcome(row)));
76
+
77
+ // "Edit(/a/b/c.md)" reads as a tool and what it touched; drawn as one
78
+ // string the path is as loud as the verb.
79
+ const label = entry.principal || entry.tool;
80
+ const what = el("span", "audit-what");
81
+ const open = label.indexOf("(");
82
+ if (open > 0 && label.endsWith(")")) {
83
+ what.appendChild(el("span", "audit-tool", label.slice(0, open)));
84
+ const arg = el("span", "audit-arg", label.slice(open + 1, -1));
85
+ arg.title = label.slice(open + 1, -1);
86
+ what.appendChild(arg);
87
+ } else {
88
+ what.appendChild(el("span", "audit-tool", label));
89
+ }
90
+ line.appendChild(what);
91
+
92
+ const notes = el("span", "audit-notes");
93
+ if (entry.rule) notes.appendChild(el("span", "audit-tag", entry.rule));
94
+ if (row.answer && row.answer.remembered) {
95
+ notes.appendChild(el("span", "audit-tag remembered", "remembered " + row.answer.remembered));
96
+ }
97
+ // An ask with no answer: the process stopped while the question was up.
98
+ if (entry.behavior === "ask" && !row.answer) notes.appendChild(el("span", "audit-tag unanswered", "unanswered"));
99
+ line.appendChild(notes);
100
+ list.appendChild(line);
101
+ }
102
+ return list;
103
+ }
104
+
105
+ /**
106
+ * The two downloads, for whichever scope is on screen.
107
+ *
108
+ * Plain links rather than buttons that fetch and build a blob: the server
109
+ * already names the file and hands it over, and a download the page assembles
110
+ * in memory is one the browser cannot resume, cannot stream, and shows in no
111
+ * download list.
112
+ */
113
+ function auditExports(session) {
114
+ const exports = el("div", "row-actions");
115
+ for (const scope of ["session", "all"]) {
116
+ const link = el("a", "ghost", t("settings.auditExport" + (scope === "all" ? "All" : "")));
117
+ link.href = q("/audit/export?session=" + encodeURIComponent(scope === "all" ? "all" : session));
118
+ // The server names it; this only tells the browser to save rather than
119
+ // to navigate, which it would otherwise do with a JSON content type.
120
+ link.setAttribute("download", "");
121
+ exports.appendChild(link);
122
+ }
123
+ return exports;
124
+ }
125
+
126
+ /** Which slice of the trail the page is showing, for this page load. */
127
+ let auditScope = "session";
128
+ let auditFilter = "all";
129
+
130
+ /**
131
+ * Every permission decision, one page of them at a time.
132
+ *
133
+ * Its own page for the same reason usage has one: what it holds outgrew the
134
+ * row it was reached from. The scope is the part that could not live there at
135
+ * all — the row reads the session it is in, and "when did I ever allow this"
136
+ * is a question about the others.
137
+ */
138
+ async function showAudit() {
139
+ const scope = auditScope;
140
+ const body = await getJson(
141
+ "/audit" + (scope === "all" ? "?session=all" : activeId ? "?session=" + encodeURIComponent(activeId) : ""),
142
+ );
143
+ if (shownPanel !== "audit") return;
144
+ panelBody.replaceChildren();
145
+ if (body.error) return void panelBody.appendChild(panelError(body.error));
146
+
147
+ const panel = el("div", "panel audit");
148
+ const head = el("div", "row-head");
149
+ head.appendChild(el("h2", null, t("settings.audit")));
150
+ // Back to where this was opened from: the sidebar row it lit is already
151
+ // showing as active, so without this the only exit is a click that looks
152
+ // like a no-op.
153
+ const back = el("button", "ghost", t("usage.back"));
154
+ back.type = "button";
155
+ back.addEventListener("click", () => {
156
+ setNav("nav-settings");
157
+ void showSettings();
158
+ });
159
+ head.appendChild(back);
160
+ panel.appendChild(head);
161
+
162
+ /*
163
+ * Two segmented controls, and they are not the same kind of control.
164
+ *
165
+ * The scope reloads, because it is a different read; the filter does not,
166
+ * because everything it can show is already here. Keeping them side by side
167
+ * anyway: which decisions and whose are one question asked in two halves,
168
+ * and splitting them across the page would hide that changing one changes
169
+ * what the other is choosing from.
170
+ */
171
+ const controls = el("div", "audit-controls");
172
+ // chosen is read at click time, not closed over: the filter changes this
173
+ // control in place, and a guard against the value it was built with would
174
+ // refuse to go back to the one it opened on.
175
+ const seg = (options, chosen, onPick) => {
176
+ const box = el("div", "seg");
177
+ for (const option of options) {
178
+ const button = el("button", "seg-item" + (option.id === chosen() ? " on" : ""));
179
+ if (option.color) {
180
+ const dot = el("span", "seg-dot");
181
+ dot.style.background = option.color;
182
+ button.appendChild(dot);
183
+ }
184
+ button.appendChild(document.createTextNode(option.label));
185
+ if (option.count !== undefined) button.appendChild(el("span", "seg-count", String(option.count)));
186
+ button.type = "button";
187
+ button.addEventListener("click", () => {
188
+ if (option.id === chosen()) return;
189
+ onPick(option.id);
190
+ });
191
+ box.appendChild(button);
192
+ }
193
+ return box;
194
+ };
195
+ controls.appendChild(seg(
196
+ [
197
+ { id: "session", label: t("audit.scope.session") },
198
+ { id: "all", label: t("audit.scope.all") },
199
+ ],
200
+ () => scope,
201
+ (id) => { auditScope = id; void showAudit(); },
202
+ ));
203
+ const filters = ["all", "allowed", "denied", "unanswered"];
204
+ const rows = auditRows(body.entries);
205
+ const tally = { all: rows.length, allowed: 0, denied: 0, unanswered: 0 };
206
+ for (const row of rows) tally[auditKind(row)] += 1;
207
+ const dots = { allowed: "var(--ok)", denied: "var(--err)", unanswered: "var(--warn)" };
208
+ const filterSeg = seg(
209
+ filters.map((id) => ({
210
+ id, label: t("audit.filter." + id), count: tally[id],
211
+ ...(dots[id] ? { color: dots[id] } : {}),
212
+ })),
213
+ () => auditFilter,
214
+ (id) => {
215
+ auditFilter = id;
216
+ // Back to the top of the list: a filter changed under a list someone
217
+ // had paged five hundred lines into is a different list.
218
+ shown = AUDIT_PAGE;
219
+ paint();
220
+ },
221
+ );
222
+ controls.appendChild(filterSeg);
223
+ panel.appendChild(controls);
224
+
225
+ const count = el("div", "muted small");
226
+ panel.appendChild(count);
227
+ const listBox = el("div");
228
+ panel.appendChild(listBox);
229
+ const foot = el("div");
230
+ panel.appendChild(foot);
231
+
232
+ // Said out loud rather than left to a total that quietly stops short: a
233
+ // machine with more sessions than one read returns is one where "nothing
234
+ // was ever denied" would otherwise be a claim this page cannot make.
235
+ if (body.more) panel.appendChild(el("div", "muted small", t("audit.capped")));
236
+ panel.appendChild(auditExports(scope === "all" ? "all" : activeId));
237
+ panelBody.appendChild(panel);
238
+
239
+ // How far down the list has been asked to go. Reset by a repaint rather
240
+ // than kept across one: a filter that changed under a list scrolled to its
241
+ // five-hundredth line is a different list.
242
+ let shown = AUDIT_PAGE;
243
+ paint();
244
+
245
+ function paint() {
246
+ const picked = auditFilter === "all" ? rows : rows.filter((row) => auditKind(row) === auditFilter);
247
+ // Only the filter is repainted in place; changing the scope is a reload,
248
+ // and the control comes back with it.
249
+ filterSeg.querySelectorAll(".seg-item").forEach((button, index) => {
250
+ button.classList.toggle("on", filters[index] === auditFilter);
251
+ });
252
+
253
+ count.textContent = [
254
+ picked.length === 1 ? t("audit.countOne") : t("audit.count", { n: picked.length }),
255
+ scope === "all" && body.sessions ? t("audit.acrossSessions", { n: body.sessions }) : "",
256
+ ].filter(Boolean).join(" · ");
257
+
258
+ listBox.replaceChildren();
259
+ foot.replaceChildren();
260
+ if (!rows.length) {
261
+ return void listBox.appendChild(el("div", "muted small",
262
+ t(scope === "all" ? "audit.noneAll" : "settings.auditEmpty")));
263
+ }
264
+ if (!picked.length) {
265
+ return void listBox.appendChild(el("div", "muted small", t("audit.noMatch")));
266
+ }
267
+ listBox.appendChild(auditList(picked.slice(0, shown), scope === "all"));
268
+ if (picked.length > shown) {
269
+ const more = el("button", "ghost", t("audit.showMore"));
270
+ more.type = "button";
271
+ more.addEventListener("click", () => {
272
+ shown += AUDIT_PAGE;
273
+ paint();
274
+ });
275
+ foot.appendChild(more);
276
+ }
277
+ }
278
+ }
279
+
280
+ /** The window the usage page is asking about, for this page load. */
281
+ let usageRange = "30d";
282
+ /**
283
+ * The first and last day of a picked window, once one has been picked.
284
+ *
285
+ * Kept as UTC day keys rather than as a pair of timestamps: they are what the
286
+ * server is asked for, what it echoes back, and what the calendar below reads
287
+ * — and a window that means one thing on the wire and another on screen is
288
+ * the single failure a date picker cannot recover from.
289
+ */
290
+ let usageFrom = null;
291
+ let usageTo = null;
292
+
293
+ const DAY_MS = 86400000;
294
+ /**
295
+ * The longest window that may be picked, matching the site's own picker.
296
+ *
297
+ * A year of daily columns is already hairs wide; past it the walk is every
298
+ * session on disk for a curve that says nothing the presets do not.
299
+ */
300
+ const MAX_CUSTOM_DAYS = 366;
301
+ const dayKey = (ms) => new Date(ms).toISOString().slice(0, 10);
302
+ const dayMs = (key) => Date.parse(key + "T00:00:00Z");
303
+ /** The first UTC day of the month holding ms. */
304
+ const monthOf = (ms) => Date.UTC(new Date(ms).getUTCFullYear(), new Date(ms).getUTCMonth(), 1);
305
+ const shiftMonth = (first, by) =>
306
+ Date.UTC(new Date(first).getUTCFullYear(), new Date(first).getUTCMonth() + by, 1);
307
+
308
+ /**
309
+ * A range of UTC days, picked on a calendar of our own.
310
+ *
311
+ * The same component the site's usage page uses, down to the class names —
312
+ * not the browser's date field, which looks different in every browser,
313
+ * cannot show a range, and draws an outlined box that belongs to no design
314
+ * here. Click a first day, then a last one; while the second is being chosen
315
+ * the range follows the pointer. Days after max cannot be picked, nor a
316
+ * second day more than maxDays from the first.
317
+ *
318
+ * Nothing is applied until Apply: closing the panel, clicking away from it or
319
+ * pressing Escape leaves the page showing the window it was already showing,
320
+ * because the days inside it are a draft until then.
321
+ */
322
+ function dateRangePicker(options) {
323
+ const root = el("span", "drp");
324
+ const maxMs = dayMs(options.max);
325
+ const maxDays = options.maxDays;
326
+
327
+ let start = dayMs(options.from);
328
+ let end = dayMs(options.to);
329
+ let hover = null;
330
+ let month = monthOf(dayMs(options.to));
331
+ let open = false;
332
+ let pop = null;
333
+
334
+ const reset = () => {
335
+ start = dayMs(options.from);
336
+ end = dayMs(options.to);
337
+ hover = null;
338
+ month = monthOf(dayMs(options.to));
339
+ };
340
+
341
+ const monthName = new Intl.DateTimeFormat(LOCALE, { month: "long", year: "numeric", timeZone: "UTC" });
342
+ const short = new Intl.DateTimeFormat(LOCALE, { month: "short", day: "numeric", timeZone: "UTC" });
343
+ const shortYear = new Intl.DateTimeFormat(LOCALE, {
344
+ month: "short", day: "numeric", year: "numeric", timeZone: "UTC",
345
+ });
346
+ // Sunday-first where the reader's language puts it there, Monday-first
347
+ // everywhere else.
348
+ const firstWeekday = /^(en|ja|ko)$/.test(LOCALE) ? 0 : 1;
349
+ const weekdayName = new Intl.DateTimeFormat(LOCALE, { weekday: "narrow", timeZone: "UTC" });
350
+ // 2023-01-01 was a Sunday.
351
+ const weekdays = Array.from({ length: 7 }, (_, index) =>
352
+ weekdayName.format(Date.UTC(2023, 0, 1 + ((firstWeekday + index) % 7))));
353
+
354
+ const span = (a, b) => {
355
+ const n = Math.round((b - a) / DAY_MS) + 1;
356
+ return n === 1 ? t("usage.range.oneDay") : t("usage.range.days", { n });
357
+ };
358
+ const rangeText = (a, b) =>
359
+ a === b ? shortYear.format(a)
360
+ : new Date(a).getUTCFullYear() === new Date(b).getUTCFullYear()
361
+ ? short.format(a) + " – " + shortYear.format(b)
362
+ : shortYear.format(a) + " – " + shortYear.format(b);
363
+
364
+ const trigger = el("button", "drp-trigger");
365
+ trigger.type = "button";
366
+ trigger.setAttribute("aria-haspopup", "dialog");
367
+ const paintTrigger = () => {
368
+ trigger.replaceChildren(svgIcon("calendar"));
369
+ trigger.appendChild(document.createTextNode(options.active
370
+ ? rangeText(dayMs(options.from), dayMs(options.to))
371
+ : t("usage.range.custom")));
372
+ trigger.setAttribute("aria-pressed", options.active ? "true" : "false");
373
+ trigger.setAttribute("aria-expanded", open ? "true" : "false");
374
+ trigger.classList.toggle("on", Boolean(options.active));
375
+ };
376
+ root.appendChild(trigger);
377
+
378
+ /*
379
+ * The cells, kept so a hover can repaint the range without rebuilding them.
380
+ *
381
+ * The structure is built once per month shown; choosing and hovering only
382
+ * toggle classes on what is already there. Rebuilding the grid on every
383
+ * mouseenter replaced the button under the pointer, which fired mouseenter
384
+ * on its replacement — a rebuild loop that swallowed the click meant to
385
+ * close the range, so picking a second day never took.
386
+ */
387
+ let cells = [];
388
+ let foot = null;
389
+
390
+ const pick = (day) => {
391
+ if (end === null) {
392
+ const first = Math.min(day, start);
393
+ const last = Math.max(day, start);
394
+ start = first;
395
+ end = last;
396
+ hover = null;
397
+ } else {
398
+ start = day;
399
+ end = null;
400
+ }
401
+ paintRange();
402
+ };
403
+
404
+ const disabled = (day) =>
405
+ day > maxMs || (end === null && Math.abs(day - start) > (maxDays - 1) * DAY_MS);
406
+
407
+ /** The chosen range, and what the footer says about it. Classes only. */
408
+ function paintRange() {
409
+ // While the second day is being chosen, the range runs to the pointer.
410
+ const lastShown = end === null ? hover : end;
411
+ const low = lastShown === null ? start : Math.min(start, lastShown);
412
+ const high = lastShown === null ? start : Math.max(start, lastShown);
413
+ for (const cell of cells) {
414
+ const at = cell.at;
415
+ const edgeStart = at === low;
416
+ const edgeEnd = at === high;
417
+ cell.button.classList.toggle("is-inside", at > low && at < high);
418
+ cell.button.classList.toggle("is-edge", edgeStart || edgeEnd);
419
+ cell.button.classList.toggle("is-start", edgeStart && high > low);
420
+ cell.button.classList.toggle("is-end", edgeEnd && high > low);
421
+ cell.button.disabled = disabled(at);
422
+ if (edgeStart || edgeEnd) cell.button.setAttribute("aria-current", "date");
423
+ else cell.button.removeAttribute("aria-current");
424
+ }
425
+ if (!foot) return;
426
+ foot.replaceChildren();
427
+ const summary = el("span", "drp-summary");
428
+ if (end === null) summary.appendChild(document.createTextNode(t("usage.range.pickEnd")));
429
+ else {
430
+ summary.appendChild(el("strong", null, rangeText(start, end)));
431
+ summary.appendChild(el("span", "muted", " · " + span(start, end)));
432
+ }
433
+ foot.appendChild(summary);
434
+
435
+ const actions = el("span", "drp-actions");
436
+ const cancel = el("button", "drp-cancel", t("usage.range.cancel"));
437
+ cancel.type = "button";
438
+ cancel.addEventListener("click", () => close());
439
+ const apply = el("button", "drp-apply", t("usage.range.apply"));
440
+ apply.type = "button";
441
+ if (end === null) apply.disabled = true;
442
+ else {
443
+ apply.addEventListener("click", () => {
444
+ const from = dayKey(start);
445
+ const to = dayKey(end);
446
+ close();
447
+ options.onApply(from, to);
448
+ });
449
+ }
450
+ actions.appendChild(cancel);
451
+ actions.appendChild(apply);
452
+ foot.appendChild(actions);
453
+ }
454
+
455
+ const renderMonth = (first, className) => {
456
+ const box = el("div", "drp-month " + className);
457
+ box.appendChild(el("div", "drp-month-name", monthName.format(first)));
458
+ const grid = el("div", "drp-grid");
459
+ grid.setAttribute("role", "grid");
460
+ for (const name of weekdays) {
461
+ const cell = el("span", "drp-weekday", name);
462
+ cell.setAttribute("aria-hidden", "true");
463
+ grid.appendChild(cell);
464
+ }
465
+ const date = new Date(first);
466
+ const year = date.getUTCFullYear();
467
+ const index = date.getUTCMonth();
468
+ const length = new Date(Date.UTC(year, index + 1, 0)).getUTCDate();
469
+ const lead = (date.getUTCDay() - firstWeekday + 7) % 7;
470
+ for (let i = 0; i < lead; i += 1) grid.appendChild(el("span"));
471
+ for (let day = 1; day <= length; day += 1) {
472
+ const at = Date.UTC(year, index, day);
473
+ const button = el("button", "drp-day" + (at === maxMs ? " is-today" : ""));
474
+ button.type = "button";
475
+ button.appendChild(el("span", null, String(day)));
476
+ button.setAttribute("aria-label", shortYear.format(at));
477
+ button.addEventListener("click", () => { if (!button.disabled) pick(at); });
478
+ button.addEventListener("mouseenter", () => {
479
+ if (end !== null || button.disabled) return;
480
+ hover = at;
481
+ paintRange();
482
+ });
483
+ cells.push({ at, button });
484
+ grid.appendChild(button);
485
+ }
486
+ box.appendChild(grid);
487
+ return box;
488
+ };
489
+
490
+ function paintPop() {
491
+ if (!pop) return;
492
+ pop.replaceChildren();
493
+ cells = [];
494
+ const nav = el("div", "drp-nav");
495
+ const back = el("button", "drp-arrow");
496
+ back.type = "button";
497
+ back.appendChild(svgIcon("chevronLeft"));
498
+ back.setAttribute("aria-label", t("usage.range.previous"));
499
+ back.addEventListener("click", () => { month = shiftMonth(month, -1); paintPop(); });
500
+ const forward = el("button", "drp-arrow");
501
+ forward.type = "button";
502
+ forward.appendChild(svgIcon("chevronRight"));
503
+ forward.setAttribute("aria-label", t("usage.range.next"));
504
+ if (month >= monthOf(maxMs)) forward.disabled = true;
505
+ else forward.addEventListener("click", () => { month = shiftMonth(month, 1); paintPop(); });
506
+ nav.appendChild(back);
507
+ nav.appendChild(forward);
508
+ pop.appendChild(nav);
509
+
510
+ const months = el("div", "drp-months");
511
+ months.appendChild(renderMonth(shiftMonth(month, -1), "is-first"));
512
+ months.appendChild(renderMonth(month, "is-second"));
513
+ pop.appendChild(months);
514
+
515
+ foot = el("div", "drp-foot");
516
+ pop.appendChild(foot);
517
+ paintRange();
518
+ }
519
+
520
+ /*
521
+ * Kept inside the window.
522
+ *
523
+ * The trigger sits near the left of a panel that can be narrower than two
524
+ * months, and the pop is anchored to it — so on a window this app is
525
+ * routinely used at, Apply was past the right edge of the screen and the
526
+ * calendar had no way to say yes. Measured rather than guessed at: the
527
+ * width depends on the locale's month names.
528
+ */
529
+ function fit() {
530
+ // The narrow layout centres the panel on the screen, so there is nothing
531
+ // to nudge; a measured offset there would drag it back off the edge.
532
+ if (!pop || matchMedia("(max-width:640px)").matches) return;
533
+ pop.style.left = "0px";
534
+ const box = pop.getBoundingClientRect();
535
+ const over = box.right - (document.documentElement.clientWidth - 12);
536
+ if (over > 0) pop.style.left = -Math.min(over, Math.max(box.left - 12, 0)) + "px";
537
+ }
538
+
539
+ // Bound while the panel is up and taken down with it: a click-away handler
540
+ // that outlives its panel is a page that reacts to clicks on nothing.
541
+ const away = (event) => { if (!root.contains(event.target)) close(); };
542
+ const escape = (event) => { if (event.key === "Escape") close(); };
543
+
544
+ function close() {
545
+ if (!open) return;
546
+ open = false;
547
+ document.removeEventListener("pointerdown", away);
548
+ document.removeEventListener("keydown", escape);
549
+ if (pop) pop.remove();
550
+ pop = null;
551
+ // Back to the window the page is showing, so reopening does not resume a
552
+ // half-made choice that was abandoned.
553
+ reset();
554
+ paintTrigger();
555
+ }
556
+
557
+ trigger.addEventListener("click", () => {
558
+ if (open) return void close();
559
+ open = true;
560
+ reset();
561
+ pop = el("div", "drp-pop");
562
+ pop.setAttribute("role", "dialog");
563
+ pop.setAttribute("aria-label", t("usage.range.custom"));
564
+ root.appendChild(pop);
565
+ paintPop();
566
+ fit();
567
+ paintTrigger();
568
+ document.addEventListener("pointerdown", away);
569
+ document.addEventListener("keydown", escape);
570
+ });
571
+
572
+ paintTrigger();
573
+ return root;
574
+ }
575
+
576
+ /**
577
+ * What was spent over time, one line per model.
578
+ *
579
+ * Drawn by hand in SVG for the same reason every icon here is: the page
580
+ * carries no external scripts, and a chart library would be the first
581
+ * exception for something that is forty lines of arithmetic.
582
+ *
583
+ * Bars rather than lines, because a day's spend is an interval total and not
584
+ * a sample of something continuous. There is no value between Tuesday and
585
+ * Wednesday, but a line segment drawn between them asserts one — and on a day
586
+ * with no work it draws a V that reads as a dip in a trajectory rather than
587
+ * as what it is, which is a day nobody worked. A missing bar cannot be
588
+ * misread.
589
+ *
590
+ * Stacked by model, which is the thing stacked *areas* are bad at and stacked
591
+ * bars are not: every column starts from the same baseline, so the reading
592
+ * that matters — how tall is this day against that one — is unaffected by
593
+ * what is underneath. The exact per-model numbers live in the readout under
594
+ * the pointer and in the list below, which is a better place to compare them
595
+ * than any chart.
596
+ *
597
+ * The models drawn are the ones with a share worth a band; the rest are
598
+ * summed into one so the columns still add up to the total above them.
599
+ */
600
+ function usageChart(body, models) {
601
+ const series = body.series || {};
602
+ const buckets = series.buckets || [];
603
+ const card = el("div", "row-card chart-card");
604
+ if (buckets.length < 2) return card;
605
+
606
+ // Five, then everything else together: past that the legend is longer than
607
+ // the chart and the lines are indistinguishable anyway.
608
+ const named = models.slice(0, 5).map((entry) => entry.model);
609
+ const others = models.slice(5).map((entry) => entry.model);
610
+ const lines = named.map((model) => ({
611
+ model,
612
+ points: buckets.map((bucket) => bucket.models[model] || 0),
613
+ }));
614
+ if (others.length) {
615
+ lines.push({
616
+ model: t("usage.otherModels", { n: others.length }),
617
+ points: buckets.map((bucket) =>
618
+ others.reduce((sum, model) => sum + (bucket.models[model] || 0), 0)),
619
+ });
620
+ }
621
+
622
+ const peak = Math.max(...buckets.map((bucket) => bucket.costUsd), 0.0001);
623
+ const W = 640;
624
+ const H = 150;
625
+ // A column per bucket, with a hair between them — and no gap at all once
626
+ // there are enough buckets that the gap would be wider than the bar.
627
+ const slot = W / buckets.length;
628
+ const gap = buckets.length > 60 ? 0 : Math.min(2, slot * 0.25);
629
+ const bw = Math.max(slot - gap, 0.5);
630
+ const bx = (i) => i * slot + gap / 2;
631
+ const height = (value) => (value / peak) * H;
632
+
633
+ const NS = "http://www.w3.org/2000/svg";
634
+ const svg = document.createElementNS(NS, "svg");
635
+ svg.setAttribute("viewBox", "0 -6 " + W + " " + (H + 12));
636
+ svg.setAttribute("class", "chart");
637
+ svg.setAttribute("preserveAspectRatio", "none");
638
+
639
+ // A line at the peak and one at the floor, so a curve has something to be
640
+ // high against. Nothing between them: gridlines on a chart this short are
641
+ // texture, not information.
642
+ for (const at of [0, H]) {
643
+ const rule = document.createElementNS(NS, "line");
644
+ rule.setAttribute("x1", "0");
645
+ rule.setAttribute("x2", String(W));
646
+ rule.setAttribute("y1", String(at));
647
+ rule.setAttribute("y2", String(at));
648
+ rule.setAttribute("class", "chart-rule");
649
+ svg.appendChild(rule);
650
+ }
651
+
652
+ // Behind the columns, so the one under the pointer is picked out without
653
+ // anything being drawn over it.
654
+ const highlight = document.createElementNS(NS, "rect");
655
+ highlight.setAttribute("y", "0");
656
+ highlight.setAttribute("height", String(H));
657
+ highlight.setAttribute("width", String(slot));
658
+ highlight.setAttribute("class", "chart-hover");
659
+ highlight.setAttribute("opacity", "0");
660
+ svg.appendChild(highlight);
661
+
662
+ buckets.forEach((bucket, i) => {
663
+ let top = H;
664
+ // Drawn from the bottom up in the legend's order, so a model keeps the
665
+ // same place in every column and the eye can follow one band across.
666
+ lines.forEach((line, index) => {
667
+ const value = line.points[i] || 0;
668
+ if (!value) return;
669
+ const h = height(value);
670
+ top -= h;
671
+ const bar = document.createElementNS(NS, "rect");
672
+ bar.setAttribute("x", String(bx(i)));
673
+ bar.setAttribute("y", String(top));
674
+ bar.setAttribute("width", String(bw));
675
+ bar.setAttribute("height", String(Math.max(h, 0.75)));
676
+ bar.setAttribute("class", "chart-bar s" + (index % 6));
677
+ svg.appendChild(bar);
678
+ });
679
+ });
680
+ card.appendChild(svg);
681
+
682
+ // What the chart is worth reading against: the tallest column, and the two
683
+ // ends of the window. Rendered as text beside the plot rather than as axis
684
+ // ticks, which at this size would be four numbers fighting for two pixels.
685
+ const scale = el("div", "chart-scale");
686
+ const reading = el("span", null, "$" + peak.toFixed(2));
687
+ scale.appendChild(reading);
688
+ scale.appendChild(el("span", "muted", t(series.step === "week" ? "usage.perWeek" : "usage.perDay")));
689
+ card.appendChild(scale);
690
+
691
+ /*
692
+ * What the column under the pointer says.
693
+ *
694
+ * Six lines and no readout is a picture of a shape, not a number: the spike
695
+ * is visible and unattributable. Rather than a floating tooltip — which
696
+ * needs placement, an edge case at both ends, and something to dismiss it —
697
+ * the line that was already showing the peak shows the hovered column
698
+ * instead, and goes back to the peak when the pointer leaves.
699
+ */
700
+ svg.addEventListener("mousemove", (e) => {
701
+ const box = svg.getBoundingClientRect();
702
+ const raw = Math.floor(((e.clientX - box.left) / box.width) * buckets.length);
703
+ const at = Math.max(0, Math.min(buckets.length - 1, raw));
704
+ const bucket = buckets[at];
705
+ highlight.setAttribute("x", String(at * slot));
706
+ highlight.setAttribute("opacity", "1");
707
+ // The model that carried the column, when one of them did: on a day with
708
+ // one model it is the whole answer, and on a mixed one it is the part
709
+ // worth reading before the legend.
710
+ const top = lines
711
+ .map((line) => ({ model: line.model, cost: line.points[at] || 0 }))
712
+ .sort((a, b) => b.cost - a.cost)[0];
713
+ const on = new Date(bucket.at).toLocaleDateString();
714
+ reading.textContent = bucket.costUsd
715
+ ? on + " · $" + bucket.costUsd.toFixed(2) + (top && top.cost ? " · " + top.model : "")
716
+ : on + " · " + t("usage.nothing");
717
+ });
718
+ svg.addEventListener("mouseleave", () => {
719
+ highlight.setAttribute("opacity", "0");
720
+ reading.textContent = "$" + peak.toFixed(2);
721
+ });
722
+
723
+ const span = el("div", "chart-span muted small");
724
+ span.appendChild(el("span", null, new Date(buckets[0].at).toLocaleDateString()));
725
+ span.appendChild(el("span", null, new Date(buckets[buckets.length - 1].at).toLocaleDateString()));
726
+ card.appendChild(span);
727
+
728
+ const legend = el("div", "chart-legend");
729
+ lines.forEach((line, index) => {
730
+ const entry = el("span", "legend-item");
731
+ entry.appendChild(el("span", "swatch s" + (index % 6)));
732
+ entry.appendChild(el("span", null, line.model));
733
+ legend.appendChild(entry);
734
+ });
735
+ card.appendChild(legend);
736
+ return card;
737
+ }
738
+
739
+ async function showUsage() {
740
+ // The days ride along only when they are the window being asked for; the
741
+ // server ignores them otherwise, and a preset request that carried a stale
742
+ // pair would be one keystroke from answering for the wrong fortnight.
743
+ const asked = usageRange === "custom" && usageFrom && usageTo
744
+ ? "/usage?range=custom&from=" + encodeURIComponent(usageFrom) + "&to=" + encodeURIComponent(usageTo)
745
+ : "/usage?range=" + encodeURIComponent(usageRange);
746
+ const body = await getJson(asked);
747
+ panelBody.replaceChildren();
748
+ if (body.error) return void panelBody.appendChild(panelError(body.error));
749
+
750
+ const panel = el("div", "panel usage");
751
+ const head = el("div", "row-head");
752
+ head.appendChild(el("h2", null, t("usage.title")));
753
+ // Back to where this was opened from, because it was opened from a row in
754
+ // Settings and the sidebar row it lit is already showing as active — with
755
+ // no way out, the only exit is a sidebar click that looks like a no-op.
756
+ const back = el("button", "ghost", t("usage.back"));
757
+ back.type = "button";
758
+ back.addEventListener("click", () => {
759
+ setNav("nav-settings");
760
+ void showSettings();
761
+ });
762
+ head.appendChild(back);
763
+ panel.appendChild(head);
764
+
765
+ /*
766
+ * How far back the page is asking about.
767
+ *
768
+ * A segmented control rather than a dropdown: there are four answers, they
769
+ * are read left to right as increasing time, and a menu would hide three of
770
+ * them behind the one that happens to be chosen. It reloads rather than
771
+ * filtering what is already here — the totals, the models and the sessions
772
+ * all answer for the window, and a page where the chart moved and the
773
+ * figures above it did not would be lying about one of them.
774
+ */
775
+ const picked = body.range || "30d";
776
+ // What the server says the window is, which is not always what was asked
777
+ // for: a picked range is clamped to today and to a year, and the calendar
778
+ // has to open on the days the figures beside it are actually counting.
779
+ const today = new Date().toISOString().slice(0, 10);
780
+ const shownFrom = body.from || today;
781
+ const shownTo = body.to || today;
782
+ const windowRow = el("div", "usage-window");
783
+ const ranges = el("div", "seg");
784
+ for (const id of ["7d", "30d", "90d", "all"]) {
785
+ const button = el("button", "seg-item" + (id === picked ? " on" : ""), t("usage.range." + id));
786
+ button.type = "button";
787
+ button.addEventListener("click", () => {
788
+ if (id === picked) return;
789
+ usageRange = id;
790
+ void showUsage();
791
+ });
792
+ ranges.appendChild(button);
793
+ }
794
+ windowRow.appendChild(ranges);
795
+ /*
796
+ * A window the presets cannot express.
797
+ *
798
+ * Beside them rather than as a fifth item on the same control: the presets
799
+ * are one click each and this one opens a calendar, and a segmented control
800
+ * whose last segment behaves differently from the other four is a control
801
+ * that has to be learned. It shows the days it is showing once it is the
802
+ * chosen window, so the page never has a range on it that nothing on screen
803
+ * names.
804
+ */
805
+ windowRow.appendChild(dateRangePicker({
806
+ from: shownFrom,
807
+ to: shownTo,
808
+ max: today,
809
+ maxDays: MAX_CUSTOM_DAYS,
810
+ active: picked === "custom",
811
+ onApply: (from, to) => {
812
+ usageRange = "custom";
813
+ usageFrom = from;
814
+ usageTo = to;
815
+ void showUsage();
816
+ },
817
+ }));
818
+ panel.appendChild(windowRow);
819
+
820
+ // Prompt tokens only. The cache share is a share of what was sent, and
821
+ // nothing that was generated could have come from a cache.
822
+ const sent = (row) => (row.input || 0) + (row.cacheRead || 0) + (row.cacheWrite || 0);
823
+ const moved = (row) => sent(row) + (row.output || 0);
824
+ // Null unless something actually came back out of the cache. A provider
825
+ // without a prompt cache reports no cache tokens at all, and "0% cached"
826
+ // against its name reads as a cache that keeps missing rather than one that
827
+ // was never there.
828
+ const cachePct = (row) =>
829
+ row.cacheRead ? Math.round((row.cacheRead / sent(row)) * 100) : null;
830
+
831
+ // Two strings rather than "1 session(s)": every other count on this page is
832
+ // read as a sentence, and one that cannot decide its own plural reads as a
833
+ // machine talking.
834
+ const overSessions = (n) =>
835
+ n === 1 ? t("usage.overSession") : t("usage.overSessions", { n });
836
+
837
+ const totals = body.totals || {};
838
+ const figures = el("div", "row-card figures");
839
+ const figure = (label, value, sub) => {
840
+ const box = el("div", "figure");
841
+ box.appendChild(el("div", "figure-label", label));
842
+ box.appendChild(el("div", "figure-value", value));
843
+ if (sub) box.appendChild(el("div", "muted small", sub));
844
+ figures.appendChild(box);
845
+ };
846
+ // Two decimals, not four. This is a bill, and a bill is read in cents; the
847
+ // fourth decimal belongs on the line under the composer, where the number
848
+ // being watched is what one more message adds.
849
+ figure(
850
+ t("usage.spend"),
851
+ "$" + (totals.costUsd || 0).toFixed(2),
852
+ overSessions(totals.sessions || 0),
853
+ );
854
+ figure(
855
+ t("usage.tokensLabel"),
856
+ formatTokens(moved(totals)),
857
+ t("usage.inOut", {
858
+ in: formatTokens(sent(totals)),
859
+ out: formatTokens(totals.output || 0),
860
+ }),
861
+ );
862
+ // An em dash, not 0%: a provider without a prompt cache reports no cache
863
+ // tokens at all, and "0% cached" would read as a cache that keeps missing.
864
+ const pct = cachePct(totals);
865
+ figure(
866
+ t("usage.cacheLabel"),
867
+ pct === null ? "—" : pct + "%",
868
+ t("usage.cacheSplit", {
869
+ read: formatTokens(totals.cacheRead || 0),
870
+ written: formatTokens(totals.cacheWrite || 0),
871
+ }),
872
+ );
873
+ panel.appendChild(figures);
874
+
875
+ const models = body.models || [];
876
+ if (models.length) panel.appendChild(usageChart(body, models));
877
+
878
+ if (models.length) {
879
+ panel.appendChild(el("h3", "section-title", t("usage.byModel")));
880
+ for (const entry of models) {
881
+ const row = el("div", "row-card");
882
+ const line = el("div", "row-head");
883
+ line.appendChild(el("span", "row-title", entry.model));
884
+ line.appendChild(el("span", "muted small", "$" + (entry.costUsd || 0).toFixed(2)));
885
+ row.appendChild(line);
886
+ const bits = [
887
+ t("usage.tokens", { n: formatTokens(moved(entry)) }),
888
+ overSessions(entry.sessions),
889
+ ];
890
+ const share = cachePct(entry);
891
+ if (share !== null) bits.push(t("usage.cached", { pct: share }));
892
+ row.appendChild(el("div", "muted small", bits.join(" · ")));
893
+ panel.appendChild(row);
894
+ }
895
+ }
896
+
897
+ const sessions = body.sessions || [];
898
+ if (sessions.length) {
899
+ panel.appendChild(el("h3", "section-title", t("usage.bySession")));
900
+ for (const entry of sessions) {
901
+ const row = el("div", "row-card");
902
+ const line = el("div", "row-head");
903
+ line.appendChild(el("span", "row-title", entry.title || t("session.untitled")));
904
+ if (entry.current) line.appendChild(el("span", "tag", t("usage.thisSession")));
905
+ line.appendChild(el("span", "muted small", "$" + (entry.costUsd || 0).toFixed(4)));
906
+ row.appendChild(line);
907
+ const detail = [
908
+ ago(entry.updatedAt),
909
+ entry.model,
910
+ t("usage.tokens", { n: formatTokens(moved(entry)) }),
911
+ ];
912
+ // The reason one session cost what it did is usually here: two runs of
913
+ // the same length differ by what the cache carried, and per-session is
914
+ // the only place that comparison can be made.
915
+ const hit = cachePct(entry);
916
+ if (hit !== null) detail.push(t("usage.cached", { pct: hit }));
917
+ row.appendChild(el("div", "muted small", detail.join(" · ")));
918
+ panel.appendChild(row);
919
+ }
920
+ }
921
+
922
+ if (!models.length) panel.appendChild(el("p", "muted", t("usage.none")));
923
+
924
+ // What the numbers cover, said in the place where someone would otherwise
925
+ // assume they cover everything.
926
+ panel.appendChild(el("p", "muted small",
927
+ body.truncated
928
+ ? t("usage.scopeCapped", { n: body.scanned })
929
+ : t("usage.scope")));
930
+ panelBody.appendChild(panel);
931
+ }
932
+
933
+ /**
934
+ * Every artifact this machine has made, in one place, with a way to find one.
935
+ *
936
+ * The transcript card that announced an artifact is still its index, and for
937
+ * one or two that is enough. It stops being enough at three: the shelf shows
938
+ * one artifact at a time and deliberately carries no list of its own, so
939
+ * getting back to the first one meant scrolling the conversation until the
940
+ * card came past. This is that list, and it is the only place in the app that
941
+ * has one.
942
+ *
943
+ * It used to show only what the process had loaded — the open tabs — because
944
+ * that is where the artifacts live in memory. That made the answer depend on
945
+ * which tabs happened to be open: a chart from last week was simply absent,
946
+ * and the note at the bottom explaining why did not make it findable. The
947
+ * server folds the revision logs now, so what is listed is what exists.
948
+ *
949
+ * Which is also why there is a search box. A list of everything is only a
950
+ * better answer than a list of some things if you can get to the one you
951
+ * want, and by the second week that means typing part of its name rather than
952
+ * scrolling a page of sessions.
953
+ */`;
954
+ //# sourceMappingURL=usage.js.map