Files
admin-panel/app/main.py
T
inference-bot a5229103f6 charts: add Energy and Carbon (co-op/support#26)
Two more metrics on the same chart, from the portal's new energy_wh and co2e_g
daily columns.

- Energy in watt-hours, carbon in grams CO2e, both for the day shown.
- Continuous metrics use the 1/2/5 axis rule (not the integer rule reserved for
  counts), so energy ticks read 500 Wh rather than being rounded to whole
  people-style numbers.
- Labels carry units, so a reader never has to guess what the axis measures.
- Same measured-vs-estimated provenance as the data layer: GreenPT models use
  facts fitted from their own impact API, everyone else a labelled proxy. The
  docs page on estimation is still to be written; the factor table and its
  sources are in metrics_snapshot.py.

Harness extended: one bar per measured day for each, no NaN in markup, and axis
labels matching ^[\d,]+(\.\d+)? (Wh|g)$ so a unit prefix or a forced integer
axis fails the build.
2026-10-28 04:36:13 +00:00

856 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-body svg { display: block; width: 100%; height: auto; overflow: visible; }
.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-energy" onclick="setTrendMetric('energy')">Energy</button>
<button class="range-btn" id="tm-co2e" onclick="setTrendMetric('co2e')">Carbon</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>
</div>
<div id="trend-body" class="trend-body"><p class="trend-empty">Loading</p></div>
</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" },
requests: { kind: "bar", title: "Requests per day" },
spend: { kind: "bar", title: "Spend per day" },
energy: { kind: "bar", title: "Energy per day" },
co2e: { kind: "bar", title: "Carbon per day" }
};
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-energy").className = "range-btn" + (m === "energy" ? " active" : "");
$("tm-co2e").className = "range-btn" + (m === "co2e" ? " 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;
if (trendMetric === "energy") return row.energy_wh;
if (trendMetric === "co2e") return row.co2e_g;
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" || trendMetric === "energy" || trendMetric === "co2e") {
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);
if (trendMetric === "energy") return Number(v).toLocaleString() + " Wh";
if (trendMetric === "co2e") return Number(v).toLocaleString() + " g";
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;
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>";
return;
}
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("");
}
// ---- 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)