Files
admin-panel/app/main.py
T
inference-bot 41b86bcc80 panel: integer y-axis for people; round money and request ticks
Nathan: the members axis should be whole numbers, since it counts people. It
was drawing fractional ticks (19.17 people) because the 1.08 headroom factor
fed straight into the label math.

Ticks now come from a step, with the axis top set to step x 4, so labels land
on round numbers:
- counts (members, requests): whole numbers, rounded up to a half power of ten
  (requests read 3,000, not 2,888). Never below 1, so a near-empty series still
  gets an integer axis rather than fractions.
- money: the 1/2/5 x 10^k rule, so ticks read $10.00 rather than $4.92.

Rendered now: members 0 | 20 | 40 | 60 | 80; requests 0 | 3,000 | 6,000 |
9,000 | 12,000; spend $0.00 | $10.00 | $20.00 | $30.00 | $40.00.

The node harness gained assertions for this, checked geometrically (tick values
ascend while their screen positions descend, the zero tick sits lowest, ticks
are evenly spaced) rather than assuming a text order, so a fractional
people-axis or a misplaced label fails before a deploy.
2026-10-28 04:08:18 +00:00

857 lines
38 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
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;
}
// Y-axis step. Counts are people and things, so their ticks must be whole
// numbers (a 1.08 headroom factor alone yields labels like 19.17 people).
// Money gets the classic 1/2/5 x 10^k rounding so ticks read $5.00, not $4.92.
function niceStep(raw) {
if (trendMetric === "spend") {
const mag = Math.pow(10, Math.floor(Math.log10(Math.max(raw, 1e-9))));
const norm = raw / mag;
const nice = norm <= 1 ? 1 : (norm <= 2 ? 2 : (norm <= 5 ? 5 : 10));
return nice * mag;
}
// Counts: whole numbers, rounded up to a half power of ten so ticks read
// 3,000 rather than 2,888. Never below 1, so a near-empty series still gets
// an integer axis instead of fractions.
const p = Math.pow(10, Math.floor(Math.log10(Math.max(raw, 1))));
return Math.max(1, Math.ceil(raw / (p / 2)) * (p / 2));
}
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);
// Axis top = one round step times four ticks: labels land on round numbers
// and the peak still clears the frame.
const step = niceStep((maxV <= 0 ? 1 : maxV * 1.08) / 4);
const top = step * 4;
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(step * g);
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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)