codex-copilot-dx 0.9.5 → 0.9.7

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 CHANGED
@@ -236,6 +236,12 @@ 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
+ The saved GitHub Copilot account appears immediately to the left of the Snapshot time. It uses the same local credentials and fingerprint-bound identity metadata as `ccdx auth status`, with username (or numeric ID fallback) and a `SAVED` label; this is not an online entitlement check. Missing, invalid, unidentified, or unreadable credentials are shown explicitly. The account read runs once on page load and on the top Refresh action, independently of adapter status, with no polling or upstream authentication calls. Its loopback-only, custom-header-protected API exposes no token, fingerprint, credential path, or raw metadata. The general status endpoint retains its existing identity-free contract.
240
+
241
+ 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.
242
+
243
+ 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.
244
+
239
245
  Inspect only the local Codex configuration:
240
246
 
241
247
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
4
4
  "description": "Use Codex App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
package/src/adapter.mjs CHANGED
@@ -65,7 +65,7 @@ export {
65
65
 
66
66
  const ADAPTER_RUNTIME_CONFIG = loadRuntimeConfig();
67
67
  const DASHBOARD_PATHS = new Set(["/", "/ui.css", "/theme.js", "/ui.js"]);
68
- const DASHBOARD_API_PATHS = new Set(["/_ccdx/ui/animation", "/_ccdx/ui/models/live", "/_ccdx/ui/usage"]);
68
+ const DASHBOARD_API_PATHS = new Set(["/_ccdx/ui/animation", "/_ccdx/ui/models/live", "/_ccdx/ui/usage", "/_ccdx/ui/auth"]);
69
69
 
70
70
  export function requestPath(reqUrl) {
71
71
  return new URL(reqUrl || "/", "http://localhost").pathname;
@@ -14,13 +14,13 @@
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>
21
21
  <section class="intro">
22
22
  <h1><span aria-hidden="true">$</span> ccdx status</h1>
23
- <p id="updated">Reading status…</p>
23
+ <div class="snapshot-meta"><p id="auth-account" role="status" title="Saved GitHub Copilot account / local credentials only; not an online entitlement check.">GitHub / READING</p><p id="updated">Reading status…</p></div>
24
24
  </section>
25
25
 
26
26
  <section class="overview" aria-label="Overview">
@@ -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>
@@ -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); }
@@ -24,6 +25,8 @@ button:disabled { opacity: .55; cursor: wait; }
24
25
  h1 { margin: 0; font-size: clamp(20px, 3vw, 27px); font-weight: 650; letter-spacing: -.035em; }
25
26
  h1 span { color: var(--accent-soft); }
26
27
  .intro p { margin: 0; color: var(--subtle); font-size: 11px; }
28
+ .snapshot-meta { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: baseline; gap: 8px 18px; min-width: 0; }
29
+ .intro .snapshot-meta p { margin: 0; overflow-wrap: anywhere; }
27
30
  .overview { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; }
28
31
  .card, .panel, .lower { min-width: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 2px; }
29
32
  .card { min-height: 139px; padding: 18px; }
@@ -78,12 +81,53 @@ td { color: var(--table-text); }
78
81
  .usage-table tr.total-row td { color: var(--accent); font-weight: 700; }
79
82
  .lower { margin-top: 11px; padding: 18px; }
80
83
  .lower ol { list-style: none; margin: 16px 0 0; padding: 0; }
81
- .lower li { display: grid; grid-template-columns: minmax(150px, 1fr) minmax(0, 2fr); gap: 14px; padding: 9px 0; border-top: 1px solid var(--border); font-size: 11px; }
84
+ .lower li { padding: 9px 0; border-top: 1px solid var(--border); font-size: 11px; }
82
85
  .lower li strong, .lower li span { overflow-wrap: anywhere; }
83
86
  .lower li strong { color: var(--text); font-weight: 650; }
84
87
  .lower li span, .lower li.empty { color: var(--muted); line-height: 1.5; }
88
+ summary { cursor: pointer; line-height: 1.6; overflow-wrap: anywhere; }
89
+ summary:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
90
+ .settings-panel > summary { list-style: none; align-items: center; }
91
+ .settings-panel > summary::-webkit-details-marker { display: none; }
92
+ .settings-panel > summary h2::before { content: "+ "; }
93
+ .settings-panel[open] > summary h2::before { content: "− "; }
94
+ .failure-detail { display: grid; grid-template-columns: minmax(110px, auto) minmax(0, 1fr); gap: 7px 18px; margin: 14px 0; line-height: 1.6; }
95
+ .failure-detail dt { color: var(--muted); }
96
+ .failure-detail dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
97
+ .analytics-section { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); }
98
+ .analytics-section > summary { color: var(--heading); font-size: 11px; font-weight: 700; letter-spacing: .08em; }
99
+ .analytics-controls { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 18px; }
100
+ .analytics-controls label { color: var(--muted); font-size: 11px; }
101
+ select { max-width: 260px; padding: 5px; font: inherit; color: var(--text); background: var(--page); border: 1px solid var(--border); }
102
+ .chart-heading { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin: 22px 0 10px; }
103
+ .chart-heading h3 { margin: 0; color: var(--heading); font-size: 11px; }
104
+ .chart-heading span, .chart-legend { color: var(--muted); font-size: 10px; }
105
+ .daily-bars { display: flex; align-items: end; gap: 3px; height: 130px; border-bottom: 1px solid var(--border); }
106
+ .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; }
107
+ .daily-bar span { display: block; flex-shrink: 0; width: 100%; }
108
+ .bar-input { background: var(--accent-soft); }
109
+ .bar-output { background: var(--ansi-39); }
110
+ .daily-bar:hover { background: var(--option-hover); }
111
+ .daily-axis { display: flex; justify-content: space-between; color: var(--muted); font-size: 10px; margin-top: 7px; }
112
+ .daily-bar[aria-pressed="true"], .calendar-day[aria-pressed="true"] { outline: 2px solid var(--strong); outline-offset: 1px; }
113
+ .chart-legend { display: flex; gap: 18px; margin-top: 9px; }
114
+ .chart-legend span::before { content: "■"; margin-right: 6px; }
115
+ .legend-input::before { color: var(--accent-soft); }
116
+ .legend-output::before { color: var(--ansi-39); }
117
+ .calendar-scroll { overflow-x: auto; padding: 4px; }
118
+ .calendar-grid { width: max-content; }
119
+ .calendar-months { display: grid; grid-auto-columns: 12px; gap: 4px; margin-bottom: 7px; color: var(--muted); font-size: 9px; height: 14px; }
120
+ .activity-calendar { display: grid; grid-template-rows: repeat(7, 12px); grid-auto-flow: column; grid-auto-columns: 12px; gap: 4px; width: max-content; }
121
+ .calendar-day { padding: 0; border: 1px solid var(--border); width: 12px; height: 12px; background: var(--button-bg); }
122
+ .calendar-day[data-level="1"] { background: color-mix(in srgb, var(--accent-soft) 25%, var(--surface)); }
123
+ .calendar-day[data-level="2"] { background: color-mix(in srgb, var(--accent-soft) 45%, var(--surface)); }
124
+ .calendar-day[data-level="3"] { background: color-mix(in srgb, var(--accent-soft) 70%, var(--surface)); }
125
+ .calendar-day[data-level="4"] { background: var(--accent-soft); }
126
+ .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
127
  footer { color: var(--footer); font-size: 10px; letter-spacing: .05em; padding: 22px 0 30px; }
86
128
  @media (max-width: 1000px) { .animation-options { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
87
129
  @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
130
  @media (max-width: 680px) { .animation-options { grid-template-columns: 1fr; } }
131
+ @media (max-width: 620px) { .snapshot-meta { justify-content: flex-start; margin-top: 9px; } }
89
132
  @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; } }
133
+ @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; } }
@@ -3,6 +3,28 @@ const text = (id, value) => { element(id).textContent = value; };
3
3
  const finite = (value) => value !== null && value !== undefined && Number.isFinite(Number(value));
4
4
  const number = (value) => finite(value) ? Math.max(0, Math.round(Number(value))).toLocaleString() : "—";
5
5
  const mib = (value) => finite(value) ? `${(Number(value) / 1048576).toFixed(1)} MiB` : "—";
6
+ let authLoading = false;
7
+
8
+ function renderAuth(data) {
9
+ const account = data.login ? `@${data.login}` : data.id ? `ID ${data.id}` : "account unknown";
10
+ const label = data.reason === "credential_read_failed" ? "UNAVAILABLE"
11
+ : !data.configured ? "NOT CONFIGURED" : !data.valid ? "INVALID" : `${account} / SAVED`;
12
+ text("auth-account", `GitHub / ${label}`);
13
+ }
14
+
15
+ async function loadAuth() {
16
+ if (authLoading) return;
17
+ authLoading = true;
18
+ text("auth-account", "GitHub / READING");
19
+ try {
20
+ const response = await fetch("/_ccdx/ui/auth", { cache: "no-store", headers: { "X-CCDX-Dashboard": "1" }, signal: AbortSignal.timeout(5000) });
21
+ const data = await response.json();
22
+ if (!response.ok || data.source !== "local_auth_status") throw new Error("Auth status unavailable");
23
+ renderAuth(data);
24
+ } catch {
25
+ text("auth-account", "GitHub / UNAVAILABLE");
26
+ } finally { authLoading = false; }
27
+ }
6
28
 
7
29
  function duration(value) {
8
30
  if (!finite(value)) return "—";
@@ -15,8 +37,10 @@ function duration(value) {
15
37
 
16
38
  function renderFailures(recent) {
17
39
  const list = element("failures");
40
+ const expanded = new Set([...list.querySelectorAll("details[open]")].map((item) => item.dataset.key));
18
41
  list.replaceChildren();
19
- const failures = Array.isArray(recent) ? recent.slice(-5).reverse() : [];
42
+ const failures = Array.isArray(recent) ? recent.slice(-10).reverse() : [];
43
+ text("failure-count", `${failures.length} retained / response.failed`);
20
44
  if (!failures.length) {
21
45
  const item = document.createElement("li");
22
46
  item.className = "empty";
@@ -26,11 +50,38 @@ function renderFailures(recent) {
26
50
  }
27
51
  for (const failure of failures) {
28
52
  const item = document.createElement("li");
29
- const title = document.createElement("strong");
30
- const detail = document.createElement("span");
31
- title.textContent = `${String(failure.model || "unknown_model").slice(0, 80)} / ${String(failure.code || "unknown_error").slice(0, 80)}`;
32
- detail.textContent = `${String(failure.message || "No message").slice(0, 240)}${failure.retried ? " / retried" : ""}`;
33
- item.append(title, detail);
53
+ const details = document.createElement("details");
54
+ details.dataset.key = `${failure.at}/${failure.response_id}/${failure.code}`;
55
+ details.open = expanded.has(details.dataset.key);
56
+ const title = document.createElement("summary");
57
+ 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"}`;
58
+ const fields = { time: failure.at, model: failure.model, event: failure.event_type, code: failure.code,
59
+ message: failure.message, response_id: failure.response_id, upstream_request_id: failure.upstream_request_id,
60
+ retry: failure.retried ? "attempted (outcome not recorded here)" : "not attempted",
61
+ retry_policy: failure.retry_policy, retry_skipped: failure.retry_skipped };
62
+ const diagnostic = document.createElement("dl");
63
+ diagnostic.className = "failure-detail";
64
+ const lines = [];
65
+ for (const [key, value] of Object.entries(fields)) {
66
+ if (value === undefined || value === null || value === "") continue;
67
+ const label = document.createElement("dt");
68
+ const content = document.createElement("dd");
69
+ label.textContent = key;
70
+ content.textContent = String(value).slice(0, 500);
71
+ diagnostic.append(label, content);
72
+ lines.push(`${key}: ${content.textContent}`);
73
+ }
74
+ const copy = document.createElement("button");
75
+ copy.type = "button";
76
+ copy.textContent = "Copy diagnostic";
77
+ copy.addEventListener("click", async () => {
78
+ try {
79
+ await navigator.clipboard.writeText(lines.join("\n"));
80
+ copy.textContent = "Copied";
81
+ } catch { copy.textContent = "Copy unavailable"; }
82
+ });
83
+ details.append(title, diagnostic, copy);
84
+ item.append(details);
34
85
  list.append(item);
35
86
  }
36
87
  }
@@ -98,8 +149,9 @@ async function refresh() {
98
149
  }
99
150
  }
100
151
 
101
- element("refresh").addEventListener("click", refresh);
152
+ element("refresh").addEventListener("click", () => { refresh(); loadAuth(); });
102
153
  refresh();
154
+ loadAuth();
103
155
 
104
156
  const animationRows = new Map();
105
157
  let animationTimelines = [];
@@ -109,6 +161,8 @@ let previewDisabledByEnvironment = false;
109
161
  let previewVisible = false;
110
162
  let previewTimer = null;
111
163
  let previewStarted = 0;
164
+ let animationLoading = false;
165
+ let animationFramesLoaded = false;
112
166
  const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
113
167
 
114
168
  function renderAnsiFrame(target, frame) {
@@ -146,14 +200,14 @@ function stopAnimationPreview() {
146
200
 
147
201
  function tickAnimationPreview() {
148
202
  previewTimer = null;
149
- if (!previewVisible || document.visibilityState !== "visible" || reducedMotion.matches || previewDisabledByEnvironment) return;
203
+ if (!animationTimelines.length || !element("animation-settings").open || !previewVisible || document.visibilityState !== "visible" || reducedMotion.matches || previewDisabledByEnvironment) return;
150
204
  renderAnimationFrames(performance.now() - previewStarted);
151
205
  previewTimer = setTimeout(tickAnimationPreview, 32);
152
206
  }
153
207
 
154
208
  function syncAnimationPreview() {
155
209
  stopAnimationPreview();
156
- if (!previewVisible || document.visibilityState !== "visible" || reducedMotion.matches || previewDisabledByEnvironment) {
210
+ if (!animationTimelines.length || !element("animation-settings").open || !previewVisible || document.visibilityState !== "visible" || reducedMotion.matches || previewDisabledByEnvironment) {
157
211
  for (const timeline of animationTimelines) timeline.lastFrame = -2;
158
212
  renderAnimationFrames(0);
159
213
  return;
@@ -186,6 +240,7 @@ function buildAnimationOptions(data) {
186
240
  selectedAnimation = theme.id;
187
241
  for (const [id, row] of animationRows) row.setAttribute("aria-pressed", String(id === theme.id));
188
242
  element("save-animation").disabled = selectedAnimation === savedAnimation;
243
+ updateAnimationState();
189
244
  syncAnimationPreview();
190
245
  });
191
246
  const name = document.createElement("span");
@@ -220,20 +275,33 @@ function animationNote(data) {
220
275
  }
221
276
 
222
277
  async function loadAnimation() {
278
+ const includeFrames = element("animation-settings").open;
279
+ if (animationLoading || (includeFrames && animationFramesLoaded)) return;
280
+ animationLoading = true;
223
281
  text("animation-state", "READING");
224
282
  try {
225
- const response = await fetch("/_ccdx/ui/animation", { cache: "no-store", headers: { "X-CCDX-Dashboard": "1" }, signal: AbortSignal.timeout(8000) });
283
+ const response = await fetch(`/_ccdx/ui/animation${includeFrames ? "" : "?frames=0"}`, { cache: "no-store", headers: { "X-CCDX-Dashboard": "1" }, signal: AbortSignal.timeout(8000) });
226
284
  const data = await response.json();
227
285
  if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
228
- buildAnimationOptions(data);
229
- text("animation-state", `CURRENT ${data.theme.toUpperCase()}`);
286
+ if (includeFrames) {
287
+ buildAnimationOptions(data);
288
+ animationFramesLoaded = true;
289
+ } else { savedAnimation = data.theme; selectedAnimation = data.theme; }
290
+ updateAnimationState();
230
291
  text("animation-note", animationNote(data));
231
292
  } catch (error) {
232
293
  text("animation-state", "UNAVAILABLE");
233
294
  text("animation-note", String(error.message || error).slice(0, 180));
295
+ } finally {
296
+ animationLoading = false;
297
+ if (!includeFrames && element("animation-settings").open) loadAnimation();
234
298
  }
235
299
  }
236
300
 
301
+ function updateAnimationState() {
302
+ text("animation-state", `CURRENT ${savedAnimation.toUpperCase()}${selectedAnimation !== savedAnimation ? " / UNSAVED" : ""}`);
303
+ }
304
+
237
305
  async function saveAnimation() {
238
306
  const button = element("save-animation");
239
307
  if (!selectedAnimation || selectedAnimation === savedAnimation) return;
@@ -249,7 +317,7 @@ async function saveAnimation() {
249
317
  const data = await response.json();
250
318
  if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
251
319
  savedAnimation = data.theme;
252
- text("animation-state", `CURRENT ${data.theme.toUpperCase()}`);
320
+ updateAnimationState();
253
321
  text("animation-note", animationNote(data));
254
322
  } catch (error) {
255
323
  text("animation-note", `Not saved / ${String(error.message || error).slice(0, 150)}`);
@@ -313,36 +381,178 @@ function usageValues(row) {
313
381
  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
382
  }
315
383
 
384
+ let usageData = null;
385
+ let usageLoading = false;
386
+ let selectedUsageDate = "";
387
+
388
+ function renderUsageTable() {
389
+ const day = selectedUsageDate && usageData.analytics?.days.find((entry) => entry.date === selectedUsageDate);
390
+ const model = element("analytics-model").value;
391
+ const total = day ? { model: "TOTAL", ...analyticsPoint(day, model) } : usageData.total;
392
+ 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;
393
+ const body = element("usage-body");
394
+ body.replaceChildren();
395
+ if (total.requests > 0) {
396
+ body.append(tableRow(usageValues(total), { total: true }));
397
+ for (const row of rows) body.append(tableRow(usageValues(row)));
398
+ } else { emptyTable("usage-body", 7, "No usage records."); }
399
+ element("clear-usage-day").hidden = !day;
400
+ text("usage-state", day ? day.date : "LOCAL LOG");
401
+ 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"}` : ""}`);
402
+ }
403
+
404
+ function analyticsPoint(day, model) {
405
+ return model ? day.models.find((row) => row.model === model) || { requests: 0, input_tokens: 0, output_tokens: 0 } : day;
406
+ }
407
+
408
+ function selectUsageDay(date) {
409
+ selectedUsageDate = date;
410
+ renderUsageTable();
411
+ renderAnalytics();
412
+ }
413
+
414
+ function dayButton(day, point) {
415
+ const button = document.createElement("button");
416
+ button.type = "button";
417
+ button.disabled = !day.available;
418
+ button.setAttribute("aria-pressed", String(day.date === selectedUsageDate));
419
+ const description = !day.available ? "no retained history"
420
+ : `${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" : ""}`;
421
+ button.title = `${day.date} / ${description}`;
422
+ button.setAttribute("aria-label", button.title);
423
+ button.addEventListener("click", () => selectUsageDay(day.date));
424
+ return button;
425
+ }
426
+
427
+ function renderAnalytics() {
428
+ const analytics = usageData?.analytics;
429
+ if (!analytics) return;
430
+ const model = element("analytics-model").value;
431
+ const days = analytics.days;
432
+ const daily = days.slice(-Number(element("analytics-range").value));
433
+ const maximum = Math.max(1, ...daily.map((day) => {
434
+ const point = analyticsPoint(day, model);
435
+ return point.input_tokens + point.output_tokens;
436
+ }));
437
+ const bars = element("daily-bars");
438
+ bars.replaceChildren();
439
+ for (const day of daily) {
440
+ const point = analyticsPoint(day, model);
441
+ const button = dayButton(day, point);
442
+ button.className = "daily-bar";
443
+ for (const key of ["output_tokens", "input_tokens"]) {
444
+ const segment = document.createElement("span");
445
+ segment.className = key === "input_tokens" ? "bar-input" : "bar-output";
446
+ segment.style.height = `${point[key] / maximum * 100}%`;
447
+ button.append(segment);
448
+ }
449
+ bars.append(button);
450
+ }
451
+ text("daily-from", daily[0].date);
452
+ text("daily-to", analytics.to);
453
+ const metric = element("analytics-metric").value;
454
+ const value = (day) => {
455
+ const point = analyticsPoint(day, model);
456
+ return metric === "tokens" ? point.input_tokens + point.output_tokens : point.requests;
457
+ };
458
+ const peak = Math.max(1, ...days.map(value));
459
+ text("activity-scale", `${metric === "tokens" ? "INPUT + OUTPUT" : "RECORDED CALLS"} / PEAK ${number(peak === 1 && days.every((day) => value(day) === 0) ? 0 : peak)}`);
460
+ const calendar = element("activity-calendar");
461
+ calendar.replaceChildren();
462
+ const offset = (new Date(`${days[0].date}T00:00:00Z`).getUTCDay() + 6) % 7;
463
+ const months = element("calendar-months");
464
+ months.replaceChildren();
465
+ const columns = Math.ceil((offset + days.length) / 7);
466
+ let previousColumn = -4;
467
+ days.forEach((day, index) => {
468
+ if (index !== 0 && !day.date.endsWith("-01")) return;
469
+ const column = Math.floor((offset + index) / 7);
470
+ if (column - previousColumn < 4 || column + 4 > columns) return;
471
+ previousColumn = column;
472
+ const label = document.createElement("span");
473
+ label.textContent = day.date.slice(0, 7);
474
+ label.style.gridColumn = `${column + 1} / span 4`;
475
+ months.append(label);
476
+ });
477
+ for (let index = 0; index < offset; index += 1) {
478
+ const spacer = document.createElement("span");
479
+ spacer.setAttribute("aria-hidden", "true");
480
+ calendar.append(spacer);
481
+ }
482
+ for (const day of days) {
483
+ const button = dayButton(day, analyticsPoint(day, model));
484
+ button.className = "calendar-day";
485
+ const count = value(day);
486
+ button.dataset.level = String(count > 0 ? Math.min(4, Math.ceil(count / peak * 4)) : 0);
487
+ calendar.append(button);
488
+ }
489
+ 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." : ""}`);
490
+ 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.");
491
+ }
492
+
493
+ function updateAnalyticsModels() {
494
+ const select = element("analytics-model");
495
+ const previous = select.value;
496
+ select.replaceChildren();
497
+ for (const model of ["", ...usageData.analytics.models]) {
498
+ const option = document.createElement("option");
499
+ option.value = model;
500
+ option.textContent = model || "All models";
501
+ select.append(option);
502
+ }
503
+ select.value = usageData.analytics.models.includes(previous) ? previous : "";
504
+ }
505
+
316
506
  async function loadUsage() {
507
+ if (usageLoading) return;
508
+ usageLoading = true;
509
+ const includeAnalytics = element("usage-analytics").open || Boolean(selectedUsageDate);
317
510
  const button = element("refresh-usage");
318
511
  button.disabled = true;
319
512
  text("usage-state", "READING LOG");
320
513
  text("usage-note", "Aggregating local usage metadata…");
321
514
  emptyTable("usage-body", 7, "Loading…");
322
515
  try {
323
- const response = await fetch("/_ccdx/ui/usage", { cache: "no-store", headers: { "X-CCDX-Dashboard": "1" }, signal: AbortSignal.timeout(15000) });
516
+ const query = includeAnalytics ? `?analytics=1&time_zone=${encodeURIComponent(Intl.DateTimeFormat().resolvedOptions().timeZone)}` : "";
517
+ const response = await fetch(`/_ccdx/ui/usage${query}`, { cache: "no-store", headers: { "X-CCDX-Dashboard": "1" }, signal: AbortSignal.timeout(15000) });
324
518
  const data = await response.json();
325
519
  if (!response.ok || data.source !== "local_usage_log") throw new Error(data.error || `HTTP ${response.status}`);
326
- const body = element("usage-body");
327
- body.replaceChildren();
328
- if (data.total.requests > 0) {
329
- body.append(tableRow(usageValues(data.total), { total: true }));
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`);
520
+ usageData = data;
521
+ if (data.analytics) updateAnalyticsModels();
522
+ renderUsageTable();
523
+ renderAnalytics();
336
524
  } catch (error) {
337
525
  text("usage-state", "LOG UNAVAILABLE");
338
526
  text("usage-note", String(error.message || error).slice(0, 180));
339
527
  emptyTable("usage-body", 7, "No usage summary available.");
528
+ if (includeAnalytics) text("analytics-note", "Analytics unavailable / refresh Usage to retry.");
340
529
  } finally {
530
+ usageLoading = false;
341
531
  button.disabled = false;
532
+ if (!includeAnalytics && element("usage-analytics").open) loadUsage();
342
533
  }
343
534
  }
344
535
 
345
536
  element("save-animation").addEventListener("click", saveAnimation);
537
+ element("animation-settings").addEventListener("toggle", () => {
538
+ element("open-animation").setAttribute("aria-expanded", String(element("animation-settings").open));
539
+ if (element("animation-settings").open) loadAnimation();
540
+ syncAnimationPreview();
541
+ });
542
+ element("open-animation").addEventListener("click", () => {
543
+ const settings = element("animation-settings");
544
+ settings.open = true;
545
+ settings.scrollIntoView({ block: "center" });
546
+ settings.querySelector("summary").focus();
547
+ });
548
+ element("usage-analytics").addEventListener("toggle", () => {
549
+ if (element("usage-analytics").open && !usageData?.analytics) loadUsage();
550
+ });
551
+ for (const id of ["analytics-model", "analytics-range", "analytics-metric"]) element(id).addEventListener("change", () => {
552
+ renderAnalytics();
553
+ if (usageData && selectedUsageDate) renderUsageTable();
554
+ });
555
+ element("clear-usage-day").addEventListener("click", () => selectUsageDay(""));
346
556
  element("refresh-models").addEventListener("click", loadModels);
347
557
  element("refresh-usage").addEventListener("click", loadUsage);
348
558
  loadAnimation();
@@ -1,5 +1,7 @@
1
1
  import os from "node:os";
2
2
  import { fetchLiveCopilotModels } from "./cli-models.mjs";
3
+ import { authStatus } from "./cli-auth.mjs";
4
+ import { redactDiagnosticText } from "./diagnostic-text.mjs";
3
5
  import { createRequestAbort, readJsonBody } from "./http-transport.mjs";
4
6
  import { isLoopbackAddress, isLoopbackHostHeader } from "./observability.mjs";
5
7
  import {
@@ -10,11 +12,13 @@ import {
10
12
  TERMINAL_ANIMATION_THEMES,
11
13
  } from "./terminal-animation.mjs";
12
14
  import { cacheReadTokens, summarizeUsageLogs, usageCacheHitRate } from "./usage.mjs";
15
+ import { createUsageAnalytics } from "./usage-analytics.mjs";
13
16
  import { readUserSettings, terminalAnimationPreference, writeTerminalAnimationTheme } from "./user-settings.mjs";
14
17
 
15
18
  const ANIMATION_PATH = "/_ccdx/ui/animation";
16
19
  const MODELS_PATH = "/_ccdx/ui/models/live";
17
20
  const USAGE_PATH = "/_ccdx/ui/usage";
21
+ const AUTH_PATH = "/_ccdx/ui/auth";
18
22
  const DISABLED_ANIMATION = /^(0|false|no|off)$/i;
19
23
 
20
24
  function sendJson(res, statusCode, value) {
@@ -107,7 +111,8 @@ export async function handleDashboardApi(req, res, pathname, {
107
111
  env = process.env,
108
112
  home = os.homedir(),
109
113
  liveModelsFn = () => fetchLiveCopilotModels({ home }),
110
- usageSummaryFn = () => summarizeUsageLogs(),
114
+ usageSummaryFn = (options) => summarizeUsageLogs(undefined, options),
115
+ authStatusFn = () => authStatus({ home }),
111
116
  } = {}) {
112
117
  if (!isLoopbackAddress(req.socket?.remoteAddress)
113
118
  || req.headers?.host === undefined
@@ -120,9 +125,31 @@ export async function handleDashboardApi(req, res, pathname, {
120
125
  return;
121
126
  }
122
127
 
128
+ if (pathname === AUTH_PATH && req.method === "GET") {
129
+ try {
130
+ const profile = authStatusFn()?.profiles?.codex;
131
+ if (!profile || typeof profile.configured !== "boolean" || typeof profile.valid !== "boolean") throw new Error("Invalid auth status");
132
+ const login = typeof profile.login === "string" && /^[a-z\d-]{1,39}$/i.test(profile.login)
133
+ && redactDiagnosticText(profile.login) === profile.login ? profile.login : "";
134
+ const id = /^\d{1,20}$/.test(String(profile.id || "")) ? String(profile.id) : "";
135
+ sendJson(res, 200, {
136
+ source: "local_auth_status",
137
+ configured: profile.configured,
138
+ valid: profile.valid,
139
+ login: profile.valid ? login : "",
140
+ id: profile.valid ? id : "",
141
+ reason: ["unconfigured", "empty_token", "credential_read_failed"].includes(profile.reason) ? profile.reason : "",
142
+ });
143
+ } catch {
144
+ sendJson(res, 500, { error: "Could not read local auth status" });
145
+ }
146
+ return;
147
+ }
148
+
123
149
  if (pathname === ANIMATION_PATH && req.method === "GET") {
124
150
  try {
125
- sendJson(res, 200, animationState(env, home, { includeFrames: true }));
151
+ const query = new URL(req.url, "http://localhost").searchParams;
152
+ sendJson(res, 200, animationState(env, home, { includeFrames: query.get("frames") !== "0" }));
126
153
  } catch {
127
154
  sendJson(res, 409, { error: "Animation settings are invalid; inspect them with ccdx animation" });
128
155
  }
@@ -189,8 +216,21 @@ export async function handleDashboardApi(req, res, pathname, {
189
216
  }
190
217
 
191
218
  if (pathname === USAGE_PATH && req.method === "GET") {
219
+ const query = new URL(req.url, "http://localhost").searchParams;
220
+ let analytics = null;
221
+ if (query.get("analytics") === "1") {
222
+ try {
223
+ const timeZone = query.get("time_zone") || "UTC";
224
+ if (timeZone.length > 100) throw new Error("Invalid time zone");
225
+ analytics = createUsageAnalytics({ timeZone });
226
+ } catch {
227
+ sendJson(res, 400, { error: "Select a valid usage time zone" });
228
+ return;
229
+ }
230
+ }
192
231
  try {
193
- sendJson(res, 200, { source: "local_usage_log", ...(usageTable(await usageSummaryFn())) });
232
+ const summary = await usageSummaryFn(analytics ? { onRecord: analytics.record } : undefined);
233
+ sendJson(res, 200, { source: "local_usage_log", ...usageTable(summary), ...(analytics ? { analytics: analytics.snapshot() } : {}) });
194
234
  } catch {
195
235
  sendJson(res, 500, { error: "Could not read local usage summary" });
196
236
  }
@@ -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)) {