feat: D82 — dashboard UI Claude.ai-style (Phase 5) (#54)

* feat: D82 — dashboard UI Claude.ai-style per-provider rows (Phase 5)

Implements ADR 0012 D82: restructures dashboard.html to render
quota_v2 data (produced by D81 / PR #53) in a Claude.ai-style
per-provider row layout. Closes v1.x roadmap #8.

## What changed

### dashboard.html (A–G)

A. New "Plan Usage" section at top (full-width, above the 2-col grid):
   - Per-provider rows rendered from `data.quota_v2`
   - Each row: provider badge (colored chip), status dot + chip
     (live/stale/unavailable), schema version tag
   - Two utilization bars (5h + 7d) with:
     - Rounded gradient bar (green <50% / amber 50-80% / red >80%)
     - Label "Current 5-hour session: 49%" / "Weekly all-models: 31%"
     - Right-side reset countdown (see B)
   - Bottom chips: representative-claim badge (purple), overage chip
     (amber/green), fallback-percentage chip, last-fresh "Updated N
     min ago" tag; stale rows show amber ⚠ stale data chip with tooltip
   - Unavailable rows: provider badge + reason text only; no bars

B. formatResetCountdown(epochSeconds):
   - < 1 hour:   "Resets in 23 min"
   - 1–24 hours: "Resets in 12hr 30min"
   - < 7 days:   "Resets Sun 9:00 PM"  (weekday + 12h time)
   - >= 7 days:  "Resets May 31 9:00 PM" (month day + time)
   - past:       "Resetting now…"
   Uses toLocaleString('en-US', { hour12: true }).

C. 60s auto-refresh (quota_v2 only) with visibilityState guard:
   - Separate timer (quotaRefreshTimer); does NOT replace the 30s poll
   - Pauses on 'hidden'; resumes + immediate re-fetch on 'visible'
   - Other 3 panels (24h, 30d, top fallback) keep 30s cadence unchanged

D. Manual refresh button (↻ Refresh) in Plan Usage header:
   - 2-second spam guard (button disables post-click)
   - Spinning ⟳ icon during fetch
   - Re-enables after fetch completes (success or error)

E. Graceful quota_v2 / legacy quota fallback:
   - If data.quota_v2 is present and non-empty → render Plan Usage rows;
     hide legacy "Quota (per provider)" panel
   - If data.quota_v2 is absent/empty → show note in Plan Usage area;
     surface legacy data.quota in the original table panel
   - Guards operator running an older OLP build (pre-D81)

F. Visual polish: rounded bars, gradient fills, airy whitespace, mobile-
   responsive (bars reflow on narrow viewports via flex-wrap). Color
   palette: #10b981 (green), #f59e0b (amber), #ef4444 (red) matching
   Tailwind emerald/amber/red-500 per spec.

G. Other 3 panels (24h, 30d, top fallback) and their 30s poll cadence
   are IDENTICAL to D51. Only the Quota panel restructures.

### docs/v1x-roadmap.md (H)

Marks entry #8 as " CLOSED (D82, v0.5.0)". Adds closure status,
PR ref, and a brief note inside the entry body. Updates reading-order
header paragraph to include #8 in the closed list.

## Authority + citations

- ADR 0012 D82 — Claude.ai-style restructure D-day spec
  (docs/adr/0012-phase-5-charter-quota-probes-dashboard.md § D-day table)
- D81 PR #53 — quota_v2 shape producer (commit 5288493);
  ProviderQuotaEntry shape per ADR 0008 Amendment 1 § 3
- Maintainer reference 2026-05-26 — claude.ai/settings/usage screenshot;
  "Resets in 1hr 6min" / "Resets Sun 9:00 PM" string format
- v1.x roadmap #8 — closed by this commit
  (docs/v1x-roadmap.md #8 — Dashboard enrichment)

## Test impact

npm test: 727 pass / 0 fail (unchanged). dashboard.html is frontend-only;
D83 ships Suite 38/39 (probe unit tests + dashboard smoke tests).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(dashboard): D82 reviewer Nit #4 — gate overage chip on real status

When entry.overage = { status: null, disabled_reason: null } (audit-query's
default shape when the provider doesn't supply overage info), the chip rendered
as amber "Overage: —" which falsely suggests a warning state.

Now: chip only renders when entry.overage.status is truthy (i.e., Anthropic
actually returned an overage-status header). Truly-missing overage info shows
no chip at all, matching the maintainer's intent.

Identified by D82 fresh-context reviewer (PR #54 thread) as the only
maintainer-visible nit worth folding in pre-merge. 1-line change. All 727
tests continue to pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dtzp555-max
2026-05-26 17:20:49 +10:00
committed by GitHub
co-authored by taodeng Claude Sonnet 4.6
parent 5288493f19
commit a41420d0fc
2 changed files with 551 additions and 26 deletions
+548 -24
View File
@@ -1,16 +1,24 @@
<!DOCTYPE html>
<!--
OLP Dashboard — Phase 3 / D51
OLP Dashboard — Phase 5 / D82
------------------------------
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).
Multi-panel owner-only dashboard per ADR 0008 § 6.
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
Panels:
0. Plan Usage (new D82 — Claude.ai-style per-provider rows; quota_v2; 1-min refresh)
1. Per-provider quota / credit pool (legacy; kept for graceful fallback when quota_v2 absent)
2. Per-provider 24h request count + cache hit rate + fallback rate (30s refresh)
3. 30-day spend trend (SVG sparkline; per-provider in tooltip) (30s refresh)
4. Top 10 fallback chains by trigger count (30s refresh)
Refresh cadence:
- Plan Usage panel: 60s (separate timer; visibilityState-guarded per ADR 0012 D82)
- Other panels: 30s (original poll cadence; paused when tab hidden)
Authority:
- ADR 0008 § 6 — dashboard layout + owner-only_block
- ADR 0012 D82 — quota_v2 Claude.ai-style restructure
- v1.x roadmap #8 — closed by this D-day
No build step, no framework, no external dependencies. Vanilla JS +
fetch + DOM render. Owner-only_block: anonymous / guest / no-auth all
@@ -43,15 +51,234 @@
.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; }
/* ───────────────────────────────────────────
Plan Usage panel — D82 Claude.ai-style rows
─────────────────────────────────────────── */
.plan-usage-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.plan-usage-header h2 { margin: 0; }
.plan-usage-meta {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.8rem;
color: #6b7280;
}
.refresh-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.75rem;
background: #fff;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 0.8rem;
color: #374151;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
white-space: nowrap;
}
.refresh-btn:hover:not(:disabled) { background: #f9fafb; border-color: #9ca3af; }
.refresh-btn:disabled { opacity: 0.55; cursor: not-allowed; }
.refresh-btn .spin { display: inline-block; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.provider-row {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 0.75rem;
background: #fff;
}
.provider-row:last-child { margin-bottom: 0; }
.provider-row.unavailable { background: #f9fafb; }
.provider-row.stale { border-color: #fcd34d; }
.provider-row-top {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.provider-badge {
display: inline-block;
padding: 0.2rem 0.55rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #fff;
}
.provider-badge.anthropic { background: #cc4b24; }
.provider-badge.codex { background: #10a37f; }
.provider-badge.mistral { background: #6d5acd; }
.provider-badge.openai { background: #10a37f; }
.provider-badge.default { background: #6b7280; }
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.live { background: #10b981; }
.status-dot.stale { background: #f59e0b; }
.status-dot.unavailable { background: #9ca3af; }
.status-chip {
display: inline-block;
padding: 0.1rem 0.45rem;
border-radius: 99px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.status-chip.live { background: #d1fae5; color: #065f46; }
.status-chip.stale { background: #fef3c7; color: #92400e; }
.status-chip.unavailable { background: #f3f4f6; color: #6b7280; }
.chip-sm {
display: inline-block;
padding: 0.1rem 0.45rem;
border-radius: 4px;
font-size: 0.7rem;
color: #374151;
background: #f3f4f6;
border: 1px solid #e5e7eb;
}
.schema-tag {
margin-left: auto;
font-size: 0.7rem;
color: #9ca3af;
}
.unavailable-reason {
font-size: 0.875rem;
color: #9ca3af;
font-style: italic;
padding: 0.25rem 0 0;
}
.last-fresh-tag {
font-size: 0.7rem;
color: #9ca3af;
}
.utilization-bars { display: flex; flex-direction: column; gap: 0.6rem; }
.util-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.util-label {
flex: 0 0 180px;
font-size: 0.8rem;
color: #6b7280;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 600px) {
.util-label { flex: 0 0 100%; }
.util-row { flex-direction: column; align-items: flex-start; }
.grid { grid-template-columns: 1fr; }
}
.util-bar-wrap {
flex: 1 1 120px;
min-width: 80px;
height: 8px;
background: #e5e7eb;
border-radius: 99px;
overflow: hidden;
}
.util-bar-fill {
height: 100%;
border-radius: 99px;
transition: width 0.3s ease;
}
.util-bar-fill.green { background: linear-gradient(90deg, #34d399, #10b981); }
.util-bar-fill.amber { background: linear-gradient(90deg, #fbbf24, #f59e0b); }
.util-bar-fill.red { background: linear-gradient(90deg, #f87171, #ef4444); }
.util-pct {
flex: 0 0 40px;
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
font-weight: 600;
color: #374151;
text-align: right;
}
.util-reset {
flex: 0 0 auto;
font-size: 0.75rem;
color: #6b7280;
white-space: nowrap;
}
.rep-claim-badge {
display: inline-block;
padding: 0.1rem 0.45rem;
border-radius: 4px;
font-size: 0.7rem;
background: #ede9fe;
color: #5b21b6;
border: 1px solid #ddd6fe;
font-weight: 600;
}
.overage-chip {
display: inline-block;
padding: 0.1rem 0.45rem;
border-radius: 4px;
font-size: 0.7rem;
background: #fef3c7;
color: #92400e;
border: 1px solid #fcd34d;
}
.overage-chip.allowed {
background: #d1fae5;
color: #065f46;
border-color: #6ee7b7;
}
.provider-row-bottom {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.65rem;
flex-wrap: wrap;
}
</style>
</head>
<body>
<h1>OLP Dashboard</h1>
<div id="meta" class="meta">Loading…</div>
<div id="banner-slot"></div>
<!-- Plan Usage panel (D82 — Claude.ai-style; full width) -->
<section class="panel" style="max-width: 1200px; margin-bottom: 1rem;">
<div class="plan-usage-header">
<h2>Plan Usage</h2>
<div class="plan-usage-meta">
<span id="quota-last-refresh"></span>
<button class="refresh-btn" id="quota-refresh-btn" title="Refresh quota data">
<span id="quota-refresh-icon"></span> Refresh
</button>
</div>
</div>
<div id="panel-plan-usage"><div class="panel-loading">Loading…</div></div>
</section>
<div class="grid">
<section class="panel">
<h2>Quota (per provider)</h2>
<section class="panel" id="legacy-quota-section" style="display:none;">
<h2>Quota (per provider) — legacy</h2>
<div id="panel-quota"><div class="panel-loading">Loading…</div></div>
</section>
<section class="panel">
@@ -67,15 +294,21 @@
<div id="panel-chains"><div class="panel-loading">Loading…</div></div>
</section>
</div>
<footer>OLP Dashboard · poll every 30s · paused when tab hidden · v0.3.0-phase3</footer>
<footer>OLP Dashboard · Plan Usage: 60s refresh · other panels: 30s · paused when tab hidden · v0.5.0-phase5</footer>
<script>
(function () {
'use strict';
const POLL_INTERVAL_MS = 30000;
let pollHandle = null;
/* ─────────────── constants ─────────────── */
const POLL_INTERVAL_MS = 30000; // 30s for legacy panels
const QUOTA_POLL_INTERVAL_MS = 60000; // 60s for Plan Usage (D82)
let pollHandle = null;
let quotaRefreshTimer = null;
/* ─────────────── DOM helpers ─────────────── */
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)) {
@@ -97,6 +330,205 @@
return node;
}
/* ─────────────── Reset countdown helper (D82 § B) ─────────────── */
/**
* formatResetCountdown(epochSeconds) → human-readable string
*
* - past: "Resetting now…"
* - < 1 hour: "Resets in 23 min"
* - < 24 hours: "Resets in 12hr 30min"
* - < 7 days: "Resets Sun 9:00 PM"
* - >= 7 days: "Resets May 31 9:00 PM"
*/
function formatResetCountdown(epochSeconds) {
if (epochSeconds == null) return '—';
const nowMs = Date.now();
const targetMs = epochSeconds * 1000;
const diffMs = targetMs - nowMs;
if (diffMs <= 0) return 'Resetting now…';
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHr = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHr / 24);
if (diffMin < 60) {
return 'Resets in ' + diffMin + ' min';
}
if (diffHr < 24) {
const remMin = diffMin - diffHr * 60;
if (remMin === 0) return 'Resets in ' + diffHr + 'hr';
return 'Resets in ' + diffHr + 'hr ' + remMin + 'min';
}
// Format as "Resets <day-of-week> <time>" or "Resets <month> <day> <time>"
const target = new Date(targetMs);
const timeStr = target.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
if (diffDay < 7) {
const dayStr = target.toLocaleString('en-US', { weekday: 'short' });
return 'Resets ' + dayStr + ' ' + timeStr;
}
const dateStr = target.toLocaleString('en-US', { month: 'short', day: 'numeric' });
return 'Resets ' + dateStr + ' ' + timeStr;
}
/* ─────────────── "Updated N min ago" helper ─────────────── */
function formatAgo(epochMs) {
if (epochMs == null) return '';
const diffMs = Date.now() - epochMs;
if (diffMs < 0) return 'just now';
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return 'Updated just now';
const diffMin = Math.floor(diffSec / 60);
if (diffMin === 1) return 'Updated 1 min ago';
if (diffMin < 60) return 'Updated ' + diffMin + ' min ago';
const diffHr = Math.floor(diffMin / 60);
if (diffHr === 1) return 'Updated ~1hr ago';
return 'Updated ~' + diffHr + 'hr ago';
}
/* ─────────────── Utilization bar color ─────────────── */
function utilizationColor(fraction) {
if (fraction == null) return 'green';
if (fraction >= 0.80) return 'red';
if (fraction >= 0.50) return 'amber';
return 'green';
}
/* ─────────────── Provider badge color class ─────────────── */
function providerBadgeClass(name) {
const n = (name || '').toLowerCase();
if (n === 'anthropic') return 'anthropic';
if (n === 'codex') return 'codex';
if (n === 'mistral') return 'mistral';
if (n === 'openai') return 'openai';
return 'default';
}
/* ─────────────── Plan Usage renderer (quota_v2) ─────────────── */
function renderPlanUsage(quotaV2) {
const target = document.getElementById('panel-plan-usage');
target.innerHTML = '';
if (!Array.isArray(quotaV2) || quotaV2.length === 0) {
target.appendChild(el('div', { class: 'panel-loading' }, 'No quota data available.'));
return;
}
const frag = document.createDocumentFragment();
for (const entry of quotaV2) {
const status = entry.status || 'unavailable';
const rowEl = el('div', { class: 'provider-row ' + status });
/* ── top bar: badge + status dot + chips + schema tag ── */
const topBar = el('div', { class: 'provider-row-top' });
topBar.appendChild(el('span', { class: 'provider-badge ' + providerBadgeClass(entry.provider) }, (entry.provider || '').toUpperCase()));
topBar.appendChild(el('span', { class: 'status-dot ' + status, title: 'Status: ' + status }));
topBar.appendChild(el('span', { class: 'status-chip ' + status }, status));
if (entry.schema_version) {
topBar.appendChild(el('span', { class: 'schema-tag' }, 'schema: ' + entry.schema_version));
}
rowEl.appendChild(topBar);
/* ── unavailable: just show reason, no bars ── */
if (status === 'unavailable') {
const reason = entry.reason || 'no public quota api or probe disabled';
rowEl.appendChild(el('div', { class: 'unavailable-reason' }, reason));
frag.appendChild(rowEl);
continue;
}
/* ── utilization bars (5h + 7d) ── */
const util = entry.utilization || {};
const reset = entry.reset || {};
const barsWrap = el('div', { class: 'utilization-bars' });
const windows = [
{ key: '5h', label: 'Current 5-hour session' },
{ key: '7d', label: 'Weekly all-models' },
];
for (const w of windows) {
const frac = util[w.key];
const resetEpoch = reset[w.key];
const color = utilizationColor(frac);
const pctStr = frac != null ? Math.round(frac * 100) + '%' : '—';
const fillPct = frac != null ? Math.min(100, Math.round(frac * 100)) : 0;
const resetStr = formatResetCountdown(resetEpoch);
const utilRow = el('div', { class: 'util-row' });
utilRow.appendChild(el('span', { class: 'util-label', title: w.label },
w.label + (frac != null ? ': ' + pctStr : '')
));
const barWrap = el('div', { class: 'util-bar-wrap' });
barWrap.appendChild(el('div', {
class: 'util-bar-fill ' + color,
style: 'width: ' + fillPct + '%',
'aria-valuenow': fillPct,
'aria-valuemin': '0',
'aria-valuemax': '100',
role: 'progressbar',
}));
utilRow.appendChild(barWrap);
utilRow.appendChild(el('span', { class: 'util-pct' }, pctStr));
utilRow.appendChild(el('span', { class: 'util-reset' }, resetStr));
barsWrap.appendChild(utilRow);
}
rowEl.appendChild(barsWrap);
/* ── bottom chips: representative-claim, overage, last-fresh ── */
const bottomBar = el('div', { class: 'provider-row-bottom' });
if (entry.representative_claim) {
const claimLabel = entry.representative_claim === 'five_hour' ? '5-hour claim'
: entry.representative_claim === 'seven_day' ? '7-day claim'
: entry.representative_claim;
bottomBar.appendChild(el('span', { class: 'rep-claim-badge', title: 'Binding window: ' + entry.representative_claim }, claimLabel));
}
if (entry.overage && entry.overage.status) {
const ov = entry.overage;
const ovStatus = (ov.status || 'unknown').toLowerCase();
const isAllowed = ovStatus === 'allowed' || ovStatus === 'active';
const chipClass = isAllowed ? 'overage-chip allowed' : 'overage-chip';
const label = 'Overage: ' + (ov.status || '—')
+ (ov.disabled_reason ? ' (' + ov.disabled_reason + ')' : '');
bottomBar.appendChild(el('span', { class: chipClass, title: label }, label));
}
if (entry.fallback_percentage != null) {
const fpPct = Math.round(entry.fallback_percentage * 100) + '%';
bottomBar.appendChild(el('span', { class: 'chip-sm', title: 'Fallback rate (last window)' }, 'Fallback ' + fpPct));
}
if (entry.last_fresh_at) {
bottomBar.appendChild(el('span', { class: 'last-fresh-tag' }, formatAgo(entry.last_fresh_at)));
}
if (status === 'stale') {
const staleTitle = entry.last_fresh_at
? 'Last successful probe was ' + formatAgo(entry.last_fresh_at) + '; backoff active'
: 'Probe data is stale; backoff active';
bottomBar.appendChild(el('span', { class: 'chip-sm', style: 'color: #92400e; background: #fef3c7; border-color: #fcd34d;', title: staleTitle }, '⚠ stale data'));
}
rowEl.appendChild(bottomBar);
frag.appendChild(rowEl);
}
target.appendChild(frag);
}
/* ─────────────── Legacy quota renderer (graceful fallback) ─────────────── */
function renderQuota(data) {
const target = document.getElementById('panel-quota');
target.innerHTML = '';
@@ -129,6 +561,28 @@
target.appendChild(table);
}
/* ─────────────── Plan Usage top-level render + quota routing ─────────────── */
function renderQuotaSection(data) {
const hasV2 = Array.isArray(data.quota_v2) && data.quota_v2.length > 0;
const legacySection = document.getElementById('legacy-quota-section');
if (hasV2) {
// D82: use enriched quota_v2 rows; hide legacy panel
legacySection.style.display = 'none';
renderPlanUsage(data.quota_v2);
} else {
// Graceful fallback: show legacy quota panel (older server build without D81)
legacySection.style.display = '';
// Also show legacy data in Plan Usage panel with a note
const target = document.getElementById('panel-plan-usage');
target.innerHTML = '';
target.appendChild(el('div', { class: 'panel-loading', style: 'color:#6b7280;' },
'quota_v2 not available (server may not have D81 yet). See legacy Quota panel below.'));
renderQuota(data.quota);
}
}
/* ─────────────── Other panel renderers (unchanged from D51) ─────────────── */
function render24h(window24h, cacheHit24h) {
const target = document.getElementById('panel-24h');
target.innerHTML = '';
@@ -196,7 +650,7 @@
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)
// Date labels (first + last only)
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);
@@ -240,6 +694,7 @@
target.appendChild(table);
}
/* ─────────────── Error / clear banner ─────────────── */
function showError(message) {
const slot = document.getElementById('banner-slot');
slot.innerHTML = '';
@@ -250,6 +705,7 @@
document.getElementById('banner-slot').innerHTML = '';
}
/* ─────────────── Fetch ─────────────── */
async function fetchDashboardData() {
const res = await fetch('/v0/management/dashboard-data', {
headers: { 'Accept': 'application/json' },
@@ -266,24 +722,82 @@
return await res.json();
}
/* ─────────────── Quota-only refresh (D82 § C — 60s timer) ─────────────── */
let _lastQuotaFetchedAt = null;
async function refreshQuotaV2() {
try {
const data = await fetchDashboardData();
clearError();
_lastQuotaFetchedAt = Date.now();
renderQuotaSection(data);
updateQuotaLastRefreshLabel();
} catch (err) {
console.warn('OLP quota refresh failed:', err.message);
}
}
function updateQuotaLastRefreshLabel() {
const span = document.getElementById('quota-last-refresh');
if (!span) return;
if (_lastQuotaFetchedAt) {
span.textContent = 'Updated ' + new Date(_lastQuotaFetchedAt).toLocaleTimeString();
}
}
/* ─────────────── 60s quota timer with visibilityState guard ─────────────── */
function startQuotaRefresh() {
if (quotaRefreshTimer !== null) return;
quotaRefreshTimer = setInterval(refreshQuotaV2, QUOTA_POLL_INTERVAL_MS);
}
function stopQuotaRefresh() {
if (quotaRefreshTimer === null) return;
clearInterval(quotaRefreshTimer);
quotaRefreshTimer = null;
}
/* ─────────────── Manual refresh button (D82 § D) ─────────────── */
(function wireRefreshButton() {
const btn = document.getElementById('quota-refresh-btn');
const icon = document.getElementById('quota-refresh-icon');
if (!btn) return;
btn.addEventListener('click', async () => {
if (btn.disabled) return;
btn.disabled = true;
icon.textContent = '⟳';
icon.classList.add('spin');
try {
await refreshQuotaV2();
} finally {
icon.classList.remove('spin');
icon.textContent = '↻';
// Re-enable after 2s spam guard
setTimeout(() => { btn.disabled = false; }, 2000);
}
});
})();
/* ─────────────── Full 30s refresh (legacy panels + meta) ─────────────── */
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);
'Last refresh: ' + generated.toLocaleString() + ' · quota every 60s · other panels every 30s';
// Quota section: also render on each full refresh to keep in sync
_lastQuotaFetchedAt = Date.now();
renderQuotaSection(data);
updateQuotaLastRefreshLabel();
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);
}
}
/* ─────────────── 30s poll (legacy panels) ─────────────── */
function startPolling() {
if (pollHandle !== null) return;
pollHandle = setInterval(refresh, POLL_INTERVAL_MS);
@@ -294,14 +808,24 @@
pollHandle = null;
}
// Pause when tab hidden, resume on visible (ADR 0008 § 6.5).
/* ─────────────── visibilitychange (both timers) ─────────────── */
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') stopPolling();
else { refresh(); startPolling(); }
if (document.visibilityState === 'hidden') {
stopPolling();
stopQuotaRefresh();
} else {
refresh();
startPolling();
refreshQuotaV2();
startQuotaRefresh();
}
});
// Initial fetch + start poll.
refresh().finally(startPolling);
/* ─────────────── Boot ─────────────── */
refresh().finally(() => {
startPolling();
if (document.visibilityState === 'visible') startQuotaRefresh();
});
})();
</script>
</body>