@oharness/server 0.7.1 → 0.9.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/dist/browser-pane.d.ts +48 -0
- package/dist/browser-pane.js +185 -0
- package/dist/browser-pane.js.map +1 -0
- package/dist/files.d.ts +40 -1
- package/dist/files.js +94 -3
- package/dist/files.js.map +1 -1
- package/dist/i18n.js +190 -0
- package/dist/i18n.js.map +1 -1
- package/dist/main.js +20 -0
- package/dist/main.js.map +1 -1
- package/dist/page/client/account.d.ts +8 -0
- package/dist/page/client/account.js +1895 -0
- package/dist/page/client/account.js.map +1 -0
- package/dist/page/client/artifacts.d.ts +7 -0
- package/dist/page/client/artifacts.js +767 -0
- package/dist/page/client/artifacts.js.map +1 -0
- package/dist/page/client/base.d.ts +8 -0
- package/dist/page/client/base.js +404 -0
- package/dist/page/client/base.js.map +1 -0
- package/dist/page/client/browser.d.ts +21 -0
- package/dist/page/client/browser.js +906 -0
- package/dist/page/client/browser.js.map +1 -0
- package/dist/page/client/composer.d.ts +8 -0
- package/dist/page/client/composer.js +1714 -0
- package/dist/page/client/composer.js.map +1 -0
- package/dist/page/client/dialogs.d.ts +7 -0
- package/dist/page/client/dialogs.js +724 -0
- package/dist/page/client/dialogs.js.map +1 -0
- package/dist/page/client/events.d.ts +7 -0
- package/dist/page/client/events.js +288 -0
- package/dist/page/client/events.js.map +1 -0
- package/dist/page/client/files.d.ts +7 -0
- package/dist/page/client/files.js +412 -0
- package/dist/page/client/files.js.map +1 -0
- package/dist/page/client/index.d.ts +16 -0
- package/dist/page/client/index.js +50 -0
- package/dist/page/client/index.js.map +1 -0
- package/dist/page/client/panels.d.ts +7 -0
- package/dist/page/client/panels.js +659 -0
- package/dist/page/client/panels.js.map +1 -0
- package/dist/page/client/pickers.d.ts +7 -0
- package/dist/page/client/pickers.js +624 -0
- package/dist/page/client/pickers.js.map +1 -0
- package/dist/page/client/routines.d.ts +6 -0
- package/dist/page/client/routines.js +928 -0
- package/dist/page/client/routines.js.map +1 -0
- package/dist/page/client/shell.d.ts +6 -0
- package/dist/page/client/shell.js +700 -0
- package/dist/page/client/shell.js.map +1 -0
- package/dist/page/client/state.d.ts +7 -0
- package/dist/page/client/state.js +1283 -0
- package/dist/page/client/state.js.map +1 -0
- package/dist/page/client/terminal.d.ts +7 -0
- package/dist/page/client/terminal.js +347 -0
- package/dist/page/client/terminal.js.map +1 -0
- package/dist/page/client/transcript.d.ts +8 -0
- package/dist/page/client/transcript.js +866 -0
- package/dist/page/client/transcript.js.map +1 -0
- package/dist/page/client/usage.d.ts +7 -0
- package/dist/page/client/usage.js +954 -0
- package/dist/page/client/usage.js.map +1 -0
- package/dist/page/style.d.ts +1 -0
- package/dist/page/style.js +3016 -0
- package/dist/page/style.js.map +1 -0
- package/dist/page.js +96 -13871
- package/dist/page.js.map +1 -1
- package/dist/server.d.ts +24 -0
- package/dist/server.js +492 -23
- package/dist/server.js.map +1 -1
- package/dist/terminal.d.ts +34 -0
- package/dist/terminal.js +211 -0
- package/dist/terminal.js.map +1 -0
- package/package.json +9 -8
|
@@ -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
|