diff --git a/app/main.py b/app/main.py
index c0472e1..a41397d 100644
--- a/app/main.py
+++ b/app/main.py
@@ -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 = """
.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 = """
Members
+
@@ -337,6 +366,31 @@ PAGE_HTML = """
+
+
+
+ Show
+
+
+
+
+
+ Period
+
+
+
+
+
+
+
+ Members
+
+
+
Loading
+
+
+
+
Admin
Operator accounts (SSO owner and bot). Not members — excluded from member counts; their usage is a collective expense.
@@ -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 = "
Forbidden
"; return; }
+ const d = await r.json();
+ if (d.error) { host.innerHTML = "