codex-copilot-dx 0.9.5 → 0.9.6
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/README.md +4 -0
- package/package.json +1 -1
- package/src/dashboard/index.html +25 -9
- package/src/dashboard/ui.css +42 -1
- package/src/dashboard/ui.js +210 -23
- package/src/dashboard-api.mjs +18 -3
- package/src/usage-analytics.mjs +77 -0
- package/src/usage.mjs +2 -1
package/README.md
CHANGED
|
@@ -236,6 +236,10 @@ The dashboard loads local status, a live GitHub Copilot model directory, and a s
|
|
|
236
236
|
|
|
237
237
|
The transparent, single-color double-peak favicon follows the dashboard's saved light/dark choice without a separate network request.
|
|
238
238
|
|
|
239
|
+
Usage Analytics is collapsed by default. Opening it adds daily input/output token bars (7/30/90 days) and a 365-day calendar of recorded usage calls or tokens, grouped in the browser's time zone. Cache-read tokens are already part of input and are not added again. Model and range filters use the current snapshot without additional requests; selecting a day shows its filtered model totals in the Usage table, and All history restores the original table. Analytics shares the existing current/rotated-log scan and exposes only bounded metadata (365 days, up to 100 model filters). Days before retained history are hatched, not presented as zero; the oldest retained day may be partial. Counts describe retained usage records, not messages, session time, or all HTTP requests. Refresh explicitly to update the snapshot; there is no background collection or polling.
|
|
240
|
+
|
|
241
|
+
Terminal Animation is collapsed at the bottom of the page, with an Animation shortcut in the header. Preview frames load only when opened; closing it, hiding the page, or requesting reduced motion stops the preview timer. Unsaved choices survive folding and reopening. Recent upstream failures expand to existing diagnostic fields (time, model, code, message, response/request IDs, and retry policy) with a Copy diagnostic control. These are the last 10 retained `response.failed` events, not complete session histories; a retry attempt does not prove recovery. Diagnostic prose is redacted by the existing recorder but can still contain sensitive context: review before copying or sharing.
|
|
242
|
+
|
|
239
243
|
Inspect only the local Codex configuration:
|
|
240
244
|
|
|
241
245
|
```bash
|
package/package.json
CHANGED
package/src/dashboard/index.html
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
<div class="shell">
|
|
15
15
|
<header class="topbar">
|
|
16
16
|
<div class="brand">CCDX <span class="brand-detail">/ LOCAL STATUS</span></div>
|
|
17
|
-
<div class="topbar-actions"><span id="connection" class="badge neutral" role="status">READING</span><button id="theme-toggle" class="icon-button" type="button" aria-label="Switch to light mode" title="Switch to light mode"><svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.93 4.93l1.42 1.42m11.3 11.3 1.42 1.42M2 12h2m16 0h2M4.93 19.07l1.42-1.42m11.3-11.3 1.42-1.42"/></svg><svg class="icon-moon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M12 3A9 9 0 1 0 21 12 7 7 0 0 1 12 3Z"/></svg></button><button id="refresh" type="button">Refresh</button></div>
|
|
17
|
+
<div class="topbar-actions"><span id="connection" class="badge neutral" role="status">READING</span><button id="theme-toggle" class="icon-button" type="button" aria-label="Switch to light mode" title="Switch to light mode"><svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.93 4.93l1.42 1.42m11.3 11.3 1.42 1.42M2 12h2m16 0h2M4.93 19.07l1.42-1.42m11.3-11.3 1.42-1.42"/></svg><svg class="icon-moon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M12 3A9 9 0 1 0 21 12 7 7 0 0 1 12 3Z"/></svg></button><button id="open-animation" type="button" aria-controls="animation-settings" aria-expanded="false">Animation</button><button id="refresh" type="button">Refresh</button></div>
|
|
18
18
|
</header>
|
|
19
19
|
|
|
20
20
|
<main>
|
|
@@ -47,12 +47,6 @@
|
|
|
47
47
|
</article>
|
|
48
48
|
</section>
|
|
49
49
|
|
|
50
|
-
<section class="panel dashboard-section" aria-label="Terminal animation">
|
|
51
|
-
<div class="panel-heading"><h2>TERMINAL ANIMATION</h2><span id="animation-state">READING</span></div>
|
|
52
|
-
<div id="animation-options" class="animation-options" role="group" aria-label="Animation theme"></div>
|
|
53
|
-
<div class="section-actions"><button id="save-animation" type="button" disabled>Save selection</button><span id="animation-note" role="status">Applies on next ccdx start.</span></div>
|
|
54
|
-
</section>
|
|
55
|
-
|
|
56
50
|
<section class="panel dashboard-section" aria-label="Live upstream models">
|
|
57
51
|
<div class="panel-heading"><h2>UPSTREAM GPT MODELS</h2><div class="heading-actions"><span id="models-state">CHECKING LIVE</span><button id="refresh-models" type="button">Refresh</button></div></div>
|
|
58
52
|
<p id="models-note" class="table-note">Querying GitHub Copilot…</p>
|
|
@@ -61,17 +55,39 @@
|
|
|
61
55
|
</section>
|
|
62
56
|
|
|
63
57
|
<section class="panel dashboard-section" aria-label="Usage by model">
|
|
64
|
-
<div class="panel-heading"><h2>USAGE</h2><div class="heading-actions"><span id="usage-state">READING LOG</span><button id="refresh-usage" type="button">Refresh</button></div></div>
|
|
58
|
+
<div class="panel-heading"><h2>USAGE</h2><div class="heading-actions"><span id="usage-state">READING LOG</span><button id="clear-usage-day" type="button" hidden>All history</button><button id="refresh-usage" type="button">Refresh</button></div></div>
|
|
65
59
|
<p id="usage-note" class="table-note">Aggregating local usage metadata…</p>
|
|
66
60
|
<div class="table-wrap"><table class="usage-table"><thead><tr><th scope="col">MODEL</th><th scope="col">CALLS</th><th scope="col">INPUT</th><th scope="col">CACHE READ</th><th scope="col">OUTPUT</th><th scope="col">TOTAL</th><th scope="col">CACHE HIT</th></tr></thead><tbody id="usage-body"><tr><td colspan="7">Loading…</td></tr></tbody></table></div>
|
|
67
61
|
<p class="hint">Cache hit = recorded cached / input tokens. ~ = incomplete history. — = unavailable or invalid counts.</p>
|
|
62
|
+
<details id="usage-analytics" class="analytics-section">
|
|
63
|
+
<summary>USAGE ANALYTICS</summary>
|
|
64
|
+
<div class="analytics-controls">
|
|
65
|
+
<label>Model <select id="analytics-model"><option value="">All models</option></select></label>
|
|
66
|
+
<label>Daily range <select id="analytics-range"><option value="7">7 days</option><option value="30" selected>30 days</option><option value="90">90 days</option></select></label>
|
|
67
|
+
<label>Activity <select id="analytics-metric"><option value="requests">Calls</option><option value="tokens">Tokens</option></select></label>
|
|
68
|
+
</div>
|
|
69
|
+
<p id="analytics-note" class="table-note" role="status">Open to read retained usage history.</p>
|
|
70
|
+
<div class="chart-heading"><h3>DAILY TOKENS</h3><span>INPUT + OUTPUT / CACHE INCLUDED IN INPUT</span></div>
|
|
71
|
+
<div id="daily-bars" class="daily-bars" aria-label="Daily token usage"></div>
|
|
72
|
+
<div class="daily-axis"><span id="daily-from"></span><span id="daily-to"></span></div>
|
|
73
|
+
<div class="chart-legend"><span class="legend-input">Input</span><span class="legend-output">Output</span></div>
|
|
74
|
+
<div class="chart-heading"><h3>REQUEST ACTIVITY · 365 DAYS</h3><span id="activity-scale">—</span></div>
|
|
75
|
+
<div class="calendar-scroll"><div class="calendar-grid"><div id="calendar-months" class="calendar-months" aria-hidden="true"></div><div id="activity-calendar" class="activity-calendar" aria-label="Daily recorded request activity"></div></div></div>
|
|
76
|
+
<p id="analytics-selection" class="hint" role="status">Select a day to inspect its model totals. No history is distinct from zero recorded calls.</p>
|
|
77
|
+
</details>
|
|
68
78
|
</section>
|
|
69
79
|
|
|
70
80
|
<section class="lower" aria-label="Recent failures">
|
|
71
|
-
<div class="panel-heading"><h2>RECENT UPSTREAM FAILURES</h2><span>response.failed</span></div>
|
|
81
|
+
<div class="panel-heading"><h2>RECENT UPSTREAM FAILURES</h2><span id="failure-count">response.failed</span></div>
|
|
72
82
|
<ol id="failures"><li class="empty">Reading…</li></ol>
|
|
73
83
|
<p class="hint">Upstream messages may contain sensitive text.</p>
|
|
74
84
|
</section>
|
|
85
|
+
|
|
86
|
+
<details id="animation-settings" class="panel dashboard-section settings-panel">
|
|
87
|
+
<summary class="panel-heading"><h2>TERMINAL ANIMATION</h2><span id="animation-state">READING</span></summary>
|
|
88
|
+
<div id="animation-options" class="animation-options" role="group" aria-label="Animation theme"></div>
|
|
89
|
+
<div class="section-actions"><button id="save-animation" type="button" disabled>Save selection</button><span id="animation-note" role="status">Applies on next ccdx start.</span></div>
|
|
90
|
+
</details>
|
|
75
91
|
</main>
|
|
76
92
|
|
|
77
93
|
<footer>LOCAL ONLY / NO BACKGROUND POLLING</footer>
|
package/src/dashboard/ui.css
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
:root { color-scheme: dark; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; --page: #0b110d; --surface: #111a13; --text: #e0e9df; --strong: #f0f6ec; --muted: #9aada0; --badge: #a9b9aa; --subtle: #78917d; --quiet: #6d8371; --footer: #617966; --border: #2b3b2f; --top-border: #2a3a2e; --accent: #a6f5a8; --accent-soft: #84e99e; --heading: #83b98b; --bad: #ff947e; --failed: #ff9f91; --incomplete: #e3c77c; --button-border: #45634b; --button-bg: #152219; --button-fg: #b2f1bd; --button-hover: #203526; --selected-bg: #192a1d; --option-hover: #1a2a1e; --table-text: #d8e2d8; --preview: #657d69; --ansi-white: #f5fff5; --ansi-159: #9af5e9; --ansi-123: #75e4d9; --ansi-87: #5ad0d4; --ansi-51: #42b9d8; --ansi-45: #3ca2d0; --ansi-39: #4389c2; --ansi-33: #4775b2; }
|
|
2
2
|
:root[data-theme="light"] { color-scheme: light; --page: #f5f8f3; --surface: #ffffff; --text: #1b2b20; --strong: #122219; --muted: #475d4e; --badge: #475d4e; --subtle: #4b6652; --quiet: #55715c; --footer: #59735f; --border: #c4d2c5; --top-border: #b8c9ba; --accent: #176b3a; --accent-soft: #176b3a; --heading: #235f37; --bad: #a33625; --failed: #a33625; --incomplete: #835600; --button-border: #79a085; --button-bg: #ebf4ec; --button-fg: #185b32; --button-hover: #dbeedd; --selected-bg: #e4f3e7; --option-hover: #edf6ee; --table-text: #233b2b; --preview: #596f60; --ansi-white: #234231; --ansi-159: #116b64; --ansi-123: #0d7771; --ansi-87: #116b75; --ansi-51: #14697e; --ansi-45: #1a6183; --ansi-39: #265f8d; --ansi-33: #365a8d; }
|
|
3
3
|
* { box-sizing: border-box; }
|
|
4
|
+
[hidden] { display: none !important; }
|
|
4
5
|
body { margin: 0; color: var(--text); background: var(--page); font-size: 13px; }
|
|
5
6
|
.shell { max-width: 1220px; margin: 0 auto; padding: 0 24px; }
|
|
6
7
|
.topbar { min-height: 68px; display: flex; justify-content: space-between; align-items: center; gap: 20px; border-bottom: 1px solid var(--top-border); }
|
|
@@ -78,12 +79,52 @@ td { color: var(--table-text); }
|
|
|
78
79
|
.usage-table tr.total-row td { color: var(--accent); font-weight: 700; }
|
|
79
80
|
.lower { margin-top: 11px; padding: 18px; }
|
|
80
81
|
.lower ol { list-style: none; margin: 16px 0 0; padding: 0; }
|
|
81
|
-
.lower li {
|
|
82
|
+
.lower li { padding: 9px 0; border-top: 1px solid var(--border); font-size: 11px; }
|
|
82
83
|
.lower li strong, .lower li span { overflow-wrap: anywhere; }
|
|
83
84
|
.lower li strong { color: var(--text); font-weight: 650; }
|
|
84
85
|
.lower li span, .lower li.empty { color: var(--muted); line-height: 1.5; }
|
|
86
|
+
summary { cursor: pointer; line-height: 1.6; overflow-wrap: anywhere; }
|
|
87
|
+
summary:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
|
88
|
+
.settings-panel > summary { list-style: none; align-items: center; }
|
|
89
|
+
.settings-panel > summary::-webkit-details-marker { display: none; }
|
|
90
|
+
.settings-panel > summary h2::before { content: "+ "; }
|
|
91
|
+
.settings-panel[open] > summary h2::before { content: "− "; }
|
|
92
|
+
.failure-detail { display: grid; grid-template-columns: minmax(110px, auto) minmax(0, 1fr); gap: 7px 18px; margin: 14px 0; line-height: 1.6; }
|
|
93
|
+
.failure-detail dt { color: var(--muted); }
|
|
94
|
+
.failure-detail dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
|
|
95
|
+
.analytics-section { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); }
|
|
96
|
+
.analytics-section > summary { color: var(--heading); font-size: 11px; font-weight: 700; letter-spacing: .08em; }
|
|
97
|
+
.analytics-controls { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 18px; }
|
|
98
|
+
.analytics-controls label { color: var(--muted); font-size: 11px; }
|
|
99
|
+
select { max-width: 260px; padding: 5px; font: inherit; color: var(--text); background: var(--page); border: 1px solid var(--border); }
|
|
100
|
+
.chart-heading { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin: 22px 0 10px; }
|
|
101
|
+
.chart-heading h3 { margin: 0; color: var(--heading); font-size: 11px; }
|
|
102
|
+
.chart-heading span, .chart-legend { color: var(--muted); font-size: 10px; }
|
|
103
|
+
.daily-bars { display: flex; align-items: end; gap: 3px; height: 130px; border-bottom: 1px solid var(--border); }
|
|
104
|
+
.daily-bar { height: 100%; flex: 1; min-width: 0; padding: 0; border: 0; display: flex; flex-direction: column; justify-content: end; background: transparent; border-radius: 0; }
|
|
105
|
+
.daily-bar span { display: block; flex-shrink: 0; width: 100%; }
|
|
106
|
+
.bar-input { background: var(--accent-soft); }
|
|
107
|
+
.bar-output { background: var(--ansi-39); }
|
|
108
|
+
.daily-bar:hover { background: var(--option-hover); }
|
|
109
|
+
.daily-axis { display: flex; justify-content: space-between; color: var(--muted); font-size: 10px; margin-top: 7px; }
|
|
110
|
+
.daily-bar[aria-pressed="true"], .calendar-day[aria-pressed="true"] { outline: 2px solid var(--strong); outline-offset: 1px; }
|
|
111
|
+
.chart-legend { display: flex; gap: 18px; margin-top: 9px; }
|
|
112
|
+
.chart-legend span::before { content: "■"; margin-right: 6px; }
|
|
113
|
+
.legend-input::before { color: var(--accent-soft); }
|
|
114
|
+
.legend-output::before { color: var(--ansi-39); }
|
|
115
|
+
.calendar-scroll { overflow-x: auto; padding: 4px; }
|
|
116
|
+
.calendar-grid { width: max-content; }
|
|
117
|
+
.calendar-months { display: grid; grid-auto-columns: 12px; gap: 4px; margin-bottom: 7px; color: var(--muted); font-size: 9px; height: 14px; }
|
|
118
|
+
.activity-calendar { display: grid; grid-template-rows: repeat(7, 12px); grid-auto-flow: column; grid-auto-columns: 12px; gap: 4px; width: max-content; }
|
|
119
|
+
.calendar-day { padding: 0; border: 1px solid var(--border); width: 12px; height: 12px; background: var(--button-bg); }
|
|
120
|
+
.calendar-day[data-level="1"] { background: color-mix(in srgb, var(--accent-soft) 25%, var(--surface)); }
|
|
121
|
+
.calendar-day[data-level="2"] { background: color-mix(in srgb, var(--accent-soft) 45%, var(--surface)); }
|
|
122
|
+
.calendar-day[data-level="3"] { background: color-mix(in srgb, var(--accent-soft) 70%, var(--surface)); }
|
|
123
|
+
.calendar-day[data-level="4"] { background: var(--accent-soft); }
|
|
124
|
+
.calendar-day:disabled, .daily-bar:disabled { background: repeating-linear-gradient(135deg, transparent, transparent 3px, var(--border) 3px, var(--border) 4px); opacity: .5; cursor: default; }
|
|
85
125
|
footer { color: var(--footer); font-size: 10px; letter-spacing: .05em; padding: 22px 0 30px; }
|
|
86
126
|
@media (max-width: 1000px) { .animation-options { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
87
127
|
@media (max-width: 900px) { .overview { grid-template-columns: repeat(2, minmax(0, 1fr)); } .details { grid-template-columns: repeat(2, minmax(0, 1fr)); } .details .panel:last-child { grid-column: 1 / -1; } }
|
|
88
128
|
@media (max-width: 680px) { .animation-options { grid-template-columns: 1fr; } }
|
|
89
129
|
@media (max-width: 620px) { .shell { padding: 0 16px; } .topbar { min-height: 60px; } .brand-detail { display: none; } .topbar-actions { gap: 11px; } .intro { display: block; padding: 29px 0 21px; } .intro p { margin-top: 9px; } .overview, .details { grid-template-columns: 1fr; } .details .panel:last-child { grid-column: auto; } .card { min-height: 122px; } .card strong { margin-top: 14px; } .lower li { display: block; } .lower li span { display: block; margin-top: 5px; } }
|
|
130
|
+
@media (max-width: 460px) { .topbar { flex-wrap: wrap; padding: 12px 0; gap: 10px; } .topbar-actions { gap: 9px; } .panel-heading { flex-wrap: wrap; } .heading-actions { gap: 10px; } .failure-detail { grid-template-columns: 1fr; gap: 3px; } .failure-detail dd { margin-bottom: 7px; } select { max-width: 210px; } }
|
package/src/dashboard/ui.js
CHANGED
|
@@ -15,8 +15,10 @@ function duration(value) {
|
|
|
15
15
|
|
|
16
16
|
function renderFailures(recent) {
|
|
17
17
|
const list = element("failures");
|
|
18
|
+
const expanded = new Set([...list.querySelectorAll("details[open]")].map((item) => item.dataset.key));
|
|
18
19
|
list.replaceChildren();
|
|
19
|
-
const failures = Array.isArray(recent) ? recent.slice(-
|
|
20
|
+
const failures = Array.isArray(recent) ? recent.slice(-10).reverse() : [];
|
|
21
|
+
text("failure-count", `${failures.length} retained / response.failed`);
|
|
20
22
|
if (!failures.length) {
|
|
21
23
|
const item = document.createElement("li");
|
|
22
24
|
item.className = "empty";
|
|
@@ -26,11 +28,38 @@ function renderFailures(recent) {
|
|
|
26
28
|
}
|
|
27
29
|
for (const failure of failures) {
|
|
28
30
|
const item = document.createElement("li");
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
const details = document.createElement("details");
|
|
32
|
+
details.dataset.key = `${failure.at}/${failure.response_id}/${failure.code}`;
|
|
33
|
+
details.open = expanded.has(details.dataset.key);
|
|
34
|
+
const title = document.createElement("summary");
|
|
35
|
+
title.textContent = `${String(failure.at || "unknown time")} / ${String(failure.model || "unknown_model").slice(0, 80)} / ${String(failure.code || "unknown_error").slice(0, 80)} / ${failure.retried ? "retry attempted" : "no retry"}`;
|
|
36
|
+
const fields = { time: failure.at, model: failure.model, event: failure.event_type, code: failure.code,
|
|
37
|
+
message: failure.message, response_id: failure.response_id, upstream_request_id: failure.upstream_request_id,
|
|
38
|
+
retry: failure.retried ? "attempted (outcome not recorded here)" : "not attempted",
|
|
39
|
+
retry_policy: failure.retry_policy, retry_skipped: failure.retry_skipped };
|
|
40
|
+
const diagnostic = document.createElement("dl");
|
|
41
|
+
diagnostic.className = "failure-detail";
|
|
42
|
+
const lines = [];
|
|
43
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
44
|
+
if (value === undefined || value === null || value === "") continue;
|
|
45
|
+
const label = document.createElement("dt");
|
|
46
|
+
const content = document.createElement("dd");
|
|
47
|
+
label.textContent = key;
|
|
48
|
+
content.textContent = String(value).slice(0, 500);
|
|
49
|
+
diagnostic.append(label, content);
|
|
50
|
+
lines.push(`${key}: ${content.textContent}`);
|
|
51
|
+
}
|
|
52
|
+
const copy = document.createElement("button");
|
|
53
|
+
copy.type = "button";
|
|
54
|
+
copy.textContent = "Copy diagnostic";
|
|
55
|
+
copy.addEventListener("click", async () => {
|
|
56
|
+
try {
|
|
57
|
+
await navigator.clipboard.writeText(lines.join("\n"));
|
|
58
|
+
copy.textContent = "Copied";
|
|
59
|
+
} catch { copy.textContent = "Copy unavailable"; }
|
|
60
|
+
});
|
|
61
|
+
details.append(title, diagnostic, copy);
|
|
62
|
+
item.append(details);
|
|
34
63
|
list.append(item);
|
|
35
64
|
}
|
|
36
65
|
}
|
|
@@ -109,6 +138,8 @@ let previewDisabledByEnvironment = false;
|
|
|
109
138
|
let previewVisible = false;
|
|
110
139
|
let previewTimer = null;
|
|
111
140
|
let previewStarted = 0;
|
|
141
|
+
let animationLoading = false;
|
|
142
|
+
let animationFramesLoaded = false;
|
|
112
143
|
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
113
144
|
|
|
114
145
|
function renderAnsiFrame(target, frame) {
|
|
@@ -146,14 +177,14 @@ function stopAnimationPreview() {
|
|
|
146
177
|
|
|
147
178
|
function tickAnimationPreview() {
|
|
148
179
|
previewTimer = null;
|
|
149
|
-
if (!previewVisible || document.visibilityState !== "visible" || reducedMotion.matches || previewDisabledByEnvironment) return;
|
|
180
|
+
if (!animationTimelines.length || !element("animation-settings").open || !previewVisible || document.visibilityState !== "visible" || reducedMotion.matches || previewDisabledByEnvironment) return;
|
|
150
181
|
renderAnimationFrames(performance.now() - previewStarted);
|
|
151
182
|
previewTimer = setTimeout(tickAnimationPreview, 32);
|
|
152
183
|
}
|
|
153
184
|
|
|
154
185
|
function syncAnimationPreview() {
|
|
155
186
|
stopAnimationPreview();
|
|
156
|
-
if (!previewVisible || document.visibilityState !== "visible" || reducedMotion.matches || previewDisabledByEnvironment) {
|
|
187
|
+
if (!animationTimelines.length || !element("animation-settings").open || !previewVisible || document.visibilityState !== "visible" || reducedMotion.matches || previewDisabledByEnvironment) {
|
|
157
188
|
for (const timeline of animationTimelines) timeline.lastFrame = -2;
|
|
158
189
|
renderAnimationFrames(0);
|
|
159
190
|
return;
|
|
@@ -186,6 +217,7 @@ function buildAnimationOptions(data) {
|
|
|
186
217
|
selectedAnimation = theme.id;
|
|
187
218
|
for (const [id, row] of animationRows) row.setAttribute("aria-pressed", String(id === theme.id));
|
|
188
219
|
element("save-animation").disabled = selectedAnimation === savedAnimation;
|
|
220
|
+
updateAnimationState();
|
|
189
221
|
syncAnimationPreview();
|
|
190
222
|
});
|
|
191
223
|
const name = document.createElement("span");
|
|
@@ -220,20 +252,33 @@ function animationNote(data) {
|
|
|
220
252
|
}
|
|
221
253
|
|
|
222
254
|
async function loadAnimation() {
|
|
255
|
+
const includeFrames = element("animation-settings").open;
|
|
256
|
+
if (animationLoading || (includeFrames && animationFramesLoaded)) return;
|
|
257
|
+
animationLoading = true;
|
|
223
258
|
text("animation-state", "READING");
|
|
224
259
|
try {
|
|
225
|
-
const response = await fetch(
|
|
260
|
+
const response = await fetch(`/_ccdx/ui/animation${includeFrames ? "" : "?frames=0"}`, { cache: "no-store", headers: { "X-CCDX-Dashboard": "1" }, signal: AbortSignal.timeout(8000) });
|
|
226
261
|
const data = await response.json();
|
|
227
262
|
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
|
|
228
|
-
|
|
229
|
-
|
|
263
|
+
if (includeFrames) {
|
|
264
|
+
buildAnimationOptions(data);
|
|
265
|
+
animationFramesLoaded = true;
|
|
266
|
+
} else { savedAnimation = data.theme; selectedAnimation = data.theme; }
|
|
267
|
+
updateAnimationState();
|
|
230
268
|
text("animation-note", animationNote(data));
|
|
231
269
|
} catch (error) {
|
|
232
270
|
text("animation-state", "UNAVAILABLE");
|
|
233
271
|
text("animation-note", String(error.message || error).slice(0, 180));
|
|
272
|
+
} finally {
|
|
273
|
+
animationLoading = false;
|
|
274
|
+
if (!includeFrames && element("animation-settings").open) loadAnimation();
|
|
234
275
|
}
|
|
235
276
|
}
|
|
236
277
|
|
|
278
|
+
function updateAnimationState() {
|
|
279
|
+
text("animation-state", `CURRENT ${savedAnimation.toUpperCase()}${selectedAnimation !== savedAnimation ? " / UNSAVED" : ""}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
237
282
|
async function saveAnimation() {
|
|
238
283
|
const button = element("save-animation");
|
|
239
284
|
if (!selectedAnimation || selectedAnimation === savedAnimation) return;
|
|
@@ -249,7 +294,7 @@ async function saveAnimation() {
|
|
|
249
294
|
const data = await response.json();
|
|
250
295
|
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
|
|
251
296
|
savedAnimation = data.theme;
|
|
252
|
-
|
|
297
|
+
updateAnimationState();
|
|
253
298
|
text("animation-note", animationNote(data));
|
|
254
299
|
} catch (error) {
|
|
255
300
|
text("animation-note", `Not saved / ${String(error.message || error).slice(0, 150)}`);
|
|
@@ -313,36 +358,178 @@ function usageValues(row) {
|
|
|
313
358
|
return [row.model, number(row.requests), number(row.input_tokens), number(row.cache_read_tokens), number(row.output_tokens), number(row.total_tokens), rate];
|
|
314
359
|
}
|
|
315
360
|
|
|
361
|
+
let usageData = null;
|
|
362
|
+
let usageLoading = false;
|
|
363
|
+
let selectedUsageDate = "";
|
|
364
|
+
|
|
365
|
+
function renderUsageTable() {
|
|
366
|
+
const day = selectedUsageDate && usageData.analytics?.days.find((entry) => entry.date === selectedUsageDate);
|
|
367
|
+
const model = element("analytics-model").value;
|
|
368
|
+
const total = day ? { model: "TOTAL", ...analyticsPoint(day, model) } : usageData.total;
|
|
369
|
+
const rows = day ? day.models.filter((row) => !model || row.model === model).sort((a, b) => b.total_tokens - a.total_tokens || a.model.localeCompare(b.model)) : usageData.rows;
|
|
370
|
+
const body = element("usage-body");
|
|
371
|
+
body.replaceChildren();
|
|
372
|
+
if (total.requests > 0) {
|
|
373
|
+
body.append(tableRow(usageValues(total), { total: true }));
|
|
374
|
+
for (const row of rows) body.append(tableRow(usageValues(row)));
|
|
375
|
+
} else { emptyTable("usage-body", 7, "No usage records."); }
|
|
376
|
+
element("clear-usage-day").hidden = !day;
|
|
377
|
+
text("usage-state", day ? day.date : "LOCAL LOG");
|
|
378
|
+
text("usage-note", `${number(total.requests)} records / ${number(rows.length)} of ${number(day ? rows.length : usageData.model_count)} models shown${day ? ` / ${usageData.analytics.time_zone} / ${model || "All models"}` : ""}`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function analyticsPoint(day, model) {
|
|
382
|
+
return model ? day.models.find((row) => row.model === model) || { requests: 0, input_tokens: 0, output_tokens: 0 } : day;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function selectUsageDay(date) {
|
|
386
|
+
selectedUsageDate = date;
|
|
387
|
+
renderUsageTable();
|
|
388
|
+
renderAnalytics();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function dayButton(day, point) {
|
|
392
|
+
const button = document.createElement("button");
|
|
393
|
+
button.type = "button";
|
|
394
|
+
button.disabled = !day.available;
|
|
395
|
+
button.setAttribute("aria-pressed", String(day.date === selectedUsageDate));
|
|
396
|
+
const description = !day.available ? "no retained history"
|
|
397
|
+
: `${number(point.requests)} recorded calls / input ${number(point.input_tokens)} / output ${number(point.output_tokens)} / cached ${number(point.cache_read_tokens)} (included in input)${point.tokens_partial ? " / partial token counts" : ""}`;
|
|
398
|
+
button.title = `${day.date} / ${description}`;
|
|
399
|
+
button.setAttribute("aria-label", button.title);
|
|
400
|
+
button.addEventListener("click", () => selectUsageDay(day.date));
|
|
401
|
+
return button;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function renderAnalytics() {
|
|
405
|
+
const analytics = usageData?.analytics;
|
|
406
|
+
if (!analytics) return;
|
|
407
|
+
const model = element("analytics-model").value;
|
|
408
|
+
const days = analytics.days;
|
|
409
|
+
const daily = days.slice(-Number(element("analytics-range").value));
|
|
410
|
+
const maximum = Math.max(1, ...daily.map((day) => {
|
|
411
|
+
const point = analyticsPoint(day, model);
|
|
412
|
+
return point.input_tokens + point.output_tokens;
|
|
413
|
+
}));
|
|
414
|
+
const bars = element("daily-bars");
|
|
415
|
+
bars.replaceChildren();
|
|
416
|
+
for (const day of daily) {
|
|
417
|
+
const point = analyticsPoint(day, model);
|
|
418
|
+
const button = dayButton(day, point);
|
|
419
|
+
button.className = "daily-bar";
|
|
420
|
+
for (const key of ["output_tokens", "input_tokens"]) {
|
|
421
|
+
const segment = document.createElement("span");
|
|
422
|
+
segment.className = key === "input_tokens" ? "bar-input" : "bar-output";
|
|
423
|
+
segment.style.height = `${point[key] / maximum * 100}%`;
|
|
424
|
+
button.append(segment);
|
|
425
|
+
}
|
|
426
|
+
bars.append(button);
|
|
427
|
+
}
|
|
428
|
+
text("daily-from", daily[0].date);
|
|
429
|
+
text("daily-to", analytics.to);
|
|
430
|
+
const metric = element("analytics-metric").value;
|
|
431
|
+
const value = (day) => {
|
|
432
|
+
const point = analyticsPoint(day, model);
|
|
433
|
+
return metric === "tokens" ? point.input_tokens + point.output_tokens : point.requests;
|
|
434
|
+
};
|
|
435
|
+
const peak = Math.max(1, ...days.map(value));
|
|
436
|
+
text("activity-scale", `${metric === "tokens" ? "INPUT + OUTPUT" : "RECORDED CALLS"} / PEAK ${number(peak === 1 && days.every((day) => value(day) === 0) ? 0 : peak)}`);
|
|
437
|
+
const calendar = element("activity-calendar");
|
|
438
|
+
calendar.replaceChildren();
|
|
439
|
+
const offset = (new Date(`${days[0].date}T00:00:00Z`).getUTCDay() + 6) % 7;
|
|
440
|
+
const months = element("calendar-months");
|
|
441
|
+
months.replaceChildren();
|
|
442
|
+
const columns = Math.ceil((offset + days.length) / 7);
|
|
443
|
+
let previousColumn = -4;
|
|
444
|
+
days.forEach((day, index) => {
|
|
445
|
+
if (index !== 0 && !day.date.endsWith("-01")) return;
|
|
446
|
+
const column = Math.floor((offset + index) / 7);
|
|
447
|
+
if (column - previousColumn < 4 || column + 4 > columns) return;
|
|
448
|
+
previousColumn = column;
|
|
449
|
+
const label = document.createElement("span");
|
|
450
|
+
label.textContent = day.date.slice(0, 7);
|
|
451
|
+
label.style.gridColumn = `${column + 1} / span 4`;
|
|
452
|
+
months.append(label);
|
|
453
|
+
});
|
|
454
|
+
for (let index = 0; index < offset; index += 1) {
|
|
455
|
+
const spacer = document.createElement("span");
|
|
456
|
+
spacer.setAttribute("aria-hidden", "true");
|
|
457
|
+
calendar.append(spacer);
|
|
458
|
+
}
|
|
459
|
+
for (const day of days) {
|
|
460
|
+
const button = dayButton(day, analyticsPoint(day, model));
|
|
461
|
+
button.className = "calendar-day";
|
|
462
|
+
const count = value(day);
|
|
463
|
+
button.dataset.level = String(count > 0 ? Math.min(4, Math.ceil(count / peak * 4)) : 0);
|
|
464
|
+
calendar.append(button);
|
|
465
|
+
}
|
|
466
|
+
text("analytics-note", `${analytics.time_zone} / ${daily[0].date} — ${analytics.to} / retained history starts ${analytics.first_date || "unknown"}; oldest day may be partial.${analytics.undated_records ? ` ${number(analytics.undated_records)} records with invalid/future timestamps excluded.` : ""}${analytics.models_truncated ? " Model filters limited to 100; all-model totals include the remainder." : ""}`);
|
|
467
|
+
text("analytics-selection", selectedUsageDate ? `Selected ${selectedUsageDate} / ${model || "All models"}: totals in the Usage table above / All history resets the table.` : "Select a day for model totals. Calls are usage records, not messages or time. Hatched cells: no retained history; empty cells: zero recorded calls.");
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function updateAnalyticsModels() {
|
|
471
|
+
const select = element("analytics-model");
|
|
472
|
+
const previous = select.value;
|
|
473
|
+
select.replaceChildren();
|
|
474
|
+
for (const model of ["", ...usageData.analytics.models]) {
|
|
475
|
+
const option = document.createElement("option");
|
|
476
|
+
option.value = model;
|
|
477
|
+
option.textContent = model || "All models";
|
|
478
|
+
select.append(option);
|
|
479
|
+
}
|
|
480
|
+
select.value = usageData.analytics.models.includes(previous) ? previous : "";
|
|
481
|
+
}
|
|
482
|
+
|
|
316
483
|
async function loadUsage() {
|
|
484
|
+
if (usageLoading) return;
|
|
485
|
+
usageLoading = true;
|
|
486
|
+
const includeAnalytics = element("usage-analytics").open || Boolean(selectedUsageDate);
|
|
317
487
|
const button = element("refresh-usage");
|
|
318
488
|
button.disabled = true;
|
|
319
489
|
text("usage-state", "READING LOG");
|
|
320
490
|
text("usage-note", "Aggregating local usage metadata…");
|
|
321
491
|
emptyTable("usage-body", 7, "Loading…");
|
|
322
492
|
try {
|
|
323
|
-
const
|
|
493
|
+
const query = includeAnalytics ? `?analytics=1&time_zone=${encodeURIComponent(Intl.DateTimeFormat().resolvedOptions().timeZone)}` : "";
|
|
494
|
+
const response = await fetch(`/_ccdx/ui/usage${query}`, { cache: "no-store", headers: { "X-CCDX-Dashboard": "1" }, signal: AbortSignal.timeout(15000) });
|
|
324
495
|
const data = await response.json();
|
|
325
496
|
if (!response.ok || data.source !== "local_usage_log") throw new Error(data.error || `HTTP ${response.status}`);
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
for (const row of data.rows) body.append(tableRow(usageValues(row)));
|
|
331
|
-
} else {
|
|
332
|
-
emptyTable("usage-body", 7, "No usage records.");
|
|
333
|
-
}
|
|
334
|
-
text("usage-state", "LOCAL LOG");
|
|
335
|
-
text("usage-note", `${number(data.total.requests)} records / ${number(data.rows.length)} of ${number(data.model_count)} models shown`);
|
|
497
|
+
usageData = data;
|
|
498
|
+
if (data.analytics) updateAnalyticsModels();
|
|
499
|
+
renderUsageTable();
|
|
500
|
+
renderAnalytics();
|
|
336
501
|
} catch (error) {
|
|
337
502
|
text("usage-state", "LOG UNAVAILABLE");
|
|
338
503
|
text("usage-note", String(error.message || error).slice(0, 180));
|
|
339
504
|
emptyTable("usage-body", 7, "No usage summary available.");
|
|
505
|
+
if (includeAnalytics) text("analytics-note", "Analytics unavailable / refresh Usage to retry.");
|
|
340
506
|
} finally {
|
|
507
|
+
usageLoading = false;
|
|
341
508
|
button.disabled = false;
|
|
509
|
+
if (!includeAnalytics && element("usage-analytics").open) loadUsage();
|
|
342
510
|
}
|
|
343
511
|
}
|
|
344
512
|
|
|
345
513
|
element("save-animation").addEventListener("click", saveAnimation);
|
|
514
|
+
element("animation-settings").addEventListener("toggle", () => {
|
|
515
|
+
element("open-animation").setAttribute("aria-expanded", String(element("animation-settings").open));
|
|
516
|
+
if (element("animation-settings").open) loadAnimation();
|
|
517
|
+
syncAnimationPreview();
|
|
518
|
+
});
|
|
519
|
+
element("open-animation").addEventListener("click", () => {
|
|
520
|
+
const settings = element("animation-settings");
|
|
521
|
+
settings.open = true;
|
|
522
|
+
settings.scrollIntoView({ block: "center" });
|
|
523
|
+
settings.querySelector("summary").focus();
|
|
524
|
+
});
|
|
525
|
+
element("usage-analytics").addEventListener("toggle", () => {
|
|
526
|
+
if (element("usage-analytics").open && !usageData?.analytics) loadUsage();
|
|
527
|
+
});
|
|
528
|
+
for (const id of ["analytics-model", "analytics-range", "analytics-metric"]) element(id).addEventListener("change", () => {
|
|
529
|
+
renderAnalytics();
|
|
530
|
+
if (usageData && selectedUsageDate) renderUsageTable();
|
|
531
|
+
});
|
|
532
|
+
element("clear-usage-day").addEventListener("click", () => selectUsageDay(""));
|
|
346
533
|
element("refresh-models").addEventListener("click", loadModels);
|
|
347
534
|
element("refresh-usage").addEventListener("click", loadUsage);
|
|
348
535
|
loadAnimation();
|
package/src/dashboard-api.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
TERMINAL_ANIMATION_THEMES,
|
|
11
11
|
} from "./terminal-animation.mjs";
|
|
12
12
|
import { cacheReadTokens, summarizeUsageLogs, usageCacheHitRate } from "./usage.mjs";
|
|
13
|
+
import { createUsageAnalytics } from "./usage-analytics.mjs";
|
|
13
14
|
import { readUserSettings, terminalAnimationPreference, writeTerminalAnimationTheme } from "./user-settings.mjs";
|
|
14
15
|
|
|
15
16
|
const ANIMATION_PATH = "/_ccdx/ui/animation";
|
|
@@ -107,7 +108,7 @@ export async function handleDashboardApi(req, res, pathname, {
|
|
|
107
108
|
env = process.env,
|
|
108
109
|
home = os.homedir(),
|
|
109
110
|
liveModelsFn = () => fetchLiveCopilotModels({ home }),
|
|
110
|
-
usageSummaryFn = () => summarizeUsageLogs(),
|
|
111
|
+
usageSummaryFn = (options) => summarizeUsageLogs(undefined, options),
|
|
111
112
|
} = {}) {
|
|
112
113
|
if (!isLoopbackAddress(req.socket?.remoteAddress)
|
|
113
114
|
|| req.headers?.host === undefined
|
|
@@ -122,7 +123,8 @@ export async function handleDashboardApi(req, res, pathname, {
|
|
|
122
123
|
|
|
123
124
|
if (pathname === ANIMATION_PATH && req.method === "GET") {
|
|
124
125
|
try {
|
|
125
|
-
|
|
126
|
+
const query = new URL(req.url, "http://localhost").searchParams;
|
|
127
|
+
sendJson(res, 200, animationState(env, home, { includeFrames: query.get("frames") !== "0" }));
|
|
126
128
|
} catch {
|
|
127
129
|
sendJson(res, 409, { error: "Animation settings are invalid; inspect them with ccdx animation" });
|
|
128
130
|
}
|
|
@@ -189,8 +191,21 @@ export async function handleDashboardApi(req, res, pathname, {
|
|
|
189
191
|
}
|
|
190
192
|
|
|
191
193
|
if (pathname === USAGE_PATH && req.method === "GET") {
|
|
194
|
+
const query = new URL(req.url, "http://localhost").searchParams;
|
|
195
|
+
let analytics = null;
|
|
196
|
+
if (query.get("analytics") === "1") {
|
|
197
|
+
try {
|
|
198
|
+
const timeZone = query.get("time_zone") || "UTC";
|
|
199
|
+
if (timeZone.length > 100) throw new Error("Invalid time zone");
|
|
200
|
+
analytics = createUsageAnalytics({ timeZone });
|
|
201
|
+
} catch {
|
|
202
|
+
sendJson(res, 400, { error: "Select a valid usage time zone" });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
192
206
|
try {
|
|
193
|
-
|
|
207
|
+
const summary = await usageSummaryFn(analytics ? { onRecord: analytics.record } : undefined);
|
|
208
|
+
sendJson(res, 200, { source: "local_usage_log", ...usageTable(summary), ...(analytics ? { analytics: analytics.snapshot() } : {}) });
|
|
194
209
|
} catch {
|
|
195
210
|
sendJson(res, 500, { error: "Could not read local usage summary" });
|
|
196
211
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { cacheReadTokens, usageCacheHitRate } from "./usage.mjs";
|
|
2
|
+
|
|
3
|
+
const DAY_MS = 86400000;
|
|
4
|
+
const DAYS = 365;
|
|
5
|
+
const MAX_MODELS = 100;
|
|
6
|
+
const TOKEN_FIELDS = ["input_tokens", "output_tokens", "total_tokens", "cached_input_tokens", "cache_read_input_tokens", "cache_hit_unknown_requests", "cache_hit_invalid_requests"];
|
|
7
|
+
|
|
8
|
+
export function createUsageAnalytics({ timeZone = "UTC", now = Date.now() } = {}) {
|
|
9
|
+
const formatter = new Intl.DateTimeFormat("sv-SE", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" });
|
|
10
|
+
const today = formatter.format(new Date(now));
|
|
11
|
+
const end = Date.parse(`${today}T00:00:00Z`);
|
|
12
|
+
const start = end - (DAYS - 1) * DAY_MS;
|
|
13
|
+
const from = new Date(start).toISOString().slice(0, 10);
|
|
14
|
+
const dates = new Map();
|
|
15
|
+
const models = new Set();
|
|
16
|
+
let first = Infinity;
|
|
17
|
+
let last = -Infinity;
|
|
18
|
+
let undated = 0;
|
|
19
|
+
let modelsTruncated = false;
|
|
20
|
+
|
|
21
|
+
function add(target, values) {
|
|
22
|
+
target.requests += 1;
|
|
23
|
+
for (const key of TOKEN_FIELDS) {
|
|
24
|
+
if (Number.isFinite(values[key]) && values[key] >= 0) target[key] = (target[key] || 0) + values[key];
|
|
25
|
+
}
|
|
26
|
+
if (!Number.isFinite(values.input_tokens) || values.input_tokens < 0 || !Number.isFinite(values.output_tokens) || values.output_tokens < 0) target.tokens_partial = true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function row(values = {}) {
|
|
30
|
+
return {
|
|
31
|
+
requests: values.requests || 0,
|
|
32
|
+
input_tokens: values.input_tokens || 0,
|
|
33
|
+
output_tokens: values.output_tokens || 0,
|
|
34
|
+
total_tokens: values.total_tokens ?? (values.input_tokens || 0) + (values.output_tokens || 0),
|
|
35
|
+
cache_read_tokens: cacheReadTokens(values) ?? null,
|
|
36
|
+
cache_hit_rate: usageCacheHitRate(values),
|
|
37
|
+
cache_hit_rate_partial: values.cache_hit_unknown_requests > 0,
|
|
38
|
+
tokens_partial: values.tokens_partial === true || values.cache_hit_invalid_requests > 0,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
record(record, summary) {
|
|
44
|
+
const timestamp = typeof record.ts === "string" ? Date.parse(record.ts) : NaN;
|
|
45
|
+
if (!Number.isFinite(timestamp) || timestamp > now) { undated += 1; return; }
|
|
46
|
+
first = Math.min(first, timestamp);
|
|
47
|
+
last = Math.max(last, timestamp);
|
|
48
|
+
if (timestamp < start - 2 * DAY_MS) return;
|
|
49
|
+
const date = formatter.format(new Date(timestamp));
|
|
50
|
+
if (date < from || date > today) return;
|
|
51
|
+
let point = dates.get(date);
|
|
52
|
+
if (!point) { point = { requests: 0, byModel: new Map() }; dates.set(date, point); }
|
|
53
|
+
add(point, summary.totals);
|
|
54
|
+
const model = String(record.model || "unknown").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 100);
|
|
55
|
+
if (!models.has(model) && models.size >= MAX_MODELS) { modelsTruncated = true; return; }
|
|
56
|
+
models.add(model);
|
|
57
|
+
if (!point.byModel.has(model)) point.byModel.set(model, { requests: 0 });
|
|
58
|
+
add(point.byModel.get(model), summary.totals);
|
|
59
|
+
},
|
|
60
|
+
snapshot() {
|
|
61
|
+
const firstDate = Number.isFinite(first) ? formatter.format(new Date(first)) : null;
|
|
62
|
+
const days = Array.from({ length: DAYS }, (_, index) => {
|
|
63
|
+
const date = new Date(start + index * DAY_MS).toISOString().slice(0, 10);
|
|
64
|
+
const point = dates.get(date);
|
|
65
|
+
return {
|
|
66
|
+
date,
|
|
67
|
+
available: firstDate !== null && date >= firstDate,
|
|
68
|
+
...row(point),
|
|
69
|
+
models: point ? [...point.byModel].map(([model, values]) => ({ model, ...row(values) })) : [],
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
return { time_zone: formatter.resolvedOptions().timeZone, from, to: today, first_date: firstDate,
|
|
73
|
+
last_date: Number.isFinite(last) ? formatter.format(new Date(last)) : null,
|
|
74
|
+
undated_records: undated, models_truncated: modelsTruncated, models: [...models].sort(), days };
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/usage.mjs
CHANGED
|
@@ -288,11 +288,12 @@ export function summarizeUsage(records) {
|
|
|
288
288
|
return summary;
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
-
export async function summarizeUsageLogs(filePath = usageLogPath(), { warn = console.error } = {}) {
|
|
291
|
+
export async function summarizeUsageLogs(filePath = usageLogPath(), { warn = console.error, onRecord } = {}) {
|
|
292
292
|
const summary = { requests: 0, totals: {}, byModel: {} };
|
|
293
293
|
for (const candidate of [rotatedFilePath(filePath), filePath]) {
|
|
294
294
|
for await (const record of iterateUsageRecords(candidate, { warn })) {
|
|
295
295
|
const one = summarizeUsage([record]);
|
|
296
|
+
onRecord?.(record, one);
|
|
296
297
|
summary.requests += one.requests;
|
|
297
298
|
addUsageTotals(summary.totals, one.totals);
|
|
298
299
|
for (const [model, values] of Object.entries(one.byModel)) {
|