codex-copilot-dx 0.8.7 → 0.8.9

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
@@ -213,6 +213,12 @@ If a configured npm mirror has not synchronized the current release yet, use the
213
213
 
214
214
  ## Diagnostics
215
215
 
216
+ ### Local dashboard
217
+
218
+ After starting `ccdx`, open [http://127.0.0.1:2026/](http://127.0.0.1:2026/) in a browser. If `ADAPTER_PORT` is customized, use that port instead. The dashboard shares the existing adapter listener; CCDX does not open a browser automatically or start another service. The page and its assets require both a loopback connection and a loopback Host header, even when LAN binding is enabled.
219
+
220
+ The dashboard loads local status, a live GitHub Copilot model directory, and a summary of local usage metadata once when opened. Each section has an explicit Refresh control; nothing polls in the background. The appearance button switches between the default dark terminal theme (sun icon) and a light theme (moon icon); the choice is stored only in that browser. The live model table uses the same fresh account validation and selectable GPT filtering as `ccdx models`, never labels cached fallback data as online, and makes no inference request. A catalog listing does not guarantee inference access. The Usage table combines the current and rotated local usage logs, shows totals and up to 100 model rows, and contains no prompt, response body, or credential fields. The animation selector previews the existing themes and saves through the same settings writer as `ccdx animation`; changes take effect on the next adapter start, and `CCDX_TERMINAL_ANIMATION=off` still disables terminal motion. Dashboard data requests require a custom header that cross-site simple requests cannot send; animation writes also require a matching same-origin `Origin`. The dashboard does not initialize the optional image handler. The overview's known-model count is the adapter's current in-memory catalog, not the live table. A missing image-generation status does not prove the feature is disabled: use `ccdx image-status` to check setup. Recent upstream error prose may contain sensitive details, so review the page before sharing screenshots.
221
+
216
222
  Inspect only the local Codex configuration:
217
223
 
218
224
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.8.7",
3
+ "version": "0.8.9",
4
4
  "description": "Use Codex App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
package/src/adapter.mjs CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  classifyAdapterRoute,
8
8
  createRequestMetrics,
9
9
  isLoopbackAddress,
10
+ isLoopbackHostHeader,
10
11
  runtimeStatusPayload,
11
12
  } from "./observability.mjs";
12
13
  import { createRequestId, runWithRequestContext } from "./request-context.mjs";
@@ -63,6 +64,8 @@ export {
63
64
  } from "./responses-request.mjs";
64
65
 
65
66
  const ADAPTER_RUNTIME_CONFIG = loadRuntimeConfig();
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"]);
66
69
 
67
70
  export function requestPath(reqUrl) {
68
71
  return new URL(reqUrl || "/", "http://localhost").pathname;
@@ -200,6 +203,14 @@ export function createAdapterHandler(options = {}) {
200
203
  upstreamTimeoutMs,
201
204
  });
202
205
  const dispatch = async (req, res, pathname) => {
206
+ if (DASHBOARD_API_PATHS.has(pathname)) {
207
+ const { handleDashboardApi } = await import("./dashboard-api.mjs");
208
+ return handleDashboardApi(req, res, pathname, options.dashboardOptions);
209
+ }
210
+ if ((req.method === "GET" || req.method === "HEAD") && DASHBOARD_PATHS.has(pathname)) {
211
+ const { serveDashboard } = await import("./dashboard.mjs");
212
+ return serveDashboard(req, res, pathname);
213
+ }
203
214
  if (pathname === "/mcp/image") {
204
215
  if (imageMcpHandler) return imageMcpHandler(req, res);
205
216
  return import("./image-mcp.mjs").then(({ createImageMcpHandler }) => {
@@ -208,7 +219,7 @@ export function createAdapterHandler(options = {}) {
208
219
  });
209
220
  }
210
221
  if (req.method === "GET" && pathname === ADAPTER_STATUS_PATH) {
211
- if (!isLoopbackAddress(req.socket?.remoteAddress)) {
222
+ if (!isLoopbackAddress(req.socket?.remoteAddress) || !isLoopbackHostHeader(req.headers?.host)) {
212
223
  res.writeHead(403, { "Content-Type": "application/json" });
213
224
  res.end(JSON.stringify({ error: "Runtime status is available only from loopback" }));
214
225
  return;
@@ -235,7 +246,7 @@ export function createAdapterHandler(options = {}) {
235
246
  }
236
247
 
237
248
  if (pathname === ADAPTER_CACHE_PATH) {
238
- if (!isLoopbackAddress(req.socket?.remoteAddress)) {
249
+ if (!isLoopbackAddress(req.socket?.remoteAddress) || !isLoopbackHostHeader(req.headers?.host)) {
239
250
  res.writeHead(403, { "Content-Type": "application/json" });
240
251
  res.end(JSON.stringify({ error: "Cache control is available only from loopback" }));
241
252
  return;
@@ -373,7 +384,9 @@ export function createAdapterHandler(options = {}) {
373
384
 
374
385
  const trackRequest = pathname !== ADAPTER_HEALTH_PATH
375
386
  && pathname !== ADAPTER_STATUS_PATH
376
- && pathname !== ADAPTER_CACHE_PATH;
387
+ && pathname !== ADAPTER_CACHE_PATH
388
+ && !DASHBOARD_API_PATHS.has(pathname)
389
+ && !((req.method === "GET" || req.method === "HEAD") && DASHBOARD_PATHS.has(pathname));
377
390
  const routeName = classifyAdapterRoute(req.method, pathname);
378
391
  const complete = trackRequest ? requestMetrics.begin(routeName) : () => {};
379
392
  const streamPerformance = trackRequest ? streamPerformanceMetrics.begin(routeName) : null;
@@ -0,0 +1,79 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="color-scheme" content="dark light">
7
+ <link rel="icon" href="data:,">
8
+ <script src="/theme.js"></script>
9
+ <link rel="stylesheet" href="/ui.css">
10
+ <script src="/ui.js" defer></script>
11
+ <title>CCDX · Local dashboard</title>
12
+ </head>
13
+ <body>
14
+ <div class="shell">
15
+ <header class="topbar">
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="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20.4 15.6A8.5 8.5 0 0 1 8.4 3.6 8.5 8.5 0 1 0 20.4 15.6Z"/></svg></button><button id="refresh" type="button">Refresh</button></div>
18
+ </header>
19
+
20
+ <main>
21
+ <section class="intro">
22
+ <h1><span aria-hidden="true">$</span> ccdx status</h1>
23
+ <p id="updated">Reading status…</p>
24
+ </section>
25
+
26
+ <section class="overview" aria-label="Overview">
27
+ <article class="card"><h2>ADAPTER</h2><strong id="version">—</strong><p id="uptime">—</p></article>
28
+ <article class="card"><h2>COPILOT</h2><strong id="copilot">—</strong><p id="token">—</p></article>
29
+ <article class="card"><h2>KNOWN MODELS</h2><strong id="models">—</strong><p id="model-source">—</p></article>
30
+ <article class="card"><h2>REQUESTS</h2><strong id="requests">—</strong><p id="request-errors">—</p></article>
31
+ </section>
32
+
33
+ <section class="details" aria-label="Runtime details">
34
+ <article class="panel">
35
+ <div class="panel-heading"><h2>MODEL OUTCOMES</h2><span>RESPONSES</span></div>
36
+ <dl class="stats"><div><dt>Completed</dt><dd id="completed">—</dd></div><div><dt>Incomplete</dt><dd id="incomplete">—</dd></div><div><dt>Failed</dt><dd id="failed">—</dd></div><div><dt>Cancelled</dt><dd id="cancelled">—</dd></div></dl>
37
+ <p class="hint">HTTP 2xx != model completed.</p>
38
+ </article>
39
+ <article class="panel">
40
+ <div class="panel-heading"><h2>LOCAL HISTORY</h2><span>MEMORY</span></div>
41
+ <strong id="history">—</strong><progress id="history-meter" class="meter" max="100" value="0" aria-label="History cache usage"></progress>
42
+ <p id="history-detail" class="hint">—</p>
43
+ </article>
44
+ <article class="panel">
45
+ <div class="panel-heading"><h2>IMAGE GENERATION</h2><span>OPTIONAL</span></div>
46
+ <strong id="image">—</strong><p id="image-detail" class="hint">—</p>
47
+ </article>
48
+ </section>
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
+ <section class="panel dashboard-section" aria-label="Live upstream models">
57
+ <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
+ <p id="models-note" class="table-note">Querying GitHub Copilot…</p>
59
+ <div class="table-wrap"><table><thead><tr><th scope="col">MODEL</th><th scope="col">VENDOR</th><th scope="col">API</th><th scope="col">FLAG</th></tr></thead><tbody id="live-models-body"><tr><td colspan="4">Loading…</td></tr></tbody></table></div>
60
+ <p class="hint">Catalog listing does not guarantee inference access.</p>
61
+ </section>
62
+
63
+ <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>
65
+ <p id="usage-note" class="table-note">Aggregating local usage metadata…</p>
66
+ <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></tr></thead><tbody id="usage-body"><tr><td colspan="6">Loading…</td></tr></tbody></table></div>
67
+ </section>
68
+
69
+ <section class="lower" aria-label="Recent failures">
70
+ <div class="panel-heading"><h2>RECENT UPSTREAM FAILURES</h2><span>response.failed</span></div>
71
+ <ol id="failures"><li class="empty">Reading…</li></ol>
72
+ <p class="hint">Upstream messages may contain sensitive text.</p>
73
+ </section>
74
+ </main>
75
+
76
+ <footer>LOCAL ONLY / NO BACKGROUND POLLING</footer>
77
+ </div>
78
+ </body>
79
+ </html>
@@ -0,0 +1,24 @@
1
+ (() => {
2
+ const key = "ccdx.dashboard.theme";
3
+ let theme = "dark";
4
+ try {
5
+ if (localStorage.getItem(key) === "light") theme = "light";
6
+ } catch { /* Private browsing may disable storage. */ }
7
+ document.documentElement.dataset.theme = theme;
8
+
9
+ document.addEventListener("DOMContentLoaded", () => {
10
+ const button = document.getElementById("theme-toggle");
11
+ const updateLabel = () => {
12
+ const label = `Switch to ${theme === "dark" ? "light" : "dark"} mode`;
13
+ button.setAttribute("aria-label", label);
14
+ button.title = label;
15
+ };
16
+ updateLabel();
17
+ button.addEventListener("click", () => {
18
+ theme = theme === "dark" ? "light" : "dark";
19
+ document.documentElement.dataset.theme = theme;
20
+ try { localStorage.setItem(key, theme); } catch { /* Keep the current selection. */ }
21
+ updateLabel();
22
+ });
23
+ });
24
+ })();
@@ -0,0 +1,88 @@
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
+ :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
+ * { box-sizing: border-box; }
4
+ body { margin: 0; color: var(--text); background: var(--page); font-size: 13px; }
5
+ .shell { max-width: 1220px; margin: 0 auto; padding: 0 24px; }
6
+ .topbar { min-height: 68px; display: flex; justify-content: space-between; align-items: center; gap: 20px; border-bottom: 1px solid var(--top-border); }
7
+ .brand { color: var(--accent); font-size: 16px; font-weight: 750; letter-spacing: .07em; white-space: nowrap; }
8
+ .brand-detail { color: var(--subtle); font-size: 11px; font-weight: 500; letter-spacing: .03em; }
9
+ .topbar-actions { display: flex; align-items: center; gap: 20px; }
10
+ .badge { color: var(--badge); white-space: nowrap; font-size: 11px; font-weight: 650; text-transform: uppercase; }
11
+ .badge::before { content: "●"; margin-right: 8px; }
12
+ .badge.good { color: var(--accent-soft); }
13
+ .badge.bad { color: var(--bad); }
14
+ button { border: 1px solid var(--button-border); border-radius: 2px; background: var(--button-bg); color: var(--button-fg); padding: 8px 13px; font: inherit; font-size: 11px; font-weight: 700; text-transform: uppercase; cursor: pointer; }
15
+ button:hover { background: var(--button-hover); }
16
+ button:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
17
+ button:disabled { opacity: .55; cursor: wait; }
18
+ .icon-button { display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px; padding: 6px; }
19
+ .icon-button svg { width: 18px; height: 18px; }
20
+ .icon-moon, :root[data-theme="light"] .icon-sun { display: none; }
21
+ :root[data-theme="light"] .icon-moon { display: block; }
22
+ .intro { display: flex; justify-content: space-between; align-items: baseline; gap: 20px; padding: 38px 0 25px; }
23
+ h1 { margin: 0; font-size: clamp(20px, 3vw, 27px); font-weight: 650; letter-spacing: -.035em; }
24
+ h1 span { color: var(--accent-soft); }
25
+ .intro p { margin: 0; color: var(--subtle); font-size: 11px; }
26
+ .overview { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; }
27
+ .card, .panel, .lower { min-width: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 2px; }
28
+ .card { min-height: 139px; padding: 18px; }
29
+ .card h2, .panel h2, .lower h2 { color: var(--heading); font-size: 11px; font-weight: 700; letter-spacing: .08em; margin: 0; }
30
+ .card strong, .panel > strong { display: block; margin-top: 21px; color: var(--strong); font-size: 23px; font-weight: 650; line-height: 1.25; letter-spacing: -.035em; overflow-wrap: anywhere; }
31
+ .card p { margin: 9px 0 0; color: var(--muted); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; }
32
+ .details { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 11px; margin-top: 11px; }
33
+ .panel { min-height: 205px; padding: 18px; }
34
+ .panel-heading { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
35
+ .panel-heading span { color: var(--quiet); font-size: 10px; white-space: nowrap; }
36
+ .stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 17px; margin: 26px 0 0; }
37
+ .stats div { display: flex; justify-content: space-between; border-bottom: 1px solid var(--border); padding-bottom: 8px; gap: 8px; }
38
+ .stats dt { color: var(--muted); font-size: 11px; }
39
+ .stats dd { margin: 0; font-size: 14px; font-weight: 650; }
40
+ #incomplete { color: var(--incomplete); }
41
+ #failed { color: var(--failed); }
42
+ .hint { color: var(--subtle); font-size: 11px; line-height: 1.5; margin: 15px 0 0; overflow-wrap: anywhere; }
43
+ .meter { display: block; width: 100%; height: 6px; border: 0; border-radius: 0; background: var(--border); margin-top: 20px; appearance: none; }
44
+ .meter::-webkit-progress-bar { background: var(--border); }
45
+ .meter::-webkit-progress-value { background: var(--accent-soft); }
46
+ .meter::-moz-progress-bar { background: var(--accent-soft); }
47
+ .dashboard-section { min-height: 0; margin-top: 11px; }
48
+ .heading-actions { display: flex; align-items: center; gap: 16px; }
49
+ .heading-actions button { padding: 5px 9px; }
50
+ .animation-options { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 18px; }
51
+ .theme-option { display: grid; grid-template-columns: 18px 76px minmax(0, 1fr); align-items: center; gap: 8px; min-width: 0; width: 100%; border: 1px solid var(--border); padding: 10px; background: transparent; color: var(--text); text-align: left; text-transform: none; font-size: 11px; cursor: pointer; }
52
+ .theme-option::before { content: "○"; color: var(--subtle); font-size: 17px; line-height: 1; }
53
+ .theme-option[aria-pressed="true"] { border-color: var(--accent-soft); background: var(--selected-bg); }
54
+ .theme-option[aria-pressed="true"]::before { content: "●"; color: var(--accent-soft); }
55
+ .theme-option:hover { background: var(--option-hover); }
56
+ .theme-name { color: var(--text); font-size: 11px; white-space: nowrap; }
57
+ .theme-name small { display: block; color: var(--subtle); font-size: 9px; margin-top: 2px; }
58
+ .theme-preview { color: var(--preview); white-space: pre; overflow: hidden; font-size: 11px; letter-spacing: 0; }
59
+ .ansi-white { color: var(--ansi-white); }
60
+ .ansi-159 { color: var(--ansi-159); }
61
+ .ansi-123 { color: var(--ansi-123); }
62
+ .ansi-87 { color: var(--ansi-87); }
63
+ .ansi-51 { color: var(--ansi-51); }
64
+ .ansi-45 { color: var(--ansi-45); }
65
+ .ansi-39 { color: var(--ansi-39); }
66
+ .ansi-33 { color: var(--ansi-33); }
67
+ .section-actions { display: flex; align-items: center; gap: 15px; margin-top: 16px; }
68
+ .section-actions span { color: var(--muted); font-size: 11px; }
69
+ .table-note { margin: 15px 0 8px; color: var(--muted); font-size: 11px; }
70
+ .table-wrap { width: 100%; overflow-x: auto; }
71
+ table { width: 100%; border-collapse: collapse; font-size: 11px; }
72
+ th { color: var(--heading); text-align: left; font-weight: 700; letter-spacing: .05em; }
73
+ th, td { padding: 9px 12px 9px 0; border-bottom: 1px solid var(--border); white-space: nowrap; }
74
+ th:last-child, td:last-child { padding-right: 0; }
75
+ td { color: var(--table-text); }
76
+ .usage-table th:not(:first-child), .usage-table td:not(:first-child) { text-align: right; }
77
+ .usage-table tr.total-row td { color: var(--accent); font-weight: 700; }
78
+ .lower { margin-top: 11px; padding: 18px; }
79
+ .lower ol { list-style: none; margin: 16px 0 0; padding: 0; }
80
+ .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; }
81
+ .lower li strong, .lower li span { overflow-wrap: anywhere; }
82
+ .lower li strong { color: var(--text); font-weight: 650; }
83
+ .lower li span, .lower li.empty { color: var(--muted); line-height: 1.5; }
84
+ footer { color: var(--footer); font-size: 10px; letter-spacing: .05em; padding: 22px 0 30px; }
85
+ @media (max-width: 1000px) { .animation-options { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
86
+ @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; } }
87
+ @media (max-width: 680px) { .animation-options { grid-template-columns: 1fr; } }
88
+ @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; } }
@@ -0,0 +1,347 @@
1
+ const element = (id) => document.getElementById(id);
2
+ const text = (id, value) => { element(id).textContent = value; };
3
+ const finite = (value) => value !== null && value !== undefined && Number.isFinite(Number(value));
4
+ const number = (value) => finite(value) ? Math.max(0, Math.round(Number(value))).toLocaleString() : "—";
5
+ const mib = (value) => finite(value) ? `${(Number(value) / 1048576).toFixed(1)} MiB` : "—";
6
+
7
+ function duration(value) {
8
+ if (!finite(value)) return "—";
9
+ const seconds = Math.max(0, Math.round(Number(value) / 1000));
10
+ const days = Math.floor(seconds / 86400);
11
+ const hours = Math.floor((seconds % 86400) / 3600);
12
+ const minutes = Math.floor((seconds % 3600) / 60);
13
+ return days ? `${days}d ${hours}h` : hours ? `${hours}h ${minutes}m` : `${minutes}m`;
14
+ }
15
+
16
+ function renderFailures(recent) {
17
+ const list = element("failures");
18
+ list.replaceChildren();
19
+ const failures = Array.isArray(recent) ? recent.slice(-5).reverse() : [];
20
+ if (!failures.length) {
21
+ const item = document.createElement("li");
22
+ item.className = "empty";
23
+ item.textContent = "No response.failed events recorded.";
24
+ list.append(item);
25
+ return;
26
+ }
27
+ for (const failure of failures) {
28
+ 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);
34
+ list.append(item);
35
+ }
36
+ }
37
+
38
+ function render(data) {
39
+ text("version", `v${data.version || "?"}`);
40
+ text("uptime", `pid ${number(data.pid)} / uptime ${duration(data.uptime_ms)}`);
41
+
42
+ const copilot = data.copilot || {};
43
+ text("copilot", copilot.account_bound ? "BOUND" : "UNCONFIRMED");
44
+ text("token", copilot.token_cached ? `service token / TTL ${duration(copilot.token_expires_in_ms)}` : "service token not cached");
45
+
46
+ const models = data.models || {};
47
+ text("models", number(models.models));
48
+ text("model-source", `source ${String(models.source || "unknown").slice(0, 80)} / live: ccdx models`);
49
+
50
+ const requests = data.requests || {};
51
+ text("requests", number(requests.total));
52
+ text("request-errors", `4xx ${number(requests.status_4xx)} / 5xx ${number(requests.status_5xx)} / active ${number(requests.active)}`);
53
+
54
+ const outcomes = data.stream_performance?.by_route?.responses?.terminal_outcomes?.totals || {};
55
+ for (const key of ["completed", "incomplete", "failed", "cancelled"]) text(key, number(outcomes[key]));
56
+
57
+ const history = data.response_history || {};
58
+ const limit = Number(history.maxBytes || data.limits?.response_history_max_bytes || 0);
59
+ const used = Number(history.bytes || 0);
60
+ text("history", `${mib(used)} / ${mib(limit)}`);
61
+ text("history-detail", `entries ${number(history.entries)} / trees ${number(history.tree_count)} / misses ${number(history.lookup_misses)} (evicted ${number(history.evicted_lookup_misses)})`);
62
+ element("history-meter").value = limit > 0 ? Math.min(100, Math.max(0, used / limit * 100)) : 0;
63
+
64
+ const image = data.image_generation;
65
+ text("image", image ? `${number(image.succeeded)} succeeded` : "NOT INITIALIZED");
66
+ text("image-detail", image
67
+ ? `active ${number(image.active)} / failed ${number(image.failed)} / delivery failures ${number(image.delivery_failures)}`
68
+ : "Setup state unknown / ccdx image-status");
69
+
70
+ renderFailures(data.response_failures?.recent);
71
+ text("updated", `Snapshot ${new Date().toLocaleTimeString()}`);
72
+ }
73
+
74
+ async function refresh() {
75
+ const button = element("refresh");
76
+ button.disabled = true;
77
+ text("connection", "READING");
78
+ element("connection").className = "badge neutral";
79
+ try {
80
+ const response = await fetch("/_ccdx/status", {
81
+ cache: "no-store",
82
+ headers: { Accept: "application/json" },
83
+ signal: AbortSignal.timeout(5000),
84
+ });
85
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
86
+ const data = await response.json();
87
+ if (data?.ok !== true || data?.name !== "codex-copilot-dx") throw new Error("Unexpected status response");
88
+ render(data);
89
+ text("connection", "LOCAL OK");
90
+ element("connection").className = "badge good";
91
+ } catch {
92
+ text("connection", "UNAVAILABLE");
93
+ element("connection").className = "badge bad";
94
+ text("updated", "Status unavailable / check ccdx, then refresh");
95
+ } finally {
96
+ button.disabled = false;
97
+ }
98
+ }
99
+
100
+ element("refresh").addEventListener("click", refresh);
101
+ refresh();
102
+
103
+ const animationRows = new Map();
104
+ let animationTimelines = [];
105
+ let savedAnimation = "";
106
+ let selectedAnimation = "";
107
+ let previewDisabledByEnvironment = false;
108
+ let previewVisible = false;
109
+ let previewTimer = null;
110
+ let previewStarted = 0;
111
+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
112
+
113
+ function renderAnsiFrame(target, frame) {
114
+ const fragments = String(frame).split(/\u001b\[([0-9;]+)m/);
115
+ const content = document.createDocumentFragment();
116
+ let color = "";
117
+ for (let index = 0; index < fragments.length; index += 2) {
118
+ if (fragments[index]) {
119
+ const span = document.createElement("span");
120
+ span.className = color;
121
+ span.textContent = fragments[index];
122
+ content.append(span);
123
+ }
124
+ const code = fragments[index + 1];
125
+ color = code === "97" ? "ansi-white"
126
+ : /^38;5;(159|123|87|51|45|39|33)$/.test(code || "") ? `ansi-${code.slice(5)}` : "";
127
+ }
128
+ target.replaceChildren(content);
129
+ }
130
+
131
+ function renderAnimationFrames(elapsed) {
132
+ for (const timeline of animationTimelines) {
133
+ const position = timeline.id === selectedAnimation ? elapsed % timeline.duration : 0;
134
+ const frameIndex = timeline.frames.findIndex(({ until }) => position < until);
135
+ if (frameIndex === timeline.lastFrame) continue;
136
+ timeline.lastFrame = frameIndex;
137
+ renderAnsiFrame(timeline.target, frameIndex < 0 ? `[${" ".repeat(20)}]` : timeline.frames[frameIndex].ansi);
138
+ }
139
+ }
140
+
141
+ function stopAnimationPreview() {
142
+ if (previewTimer !== null) clearTimeout(previewTimer);
143
+ previewTimer = null;
144
+ }
145
+
146
+ function tickAnimationPreview() {
147
+ previewTimer = null;
148
+ if (!previewVisible || document.visibilityState !== "visible" || reducedMotion.matches || previewDisabledByEnvironment) return;
149
+ renderAnimationFrames(performance.now() - previewStarted);
150
+ previewTimer = setTimeout(tickAnimationPreview, 32);
151
+ }
152
+
153
+ function syncAnimationPreview() {
154
+ stopAnimationPreview();
155
+ if (!previewVisible || document.visibilityState !== "visible" || reducedMotion.matches || previewDisabledByEnvironment) {
156
+ for (const timeline of animationTimelines) timeline.lastFrame = -2;
157
+ renderAnimationFrames(0);
158
+ return;
159
+ }
160
+ previewStarted = performance.now();
161
+ tickAnimationPreview();
162
+ }
163
+
164
+ new IntersectionObserver(([entry]) => {
165
+ previewVisible = entry.isIntersecting;
166
+ syncAnimationPreview();
167
+ }).observe(element("animation-options"));
168
+ document.addEventListener("visibilitychange", syncAnimationPreview);
169
+ reducedMotion.addEventListener("change", syncAnimationPreview);
170
+
171
+ function buildAnimationOptions(data) {
172
+ const container = element("animation-options");
173
+ container.replaceChildren();
174
+ animationRows.clear();
175
+ animationTimelines = [];
176
+ savedAnimation = data.theme;
177
+ selectedAnimation = data.theme;
178
+ previewDisabledByEnvironment = data.disabled_by_environment === true;
179
+ for (const [index, theme] of data.themes.entries()) {
180
+ const option = document.createElement("button");
181
+ option.type = "button";
182
+ option.className = "theme-option";
183
+ option.setAttribute("aria-pressed", String(theme.id === data.theme));
184
+ option.addEventListener("click", () => {
185
+ selectedAnimation = theme.id;
186
+ for (const [id, row] of animationRows) row.setAttribute("aria-pressed", String(id === theme.id));
187
+ element("save-animation").disabled = selectedAnimation === savedAnimation;
188
+ syncAnimationPreview();
189
+ });
190
+ const name = document.createElement("span");
191
+ name.className = "theme-name";
192
+ name.textContent = `${String(index + 1).padStart(2, "0")} ${theme.label}`;
193
+ if (theme.default) {
194
+ const marker = document.createElement("small");
195
+ marker.textContent = "DEFAULT";
196
+ name.append(marker);
197
+ }
198
+ const preview = document.createElement("span");
199
+ preview.className = "theme-preview";
200
+ preview.setAttribute("aria-hidden", "true");
201
+ option.append(name, preview);
202
+ container.append(option);
203
+ animationRows.set(theme.id, option);
204
+ let until = 0;
205
+ const frames = theme.frames.map((frame) => {
206
+ until += frame.delay_ms;
207
+ return { ansi: frame.ansi, until };
208
+ });
209
+ animationTimelines.push({ id: theme.id, target: preview, frames, duration: until + theme.loop_pause_ms, lastFrame: -2 });
210
+ }
211
+ element("save-animation").disabled = true;
212
+ syncAnimationPreview();
213
+ }
214
+
215
+ function animationNote(data) {
216
+ return data.disabled_by_environment
217
+ ? "Disabled by CCDX_TERMINAL_ANIMATION; saved choice applies after override removal and next start."
218
+ : "Saved choice applies on next ccdx start.";
219
+ }
220
+
221
+ async function loadAnimation() {
222
+ text("animation-state", "READING");
223
+ try {
224
+ const response = await fetch("/_ccdx/ui/animation", { cache: "no-store", headers: { "X-CCDX-Dashboard": "1" }, signal: AbortSignal.timeout(8000) });
225
+ const data = await response.json();
226
+ if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
227
+ buildAnimationOptions(data);
228
+ text("animation-state", `CURRENT ${data.theme.toUpperCase()}`);
229
+ text("animation-note", animationNote(data));
230
+ } catch (error) {
231
+ text("animation-state", "UNAVAILABLE");
232
+ text("animation-note", String(error.message || error).slice(0, 180));
233
+ }
234
+ }
235
+
236
+ async function saveAnimation() {
237
+ const button = element("save-animation");
238
+ if (!selectedAnimation || selectedAnimation === savedAnimation) return;
239
+ button.disabled = true;
240
+ text("animation-note", "Saving…");
241
+ try {
242
+ const response = await fetch("/_ccdx/ui/animation", {
243
+ method: "POST",
244
+ headers: { "Content-Type": "application/json", "X-CCDX-Dashboard": "1" },
245
+ body: JSON.stringify({ theme: selectedAnimation }),
246
+ signal: AbortSignal.timeout(8000),
247
+ });
248
+ const data = await response.json();
249
+ if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
250
+ savedAnimation = data.theme;
251
+ text("animation-state", `CURRENT ${data.theme.toUpperCase()}`);
252
+ text("animation-note", animationNote(data));
253
+ } catch (error) {
254
+ text("animation-note", `Not saved / ${String(error.message || error).slice(0, 150)}`);
255
+ } finally {
256
+ button.disabled = selectedAnimation === savedAnimation;
257
+ }
258
+ }
259
+
260
+ function emptyTable(bodyId, columns, message) {
261
+ const body = element(bodyId);
262
+ body.replaceChildren();
263
+ const row = document.createElement("tr");
264
+ const cell = document.createElement("td");
265
+ cell.colSpan = columns;
266
+ cell.textContent = message;
267
+ row.append(cell);
268
+ body.append(row);
269
+ }
270
+
271
+ function tableRow(values, { total = false } = {}) {
272
+ const row = document.createElement("tr");
273
+ if (total) row.className = "total-row";
274
+ for (const value of values) {
275
+ const cell = document.createElement("td");
276
+ cell.textContent = value;
277
+ row.append(cell);
278
+ }
279
+ return row;
280
+ }
281
+
282
+ async function loadModels() {
283
+ const button = element("refresh-models");
284
+ button.disabled = true;
285
+ text("models-state", "CHECKING LIVE");
286
+ text("models-note", "Querying GitHub Copilot…");
287
+ emptyTable("live-models-body", 4, "Loading…");
288
+ try {
289
+ const response = await fetch("/_ccdx/ui/models/live", { cache: "no-store", headers: { "X-CCDX-Dashboard": "1" }, signal: AbortSignal.timeout(15000) });
290
+ const data = await response.json();
291
+ if (!response.ok || data.source !== "live") throw new Error(data.error || `HTTP ${response.status}`);
292
+ const body = element("live-models-body");
293
+ body.replaceChildren();
294
+ for (const model of data.models) {
295
+ body.append(tableRow([model.id, model.vendor, model.endpoints.join(" / "), model.preview ? "preview" : "—"]));
296
+ }
297
+ if (!data.models.length) emptyTable("live-models-body", 4, "No selectable GPT models advertised.");
298
+ text("models-state", "LIVE SNAPSHOT");
299
+ text("models-note", `${number(data.selectable)} selectable / ${number(data.advertised)} advertised / ${data.upstream_host} / ${new Date(data.checked_at).toLocaleTimeString()}`);
300
+ } catch (error) {
301
+ text("models-state", "LIVE LOOKUP FAILED");
302
+ text("models-note", String(error.message || error).slice(0, 240));
303
+ emptyTable("live-models-body", 4, "No live catalog available.");
304
+ } finally {
305
+ button.disabled = false;
306
+ }
307
+ }
308
+
309
+ function usageValues(row) {
310
+ return [row.model, number(row.requests), number(row.input_tokens), number(row.cache_read_tokens), number(row.output_tokens), number(row.total_tokens)];
311
+ }
312
+
313
+ async function loadUsage() {
314
+ const button = element("refresh-usage");
315
+ button.disabled = true;
316
+ text("usage-state", "READING LOG");
317
+ text("usage-note", "Aggregating local usage metadata…");
318
+ emptyTable("usage-body", 6, "Loading…");
319
+ try {
320
+ const response = await fetch("/_ccdx/ui/usage", { cache: "no-store", headers: { "X-CCDX-Dashboard": "1" }, signal: AbortSignal.timeout(15000) });
321
+ const data = await response.json();
322
+ if (!response.ok || data.source !== "local_usage_log") throw new Error(data.error || `HTTP ${response.status}`);
323
+ const body = element("usage-body");
324
+ body.replaceChildren();
325
+ if (data.total.requests > 0) {
326
+ body.append(tableRow(usageValues(data.total), { total: true }));
327
+ for (const row of data.rows) body.append(tableRow(usageValues(row)));
328
+ } else {
329
+ emptyTable("usage-body", 6, "No usage records.");
330
+ }
331
+ text("usage-state", "LOCAL LOG");
332
+ text("usage-note", `${number(data.total.requests)} records / ${number(data.rows.length)} of ${number(data.model_count)} models shown`);
333
+ } catch (error) {
334
+ text("usage-state", "LOG UNAVAILABLE");
335
+ text("usage-note", String(error.message || error).slice(0, 180));
336
+ emptyTable("usage-body", 6, "No usage summary available.");
337
+ } finally {
338
+ button.disabled = false;
339
+ }
340
+ }
341
+
342
+ element("save-animation").addEventListener("click", saveAnimation);
343
+ element("refresh-models").addEventListener("click", loadModels);
344
+ element("refresh-usage").addEventListener("click", loadUsage);
345
+ loadAnimation();
346
+ loadModels();
347
+ loadUsage();
@@ -0,0 +1,204 @@
1
+ import os from "node:os";
2
+ import { fetchLiveCopilotModels } from "./cli-models.mjs";
3
+ import { createRequestAbort, readJsonBody } from "./http-transport.mjs";
4
+ import { isLoopbackAddress, isLoopbackHostHeader } from "./observability.mjs";
5
+ import {
6
+ DEFAULT_TERMINAL_ANIMATION_THEME,
7
+ getTerminalAnimationFrameDelay,
8
+ isTerminalAnimationTheme,
9
+ renderTerminalAnimationFrame,
10
+ TERMINAL_ANIMATION_THEMES,
11
+ } from "./terminal-animation.mjs";
12
+ import { summarizeUsageLogs } from "./usage.mjs";
13
+ import { readUserSettings, terminalAnimationPreference, writeTerminalAnimationTheme } from "./user-settings.mjs";
14
+
15
+ const ANIMATION_PATH = "/_ccdx/ui/animation";
16
+ const MODELS_PATH = "/_ccdx/ui/models/live";
17
+ const USAGE_PATH = "/_ccdx/ui/usage";
18
+ const DISABLED_ANIMATION = /^(0|false|no|off)$/i;
19
+
20
+ function sendJson(res, statusCode, value) {
21
+ res.writeHead(statusCode, {
22
+ "Content-Type": "application/json; charset=utf-8",
23
+ "Cache-Control": "no-store",
24
+ "X-Content-Type-Options": "nosniff",
25
+ });
26
+ res.end(JSON.stringify(value));
27
+ }
28
+
29
+ function safeText(value, length = 160) {
30
+ return String(value ?? "").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim().slice(0, length);
31
+ }
32
+
33
+ function liveModelError(error) {
34
+ const message = String(error?.message || error || "");
35
+ const status = /\bHTTP (\d{3})\b/.exec(message)?.[1];
36
+ if (status) return `Live model lookup failed (HTTP ${status}). Run ccdx models for details.`;
37
+ if (/timed out/i.test(message)) return "Live model lookup timed out. Retry or run ccdx models.";
38
+ if (/GitHub token not found/i.test(message)) return "GitHub token not found. Start ccdx to sign in.";
39
+ return "Live model lookup failed. Run ccdx models for details.";
40
+ }
41
+
42
+ function animationState(env, home, { includeFrames = false } = {}) {
43
+ readUserSettings({ env, home, strict: true });
44
+ const preference = terminalAnimationPreference({ env, home });
45
+ return {
46
+ theme: preference.theme,
47
+ source: preference.source,
48
+ disabled_by_environment: DISABLED_ANIMATION.test(String(env.CCDX_TERMINAL_ANIMATION || "").trim()),
49
+ themes: TERMINAL_ANIMATION_THEMES.map((theme) => ({
50
+ id: theme.id,
51
+ label: theme.label,
52
+ default: theme.id === DEFAULT_TERMINAL_ANIMATION_THEME,
53
+ ...(includeFrames ? {
54
+ loop_pause_ms: theme.loopPauseMs,
55
+ frames: Array.from({ length: theme.frameCount }, (_, offset) => {
56
+ const index = theme.startFrame + offset;
57
+ return {
58
+ ansi: renderTerminalAnimationFrame(theme.id, index),
59
+ delay_ms: getTerminalAnimationFrameDelay(theme.id, index),
60
+ };
61
+ }),
62
+ } : {}),
63
+ })),
64
+ };
65
+ }
66
+
67
+ function sameOrigin(req) {
68
+ const origin = req.headers?.origin;
69
+ const host = req.headers?.host;
70
+ if (typeof origin !== "string" || typeof host !== "string") return false;
71
+ try {
72
+ return new URL(origin).origin === new URL(`http://${host}`).origin;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ function finite(value) {
79
+ return Number.isFinite(value) ? Math.max(0, value) : null;
80
+ }
81
+
82
+ function cacheReadTokens(usage = {}) {
83
+ const values = [usage.cache_read_input_tokens, usage.cached_input_tokens].filter(Number.isFinite);
84
+ return values.length ? values.reduce((sum, value) => sum + value, 0) : null;
85
+ }
86
+
87
+ function usageRow(model, values) {
88
+ return {
89
+ model: safeText(model, 100),
90
+ requests: finite(values.requests),
91
+ input_tokens: finite(values.input_tokens),
92
+ cache_read_tokens: cacheReadTokens(values),
93
+ output_tokens: finite(values.output_tokens),
94
+ total_tokens: finite(values.total_tokens),
95
+ };
96
+ }
97
+
98
+ function usageTable(summary) {
99
+ const models = Object.entries(summary.byModel || {})
100
+ .sort(([leftName, left], [rightName, right]) => (right.total_tokens || 0) - (left.total_tokens || 0)
101
+ || leftName.localeCompare(rightName, "en"));
102
+ return {
103
+ total: usageRow("TOTAL", { requests: summary.requests, ...summary.totals }),
104
+ model_count: models.length,
105
+ rows: models.slice(0, 100).map(([name, values]) => usageRow(name, values)),
106
+ };
107
+ }
108
+
109
+ export async function handleDashboardApi(req, res, pathname, {
110
+ env = process.env,
111
+ home = os.homedir(),
112
+ liveModelsFn = () => fetchLiveCopilotModels({ home }),
113
+ usageSummaryFn = () => summarizeUsageLogs(),
114
+ } = {}) {
115
+ if (!isLoopbackAddress(req.socket?.remoteAddress)
116
+ || req.headers?.host === undefined
117
+ || !isLoopbackHostHeader(req.headers.host)) {
118
+ sendJson(res, 403, { error: "Dashboard API is available only at a loopback address" });
119
+ return;
120
+ }
121
+ if (req.headers?.["x-ccdx-dashboard"] !== "1") {
122
+ sendJson(res, 403, { error: "Dashboard API requires a same-origin page request" });
123
+ return;
124
+ }
125
+
126
+ if (pathname === ANIMATION_PATH && req.method === "GET") {
127
+ try {
128
+ sendJson(res, 200, animationState(env, home, { includeFrames: true }));
129
+ } catch {
130
+ sendJson(res, 409, { error: "Animation settings are invalid; inspect them with ccdx animation" });
131
+ }
132
+ return;
133
+ }
134
+
135
+ if (pathname === ANIMATION_PATH && req.method === "POST") {
136
+ if (!sameOrigin(req)) {
137
+ sendJson(res, 403, { error: "Animation changes require the same-origin dashboard" });
138
+ return;
139
+ }
140
+ if (String(req.headers?.["content-type"] || "").split(";", 1)[0].trim().toLowerCase() !== "application/json") {
141
+ sendJson(res, 415, { error: "Animation changes require application/json" });
142
+ return;
143
+ }
144
+ const abort = createRequestAbort(req, res);
145
+ abort.setTimeout(5000, "request_body_timeout");
146
+ let body;
147
+ try {
148
+ body = await readJsonBody(req, { maxBodyBytes: 2048, maxDecodedBodyBytes: 2048, signal: abort.signal });
149
+ } catch (error) {
150
+ sendJson(res, error?.statusCode === 413 ? 413 : 400, { error: "Invalid animation request body" });
151
+ return;
152
+ } finally {
153
+ abort.cleanup();
154
+ }
155
+ if (!isTerminalAnimationTheme(body?.theme)) {
156
+ sendJson(res, 400, { error: "Select a listed animation theme" });
157
+ return;
158
+ }
159
+ try {
160
+ const result = writeTerminalAnimationTheme(body.theme, { env, home });
161
+ sendJson(res, 200, { changed: result.changed, ...animationState(env, home) });
162
+ } catch {
163
+ sendJson(res, 409, { error: "Animation setting was not saved; check ccdx animation" });
164
+ }
165
+ return;
166
+ }
167
+
168
+ if (pathname === MODELS_PATH && req.method === "GET") {
169
+ try {
170
+ const catalog = await liveModelsFn();
171
+ if (!Array.isArray(catalog?.models) || !Number.isFinite(catalog?.advertised)) {
172
+ throw new Error("Invalid live model catalog");
173
+ }
174
+ const models = catalog.models;
175
+ sendJson(res, 200, {
176
+ source: "live",
177
+ checked_at: new Date().toISOString(),
178
+ upstream_host: safeText(catalog?.upstreamHost || "GitHub Copilot", 120),
179
+ advertised: finite(catalog?.advertised),
180
+ selectable: models.length,
181
+ models: models.slice(0, 500).map((model) => ({
182
+ id: safeText(model.id, 100),
183
+ vendor: safeText(model.vendor, 80),
184
+ endpoints: Array.isArray(model.endpoints) ? model.endpoints.filter((value) => value === "responses" || value === "chat") : [],
185
+ preview: model.preview === true,
186
+ })),
187
+ });
188
+ } catch (error) {
189
+ sendJson(res, 502, { error: liveModelError(error) });
190
+ }
191
+ return;
192
+ }
193
+
194
+ if (pathname === USAGE_PATH && req.method === "GET") {
195
+ try {
196
+ sendJson(res, 200, { source: "local_usage_log", ...(usageTable(await usageSummaryFn())) });
197
+ } catch {
198
+ sendJson(res, 500, { error: "Could not read local usage summary" });
199
+ }
200
+ return;
201
+ }
202
+
203
+ sendJson(res, 405, { error: "Method not allowed" });
204
+ }
@@ -0,0 +1,31 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { isLoopbackAddress, isLoopbackHostHeader } from "./observability.mjs";
3
+
4
+ const ASSETS = new Map([
5
+ ["/", ["./dashboard/index.html", "text/html; charset=utf-8"]],
6
+ ["/ui.css", ["./dashboard/ui.css", "text/css; charset=utf-8"]],
7
+ ["/theme.js", ["./dashboard/theme.js", "text/javascript; charset=utf-8"]],
8
+ ["/ui.js", ["./dashboard/ui.js", "text/javascript; charset=utf-8"]],
9
+ ]);
10
+
11
+ export async function serveDashboard(req, res, pathname) {
12
+ if (!isLoopbackAddress(req.socket?.remoteAddress)
13
+ || req.headers?.host === undefined
14
+ || !isLoopbackHostHeader(req.headers.host)) {
15
+ res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
16
+ res.end("The CCDX dashboard is available only at a loopback address.");
17
+ return;
18
+ }
19
+
20
+ const [file, contentType] = ASSETS.get(pathname);
21
+ const body = await readFile(new URL(file, import.meta.url));
22
+ res.writeHead(200, {
23
+ "Content-Type": contentType,
24
+ "Cache-Control": "no-store",
25
+ "Content-Security-Policy": "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
26
+ "X-Content-Type-Options": "nosniff",
27
+ "X-Frame-Options": "DENY",
28
+ "Referrer-Policy": "no-referrer",
29
+ });
30
+ res.end(req.method === "HEAD" ? undefined : body);
31
+ }
@@ -108,6 +108,19 @@ export function isLoopbackAddress(address) {
108
108
  return isIP(ipv4) === 4 && ipv4.startsWith("127.");
109
109
  }
110
110
 
111
+ export function isLoopbackHostHeader(host) {
112
+ if (host === undefined) return true; // HTTP/1.0 and in-process callers may omit Host.
113
+ if (typeof host !== "string" || !host || host.includes(",")) return false;
114
+ try {
115
+ const url = new URL(`http://${host}`);
116
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
117
+ return !url.username && !url.password && url.pathname === "/" && !url.search && !url.hash
118
+ && (hostname === "localhost" || isLoopbackAddress(hostname));
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
123
+
111
124
  function modelRegistryStatus(modelRegistry) {
112
125
  const modelData = Array.isArray(modelRegistry?.models)
113
126
  ? modelRegistry.models