From 11ae968889ea69f5b283ffd21fff479dd62d1041 Mon Sep 17 00:00:00 2001 From: Inferencebot Date: Wed, 28 Oct 2026 04:01:52 +0000 Subject: [PATCH] =?UTF-8?q?panel:=20Trends=20tab=20=E2=80=94=20members=20a?= =?UTF-8?q?nd=20usage=20over=20time,=20charted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/main.py | 195 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 194 insertions(+), 1 deletion(-) 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 = """ + +