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 +6 -0
- package/package.json +1 -1
- package/src/adapter.mjs +1 -1
- package/src/dashboard/index.html +26 -10
- package/src/dashboard/ui.css +45 -1
- package/src/dashboard/ui.js +234 -24
- package/src/dashboard-api.mjs +43 -3
- package/src/usage-analytics.mjs +77 -0
- package/src/usage.mjs +2 -1
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
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;
|
package/src/dashboard/index.html
CHANGED
|
@@ -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>
|
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); }
|
|
@@ -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 {
|
|
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; } }
|
package/src/dashboard/ui.js
CHANGED
|
@@ -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(-
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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(
|
|
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
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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`);
|
|
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();
|
package/src/dashboard-api.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)) {
|