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.
837 lines
37 KiB
Python
837 lines
37 KiB
Python
"""Admin Panel — co-op-wide member overview, spend, and balance management.
|
|
|
|
Security model:
|
|
- Authentication is done by Cloudron's proxyAuth wall (SSO). Cloudron injects
|
|
the authenticated user's username via X-Remote-User.
|
|
- Access is further restricted to a small ADMIN_USERNAMES allowlist (checked
|
|
against the injected username), so only designated admins see this panel.
|
|
- This app calls the member portal's /admin/overview and /admin/set-balance
|
|
endpoints, authenticated with the portal's WEBHOOK_TOKEN (ADMIN_TOKEN).
|
|
|
|
Environment (Cloudron env vars):
|
|
PORTAL_BASE — base URL of the member portal
|
|
ADMIN_TOKEN — the portal's admin/webhook token (for /admin/* endpoints)
|
|
ADMIN_USERNAMES — comma-separated list of allowed Cloudron usernames
|
|
"""
|
|
|
|
import os
|
|
import logging
|
|
|
|
import httpx
|
|
from fastapi import FastAPI, Request, HTTPException
|
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
|
|
from app import oidc
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger("admin-panel")
|
|
|
|
PORTAL_BASE = os.environ.get("PORTAL_BASE", "").rstrip("/")
|
|
ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "")
|
|
ADMIN_USERNAMES = {
|
|
u.strip().lower()
|
|
for u in os.environ.get("ADMIN_USERNAMES", "").split(",")
|
|
if u.strip()
|
|
}
|
|
|
|
app = FastAPI()
|
|
|
|
|
|
def get_identity(request: Request) -> str:
|
|
"""Return the authenticated user's identity (Cloudron username).
|
|
|
|
Reads the validated OIDC session cookie. No header fallback — the app no
|
|
longer trusts a client-supplied identity header (that path existed only
|
|
during the proxyAuth → OIDC transition and is now removed).
|
|
"""
|
|
if not oidc.is_oidc_configured():
|
|
return ""
|
|
token = request.cookies.get(oidc.SESSION_COOKIE)
|
|
if not token:
|
|
return ""
|
|
return oidc.read_session(token) or ""
|
|
|
|
|
|
def is_admin(request: Request) -> bool:
|
|
identity = get_identity(request)
|
|
if not identity:
|
|
return False
|
|
return identity.lower() in ADMIN_USERNAMES
|
|
|
|
|
|
async def portal_get(path: str) -> dict:
|
|
async with httpx.AsyncClient(timeout=20.0) as client:
|
|
r = await client.get(f"{PORTAL_BASE}{path}", headers={"X-Admin-Token": ADMIN_TOKEN})
|
|
if r.status_code != 200:
|
|
raise HTTPException(502, f"Portal error {r.status_code}")
|
|
return r.json()
|
|
|
|
|
|
async def portal_post(path: str, payload: dict) -> dict:
|
|
async with httpx.AsyncClient(timeout=20.0) as client:
|
|
r = await client.post(f"{PORTAL_BASE}{path}", json=payload, headers={"X-Admin-Token": ADMIN_TOKEN})
|
|
if r.status_code not in (200, 201):
|
|
try:
|
|
detail = r.json().get("detail", r.text)
|
|
except Exception:
|
|
detail = r.text
|
|
raise HTTPException(502, f"Portal error {r.status_code}: {detail}")
|
|
return r.json()
|
|
|
|
|
|
@app.get("/healthz")
|
|
async def healthz():
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.get("/")
|
|
async def index(request: Request):
|
|
identity = get_identity(request)
|
|
# Unauthenticated + OIDC configured → send to the OIDC login.
|
|
if not identity:
|
|
if oidc.is_oidc_configured():
|
|
return RedirectResponse("/auth/openid/login", status_code=302)
|
|
return HTMLResponse(
|
|
"<h1>Forbidden</h1><p>This panel is restricted to administrators.</p>",
|
|
status_code=403,
|
|
)
|
|
# Authenticated but not an admin → forbid.
|
|
if identity.lower() not in ADMIN_USERNAMES:
|
|
return HTMLResponse(
|
|
"<h1>Forbidden</h1><p>This panel is restricted to administrators.</p>",
|
|
status_code=403,
|
|
)
|
|
return HTMLResponse(render_page())
|
|
|
|
|
|
@app.get("/api/overview")
|
|
async def api_overview(request: Request):
|
|
if not is_admin(request):
|
|
return JSONResponse({"error": "forbidden"}, status_code=403)
|
|
return await portal_get("/admin/overview")
|
|
|
|
|
|
@app.get("/api/model-usage")
|
|
async def api_model_usage(request: Request):
|
|
if not is_admin(request):
|
|
return JSONResponse({"error": "forbidden"}, status_code=403)
|
|
try:
|
|
return await portal_get("/admin/model-usage")
|
|
except HTTPException as e:
|
|
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):
|
|
return JSONResponse({"error": "forbidden"}, status_code=403)
|
|
body = await request.json()
|
|
email = (body.get("email") or "").strip().lower()
|
|
if not email:
|
|
return JSONResponse({"error": "Missing email"}, status_code=400)
|
|
payload = {"email": email}
|
|
if "add" in body:
|
|
payload["add"] = float(body["add"])
|
|
elif "balance" in body:
|
|
payload["balance"] = float(body["balance"])
|
|
else:
|
|
return JSONResponse({"error": "Provide 'balance' or 'add'"}, status_code=400)
|
|
try:
|
|
return await portal_post("/admin/set-balance", payload)
|
|
except HTTPException as e:
|
|
return JSONResponse({"error": e.detail}, status_code=e.status_code)
|
|
|
|
|
|
@app.post("/api/set-credits")
|
|
async def api_set_credits(request: Request):
|
|
if not is_admin(request):
|
|
return JSONResponse({"error": "forbidden"}, status_code=403)
|
|
body = await request.json()
|
|
email = (body.get("email") or "").strip().lower()
|
|
if not email:
|
|
return JSONResponse({"error": "Missing email"}, status_code=400)
|
|
payload = {"email": email, "reason": body.get("reason") or "admin adjustment"}
|
|
if "add" in body:
|
|
payload["add"] = float(body["add"])
|
|
elif "set" in body:
|
|
payload["set"] = float(body["set"])
|
|
else:
|
|
return JSONResponse({"error": "Provide 'set' or 'add'"}, status_code=400)
|
|
try:
|
|
return await portal_post("/admin/set-credits", payload)
|
|
except HTTPException as e:
|
|
return JSONResponse({"error": e.detail}, status_code=e.status_code)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# OIDC routes — authorization-code flow against Cloudron's OIDC provider.
|
|
# Active only when the `oidc` addon is configured (CLOUDRON_OIDC_* present).
|
|
# During the transition the legacy proxyAuth header still works as a fallback.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@app.get("/auth/openid/login")
|
|
async def oidc_login(request: Request):
|
|
if not oidc.is_oidc_configured():
|
|
raise HTTPException(404, "OIDC not configured")
|
|
login_url, state, nonce = oidc.build_login_url(request.headers.get("host", ""))
|
|
resp = RedirectResponse(login_url, status_code=302)
|
|
# Short-lived, HttpOnly, SameSite=Lax cookies to carry state/nonce.
|
|
resp.set_cookie("oidc_state", state, max_age=600, httponly=True, samesite="lax")
|
|
resp.set_cookie("oidc_nonce", nonce, max_age=600, httponly=True, samesite="lax")
|
|
return resp
|
|
|
|
|
|
@app.get("/auth/openid/callback")
|
|
async def oidc_callback(request: Request):
|
|
if not oidc.is_oidc_configured():
|
|
raise HTTPException(404, "OIDC not configured")
|
|
code = request.query_params.get("code")
|
|
state = request.query_params.get("state")
|
|
expected_state = request.cookies.get("oidc_state")
|
|
nonce = request.cookies.get("oidc_nonce")
|
|
|
|
if not code or not state or not expected_state or not nonce:
|
|
return HTMLResponse("<h1>Login failed</h1><p>Incomplete OIDC callback.</p>", status_code=400)
|
|
if state != expected_state:
|
|
return HTMLResponse("<h1>Login failed</h1><p>State mismatch (possible CSRF).</p>", status_code=400)
|
|
|
|
try:
|
|
identity = await oidc.exchange_code(code, request.headers.get("host", ""), nonce)
|
|
except ValueError as e:
|
|
logger.warning("OIDC login failed: %s", e)
|
|
return HTMLResponse("<h1>Login failed</h1><p>Could not complete sign-in.</p>", status_code=400)
|
|
|
|
session = oidc.write_session(identity)
|
|
resp = RedirectResponse("/", status_code=302)
|
|
resp.set_cookie(oidc.SESSION_COOKIE, session, max_age=oidc.SESSION_MAX_AGE, httponly=True, samesite="lax")
|
|
resp.delete_cookie("oidc_state")
|
|
resp.delete_cookie("oidc_nonce")
|
|
return resp
|
|
|
|
|
|
@app.get("/logout")
|
|
async def logout():
|
|
resp = RedirectResponse("/", status_code=302)
|
|
resp.delete_cookie(oidc.SESSION_COOKIE)
|
|
return resp
|
|
|
|
|
|
def _esc(s: str) -> str:
|
|
return s.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
|
|
|
|
def render_page() -> str:
|
|
return PAGE_HTML
|
|
|
|
|
|
PAGE_HTML = """<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Inference Cooperative · Admin</title>
|
|
<style>
|
|
:root {
|
|
--cream: #faf8f5; --ink: #2d3327; --muted: #6b7a62;
|
|
--green-deep: #5b8c5a; --green-mid: #6ba86b; --border: #e4e0d8; --danger: #b3543a;
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: 'Figtree', -apple-system, 'Segoe UI', system-ui, sans-serif; background: var(--cream); color: var(--ink); line-height: 1.5; }
|
|
.wrap { max-width: 1120px; margin: 0 auto; padding: 32px 24px 48px; }
|
|
header { display: flex; align-items: center; gap: 14px; margin-bottom: 24px; }
|
|
.brand h1 { font-size: 20px; font-weight: 700; }
|
|
.brand .sub { color: var(--muted); font-size: 13px; }
|
|
.totals { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
|
.tot { background: #fff; border: 1px solid var(--border); border-radius: 12px; padding: 16px 20px; }
|
|
.tot .num { font-size: 24px; font-weight: 700; color: var(--green-deep); }
|
|
.tot .lbl { font-size: 12px; color: var(--muted); }
|
|
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
|
th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid #f0ede6; font-size: 13px; }
|
|
th { background: #f6f4ee; color: var(--muted); font-weight: 600; font-size: 12px; }
|
|
tr:last-child td { border-bottom: none; }
|
|
.pill { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
|
.pill.active { background: #eaf3ea; color: #2f5c30; }
|
|
.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; }
|
|
.flash.show { display: block; }
|
|
.flash.ok { background: #eaf3ea; color: #2f5c30; }
|
|
.flash.err { background: #f6e9e5; color: var(--danger); }
|
|
.adjust { display: inline-flex; gap: 6px; align-items: center; white-space: nowrap; }
|
|
.adjust input { width: 56px; padding: 4px 6px; border: 1px solid var(--border); border-radius: 6px; font-size: 12px; flex-shrink: 0; }
|
|
.adjust button { cursor: pointer; border: none; border-radius: 6px; background: var(--green-deep); color: #fff; font-size: 11px; font-weight: 600; padding: 4px 8px; flex-shrink: 0; }
|
|
.adjust button:hover { background: #4e7a4d; }
|
|
.adjust .credits-btn { background: var(--bronze, #a97a4a); }
|
|
.adjust .credits-btn:hover { background: #92693c; }
|
|
td:last-child, th:last-child { white-space: nowrap; }
|
|
.tabs { display: flex; gap: 8px; margin: 28px 0 12px; align-items: center; flex-wrap: wrap; }
|
|
.tabs h2 { font-size: 17px; font-weight: 700; margin-right: 8px; }
|
|
.tab-btn { cursor: pointer; border: 1px solid var(--border); background: #fff; color: var(--muted); border-radius: 8px; font-size: 13px; font-weight: 600; padding: 6px 14px; }
|
|
.tab-btn.active { background: var(--green-deep); color: #fff; border-color: var(--green-deep); }
|
|
.range-group { display: inline-flex; gap: 6px; margin-left: auto; align-items: center; }
|
|
.range-label { font-size: 12px; color: var(--muted); margin-right: 2px; }
|
|
.range-btn { cursor: pointer; border: 1px solid var(--border); background: #fff; color: var(--muted); border-radius: 8px; font-size: 12px; font-weight: 600; padding: 5px 12px; }
|
|
.range-btn.active { background: var(--green-mid); color: #fff; border-color: var(--green-mid); }
|
|
.members-all-time .balance-col.allowance-only,
|
|
.members-all-time .balance-col.remaining-only { display: none; }
|
|
.members-all-time .credits-col-head .mode-note::after,
|
|
.table-scroll { overflow-x: auto; border-radius: 12px; }
|
|
.table-scroll table { min-width: 980px; }
|
|
th.sortable { cursor: pointer; user-select: none; }
|
|
th.sortable:hover { color: var(--green-deep); }
|
|
th .arrow { font-size: 10px; margin-left: 3px; opacity: 0.7; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<header>
|
|
<div class="brand">
|
|
<h1>Inference Cooperative</h1>
|
|
<div class="sub">Admin panel</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="totals">
|
|
<div class="tot"><div class="num" id="tot-members">—</div><div class="lbl">members</div></div>
|
|
<div class="tot"><div class="num" id="tot-spend-cycle">—</div><div class="lbl">current balance used</div></div>
|
|
<div class="tot"><div class="num" id="tot-spend-all">—</div><div class="lbl">all-time spend</div></div>
|
|
</div>
|
|
|
|
<div class="flash" id="flash"></div>
|
|
|
|
<div class="tabs" style="margin-top:28px;">
|
|
<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>
|
|
</span>
|
|
<span class="range-group" id="range-group" style="display:none;">
|
|
<span class="range-label">Period</span>
|
|
<button class="range-btn" id="range-30" onclick="setRange(30)">Last 30 days</button>
|
|
<button class="range-btn active" id="range-all" onclick="setRange(0)">All time</button>
|
|
</span>
|
|
</div>
|
|
|
|
<div class="table-scroll">
|
|
<table id="table-members">
|
|
<thead>
|
|
<tr>
|
|
<th class="sortable" onclick="sortMembers('email')">Member<span class="arrow" id="arrow-members-email"></span></th>
|
|
<th class="sortable" onclick="sortMembers('active')">Status<span class="arrow" id="arrow-members-active"></span></th>
|
|
<th class="sortable" onclick="sortMembers('activated')">Activated<span class="arrow" id="arrow-members-activated"></span></th>
|
|
<th class="sortable" onclick="sortMembers('manual')">Source<span class="arrow" id="arrow-members-manual"></span></th>
|
|
<th class="sortable balance-col allowance-only" onclick="sortMembers('balance')">Allowance<span class="arrow" id="arrow-members-balance"></span></th>
|
|
<th class="sortable balance-col credits-col-head" onclick="sortMembers('credit_balance')">Credits<span class="mode-note"></span><span class="arrow" id="arrow-members-credit_balance"></span></th>
|
|
<th class="sortable" onclick="sortMembers('spend')"><span id="spend-mode-label">Spend</span><span class="arrow" id="arrow-members-spend"></span></th>
|
|
<th class="sortable balance-col" onclick="sortMembers('remaining')">Remaining<span class="arrow" id="arrow-members-remaining"></span></th>
|
|
<th class="sortable balance-col" onclick="sortMembers('reset_at')">Reset<span class="arrow" id="arrow-members-reset_at"></span></th>
|
|
<th>Adjust</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="rows"><tr><td colspan="8" style="color:var(--muted)">Loading…</td></tr></tbody>
|
|
</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>
|
|
<div class="table-scroll">
|
|
<table id="table-admins">
|
|
<thead>
|
|
<tr>
|
|
<th>Account</th>
|
|
<th>Status</th>
|
|
<th>Activated</th>
|
|
<th><span id="spend-mode-label-admin">Spend</span></th>
|
|
<th>Reset</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="admin-rows"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="table-scroll" style="margin-top:12px;">
|
|
<table id="table-models" style="display:none;">
|
|
<thead>
|
|
<tr>
|
|
<th class="sortable" onclick="sortModels('model')">Model<span class="arrow" id="arrow-models-model"></span></th>
|
|
<th class="sortable" onclick="sortModels('spend')">Spend<span class="arrow" id="arrow-models-spend"></span></th>
|
|
<th class="sortable" onclick="sortModels('tokens')">Tokens<span class="arrow" id="arrow-models-tokens"></span></th>
|
|
<th class="sortable" onclick="sortModels('requests')">Requests<span class="arrow" id="arrow-models-requests"></span></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="model-rows"><tr><td colspan="4" style="color:var(--muted)">Loading…</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
function flash(msg, ok) {
|
|
const f = $('flash');
|
|
f.textContent = msg;
|
|
f.className = 'flash show ' + (ok ? 'ok' : 'err');
|
|
setTimeout(() => { f.className = 'flash'; }, 6000);
|
|
}
|
|
|
|
function fmt(n) { const v = Math.abs(Number(n)) < 0.005 ? 0 : (Number(n) || 0); return '$' + v.toFixed(2); }
|
|
|
|
async function loadModels() {
|
|
try {
|
|
const r = await fetch('/api/model-usage?days=' + rangeDays);
|
|
if (r.status === 403) return;
|
|
const d = await r.json();
|
|
if (d.error) { modelsData = []; $('model-rows').innerHTML = '<tr><td colspan="4" style="color:var(--muted)">' + esc(d.error) + '</td></tr>'; return; }
|
|
modelsData = d.models || [];
|
|
if (currentView === 'models') renderModels();
|
|
} catch (e) {
|
|
modelsData = [];
|
|
$('model-rows').innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Could not load model usage.</td></tr>';
|
|
}
|
|
}
|
|
|
|
async function load() {
|
|
try {
|
|
const r = await fetch('/api/overview');
|
|
if (r.status === 403) { document.body.innerHTML = '<h1>Forbidden</h1>'; return; }
|
|
const d = await r.json();
|
|
$('tot-members').textContent = d.totals.members;
|
|
$('tot-spend-cycle').textContent = fmt(d.totals.total_spend);
|
|
$('tot-spend-all').textContent = fmt(d.totals.total_all_time_spend ?? d.totals.total_spend);
|
|
membersData = d.members || [];
|
|
adminsData = d.admins || [];
|
|
if (currentView === 'members') renderMembers();
|
|
} catch (e) {
|
|
flash('Could not load: ' + e.message, false);
|
|
}
|
|
}
|
|
|
|
// ---- 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 = [];
|
|
let modelsData = [];
|
|
let currentView = 'members';
|
|
let memberSort = { key: 'spend', dir: -1 }; // leaderboard default: most spend first
|
|
let modelSort = { key: 'spend', dir: -1 };
|
|
let rangeDays = 0; // 0 = all time
|
|
let memberMode = 'balance'; // 'balance' | 'all' — Members view: current-balance figures vs all-time spend
|
|
|
|
function arrowFor(key, sortState) {
|
|
if (sortState.key !== key) return '';
|
|
return sortState.dir === 1 ? '▲' : '▼';
|
|
}
|
|
|
|
function clearArrows(table, sortState) {
|
|
document.querySelectorAll('#table-' + table + ' .arrow').forEach(el => { el.textContent = ''; });
|
|
document.querySelectorAll('#table-' + table + ' th').forEach(th => {
|
|
const span = th.querySelector('.arrow');
|
|
if (!span) return;
|
|
const key = th.getAttribute('onclick')?.match(/'([^']+)'/)?.[1];
|
|
if (key) span.textContent = arrowFor(key, sortState);
|
|
});
|
|
}
|
|
|
|
function switchView(view) {
|
|
currentView = view;
|
|
$('table-members').style.display = view === 'members' ? '' : 'none';
|
|
$('table-models').style.display = view === 'models' ? '' : 'none';
|
|
// 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' : '');
|
|
$('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();
|
|
}
|
|
|
|
function setRange(days) {
|
|
rangeDays = days;
|
|
$('range-30').className = 'range-btn' + (days === 30 ? ' active' : '');
|
|
$('range-all').className = 'range-btn' + (days === 0 ? ' active' : '');
|
|
loadModels();
|
|
}
|
|
|
|
function setMemberMode(mode) {
|
|
memberMode = mode;
|
|
$('mmode-balance').className = 'range-btn' + (mode === 'balance' ? ' active' : '');
|
|
$('mmode-all').className = 'range-btn' + (mode === 'all' ? ' active' : '');
|
|
// Header label reflects the mode
|
|
$('spend-mode-label').textContent = mode === 'all' ? 'All-time spend' : 'Spend';
|
|
const adminLabel = document.getElementById('spend-mode-label-admin');
|
|
if (adminLabel) adminLabel.textContent = mode === 'all' ? 'All-time spend' : 'Spend (30 days)';
|
|
// Balance-only columns (Allowance/Credits/Remaining/Reset) hide in all-time mode
|
|
$('table-members').parentElement.classList.toggle('members-all-time', mode === 'all');
|
|
const adminTable = document.getElementById('table-admins');
|
|
if (adminTable) adminTable.parentElement.classList.toggle('admins-all-time', mode === 'all');
|
|
renderMembers();
|
|
}
|
|
|
|
function sortMembers(key) {
|
|
memberSort = { key, dir: memberSort.key === key ? -memberSort.dir : 1 };
|
|
clearArrows('members', memberSort);
|
|
render();
|
|
}
|
|
|
|
function memberSpend(m) {
|
|
return memberMode === 'all' ? (m.all_time_spend ?? m.spend ?? 0) : (m.spend ?? 0);
|
|
}
|
|
|
|
function sortModels(key) {
|
|
modelSort = { key, dir: modelSort.key === key ? -modelSort.dir : -1 };
|
|
clearArrows('models', modelSort);
|
|
render();
|
|
}
|
|
|
|
function render() {
|
|
if (currentView === 'members') renderMembers();
|
|
else renderModels();
|
|
}
|
|
|
|
function renderMembers() {
|
|
const rows = [...membersData].sort((a, b) => {
|
|
let va = a[memberSort.key], vb = b[memberSort.key];
|
|
if (memberSort.key === 'spend') { va = memberSpend(a); vb = memberSpend(b); }
|
|
if (memberSort.key === 'active' || memberSort.key === 'activated') { va = va ? 1 : 0; vb = vb ? 1 : 0; }
|
|
if (typeof va === 'string' || typeof vb === 'string') {
|
|
return String(va ?? '').localeCompare(String(vb ?? '')) * memberSort.dir;
|
|
}
|
|
return ((va ?? 0) - (vb ?? 0)) * memberSort.dir;
|
|
});
|
|
$('rows').innerHTML = rows.map(m => {
|
|
const reset = m.reset_at ? m.reset_at.slice(0,10) : '—';
|
|
const pill = m.active ? 'active' : 'inactive';
|
|
const act = m.activated
|
|
? '<span class="pill active">yes</span>'
|
|
: '<span class="pill inactive">pending</span>';
|
|
const credits = m.credit_balance ?? 0;
|
|
const creditsLeft = m.credits_remaining ?? credits;
|
|
// Balance view: credits LEFT. All-time view: total purchased.
|
|
const creditsShown = memberMode === 'all' ? credits : creditsLeft;
|
|
const creditsCell = creditsShown > 0
|
|
? fmt(creditsShown)
|
|
: '<span style="color:var(--muted)">—</span>';
|
|
const src = m.manual
|
|
? '<span class="pill manual" title="Manual member (no OC subscription; exempt from sweep)">manual</span>'
|
|
: '<span class="pill active" title="Open Collective Membership subscription">OC</span>';
|
|
// Effective Matrix handle: dashboard-set external MXID, else the local
|
|
// @username:inference.coop identity (pending users have no username yet).
|
|
// Account cell: username on top (prominent); email + Matrix handle beneath
|
|
// in the muted secondary style. Users who never finished setup have no
|
|
// username — fall back to the email in the top position.
|
|
const top = m.username || m.email;
|
|
const mxid = m.matrix_mxid || (m.username ? '@' + m.username + ':inference.coop' : '');
|
|
const mxidLine = mxid ? '<div style="color:var(--muted);font-size:11px">' + esc(mxid) + '</div>' : '';
|
|
const emailLine = m.username ? '<div style="color:var(--muted);font-size:11px">' + esc(m.email) + '</div>' : '';
|
|
const org = m.org
|
|
? ' <span class="pill org" title="Organizational membership: dues paid by OC organization">org: ' + esc(m.org) + '</span>'
|
|
: '';
|
|
return '<tr>' +
|
|
'<td><strong>' + esc(top) + '</strong>' + emailLine + mxidLine +
|
|
(org ? '<div style="margin-top:3px">' + org + '</div>' : '') + '</td>' +
|
|
'<td><span class="pill ' + pill + '">' + pill + '</span></td>' +
|
|
'<td>' + act + '</td>' +
|
|
'<td>' + src + '</td>' +
|
|
'<td class="num balance-col allowance-only">' + fmt(m.balance) + '</td>' +
|
|
'<td class="num balance-col">' + creditsCell + '</td>' +
|
|
'<td class="num">' + fmt(memberSpend(m)) + '</td>' +
|
|
'<td class="num balance-col remaining-col remaining-only">' + fmt(m.remaining) + '</td>' +
|
|
'<td class="balance-col">' + esc(reset) + '</td>' +
|
|
'<td><div class="adjust">' +
|
|
'<input id="add-' + i(m.email) + '" placeholder="+$" />' +
|
|
'<button data-email="' + esc(m.email) + '" onclick="addBalance(this.dataset.email)" title="Add monthly allowance">Allow</button>' +
|
|
'<button data-email="' + esc(m.email) + '" class="credits-btn" onclick="addCredits(this.dataset.email)" title="Add non-expiring credits">Credit</button>' +
|
|
'</div></td>' +
|
|
'</tr>';
|
|
}).join('');
|
|
clearArrows('members', memberSort);
|
|
|
|
// Admin category — operator accounts (SSO owner + bot), separate from members.
|
|
const adminSection = document.getElementById('admins-section');
|
|
if (!adminsData.length) { adminSection.style.display = 'none'; return; }
|
|
adminSection.style.display = '';
|
|
$('admin-rows').innerHTML = adminsData.map(m => {
|
|
const reset = m.reset_at ? m.reset_at.slice(0,10) : '—';
|
|
const adminTop = m.username || m.email;
|
|
return '<tr>' +
|
|
'<td><strong>' + esc(adminTop) + '</strong>' + (m.username ? '<div style="color:var(--muted);font-size:11px">' + esc(m.email) + '</div>' : '') + '</td>' +
|
|
'<td><span class="pill manual" title="Operator account (SSO owner / bot), not a member">admin</span></td>' +
|
|
'<td>' + (m.activated ? '<span class="pill active">yes</span>' : '<span class="pill inactive">pending</span>') + '</td>' +
|
|
'<td class="num">' + fmt(memberSpend(m)) + '</td>' +
|
|
'<td>' + esc(reset) + '</td>' +
|
|
'</tr>';
|
|
}).join('');
|
|
}
|
|
|
|
async function addCredits(email) {
|
|
const el = $('add-' + i(email));
|
|
const val = parseFloat(el.value);
|
|
if (!val || isNaN(val)) { flash('Enter a number', false); return; }
|
|
try {
|
|
const r = await fetch('/api/set-credits', {
|
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({ email, add: val, reason: 'admin adjustment (panel)' })
|
|
});
|
|
const d = await r.json();
|
|
if (d.error) { flash(d.error, false); }
|
|
else { flash('Added ' + fmt(val) + ' credits to ' + email, true); el.value = ''; load(); }
|
|
} catch (e) { flash(e.message, false); }
|
|
}
|
|
|
|
function renderModels() {
|
|
const rows = [...modelsData].sort((a, b) => {
|
|
const va = a[modelSort.key], vb = b[modelSort.key];
|
|
if (typeof va === 'string' || typeof vb === 'string') {
|
|
return String(va ?? '').localeCompare(String(vb ?? '')) * modelSort.dir;
|
|
}
|
|
return ((va ?? 0) - (vb ?? 0)) * modelSort.dir;
|
|
});
|
|
if (!rows.length) {
|
|
$('model-rows').innerHTML = '<tr><td colspan="4" style="color:var(--muted)">No usage recorded.</td></tr>';
|
|
return;
|
|
}
|
|
$('model-rows').innerHTML = rows.map(m =>
|
|
'<tr>' +
|
|
'<td>' + esc(m.model) + '</td>' +
|
|
'<td class="num">' + fmt(m.spend) + '</td>' +
|
|
'<td class="num">' + (m.tokens || 0).toLocaleString() + '</td>' +
|
|
'<td class="num">' + (m.requests || 0).toLocaleString() + '</td>' +
|
|
'</tr>'
|
|
).join('');
|
|
clearArrows('models', modelSort);
|
|
}
|
|
|
|
function i(email) { return email.replace(/[^a-z0-9]/g, '_'); }
|
|
|
|
function esc(s) {
|
|
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
}
|
|
|
|
async function addBalance(email) {
|
|
const el = $('add-' + i(email));
|
|
const val = parseFloat(el.value);
|
|
if (!val || isNaN(val)) { flash('Enter a number', false); return; }
|
|
try {
|
|
const r = await fetch('/api/set-balance', {
|
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({ email, add: val })
|
|
});
|
|
const d = await r.json();
|
|
if (d.error) { flash(d.error, false); }
|
|
else { flash('Added ' + fmt(val) + ' to ' + email, true); el.value = ''; load(); }
|
|
} catch (e) { flash(e.message, false); }
|
|
}
|
|
|
|
load();
|
|
loadModels();
|
|
</script>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
@app.exception_handler(HTTPException)
|
|
async def http_exception_handler(request: Request, exc: HTTPException):
|
|
return JSONResponse({"error": exc.detail}, status_code=exc.status_code)
|