panel: Trends tab — members and usage over time, charted

Nathan asked to see a chart. The portal now owns a daily metrics series
(metrics_daily, written 03:15 UTC by the portal's own scheduler); this is the
first surface to read it.

- New route /api/metrics proxies the portal's /admin/metrics/series (admin
  token stays server-side, same pattern as /api/model-usage).
- New Trends tab, added to switchView in the same edit (every view toggled
  there must be shown/hidden there). Lazy: the series loads on first visit, so
  the panel's initial load is unchanged.
- Metric toggle (Members / Spend / Requests) and period toggle (30 / 90 / all
  time). Fetched once at the widest range and re-rendered client-side, so the
  toggles never refetch.
- Inline SVG, hand-rolled: no chart library, no CDN, no external dependency.
  Colours come from the panel's existing CSS vars, so it matches the rest.
- Gaps are drawn as gaps. A NULL metric means "not measured" and the line
  deliberately breaks rather than drawing through a day we never recorded
  (backfilled members_active is NULL every day, by design). The sub-line says
  how many days are unmeasured.
- Members shows the cumulative total; spend and requests are per-day bars.

Verified against the live 55-day series with a node harness running the real
page functions: 22 checks, including that a NULL breaks the line into two runs,
that no NaN reaches the markup, that the toggles do not refetch, and that the
SVG carries no hardcoded hex.
This commit is contained in:
inference-bot committed 2026-10-28 04:01:52 +00:00
1 parent 476394b946
commit 11ae968889
1 file changed
+194 -1
+194 -1
View File
@@ -121,6 +121,22 @@ async def api_model_usage(request: Request):
return JSONResponse({"error": e.detail}, status_code=e.status_code)
@app.get("/api/metrics")
async def api_metrics(request: Request, days: int = 730):
"""Daily metrics series from the portal (members + usage history).
The portal owns this data (metrics_daily, refreshed 03:15 UTC); the panel
only reads it. Fetched once at the widest range and sliced client-side, so
the period toggle never refetches.
"""
if not is_admin(request):
return JSONResponse({"error": "forbidden"}, status_code=403)
try:
return await portal_get(f"/admin/metrics/series?days={max(1, min(int(days), 730))}")
except HTTPException as e:
return JSONResponse({"error": e.detail}, status_code=e.status_code)
@app.post("/api/set-balance")
async def api_set_balance(request: Request):
if not is_admin(request):
@@ -254,6 +270,18 @@ PAGE_HTML = """<!DOCTYPE html>
.pill.inactive { background: #f6e9e5; color: var(--danger); }
.pill.manual { background: #f1e8da; color: #a97a4a; }
.pill.org { background: #e7edf5; color: #4a6a8a; }
/* Trends tab (charts). Colours come from the same vars as the rest of the
panel; the SVG is inline so there is no chart library and no CDN. */
.trend-controls { display: flex; gap: 22px; flex-wrap: wrap; margin: 14px 0 0; }
.trend-controls .range-group { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.trend-card { background: #fff; border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px 12px; margin-top: 12px; }
.trend-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; }
.trend-title { font-weight: 700; font-size: 14px; color: var(--ink); }
.trend-sub { color: var(--muted); font-size: 12px; }
.trend-body svg { display: block; width: 100%; height: auto; overflow: visible; }
.trend-note { color: var(--muted); font-size: 12px; margin: 6px 0 0; }
.trend-empty { color: var(--muted); font-size: 13px; margin: 28px 0; text-align: center; }
@media (max-width: 600px) { .trend-controls { gap: 10px; } .trend-card { padding: 12px 10px; } }
.num { font-variant-numeric: tabular-nums; }
.remaining-col { font-weight: 700; color: var(--green-deep); background: #eaf3ea; }
.flash { padding: 12px 16px; border-radius: 8px; margin: 16px 0; font-size: 14px; display: none; }
@@ -306,6 +334,7 @@ PAGE_HTML = """<!DOCTYPE html>
<h2 id="view-title">Members</h2>
<button class="tab-btn active" id="tab-members" onclick="switchView('members')">Members</button>
<button class="tab-btn" id="tab-models" onclick="switchView('models')">Model usage</button>
<button class="tab-btn" id="tab-trends" onclick="switchView('trends')">Trends</button>
<span class="range-group" id="member-mode-group">
<button class="range-btn active" id="mmode-balance" onclick="setMemberMode('balance')">Balance</button>
<button class="range-btn" id="mmode-all" onclick="setMemberMode('all')">All time</button>
@@ -337,6 +366,31 @@ PAGE_HTML = """<!DOCTYPE html>
</table>
</div>
<div id="trends-section" style="display:none;">
<div class="trend-controls">
<span class="range-group">
<span class="range-label">Show</span>
<button class="range-btn active" id="tm-members" onclick="setTrendMetric('members')">Members</button>
<button class="range-btn" id="tm-spend" onclick="setTrendMetric('spend')">Spend</button>
<button class="range-btn" id="tm-requests" onclick="setTrendMetric('requests')">Requests</button>
</span>
<span class="range-group">
<span class="range-label">Period</span>
<button class="range-btn" id="tr-30" onclick="setTrendDays(30)">Last 30 days</button>
<button class="range-btn active" id="tr-90" onclick="setTrendDays(90)">Last 90 days</button>
<button class="range-btn" id="tr-all" onclick="setTrendDays(0)">All time</button>
</span>
</div>
<div class="trend-card">
<div class="trend-head">
<span class="trend-title" id="trend-title">Members</span>
<span class="trend-sub" id="trend-sub"></span>
</div>
<div id="trend-body" class="trend-body"><p class="trend-empty">Loading</p></div>
<p class="trend-note" id="trend-note"></p>
</div>
</div>
<div id="admins-section" style="display:none;">
<h3 style="margin:20px 0 8px;">Admin</h3>
<p style="color:var(--muted);font-size:12px;margin:0 0 8px;">Operator accounts (SSO owner and bot). Not members — excluded from member counts; their usage is a collective expense.</p>
@@ -413,6 +467,141 @@ async function load() {
}
}
// ---- Trends (members + usage over time) ----
// Data comes from the portal, which owns it: metrics_daily is written by a
// 03:15 UTC scheduler task inside the portal app. Fetched once at the widest
// range; the metric and period toggles re-render client-side without refetching.
let trendsData = null;
let trendMetric = "members"; // members | spend | requests
let trendDays = 90; // 0 = all time
const TREND_CONF = {
members: { kind: "line", title: "Members", unit: "count",
note: "Cumulative members as of each day. A gap means days that were never measured, not zero members." },
spend: { kind: "bar", title: "Spend per day", unit: "money",
note: "Spend charged to the co-op that day. Days with no recorded usage are gaps, not zeroes." },
requests: { kind: "bar", title: "Requests per day", unit: "count",
note: "Gateway requests served that day. Days with no recorded usage are gaps, not zeroes." }
};
async function loadMetrics() {
if (trendsData) { renderTrends(); return; }
const host = $("trend-body");
try {
const r = await fetch("/api/metrics?days=730");
if (r.status === 403) { host.innerHTML = "<p class=trend-empty>Forbidden</p>"; return; }
const d = await r.json();
if (d.error) { host.innerHTML = "<p class=trend-empty>" + d.error + "</p>"; return; }
trendsData = d.series || [];
renderTrends();
} catch (e) {
host.innerHTML = "<p class=trend-empty>Could not load trends</p>";
}
}
function setTrendMetric(m) {
trendMetric = m;
$("tm-members").className = "range-btn" + (m === "members" ? " active" : "");
$("tm-spend").className = "range-btn" + (m === "spend" ? " active" : "");
$("tm-requests").className = "range-btn" + (m === "requests" ? " active" : "");
renderTrends();
}
function setTrendDays(d) {
trendDays = d;
$("tr-30").className = "range-btn" + (d === 30 ? " active" : "");
$("tr-90").className = "range-btn" + (d === 90 ? " active" : "");
$("tr-all").className = "range-btn" + (d === 0 ? " active" : "");
renderTrends();
}
function trendVal(row) {
if (trendMetric === "members") return row.members_total;
if (trendMetric === "spend") return row.spend_usd;
return row.requests;
}
function trendLabel(v) {
if (v === null || v === undefined) return "not measured";
if (trendMetric === "spend") return fmt(v);
return Number(v).toLocaleString();
}
function renderTrends() {
const host = $("trend-body");
if (!host || !trendsData) return;
const conf = TREND_CONF[trendMetric];
const rows = trendDays === 0 ? trendsData : trendsData.slice(-trendDays);
$("trend-title").textContent = conf.title;
$("trend-note").textContent = conf.note;
const vals = rows.map(trendVal);
const have = vals.filter(v => v !== null && v !== undefined);
if (have.length < 2) {
host.innerHTML = "<p class=trend-empty>Not enough history yet.</p>";
$("trend-sub").textContent = "";
return;
}
const gaps = vals.length - have.length;
const maxV = Math.max.apply(null, have);
const top = maxV <= 0 ? 1 : maxV * 1.08; // headroom so the peak clears the frame
const W = 1000, H = 250, padL = 62, padR = 14, padT = 14, padB = 30;
const plotW = W - padL - padR, plotH = H - padT - padB;
const baseY = padT + plotH;
const n = vals.length;
const xAt = i => padL + (n <= 1 ? plotW / 2 : (plotW * i) / (n - 1));
const yAt = v => baseY - (plotH * v) / top;
const parts = [];
parts.push('<svg viewBox="0 0 ' + W + ' ' + H + '" role="img" aria-label="' + conf.title + ' over time">');
for (let g = 0; g <= 4; g++) {
const yy = baseY - (plotH * g) / 4;
const lab = trendLabel((top * g) / 4);
parts.push('<line x1="' + padL + '" y1="' + yy + '" x2="' + (W - padR) + '" y2="' + yy + '" stroke="var(--border)"/>');
parts.push('<text x="' + (padL - 8) + '" y="' + (yy + 4) + '" text-anchor="end" font-size="11" fill="var(--muted)">' + lab + '</text>');
}
if (conf.kind === "bar") {
const bw = Math.max(1, plotW / n - 1);
for (let i = 0; i < n; i++) {
const v = vals[i];
if (v === null || v === undefined) continue;
const yy = yAt(v);
parts.push('<rect x="' + (xAt(i) - bw / 2) + '" y="' + yy + '" width="' + bw + '" height="' + Math.max(0.6, baseY - yy) + '" fill="var(--green-mid)"><title>' + rows[i].day + ": " + trendLabel(v) + "</title></rect>");
}
} else {
// Break the line into contiguous measured runs: a gap stays a gap.
const runs = [];
let run = [];
for (let i = 0; i < n; i++) {
if (vals[i] === null || vals[i] === undefined) { if (run.length) runs.push(run); run = []; }
else run.push(i);
}
if (run.length) runs.push(run);
for (let s = 0; s < runs.length; s++) {
const r0 = runs[s];
const pts = r0.map(i => xAt(i) + "," + yAt(vals[i])).join(" ");
parts.push('<path d="M' + xAt(r0[0]) + "," + baseY + " L" + r0.map(i => xAt(i) + "," + yAt(vals[i])).join(" L") + " L" + xAt(r0[r0.length - 1]) + "," + baseY + ' Z" fill="var(--green-deep)" fill-opacity="0.10"/>');
parts.push('<polyline points="' + pts + '" fill="none" stroke="var(--green-deep)" stroke-width="2" stroke-linejoin="round"/>');
}
for (let i = 0; i < n; i++) {
if (vals[i] === null || vals[i] === undefined) continue;
parts.push('<circle cx="' + xAt(i) + '" cy="' + yAt(vals[i]) + '" r="7" fill="transparent"><title>' + rows[i].day + ": " + trendLabel(vals[i]) + "</title></circle>");
}
}
const marks = n > 2 ? [0, Math.floor((n - 1) / 2), n - 1] : [0, n - 1];
for (let mi = 0; mi < marks.length; mi++) {
const i = marks[mi];
const anchor = mi === 0 ? "start" : (mi === marks.length - 1 ? "end" : "middle");
parts.push('<text x="' + xAt(i) + '" y="' + (H - 9) + '" text-anchor="' + anchor + '" font-size="11" fill="var(--muted)">' + String(rows[i].day).slice(5) + '</text>');
}
parts.push("</svg>");
host.innerHTML = parts.join("");
$("trend-sub").textContent = rows.length + " days" + (gaps > 0 ? " - " + gaps + " not measured" : "");
}
// ---- shared state ----
let membersData = [];
let adminsData = [];
@@ -445,12 +634,16 @@ function switchView(view) {
// The Admin section is a members-view category; hide it in the Models view
// (model usage already integrates admin spend into the totals).
$('admins-section').style.display = view === 'members' ? '' : 'none';
$('trends-section').style.display = view === 'trends' ? '' : 'none';
$('tab-members').className = 'tab-btn' + (view === 'members' ? ' active' : '');
$('tab-models').className = 'tab-btn' + (view === 'models' ? ' active' : '');
$('view-title').textContent = view === 'members' ? 'Members' : 'Model usage';
$('tab-trends').className = 'tab-btn' + (view === 'trends' ? ' active' : '');
$('view-title').textContent = view === 'members' ? 'Members' : (view === 'models' ? 'Model usage' : 'Trends');
// Mode groups: Balance/All-time for members; Period for models.
$('range-group').style.display = view === 'models' ? '' : 'none';
$('member-mode-group').style.display = view === 'members' ? '' : 'none';
// Trends renders on first visit only, so the panel's initial load stays fast.
if (view === 'trends') loadMetrics();
render();
}