diff --git a/AGENTS.md b/AGENTS.md index 1ceb81e..7fb2520 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ...`). - `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).** -- `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. - `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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 85ffb2d..eed1fb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,41 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this ## 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 `` 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) 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. diff --git a/dashboard.html b/dashboard.html index 4a68d12..9302613 100644 --- a/dashboard.html +++ b/dashboard.html @@ -1,17 +1,20 @@ <!DOCTYPE html> <!-- - OLP Dashboard — Phase 3 / D50 PLACEHOLDER - ------------------------------------------- - This file is a stub shipped at D50 to wire the /dashboard route. D51 lands - the real multi-panel dashboard per ADR 0008 §6 (quota / per-provider 24h - counts / 30d spend trend / top fallback chains). + OLP Dashboard — Phase 3 / D51 + ------------------------------ + Multi-panel owner-only dashboard per ADR 0008 § 6. Polls + /v0/management/dashboard-data every 30 seconds (paused when the + page is hidden via document.visibilityState). - At D50, /dashboard returns this placeholder for owner-tier identities; - non-owner / unauth identities receive 401 (owner_only_block per ADR 0008 - §8). The 3 backing endpoints (/v0/management/dashboard-data, - /v0/management/quota, /cache/stats) are live + return real data; you can - curl them directly with an owner-tier Bearer token to see the JSON - shapes that D51 will render. + Panels (per spec v0.1 § 4.6 + ADR 0008 Lane 5 = B full): + 1. Per-provider quota / credit pool + 2. Per-provider 24h request count + cache hit rate + fallback rate + 3. 30-day spend trend (SVG sparkline; per-provider in tooltip) + 4. Top 10 fallback chains by trigger count + + 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"> <head> @@ -19,36 +22,287 @@ <title>OLP Dashboard

OLP Dashboard

-

- D50 placeholder. The full dashboard UI lands at D51 per - ADR 0008 § 6. - The three management endpoints are already live and serving real data — you can - curl them with an owner-tier Bearer token to preview the JSON shapes D51 will render. -

-

Backing endpoints (live at D50)

- -

Auth

-

- All /dashboard and /v0/management/* + /cache/stats endpoints are - owner_only_block per ADR 0007 § 7 + ADR 0008 § 8. Non-owner identities - (including anonymous when allow_anonymous: true) receive 401. -

-

Example request

-
curl -H "Authorization: Bearer olp_..." http://localhost:3456/v0/management/dashboard-data
-

— shipped at D50 (Phase 3); replaced at D51.

+
Loading…
+ +
+
+

Quota (per provider)

+
Loading…
+
+
+

Last 24h — request count · cache hit · fallback rate

+
Loading…
+
+
+

Request count — last 30 days (UTC)

+
Loading…
+
+
+

Top fallback chains (last 24h)

+
Loading…
+
+
+ + diff --git a/test-features.mjs b/test-features.mjs index 37690d2..e36871c 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -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