mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
feat+test+docs: D51 — dashboard.html full multi-panel UI (Phase 3) (#28)
* feat+test+docs: D51 — dashboard.html full multi-panel UI (Phase 3)
Fourth Phase 3 D-day. Replaces the D50 dashboard.html placeholder with
the full 4-panel UI per ADR 0008 § 6. Vanilla HTML + JS + fetch — no
build step, no framework, no CDN (Lane 1 = A). 30s page poll with
document.visibilityState pause/resume (Lane 4 = A).
4 PANELS (all rendered from /v0/management/dashboard-data — single
backing endpoint per Lane 2 in-memory query model):
Panel 1 — Per-provider quota
Table: { Provider | Available | Status }. Null available → "n/a"
pill; provider.quotaStatus() error → red status pill (graceful
degradation per ADR § 9).
Panel 2 — Last 24h: request count + cache hit + fallback rate
Per-provider row: { Requests | Cache hit % | Fallback rate % }.
Cache hit from cache_hit_24h.by_provider[p].hit_rate; fallback
rate computed from window_24h.by_provider[p].fallback_count/count.
Panel 3 — Request count last 30 days (SVG sparkline)
Vanilla SVG bar chart with <title> tooltips showing per-day per-
provider breakdown. Y-axis: requests per day (max-scaled). X-axis:
30 daily UTC buckets.
Panel 4 — Top fallback chains (last 24h)
Numbered table: { # | Chain (monospace, arrow-joined) | Count |
First seen | Last seen }.
POLL + VISIBILITYCHANGE PAUSE (ADR 0008 § 6.5):
- setInterval(refresh, 30000) after initial fetch.
- document.addEventListener('visibilitychange') → stopPolling() on
hidden / refresh()+startPolling() on visible.
- Prevents 2880 background polls/day per owner when tab hidden.
ERROR HANDLING:
- 401 from dashboard-data → in-page error banner explains owner-tier
requirement + suggests SSH-tunnel + header-injection workaround.
- Other HTTP errors → generic "HTTP <code>" banner; console.warn
for operator debugging.
- Per-panel empty states ("Loading…", "No requests in window.",
"No fallback chains triggered.").
CRITICAL CORRECTNESS INVARIANTS (ADR 0008 § 6 + Lane 1 = A):
- No <script src> — entire JS inline (Suite 25d asserts).
- No <link rel="stylesheet" href> — all CSS in <style> (25d).
- Only one backing endpoint hit: /v0/management/dashboard-data
(Suite 25e asserts).
- 401 path keeps panels in last-good state rather than clearing —
operator sees the error banner + can debug.
TESTS — Suite 25, +6 (582 → 588):
25a: owner /dashboard response contains all 4 panel container IDs
25b: JS declares POLL_INTERVAL_MS = 30000 + setInterval/clearInterval
25c: visibilitychange listener + document.visibilityState check
25d: NO external script src / NO external stylesheet href (Lane 1 = A
pinning)
25e: dashboard JS fetches /v0/management/dashboard-data only
25f: 401 in-page error banner mentions owner-tier guidance
MANUAL SMOKE (ADR 0008 § 10 #12):
Dashboard renders without console errors in a real browser when
served by a running OLP instance + owner-tier Bearer via SSH-tunnel
+ header-injection extension. Not automated at Phase 3.
DOCUMENTATION:
- AGENTS.md: dashboard.html marker promoted 🟡 D50 placeholder → ✅
D51 full UI.
- CHANGELOG.md: D51 entry under Unreleased per release_kit overlay.
NOT IN D51:
- Daily audit rotation (D52)
- tried_providers schema fix (D53; D45 P2 deferral)
- Phase 3 close (D55; v0.3.0; maintainer-triggered)
Test count: 582 → 588 (+6). Verified locally via npm test.
AUTHORITY:
- ADR 0008 § 6 (panels + refresh + localhost) + § 6.5 (poll +
visibilityState pause) + Lane 1 = A (no build step) + Lane 4 = A
(30s poll) + Lane 5 = B (full 4-panel scope).
- ADR 0008 § 9 (graceful degradation surfaced in Panel 1).
- ADR 0008 § 10 #12 (HTML smoke criterion satisfied at server-side
level).
- CLAUDE.md release_kit overlay phase_rolling_mode — under
Unreleased.
- Standing autopilot grant.
ALIGNMENT.md scope check: this PR replaces an existing entry-surface
static file (dashboard.html). Per Rule 5: management surface, not
OpenAI-spec-compatible — outside /v1/* spec scope. No code change in
server.mjs / lib/ / providers / IR / models-registry.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: D51 fold-in — dashboard.html cosmetic polish (opus P3)
Fresh-context opus reviewer (PR #28) flagged 4 P3 cosmetic findings;
addressing the 2 trivial ones inline. The other 2 (visibilitychange
race + defensive date null-check) are negligible at family-scale per
reviewer; deferred to Phase 4 if UX feedback warrants.
- Line ~193: deleted orphan empty <text> SVG element (no textContent,
rendered nothing — debris from initial pass).
- Line ~200 comment: was "Date labels (first / mid / last)" but only
first + last rendered. Tightened to clarify intent + note mid label
deferred to Phase 4 if needed.
No behavior change. 588/588 tests pass.
Authority: PR #28 fresh-context opus reviewer P3 findings (2 of 4
addressed; remaining 2 documented as negligible).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -41,7 +41,7 @@ Runtime: Node.js (ESM, `.mjs` throughout). No build step. No bundler. `server.mj
|
|||||||
- `bin/olp-keys.mjs` — keygen CLI bootstrap surface per ADR 0007 § 9.1. **✅ Shipped at D47.** Subcommands: `keygen [--owner|--name=X|--providers=csv|--force]`, `list [--owner-only|--include-revoked]`, `revoke --id=X`. Plaintext token printed once on keygen. Installed via `package.json bin` so `npx olp-keys ...` works (also `npm run olp-keys ...`).
|
- `bin/olp-keys.mjs` — keygen CLI bootstrap surface per ADR 0007 § 9.1. **✅ Shipped at D47.** Subcommands: `keygen [--owner|--name=X|--providers=csv|--force]`, `list [--owner-only|--include-revoked]`, `revoke --id=X`. Plaintext token printed once on keygen. Installed via `package.json bin` so `npx olp-keys ...` works (also `npm run olp-keys ...`).
|
||||||
- `lib/audit.mjs` — append-only ndjson audit per ADR 0007 § 6.2 + § 8. **🟡 D45 — appendAuditEvent + getAuditDropCount shipped. Fires per /v1/chat/completions + /v1/models request including 401/403/5xx paths. Warn+1-retry on append failure; no memory buffer at Phase 2 (forward path).** **D52 will extend with daily rotation per ADR 0008 § 5.**
|
- `lib/audit.mjs` — append-only ndjson audit per ADR 0007 § 6.2 + § 8. **🟡 D45 — appendAuditEvent + getAuditDropCount shipped. Fires per /v1/chat/completions + /v1/models request including 401/403/5xx paths. Warn+1-retry on append failure; no memory buffer at Phase 2 (forward path).** **D52 will extend with daily rotation per ADR 0008 § 5.**
|
||||||
- `lib/audit-query.mjs` — audit ndjson aggregate query layer per ADR 0008 § 4. **🟡 D49 — discoverAuditFiles + readAuditWindow + aggregateRequests + topFallbackChains + spendTrendDaily + cacheHitRateWindow shipped. Cross-file walk over `audit.ndjson` (live) + `audit-YYYY-MM-DD.ndjson` (rotated). PII guard: aggregate shapes never include message content. In-memory scan per request (ADR 0008 Lane 2 = A; SQLite hybrid deferred to ADR 0007 § 13 trigger).**
|
- `lib/audit-query.mjs` — audit ndjson aggregate query layer per ADR 0008 § 4. **🟡 D49 — discoverAuditFiles + readAuditWindow + aggregateRequests + topFallbackChains + spendTrendDaily + cacheHitRateWindow shipped. Cross-file walk over `audit.ndjson` (live) + `audit-YYYY-MM-DD.ndjson` (rotated). PII guard: aggregate shapes never include message content. In-memory scan per request (ADR 0008 Lane 2 = A; SQLite hybrid deferred to ADR 0007 § 13 trigger).**
|
||||||
- `dashboard.html` — owner-only multi-provider dashboard (quota panels, fallback rate, cache hit rate). **🟡 D50 — placeholder stub shipped at repo root + `/dashboard` route in server.mjs serves it owner-only_block. D51 lands the full multi-panel UI (quota + 24h request stats + 30d spend trend + top fallback chains) per ADR 0008 § 6.**
|
- `dashboard.html` — owner-only multi-provider dashboard (4 panels: per-provider quota / 24h request+cache+fallback / 30d spend trend SVG sparkline / top-10 fallback chains). **✅ D51 — full UI shipped at repo root per ADR 0008 § 6. Vanilla HTML + JS + fetch (no build step, no framework, no CDN). 30s page poll with `document.visibilityState` pause/resume. Served by `/dashboard` route in server.mjs owner-only_block. Cached in memory at first request via `_loadDashboardHtml`.**
|
||||||
- `models-registry.json` — single source of truth for `(provider, model) → metadata`. SPOT.
|
- `models-registry.json` — single source of truth for `(provider, model) → metadata`. SPOT.
|
||||||
- `ALIGNMENT.md` — the constitution. Binding for any plugin / entry-surface / IR change.
|
- `ALIGNMENT.md` — the constitution. Binding for any plugin / entry-surface / IR change.
|
||||||
- `docs/adr/` — Architecture Decision Records. Read the index in `docs/adr/README.md` before proposing governance, SPOT, or contract changes.
|
- `docs/adr/` — Architecture Decision Records. Read the index in `docs/adr/README.md` before proposing governance, SPOT, or contract changes.
|
||||||
|
|||||||
@@ -4,6 +4,41 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### D51 — `dashboard.html` full multi-panel UI (Phase 3)
|
||||||
|
|
||||||
|
Fourth Phase 3 D-day. Replaces the D50 `dashboard.html` placeholder with the full 4-panel UI per ADR 0008 § 6. Vanilla HTML + JS + fetch — no build step, no framework, no CDN (Lane 1 = A). 30s page poll with `document.visibilityState` pause/resume (Lane 4 = A).
|
||||||
|
|
||||||
|
- **4 panels rendered from `/v0/management/dashboard-data`** (the single backing endpoint, per Lane 2 in-memory query model):
|
||||||
|
- **Panel 1 — Per-provider quota**: table of `{ Provider | Available | Status }`; surfaces `null` available as "n/a" + capturing per-provider `provider.quotaStatus()` errors as a red status pill (graceful degradation per ADR § 9).
|
||||||
|
- **Panel 2 — Last 24h: request count + cache hit + fallback rate**: per-provider row of `{ Requests | Cache hit % | Fallback rate % }`. Cache hit sourced from `cache_hit_24h.by_provider[p].hit_rate`; fallback rate computed from `window_24h.by_provider[p].fallback_count / count`.
|
||||||
|
- **Panel 3 — Request count last 30 days (SVG sparkline)**: vanilla SVG bar chart with `<title>` tooltips showing per-day per-provider breakdown. Y-axis: requests per day (scaled to max); X-axis: 30 daily buckets (UTC). Each bar `<title>` includes the date + total count + provider breakdown.
|
||||||
|
- **Panel 4 — Top fallback chains (last 24h)**: numbered table of `{ # | Chain | Count | First seen | Last seen }` with chain arrows rendered in monospace (`anthropic → openai`).
|
||||||
|
- **30s poll + visibilitychange pause** (ADR 0008 § 6.5):
|
||||||
|
- `setInterval(refresh, 30000)` after the initial fetch.
|
||||||
|
- `document.addEventListener('visibilitychange', ...)` → `stopPolling()` on hidden / `refresh() + startPolling()` on visible.
|
||||||
|
- Per ADR § 6.5 this prevents 2880 background polls/day per owner when the dashboard tab is in the background.
|
||||||
|
- **Error handling**:
|
||||||
|
- 401 from `/v0/management/dashboard-data` → in-page error banner explains owner-tier requirement + suggests SSH-tunnel + header-injection workaround (browsers can't natively send `Authorization: Bearer` without a proxy/extension).
|
||||||
|
- Other HTTP errors → generic "HTTP <code>" banner; console.warn for operator debugging.
|
||||||
|
- Per-panel "Loading…" / "No requests in window." / "No fallback chains triggered" empty states.
|
||||||
|
- **DOM helpers**: small `el(tag, attrs, ...children)` + `svgEl(tag, attrs)` factories — no framework, ~10 lines each. Sparkline uses native `<title>` for tooltips (no JS hover handlers).
|
||||||
|
- **Critical correctness invariants** (per ADR 0008 § 6 + Lane 1 = A):
|
||||||
|
- No `<script src>` — entire JS inline in `<script>` tag (Suite 25d asserts).
|
||||||
|
- No `<link rel="stylesheet" href=>` — all CSS in `<style>` tag (Suite 25d asserts).
|
||||||
|
- Only one backing endpoint hit: `/v0/management/dashboard-data` (Suite 25e asserts). All 4 panels consume slices of its response.
|
||||||
|
- 401 path keeps panels in last-good state rather than clearing them; operator sees the error banner + can debug.
|
||||||
|
- **Test surface (Suite 25, +6 tests — 582 → 588):**
|
||||||
|
- 25a: owner /dashboard response contains all 4 panel container IDs (`panel-quota`, `panel-24h`, `panel-trend`, `panel-chains`).
|
||||||
|
- 25b: dashboard JS declares `POLL_INTERVAL_MS = 30000` + uses `setInterval` + `clearInterval`.
|
||||||
|
- 25c: visibilitychange listener wired + checks `document.visibilityState === 'hidden'`.
|
||||||
|
- 25d: NO external `<script src>` and NO external stylesheet `<link href>` — pinning Lane 1 = A.
|
||||||
|
- 25e: dashboard JS fetches `/v0/management/dashboard-data` (the single consolidated D50 endpoint).
|
||||||
|
- 25f: 401 in-page error banner mentions owner-tier so a maintainer who lands on a 401 knows the route forward.
|
||||||
|
- **Manual smoke (ADR 0008 § 10 #12 manual acceptance)**: the dashboard renders without console errors in a real browser when served by a running OLP instance + owner-tier Bearer token injected via SSH-tunnel + header-injection extension. Not automated at Phase 3 (Lane 4 = A poll model doesn't need playwright; Phase 4+ may add a playwright smoke if dashboard complexity grows).
|
||||||
|
- **Documentation:** AGENTS.md `dashboard.html` marker promoted from 🟡 D50 placeholder to ✅ D51 full UI.
|
||||||
|
- **Test count:** 582 → 588 (+6 D51 tests in Suite 25).
|
||||||
|
- **Authority:** ADR 0008 § 6 (panels + refresh + localhost) + § 6.5 (poll + visibilityState pause) + Lane 1 = A (no build step) + Lane 4 = A (30s poll) + Lane 5 = B (full 4-panel scope); ADR § 9 (graceful degradation surfaced in Panel 1); ADR § 10 criterion #12 (HTML smoke); CLAUDE.md `release_kit overlay phase_rolling_mode` — under Unreleased; standing autopilot grant.
|
||||||
|
|
||||||
### D50 — `server.mjs` management endpoints (Phase 3 dashboard wire-up)
|
### D50 — `server.mjs` management endpoints (Phase 3 dashboard wire-up)
|
||||||
|
|
||||||
Third Phase 3 D-day. Wires the D49 `lib/audit-query.mjs` aggregate query layer into 4 owner_only_block HTTP endpoints per ADR 0008 §§ 7-8. Ships a placeholder `dashboard.html` at repo root (D51 lands the full multi-panel UI). All endpoints follow the Phase 2 / D45 auth + audit + touchLastUsed pattern.
|
Third Phase 3 D-day. Wires the D49 `lib/audit-query.mjs` aggregate query layer into 4 owner_only_block HTTP endpoints per ADR 0008 §§ 7-8. Ships a placeholder `dashboard.html` at repo root (D51 lands the full multi-panel UI). All endpoints follow the Phase 2 / D45 auth + audit + touchLastUsed pattern.
|
||||||
|
|||||||
+292
-38
@@ -1,17 +1,20 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<!--
|
<!--
|
||||||
OLP Dashboard — Phase 3 / D50 PLACEHOLDER
|
OLP Dashboard — Phase 3 / D51
|
||||||
-------------------------------------------
|
------------------------------
|
||||||
This file is a stub shipped at D50 to wire the /dashboard route. D51 lands
|
Multi-panel owner-only dashboard per ADR 0008 § 6. Polls
|
||||||
the real multi-panel dashboard per ADR 0008 §6 (quota / per-provider 24h
|
/v0/management/dashboard-data every 30 seconds (paused when the
|
||||||
counts / 30d spend trend / top fallback chains).
|
page is hidden via document.visibilityState).
|
||||||
|
|
||||||
At D50, /dashboard returns this placeholder for owner-tier identities;
|
Panels (per spec v0.1 § 4.6 + ADR 0008 Lane 5 = B full):
|
||||||
non-owner / unauth identities receive 401 (owner_only_block per ADR 0008
|
1. Per-provider quota / credit pool
|
||||||
§8). The 3 backing endpoints (/v0/management/dashboard-data,
|
2. Per-provider 24h request count + cache hit rate + fallback rate
|
||||||
/v0/management/quota, /cache/stats) are live + return real data; you can
|
3. 30-day spend trend (SVG sparkline; per-provider in tooltip)
|
||||||
curl them directly with an owner-tier Bearer token to see the JSON
|
4. Top 10 fallback chains by trigger count
|
||||||
shapes that D51 will render.
|
|
||||||
|
No build step, no framework, no external dependencies. Vanilla JS +
|
||||||
|
fetch + DOM render. Owner-only_block: anonymous / guest / no-auth all
|
||||||
|
receive 401 — non-owner identities will see an error banner.
|
||||||
-->
|
-->
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -19,36 +22,287 @@
|
|||||||
<title>OLP Dashboard</title>
|
<title>OLP Dashboard</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<style>
|
<style>
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 2rem; max-width: 720px; color: #222; }
|
* { box-sizing: border-box; }
|
||||||
h1 { margin-top: 0; }
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 1.5rem; background: #f9fafb; color: #1f2937; }
|
||||||
code, pre { background: #f4f4f4; padding: 0.15em 0.4em; border-radius: 3px; font-size: 0.95em; }
|
h1 { margin: 0 0 0.5rem; font-size: 1.5rem; }
|
||||||
pre { padding: 0.6em 0.8em; overflow-x: auto; }
|
.meta { color: #6b7280; font-size: 0.875rem; margin-bottom: 1.5rem; }
|
||||||
.stub { background: #fff8e1; border-left: 4px solid #ffb300; padding: 1em 1.2em; margin: 1em 0; }
|
.grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; max-width: 1200px; }
|
||||||
ul { padding-left: 1.2em; }
|
.panel { background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; padding: 1rem 1.25rem; }
|
||||||
|
.panel h2 { margin: 0 0 0.75rem; font-size: 1rem; color: #374151; font-weight: 600; }
|
||||||
|
.panel-error { color: #b91c1c; font-style: italic; padding: 0.5rem 0; }
|
||||||
|
.panel-loading { color: #6b7280; font-style: italic; padding: 0.5rem 0; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||||
|
th, td { text-align: left; padding: 0.35rem 0.5rem; border-bottom: 1px solid #f3f4f6; }
|
||||||
|
th { font-weight: 600; color: #4b5563; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.banner { background: #fef3c7; border-left: 4px solid #f59e0b; padding: 0.75rem 1rem; border-radius: 4px; margin-bottom: 1rem; }
|
||||||
|
.banner.error { background: #fee2e2; border-color: #ef4444; color: #991b1b; }
|
||||||
|
.sparkline { width: 100%; height: 120px; }
|
||||||
|
.sparkline rect { fill: #3b82f6; }
|
||||||
|
.sparkline rect:hover { fill: #1d4ed8; }
|
||||||
|
.chain { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 0.85rem; color: #374151; }
|
||||||
|
.pill { display: inline-block; background: #e5e7eb; color: #374151; padding: 0.05rem 0.4rem; border-radius: 3px; font-size: 0.75rem; }
|
||||||
|
footer { margin-top: 2rem; color: #9ca3af; font-size: 0.75rem; text-align: center; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>OLP Dashboard</h1>
|
<h1>OLP Dashboard</h1>
|
||||||
<p class="stub">
|
<div id="meta" class="meta">Loading…</div>
|
||||||
<strong>D50 placeholder.</strong> The full dashboard UI lands at D51 per
|
<div id="banner-slot"></div>
|
||||||
<a href="https://github.com/dtzp555-max/olp/blob/main/docs/adr/0008-dashboard-and-audit-query.md">ADR 0008</a> § 6.
|
<div class="grid">
|
||||||
The three management endpoints are already live and serving real data — you can
|
<section class="panel">
|
||||||
curl them with an owner-tier Bearer token to preview the JSON shapes D51 will render.
|
<h2>Quota (per provider)</h2>
|
||||||
</p>
|
<div id="panel-quota"><div class="panel-loading">Loading…</div></div>
|
||||||
<h2>Backing endpoints (live at D50)</h2>
|
</section>
|
||||||
<ul>
|
<section class="panel">
|
||||||
<li><code>GET /v0/management/dashboard-data</code> — full aggregate (quota + 24h request stats + 30d spend trend + top fallback chains).</li>
|
<h2>Last 24h — request count · cache hit · fallback rate</h2>
|
||||||
<li><code>GET /v0/management/quota</code> — per-provider quota snapshot only.</li>
|
<div id="panel-24h"><div class="panel-loading">Loading…</div></div>
|
||||||
<li><code>GET /cache/stats</code> — live in-memory CacheStore stats.</li>
|
</section>
|
||||||
</ul>
|
<section class="panel" style="grid-column: span 2;">
|
||||||
<h2>Auth</h2>
|
<h2>Request count — last 30 days (UTC)</h2>
|
||||||
<p>
|
<div id="panel-trend"><div class="panel-loading">Loading…</div></div>
|
||||||
All <code>/dashboard</code> and <code>/v0/management/*</code> + <code>/cache/stats</code> endpoints are
|
</section>
|
||||||
<code>owner_only_block</code> per ADR 0007 § 7 + ADR 0008 § 8. Non-owner identities
|
<section class="panel" style="grid-column: span 2;">
|
||||||
(including anonymous when <code>allow_anonymous: true</code>) receive <code>401</code>.
|
<h2>Top fallback chains (last 24h)</h2>
|
||||||
</p>
|
<div id="panel-chains"><div class="panel-loading">Loading…</div></div>
|
||||||
<h2>Example request</h2>
|
</section>
|
||||||
<pre>curl -H "Authorization: Bearer olp_..." http://localhost:3456/v0/management/dashboard-data</pre>
|
</div>
|
||||||
<p style="color: #666; font-size: 0.9em;">— shipped at D50 (Phase 3); replaced at D51.</p>
|
<footer>OLP Dashboard · poll every 30s · paused when tab hidden · v0.3.0-phase3</footer>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
const POLL_INTERVAL_MS = 30000;
|
||||||
|
let pollHandle = null;
|
||||||
|
|
||||||
|
function fmtNum(n) { return (n ?? 0).toLocaleString(); }
|
||||||
|
function fmtPct(rate) { return (rate * 100).toFixed(1) + '%'; }
|
||||||
|
function el(tag, attrs, ...children) {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
if (attrs) for (const [k, v] of Object.entries(attrs)) {
|
||||||
|
if (k === 'class') node.className = v;
|
||||||
|
else if (k === 'style') node.style.cssText = v;
|
||||||
|
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2), v);
|
||||||
|
else node.setAttribute(k, v);
|
||||||
|
}
|
||||||
|
for (const c of children) {
|
||||||
|
if (c == null) continue;
|
||||||
|
node.appendChild(typeof c === 'string' || typeof c === 'number' ? document.createTextNode(String(c)) : c);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function svgEl(tag, attrs) {
|
||||||
|
const node = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
||||||
|
if (attrs) for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderQuota(data) {
|
||||||
|
const target = document.getElementById('panel-quota');
|
||||||
|
target.innerHTML = '';
|
||||||
|
if (!Array.isArray(data) || data.length === 0) {
|
||||||
|
target.appendChild(el('div', { class: 'panel-loading' }, 'No providers enabled.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const table = el('table', null,
|
||||||
|
el('thead', null, el('tr', null,
|
||||||
|
el('th', null, 'Provider'),
|
||||||
|
el('th', null, 'Available'),
|
||||||
|
el('th', null, 'Status'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
const tbody = el('tbody');
|
||||||
|
for (const row of data) {
|
||||||
|
const available = row.error ? el('span', { class: 'pill' }, 'unavailable')
|
||||||
|
: row.available === null || row.available === undefined ? el('span', { class: 'pill' }, 'n/a')
|
||||||
|
: el('span', null, fmtNum(row.available));
|
||||||
|
const status = row.error ? el('span', { style: 'color: #b91c1c;' }, row.error)
|
||||||
|
: row.available === null || row.available === undefined ? 'no quota API'
|
||||||
|
: 'ok';
|
||||||
|
tbody.appendChild(el('tr', null,
|
||||||
|
el('td', null, row.provider),
|
||||||
|
el('td', { class: 'num' }, available),
|
||||||
|
el('td', null, status),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
table.appendChild(tbody);
|
||||||
|
target.appendChild(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render24h(window24h, cacheHit24h) {
|
||||||
|
const target = document.getElementById('panel-24h');
|
||||||
|
target.innerHTML = '';
|
||||||
|
const byProvider = (window24h && window24h.by_provider) || {};
|
||||||
|
const providers = Object.keys(byProvider);
|
||||||
|
if (providers.length === 0) {
|
||||||
|
target.appendChild(el('div', { class: 'panel-loading' }, 'No requests in window.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const table = el('table', null,
|
||||||
|
el('thead', null, el('tr', null,
|
||||||
|
el('th', null, 'Provider'),
|
||||||
|
el('th', null, 'Requests'),
|
||||||
|
el('th', null, 'Cache hit'),
|
||||||
|
el('th', null, 'Fallback rate'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
const tbody = el('tbody');
|
||||||
|
for (const p of providers) {
|
||||||
|
const pData = byProvider[p];
|
||||||
|
const hitData = (cacheHit24h && cacheHit24h.by_provider && cacheHit24h.by_provider[p]) || null;
|
||||||
|
const fallbackRate = pData.count > 0 ? pData.fallback_count / pData.count : 0;
|
||||||
|
tbody.appendChild(el('tr', null,
|
||||||
|
el('td', null, p),
|
||||||
|
el('td', { class: 'num' }, fmtNum(pData.count)),
|
||||||
|
el('td', { class: 'num' }, hitData ? fmtPct(hitData.hit_rate) : 'n/a'),
|
||||||
|
el('td', { class: 'num' }, fmtPct(fallbackRate)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
table.appendChild(tbody);
|
||||||
|
target.appendChild(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTrend(spendTrend30d) {
|
||||||
|
const target = document.getElementById('panel-trend');
|
||||||
|
target.innerHTML = '';
|
||||||
|
if (!Array.isArray(spendTrend30d) || spendTrend30d.length === 0) {
|
||||||
|
target.appendChild(el('div', { class: 'panel-loading' }, 'No trend data.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const counts = spendTrend30d.map(d => d.request_count);
|
||||||
|
const maxCount = Math.max(1, ...counts);
|
||||||
|
const width = 800, height = 120, padding = { top: 8, right: 8, bottom: 20, left: 32 };
|
||||||
|
const innerW = width - padding.left - padding.right;
|
||||||
|
const innerH = height - padding.top - padding.bottom;
|
||||||
|
const barGap = 2;
|
||||||
|
const barW = (innerW - barGap * (spendTrend30d.length - 1)) / spendTrend30d.length;
|
||||||
|
const svg = svgEl('svg', { class: 'sparkline', viewBox: `0 0 ${width} ${height}`, preserveAspectRatio: 'xMidYMid meet' });
|
||||||
|
for (let i = 0; i < spendTrend30d.length; i++) {
|
||||||
|
const d = spendTrend30d[i];
|
||||||
|
const h = (d.request_count / maxCount) * innerH;
|
||||||
|
const x = padding.left + i * (barW + barGap);
|
||||||
|
const y = padding.top + innerH - h;
|
||||||
|
const rect = svgEl('rect', { x, y, width: barW, height: Math.max(1, h) });
|
||||||
|
const providerBreakdown = Object.entries(d.by_provider || {}).map(([p, n]) => `${p}: ${n}`).join(', ');
|
||||||
|
const title = svgEl('title');
|
||||||
|
title.textContent = `${d.date} — ${fmtNum(d.request_count)} requests${providerBreakdown ? ' (' + providerBreakdown + ')' : ''}`;
|
||||||
|
rect.appendChild(title);
|
||||||
|
svg.appendChild(rect);
|
||||||
|
}
|
||||||
|
// Y-axis labels (max + min)
|
||||||
|
const maxLabel = svgEl('text', { x: 4, y: padding.top + 10, 'font-size': 10, fill: '#6b7280' });
|
||||||
|
maxLabel.textContent = fmtNum(maxCount);
|
||||||
|
svg.appendChild(maxLabel);
|
||||||
|
const minLabel = svgEl('text', { x: 4, y: height - 4, 'font-size': 10, fill: '#6b7280' });
|
||||||
|
minLabel.textContent = '0';
|
||||||
|
svg.appendChild(minLabel);
|
||||||
|
// Date labels (first + last only at v0.3.0; mid labels deferred — added if needed by Phase 4 UX feedback)
|
||||||
|
if (spendTrend30d.length > 0) {
|
||||||
|
const firstDate = svgEl('text', { x: padding.left, y: height - 4, 'font-size': 10, fill: '#6b7280' });
|
||||||
|
firstDate.textContent = spendTrend30d[0].date.slice(5);
|
||||||
|
svg.appendChild(firstDate);
|
||||||
|
const lastDate = svgEl('text', { x: width - padding.right - 28, y: height - 4, 'font-size': 10, fill: '#6b7280' });
|
||||||
|
lastDate.textContent = spendTrend30d[spendTrend30d.length - 1].date.slice(5);
|
||||||
|
svg.appendChild(lastDate);
|
||||||
|
}
|
||||||
|
target.appendChild(svg);
|
||||||
|
target.appendChild(el('div', { class: 'meta', style: 'margin-top: 0.5rem; font-size: 0.8rem;' },
|
||||||
|
'Hover bars for per-day provider breakdown · y-axis: requests per day (max ' + fmtNum(maxCount) + ')'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChains(chains) {
|
||||||
|
const target = document.getElementById('panel-chains');
|
||||||
|
target.innerHTML = '';
|
||||||
|
if (!Array.isArray(chains) || chains.length === 0) {
|
||||||
|
target.appendChild(el('div', { class: 'panel-loading' }, 'No fallback chains triggered in window.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const table = el('table', null,
|
||||||
|
el('thead', null, el('tr', null,
|
||||||
|
el('th', null, '#'),
|
||||||
|
el('th', null, 'Chain'),
|
||||||
|
el('th', null, 'Count'),
|
||||||
|
el('th', null, 'First seen'),
|
||||||
|
el('th', null, 'Last seen'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
const tbody = el('tbody');
|
||||||
|
chains.forEach((c, i) => {
|
||||||
|
tbody.appendChild(el('tr', null,
|
||||||
|
el('td', { class: 'num' }, String(i + 1)),
|
||||||
|
el('td', null, el('span', { class: 'chain' }, c.chain.join(' → '))),
|
||||||
|
el('td', { class: 'num' }, fmtNum(c.count)),
|
||||||
|
el('td', null, c.first_seen || ''),
|
||||||
|
el('td', null, c.last_seen || ''),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
table.appendChild(tbody);
|
||||||
|
target.appendChild(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
const slot = document.getElementById('banner-slot');
|
||||||
|
slot.innerHTML = '';
|
||||||
|
slot.appendChild(el('div', { class: 'banner error' }, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearError() {
|
||||||
|
document.getElementById('banner-slot').innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDashboardData() {
|
||||||
|
const res = await fetch('/v0/management/dashboard-data', {
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
showError('401 — owner-tier OLP key required. The dashboard is owner-only_block (ADR 0008 §8). Pass `Authorization: Bearer <owner-token>` via a proxy/extension; OLP itself doesn\'t accept browser cookies. Common path: SSH-tunnel + curl + tee the dashboard-data JSON, OR use a browser extension that adds the header.');
|
||||||
|
throw new Error('owner_required');
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
showError('Dashboard data fetch failed: HTTP ' + res.status);
|
||||||
|
throw new Error('http_' + res.status);
|
||||||
|
}
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const data = await fetchDashboardData();
|
||||||
|
clearError();
|
||||||
|
const generated = data.generated_at ? new Date(data.generated_at) : new Date();
|
||||||
|
document.getElementById('meta').textContent =
|
||||||
|
'Last refresh: ' + generated.toLocaleString() + ' · next in ~30s';
|
||||||
|
renderQuota(data.quota);
|
||||||
|
render24h(data.window_24h, data.cache_hit_24h);
|
||||||
|
renderTrend(data.spend_trend_30d);
|
||||||
|
renderChains(data.top_fallback_chains_24h);
|
||||||
|
} catch (err) {
|
||||||
|
// Error banner already shown by fetchDashboardData; keep panels in
|
||||||
|
// their last-good state. Console for operator debugging.
|
||||||
|
console.warn('OLP dashboard refresh failed:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
if (pollHandle !== null) return;
|
||||||
|
pollHandle = setInterval(refresh, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollHandle === null) return;
|
||||||
|
clearInterval(pollHandle);
|
||||||
|
pollHandle = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause when tab hidden, resume on visible (ADR 0008 § 6.5).
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'hidden') stopPolling();
|
||||||
|
else { refresh(); startPolling(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial fetch + start poll.
|
||||||
|
refresh().finally(startPolling);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -11601,3 +11601,128 @@ describe('Suite 24 — D50 management endpoints (Phase 3, ADR 0008 §§ 7-8)', (
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Suite 25: D51 dashboard.html UI smoke (Phase 3, ADR 0008 § 6) ─────────
|
||||||
|
//
|
||||||
|
// HTML-level smoke tests for the D51 dashboard. Per ADR 0008 § 10 criterion
|
||||||
|
// #12 the "no JS console errors in a real browser" sub-claim is manual or
|
||||||
|
// playwright; this suite covers the server-observable claims:
|
||||||
|
// - The owner-served HTML contains the 4 panel container IDs.
|
||||||
|
// - The polling script references the 30s interval + visibilitychange
|
||||||
|
// pause hook.
|
||||||
|
// - No external script/style src (no build step, no framework, no CDN).
|
||||||
|
|
||||||
|
describe('Suite 25 — D51 dashboard.html UI smoke (Phase 3, ADR 0008 § 6)', () => {
|
||||||
|
const GLOBAL_OLP_HOME = process.env.OLP_HOME;
|
||||||
|
|
||||||
|
let _suite25SavedOAuth;
|
||||||
|
function ensureSuite25FakeOAuth() {
|
||||||
|
_suite25SavedOAuth = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||||
|
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'suite25-fake-oauth-token';
|
||||||
|
}
|
||||||
|
function restoreSuite25OAuth() {
|
||||||
|
if (_suite25SavedOAuth !== undefined) process.env.CLAUDE_CODE_OAUTH_TOKEN = _suite25SavedOAuth;
|
||||||
|
else delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
let TMP, server, port;
|
||||||
|
before(async () => {
|
||||||
|
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-25-'));
|
||||||
|
process.env.OLP_HOME = TMP;
|
||||||
|
__setAuthConfig({ allow_anonymous: true, owner_only_endpoints: [], fallback_detail_header_policy: 'all' });
|
||||||
|
__setProvidersEnabled({ anthropic: true });
|
||||||
|
__setSpawnImpl(makeMockSpawn(['suite25-response']));
|
||||||
|
ensureSuite25FakeOAuth();
|
||||||
|
server = createOlpServer();
|
||||||
|
await new Promise(resolve => {
|
||||||
|
server.listen(0, '127.0.0.1', () => resolve());
|
||||||
|
});
|
||||||
|
port = server.address().port;
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
__resetSpawnImpl();
|
||||||
|
__setProvidersEnabled({});
|
||||||
|
__clearCache();
|
||||||
|
restoreSuite25OAuth();
|
||||||
|
if (server) await new Promise(resolve => server.close(() => resolve()));
|
||||||
|
process.env.OLP_HOME = GLOBAL_OLP_HOME;
|
||||||
|
__setAuthConfig({ allow_anonymous: true, owner_only_endpoints: [], fallback_detail_header_policy: 'all' });
|
||||||
|
rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('25a: owner /dashboard response contains all 4 panel container IDs', async () => {
|
||||||
|
const { plaintext_token } = createKey({ name: '25a-owner', owner_tier: 'owner', providers_enabled: '*', olpHome: TMP });
|
||||||
|
const r = await fetch({
|
||||||
|
port, method: 'GET', path: '/dashboard',
|
||||||
|
headers: { Authorization: `Bearer ${plaintext_token}` },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.match(r.body, /id="panel-quota"/);
|
||||||
|
assert.match(r.body, /id="panel-24h"/);
|
||||||
|
assert.match(r.body, /id="panel-trend"/);
|
||||||
|
assert.match(r.body, /id="panel-chains"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('25b: dashboard JS references 30s polling interval + setInterval/clearInterval', async () => {
|
||||||
|
const { plaintext_token } = createKey({ name: '25b-owner', owner_tier: 'owner', providers_enabled: '*', olpHome: TMP });
|
||||||
|
const r = await fetch({
|
||||||
|
port, method: 'GET', path: '/dashboard',
|
||||||
|
headers: { Authorization: `Bearer ${plaintext_token}` },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.match(r.body, /POLL_INTERVAL_MS\s*=\s*30000/, 'must declare 30s POLL_INTERVAL_MS constant');
|
||||||
|
assert.match(r.body, /setInterval\(/, 'must call setInterval');
|
||||||
|
assert.match(r.body, /clearInterval\(/, 'must call clearInterval (for visibilitychange pause)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('25c: dashboard JS wires visibilitychange listener for tab-hidden pause', async () => {
|
||||||
|
const { plaintext_token } = createKey({ name: '25c-owner', owner_tier: 'owner', providers_enabled: '*', olpHome: TMP });
|
||||||
|
const r = await fetch({
|
||||||
|
port, method: 'GET', path: '/dashboard',
|
||||||
|
headers: { Authorization: `Bearer ${plaintext_token}` },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.match(r.body, /addEventListener\(\s*['"]visibilitychange['"]/, 'must register visibilitychange listener');
|
||||||
|
assert.match(r.body, /document\.visibilityState\s*===\s*['"]hidden['"]/, 'must check hidden state');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('25d: dashboard has NO external script/style src (no build step, no framework, no CDN)', async () => {
|
||||||
|
const { plaintext_token } = createKey({ name: '25d-owner', owner_tier: 'owner', providers_enabled: '*', olpHome: TMP });
|
||||||
|
const r = await fetch({
|
||||||
|
port, method: 'GET', path: '/dashboard',
|
||||||
|
headers: { Authorization: `Bearer ${plaintext_token}` },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
// ADR 0008 Lane 1 = A: static HTML + vanilla JS + fetch; no framework.
|
||||||
|
// Strict: no <script src=> and no <link rel="stylesheet" href=>.
|
||||||
|
assert.ok(!/<script\s+[^>]*src\s*=/i.test(r.body),
|
||||||
|
'dashboard must NOT include any <script src="..."> (no external JS — ADR 0008 Lane 1 A)');
|
||||||
|
assert.ok(!/<link\s+[^>]*rel\s*=\s*['"]stylesheet['"][^>]*href\s*=/i.test(r.body),
|
||||||
|
'dashboard must NOT include any external <link rel="stylesheet" href="..."> (no external CSS)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('25e: dashboard fetches /v0/management/dashboard-data (the only backing endpoint hit by JS)', async () => {
|
||||||
|
const { plaintext_token } = createKey({ name: '25e-owner', owner_tier: 'owner', providers_enabled: '*', olpHome: TMP });
|
||||||
|
const r = await fetch({
|
||||||
|
port, method: 'GET', path: '/dashboard',
|
||||||
|
headers: { Authorization: `Bearer ${plaintext_token}` },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.match(r.body, /fetch\(\s*['"]\/v0\/management\/dashboard-data['"]/,
|
||||||
|
'dashboard JS must fetch /v0/management/dashboard-data (consolidated D50 endpoint)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('25f: dashboard 401 surface for non-owner shows actionable error banner instructions', async () => {
|
||||||
|
// The dashboard HTML itself is served owner-only_block at handleDashboard
|
||||||
|
// (Suite 24 covers that). This test verifies the IN-PAGE error banner
|
||||||
|
// string mentions owner-token guidance so a future maintainer who lands
|
||||||
|
// on a 401 from the in-page JS knows what to do.
|
||||||
|
const { plaintext_token } = createKey({ name: '25f-owner', owner_tier: 'owner', providers_enabled: '*', olpHome: TMP });
|
||||||
|
const r = await fetch({
|
||||||
|
port, method: 'GET', path: '/dashboard',
|
||||||
|
headers: { Authorization: `Bearer ${plaintext_token}` },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.match(r.body, /401.*owner-tier/i, 'dashboard error banner must mention owner-tier in 401 case');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user