mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
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:
+548
-24
@@ -1,16 +1,24 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<!--
|
<!--
|
||||||
OLP Dashboard — Phase 3 / D51
|
OLP Dashboard — Phase 5 / D82
|
||||||
------------------------------
|
------------------------------
|
||||||
Multi-panel owner-only dashboard per ADR 0008 § 6. Polls
|
Multi-panel owner-only dashboard per ADR 0008 § 6.
|
||||||
/v0/management/dashboard-data every 30 seconds (paused when the
|
|
||||||
page is hidden via document.visibilityState).
|
|
||||||
|
|
||||||
Panels (per spec v0.1 § 4.6 + ADR 0008 Lane 5 = B full):
|
Panels:
|
||||||
1. Per-provider quota / credit pool
|
0. Plan Usage (new D82 — Claude.ai-style per-provider rows; quota_v2; 1-min refresh)
|
||||||
2. Per-provider 24h request count + cache hit rate + fallback rate
|
1. Per-provider quota / credit pool (legacy; kept for graceful fallback when quota_v2 absent)
|
||||||
3. 30-day spend trend (SVG sparkline; per-provider in tooltip)
|
2. Per-provider 24h request count + cache hit rate + fallback rate (30s refresh)
|
||||||
4. Top 10 fallback chains by trigger count
|
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 +
|
No build step, no framework, no external dependencies. Vanilla JS +
|
||||||
fetch + DOM render. Owner-only_block: anonymous / guest / no-auth all
|
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; }
|
.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; }
|
.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; }
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>OLP Dashboard</h1>
|
<h1>OLP Dashboard</h1>
|
||||||
<div id="meta" class="meta">Loading…</div>
|
<div id="meta" class="meta">Loading…</div>
|
||||||
<div id="banner-slot"></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">
|
<div class="grid">
|
||||||
<section class="panel">
|
<section class="panel" id="legacy-quota-section" style="display:none;">
|
||||||
<h2>Quota (per provider)</h2>
|
<h2>Quota (per provider) — legacy</h2>
|
||||||
<div id="panel-quota"><div class="panel-loading">Loading…</div></div>
|
<div id="panel-quota"><div class="panel-loading">Loading…</div></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
@@ -67,15 +294,21 @@
|
|||||||
<div id="panel-chains"><div class="panel-loading">Loading…</div></div>
|
<div id="panel-chains"><div class="panel-loading">Loading…</div></div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'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 fmtNum(n) { return (n ?? 0).toLocaleString(); }
|
||||||
function fmtPct(rate) { return (rate * 100).toFixed(1) + '%'; }
|
function fmtPct(rate) { return (rate * 100).toFixed(1) + '%'; }
|
||||||
|
|
||||||
function el(tag, attrs, ...children) {
|
function el(tag, attrs, ...children) {
|
||||||
const node = document.createElement(tag);
|
const node = document.createElement(tag);
|
||||||
if (attrs) for (const [k, v] of Object.entries(attrs)) {
|
if (attrs) for (const [k, v] of Object.entries(attrs)) {
|
||||||
@@ -97,6 +330,205 @@
|
|||||||
return node;
|
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) {
|
function renderQuota(data) {
|
||||||
const target = document.getElementById('panel-quota');
|
const target = document.getElementById('panel-quota');
|
||||||
target.innerHTML = '';
|
target.innerHTML = '';
|
||||||
@@ -129,6 +561,28 @@
|
|||||||
target.appendChild(table);
|
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) {
|
function render24h(window24h, cacheHit24h) {
|
||||||
const target = document.getElementById('panel-24h');
|
const target = document.getElementById('panel-24h');
|
||||||
target.innerHTML = '';
|
target.innerHTML = '';
|
||||||
@@ -196,7 +650,7 @@
|
|||||||
const minLabel = svgEl('text', { x: 4, y: height - 4, 'font-size': 10, fill: '#6b7280' });
|
const minLabel = svgEl('text', { x: 4, y: height - 4, 'font-size': 10, fill: '#6b7280' });
|
||||||
minLabel.textContent = '0';
|
minLabel.textContent = '0';
|
||||||
svg.appendChild(minLabel);
|
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) {
|
if (spendTrend30d.length > 0) {
|
||||||
const firstDate = svgEl('text', { x: padding.left, y: height - 4, 'font-size': 10, fill: '#6b7280' });
|
const firstDate = svgEl('text', { x: padding.left, y: height - 4, 'font-size': 10, fill: '#6b7280' });
|
||||||
firstDate.textContent = spendTrend30d[0].date.slice(5);
|
firstDate.textContent = spendTrend30d[0].date.slice(5);
|
||||||
@@ -240,6 +694,7 @@
|
|||||||
target.appendChild(table);
|
target.appendChild(table);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─────────────── Error / clear banner ─────────────── */
|
||||||
function showError(message) {
|
function showError(message) {
|
||||||
const slot = document.getElementById('banner-slot');
|
const slot = document.getElementById('banner-slot');
|
||||||
slot.innerHTML = '';
|
slot.innerHTML = '';
|
||||||
@@ -250,6 +705,7 @@
|
|||||||
document.getElementById('banner-slot').innerHTML = '';
|
document.getElementById('banner-slot').innerHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─────────────── Fetch ─────────────── */
|
||||||
async function fetchDashboardData() {
|
async function fetchDashboardData() {
|
||||||
const res = await fetch('/v0/management/dashboard-data', {
|
const res = await fetch('/v0/management/dashboard-data', {
|
||||||
headers: { 'Accept': 'application/json' },
|
headers: { 'Accept': 'application/json' },
|
||||||
@@ -266,24 +722,82 @@
|
|||||||
return await res.json();
|
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() {
|
async function refresh() {
|
||||||
try {
|
try {
|
||||||
const data = await fetchDashboardData();
|
const data = await fetchDashboardData();
|
||||||
clearError();
|
clearError();
|
||||||
const generated = data.generated_at ? new Date(data.generated_at) : new Date();
|
const generated = data.generated_at ? new Date(data.generated_at) : new Date();
|
||||||
document.getElementById('meta').textContent =
|
document.getElementById('meta').textContent =
|
||||||
'Last refresh: ' + generated.toLocaleString() + ' · next in ~30s';
|
'Last refresh: ' + generated.toLocaleString() + ' · quota every 60s · other panels every 30s';
|
||||||
renderQuota(data.quota);
|
// 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);
|
render24h(data.window_24h, data.cache_hit_24h);
|
||||||
renderTrend(data.spend_trend_30d);
|
renderTrend(data.spend_trend_30d);
|
||||||
renderChains(data.top_fallback_chains_24h);
|
renderChains(data.top_fallback_chains_24h);
|
||||||
} catch (err) {
|
} 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);
|
console.warn('OLP dashboard refresh failed:', err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─────────────── 30s poll (legacy panels) ─────────────── */
|
||||||
function startPolling() {
|
function startPolling() {
|
||||||
if (pollHandle !== null) return;
|
if (pollHandle !== null) return;
|
||||||
pollHandle = setInterval(refresh, POLL_INTERVAL_MS);
|
pollHandle = setInterval(refresh, POLL_INTERVAL_MS);
|
||||||
@@ -294,14 +808,24 @@
|
|||||||
pollHandle = null;
|
pollHandle = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pause when tab hidden, resume on visible (ADR 0008 § 6.5).
|
/* ─────────────── visibilitychange (both timers) ─────────────── */
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.visibilityState === 'hidden') stopPolling();
|
if (document.visibilityState === 'hidden') {
|
||||||
else { refresh(); startPolling(); }
|
stopPolling();
|
||||||
|
stopQuotaRefresh();
|
||||||
|
} else {
|
||||||
|
refresh();
|
||||||
|
startPolling();
|
||||||
|
refreshQuotaV2();
|
||||||
|
startQuotaRefresh();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initial fetch + start poll.
|
/* ─────────────── Boot ─────────────── */
|
||||||
refresh().finally(startPolling);
|
refresh().finally(() => {
|
||||||
|
startPolling();
|
||||||
|
if (document.visibilityState === 'visible') startQuotaRefresh();
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+3
-2
@@ -8,7 +8,7 @@
|
|||||||
3. **Where** does the work live in the tree today (file + anchor).
|
3. **Where** does the work live in the tree today (file + anchor).
|
||||||
4. **When** does it need to land (trigger: load profile, security event, governance amendment).
|
4. **When** does it need to land (trigger: load profile, security event, governance amendment).
|
||||||
|
|
||||||
**Reading order for a v1.x sprint kickoff.** As of 2026-05-25, #1 (streaming SF, D57+D58) and #2 (multi-key auth, Phase 2) are CLOSED, and #4 and #7 closed in D56. Remaining v1.x scope: #3 (soft trigger reactivation), #5 (provider cacheKeyFields mask), #6 (streaming SPAWN_FAILED salvage — unbundled from #1 at #1 close). All three remaining items have explicit "trigger to start" gates that have not fired.
|
**Reading order for a v1.x sprint kickoff.** As of 2026-05-26, #1 (streaming SF, D57+D58), #2 (multi-key auth, Phase 2), #4 and #7 (closed in D56), and #8 (dashboard enrichment, D82 Phase 5) are CLOSED. Remaining v1.x scope: #3 (soft trigger reactivation), #5 (provider cacheKeyFields mask), #6 (streaming SPAWN_FAILED salvage — unbundled from #1 at #1 close). All three remaining items have explicit "trigger to start" gates that have not fired.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -88,8 +88,9 @@
|
|||||||
- **Tracking.** Not a GitHub issue. Tracked here.
|
- **Tracking.** Not a GitHub issue. Tracked here.
|
||||||
- **Trigger to start.** First report of streaming-path SPAWN_FAILED mid-stream where partial-chunk salvage would have helped a downstream caller. Practically unlikely at family scale.
|
- **Trigger to start.** First report of streaming-path SPAWN_FAILED mid-stream where partial-chunk salvage would have helped a downstream caller. Practically unlikely at family scale.
|
||||||
|
|
||||||
## #8 — Dashboard enrichment: per-provider subscription quota + reset times + 1-min refresh + manual refresh (D78 follow-up)
|
## #8 — Dashboard enrichment: per-provider subscription quota + reset times + 1-min refresh + manual refresh (D78 follow-up) — ✅ **CLOSED (D82, v0.5.0)**
|
||||||
|
|
||||||
|
- **Status.** Closed at D82 (Phase 5). `dashboard.html` restructured to Claude.ai-style per-provider rows rendering `quota_v2`. Closed by PR on branch `d82-dashboard-ui-claude-ai-style`; ships with v0.5.0. 60s quota auto-refresh + manual refresh button + visibilityState guard implemented. Graceful fallback to legacy `quota` field when server runs a pre-D81 build.
|
||||||
- **What.** Phase 3 dashboard (D51 `dashboard.html`, v0.3.0) shows: per-provider quota (currently always "n/a — no quota api"), last-24h request count + cache hit + fallback rate, 30d request-count sparkline, top fallback chains. **Maintainer request 2026-05-26 post-D78**: extend to show what each enabled provider's subscription is actually consuming, with reset times visible, refresh once per minute (current 30s is OK but maintainer specified 1min target), and a manual refresh button. Reference design: Claude.ai's own `claude.ai/settings/usage` page — current session bar with "Resets in 1hr 6min", weekly all-models bar with "Resets Sun 9:00 PM", per-model bar (Sonnet only), additional features (routine runs), usage credits + monthly spend limit + auto-reload toggle.
|
- **What.** Phase 3 dashboard (D51 `dashboard.html`, v0.3.0) shows: per-provider quota (currently always "n/a — no quota api"), last-24h request count + cache hit + fallback rate, 30d request-count sparkline, top fallback chains. **Maintainer request 2026-05-26 post-D78**: extend to show what each enabled provider's subscription is actually consuming, with reset times visible, refresh once per minute (current 30s is OK but maintainer specified 1min target), and a manual refresh button. Reference design: Claude.ai's own `claude.ai/settings/usage` page — current session bar with "Resets in 1hr 6min", weekly all-models bar with "Resets Sun 9:00 PM", per-model bar (Sonnet only), additional features (routine runs), usage credits + monthly spend limit + auto-reload toggle.
|
||||||
- **Why deferred.** v0.3.0/v0.4.x ships the dashboard frame but `provider.quotaStatus()` returns `null` in all three v0.1 plugins (anthropic / openai / mistral). The ratifying spec in ADR 0004 Amendment 2 punts `quotaStatus()` to v1.x ("soft trigger reactivation") — this dashboard ask is the **operator-facing reason** that work would land.
|
- **Why deferred.** v0.3.0/v0.4.x ships the dashboard frame but `provider.quotaStatus()` returns `null` in all three v0.1 plugins (anthropic / openai / mistral). The ratifying spec in ADR 0004 Amendment 2 punts `quotaStatus()` to v1.x ("soft trigger reactivation") — this dashboard ask is the **operator-facing reason** that work would land.
|
||||||
- **What this requires.** Per-provider plugin work + dashboard.html UI work + audit-query.mjs aggregation:
|
- **What this requires.** Per-provider plugin work + dashboard.html UI work + audit-query.mjs aggregation:
|
||||||
|
|||||||
Reference in New Issue
Block a user