mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
* release(v0.5.1): hotfix — quota probe cache/backoff/schema-drift correctness (codex review) Addresses three production-quality findings from codex's post-v0.5.0 review (codex review on PR #57, reproduced with local mocks): F1 [P1] — Doctor bypass of cache + backoff (ADR 0013 Rule 3) `anthropic.quota_probe_reachable` called `_probeOnce(auth)` directly, bypassing `quotaProbeState.backoffUntil`. Successive `olp doctor` invocations within a backoff window each hit upstream — violating ADR 0013 Rule 3 (backoff is mandatory for ALL consumers). Fix: doctor now routes through `quotaStatus()`. ADR 0013 Rule 3 clarified: "All consumers of `quotaStatus()`, including `olp doctor` checks, MUST route through `quotaStatus()` and MUST NOT call `_probeOnce()` directly." F2 [P2] — 200 with empty ratelimit headers cached as live data (ADR 0013 Rule 5) A 200 OK with zero `anthropic-ratelimit-*` headers was cached as `stale: false` (live). Minimum-viable-schema gate added to `_probeOnce`: requires 5h-utilization + 5h-reset + 7d-utilization + 7d-reset present; absence → `failureKind: 'schema_drift'`, backoff scheduled, result NOT cached. ADR 0013 Rule 5 updated with the gate specification. F3 [P2] — Dashboard-data loses failure detail (ADR 0013 Rule 6) `aggregateProviderQuota()` collapsed all failure modes into `status: 'unavailable'` ("no public quota api or probe disabled") — same as providers with no API at all. Fix: `quotaStatus()` v0.5.1 contract — `null` ONLY for opt-in-off; failures return `{ probe_status: 'unreachable', failure: { kind, message, backoff_until? } }`. New `failure_kind` enum: no_credentials | auth_failed | rate_limited | schema_drift | network | other. Dashboard renders `unreachable` with red border + failure detail. Authority: ADR 0013 Rules 3, 5, 6 (cache + backoff + schema-drift + failure transparency) ADR 0008 Amendment 2 (richer quota_v2 shape; new unreachable status) ADR 0002 Amendment 8 unchanged (constitutional permission for the probe) Codex review findings F1–F3 (codex on PR #57) Changes: - lib/providers/anthropic.mjs: quotaProbeState gains lastError + failureKind; _probeOnce: min-field gate + failureKind population; quotaStatus(): v0.5.1 contract (null=disabled only; probe_status:live/stale/unreachable); doctorChecks routes through quotaStatus(); reset functions updated - lib/audit-query.mjs: _normalizeAnthropicQuota handles probe_status field; aggregateProviderQuota emits failure/failure_kind/backoff_until; unreachable status - dashboard.html: unreachable CSS classes + render path + footer v0.5.1 - test-features.mjs: 38f/j/l updated for v0.5.1 shape; 38r refactored for F1; 38g/k gain probe_status assertions; 38u/v/w new regression tests; 756→759 tests - docs/adr/0008: Amendment 2 (richer ProviderQuotaEntry + quotaStatus contract) - docs/adr/0013: Rule 3 clarification (doctor must use quotaStatus); Rule 5 min-viable-schema gate specification - package.json: 0.5.0 → 0.5.1 - CHANGELOG.md: v0.5.1 hotfix entry promoted from Unreleased - README.md / AGENTS.md: Phase 5 closed at v0.5.1; Phase 6 next Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs+code: PR #58 fold-in — reviewer Nits #1 + #2 (ADR doc-drift + opt_in_off enum) Fresh-context reviewer (PR #58) verdict: APPROVE_WITH_MINOR, 0 blocking, 4 nits. Folding in #1 + #2 (both 1-line cosmetic-but-correct fixes). Deferring #3 (test-only edge case in module-level seam restore) and #4 (pre-existing codex F4 — bin/olp.mjs + olp-plugin still read legacy quota shape; separate PR planned). Nit #1 — ADR 0002 Amendment 8 documentation drift. Amendment 8 at v0.5.0 line 20 said "the function returns `null` rather than throwing", and line 33 said "stale-cache-on-failure (`null` is returned only when no cache entry exists; if a stale entry exists it's returned with a `stale: true` marker)". v0.5.1 refined this contract: `null` is now reserved STRICTLY for opt-in-off, and all failure modes return `{ probe_status: 'unreachable' | 'stale', failure: {...} }`. The substantive idempotent-failure constraint (no throw to caller) is unchanged. The operational description in Amendment 8 was stale — fixed to cross-reference ADR 0008 Amendment 2 + ADR 0013 Rule 6 for the v0.5.1 contract refinement. Also references Suite 38 (38u/38v/38w) as the regression coverage producing the new shape. Nit #2 — `failureKind: 'opt_in_off'` declared but never produced. The enum value was listed in both the code comment (anthropic.mjs:250) and ADR 0008 Amendment 2 (line 30) but never actually assigned — because when opt-in is off, `quotaStatus()` returns the literal `null` BEFORE any state mutation happens. The enum value was dead. Fix: removed `opt_in_off` from both enum declarations + added an inline note explaining that consumers (audit-query, doctor) distinguish opt-in-off by checking `quotaStatus() === null`, not via failureKind. Deferred: - Nit #3 (test-seam restore edge case): if a caller pre-sets `_quotaAuthReadFnForTest` AND passes a non-default `_authReadFn`, the finally block restores to null clobbering pre-set value. Test-only impact, no production risk. Pure hygiene; defer. - Nit #4 (codex F4): bin/olp.mjs cmdUsage and olp-plugin/index.js still read legacy `body.quota` field, never consume `quota_v2`. Reviewer confirmed neither crashes — both gracefully fall through to "no quota api" branch. Out of scope for this hotfix per the hotfix dispatch contract; separate PR will migrate them. Tests: 759/759 still pass post-fold-in. No test changes needed. Authority: PR #58 review thread + ADR 0013 Rule 6 (failure transparency) + ADR 0008 Amendment 2 (ProviderQuotaEntry v0.5.1 shape). 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>
860 lines
32 KiB
HTML
860 lines
32 KiB
HTML
<!DOCTYPE html>
|
|
<!--
|
|
OLP Dashboard — Phase 5 / D82
|
|
------------------------------
|
|
Multi-panel owner-only dashboard per ADR 0008 § 6.
|
|
|
|
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
|
|
receive 401 — non-owner identities will see an error banner.
|
|
-->
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>OLP Dashboard</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<style>
|
|
* { box-sizing: border-box; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 1.5rem; background: #f9fafb; color: #1f2937; }
|
|
h1 { margin: 0 0 0.5rem; font-size: 1.5rem; }
|
|
.meta { color: #6b7280; font-size: 0.875rem; margin-bottom: 1.5rem; }
|
|
.grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; max-width: 1200px; }
|
|
.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; }
|
|
|
|
/* ───────────────────────────────────────────
|
|
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.unreachable { border-color: #fca5a5; background: #fff5f5; }
|
|
|
|
.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-dot.unreachable { background: #ef4444; }
|
|
|
|
.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; }
|
|
.status-chip.unreachable { background: #fee2e2; color: #991b1b; }
|
|
|
|
.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;
|
|
}
|
|
.unreachable-reason {
|
|
font-size: 0.875rem;
|
|
color: #b91c1c;
|
|
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" 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">
|
|
<h2>Last 24h — request count · cache hit · fallback rate</h2>
|
|
<div id="panel-24h"><div class="panel-loading">Loading…</div></div>
|
|
</section>
|
|
<section class="panel" style="grid-column: span 2;">
|
|
<h2>Request count — last 30 days (UTC)</h2>
|
|
<div id="panel-trend"><div class="panel-loading">Loading…</div></div>
|
|
</section>
|
|
<section class="panel" style="grid-column: span 2;">
|
|
<h2>Top fallback chains (last 24h)</h2>
|
|
<div id="panel-chains"><div class="panel-loading">Loading…</div></div>
|
|
</section>
|
|
</div>
|
|
<footer>OLP Dashboard · Plan Usage: 60s refresh · other panels: 30s · paused when tab hidden · v0.5.1</footer>
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
|
|
/* ─────────────── 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)) {
|
|
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;
|
|
}
|
|
|
|
/* ─────────────── 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;
|
|
}
|
|
|
|
/* ── unreachable (v0.5.1): probe failed + no cache — show failure detail ── */
|
|
if (status === 'unreachable') {
|
|
const failure = entry.failure || {};
|
|
const kind = failure.kind || 'unknown';
|
|
const msg = failure.message || 'probe failed — no cached data available';
|
|
const shortText = `${kind}: ${msg}`;
|
|
rowEl.appendChild(el('div', { class: 'unreachable-reason' }, shortText));
|
|
if (failure.backoff_until) {
|
|
const backoffMs = Math.max(0, failure.backoff_until - Date.now());
|
|
const backoffSec = Math.round(backoffMs / 1000);
|
|
if (backoffSec > 0) {
|
|
rowEl.appendChild(el('div', { class: 'unavailable-reason' }, `backoff active: ${backoffSec}s remaining`));
|
|
}
|
|
}
|
|
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 = '';
|
|
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);
|
|
}
|
|
|
|
/* ─────────────── 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 = '';
|
|
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)
|
|
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);
|
|
}
|
|
|
|
/* ─────────────── Error / clear banner ─────────────── */
|
|
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 = '';
|
|
}
|
|
|
|
/* ─────────────── Fetch ─────────────── */
|
|
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();
|
|
}
|
|
|
|
/* ─────────────── 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() + ' · 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) {
|
|
console.warn('OLP dashboard refresh failed:', err.message);
|
|
}
|
|
}
|
|
|
|
/* ─────────────── 30s poll (legacy panels) ─────────────── */
|
|
function startPolling() {
|
|
if (pollHandle !== null) return;
|
|
pollHandle = setInterval(refresh, POLL_INTERVAL_MS);
|
|
}
|
|
function stopPolling() {
|
|
if (pollHandle === null) return;
|
|
clearInterval(pollHandle);
|
|
pollHandle = null;
|
|
}
|
|
|
|
/* ─────────────── visibilitychange (both timers) ─────────────── */
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'hidden') {
|
|
stopPolling();
|
|
stopQuotaRefresh();
|
|
} else {
|
|
refresh();
|
|
startPolling();
|
|
refreshQuotaV2();
|
|
startQuotaRefresh();
|
|
}
|
|
});
|
|
|
|
/* ─────────────── Boot ─────────────── */
|
|
refresh().finally(() => {
|
|
startPolling();
|
|
if (document.visibilityState === 'visible') startQuotaRefresh();
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|