dashboard: "The co-op over time" chart in the Co-op usage view
Members asked for charts to share with the co-op. This is the member-facing half of it, in the view where co-op-wide data already lives. - /api/metrics proxies the portal's new /broker/metrics (aggregate, non-personal: co-op size, requests served, spend). No per-member figure is fetched. - Same chart as the admin panel's Trends tab: inline SVG, no library, no CDN, colours from the shared palette vars. Members / Requests / Spend, and 30 / 90 / all time, re-rendered client-side without refetching. - Lazy: loads the first time someone opens the co-op view, so the dashboard's initial load is unchanged. - Gaps stay gaps. A day never measured breaks the line rather than reading as zero members, with the sub-line saying how many days are missing. - Integer y-axis for people and requests; 1/2/5 money ticks, per the panel fix. Verified with the same node harness used for the panel (shared, so it asserts the same invariants against the live series).
This commit is contained in:
1 parent
2dee1ea71d
commit
8e8cb90d11
1 file changed
+196
+196
@@ -133,6 +133,23 @@ async def api_usage(request: Request):
|
|||||||
return JSONResponse({"error": e.detail}, status_code=e.status_code)
|
return JSONResponse({"error": e.detail}, status_code=e.status_code)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/metrics")
|
||||||
|
async def api_metrics(request: Request):
|
||||||
|
"""Co-op metrics over time, for the Co-op usage view.
|
||||||
|
|
||||||
|
Aggregate and non-personal (co-op size, requests served, spend), the same
|
||||||
|
class as the model leaderboard. Fetched at the widest range so the metric
|
||||||
|
and period toggles re-render client-side without refetching.
|
||||||
|
"""
|
||||||
|
identity = get_user_identity(request)
|
||||||
|
if not identity:
|
||||||
|
return JSONResponse({"error": "unauthenticated"}, status_code=401)
|
||||||
|
try:
|
||||||
|
return await broker_get(identity, "/broker/metrics?days=730")
|
||||||
|
except HTTPException as e:
|
||||||
|
return JSONResponse({"error": e.detail}, status_code=e.status_code)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/keys")
|
@app.post("/api/keys")
|
||||||
async def api_create_key(request: Request):
|
async def api_create_key(request: Request):
|
||||||
identity = get_user_identity(request)
|
identity = get_user_identity(request)
|
||||||
@@ -363,6 +380,15 @@ PAGE_HTML = """<!DOCTYPE html>
|
|||||||
|
|
||||||
/* Leaderboard */
|
/* Leaderboard */
|
||||||
.board-note { color: var(--muted); font-size: 12px; margin-bottom: 10px; }
|
.board-note { color: var(--muted); font-size: 12px; margin-bottom: 10px; }
|
||||||
|
/* The co-op over time: same chart as the admin panel's Trends tab, sized for
|
||||||
|
members. Inline SVG, no library, colours from the shared palette vars. */
|
||||||
|
.coop-trend { border: 1px solid var(--border, #e4e0d8); border-radius: 12px; padding: 14px 16px 12px; margin-bottom: 20px; }
|
||||||
|
.coop-trend-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||||
|
.coop-trend-title { font-weight: 700; font-size: 15px; color: var(--ink, #2d3327); }
|
||||||
|
.coop-trend-sub { color: var(--muted); font-size: 12px; }
|
||||||
|
.coop-trend-controls { display: flex; gap: 18px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||||
|
.trend-group { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.coop-trend-body svg { display: block; width: 100%; height: auto; overflow: visible; }
|
||||||
.board-row {
|
.board-row {
|
||||||
display: flex; align-items: flex-start; gap: 14px;
|
display: flex; align-items: flex-start; gap: 14px;
|
||||||
padding: 12px 0; border-top: 1px solid #f0ede6;
|
padding: 12px 0; border-top: 1px solid #f0ede6;
|
||||||
@@ -533,6 +559,28 @@ PAGE_HTML = """<!DOCTYPE html>
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="view-coop" style="display:none;">
|
<div id="view-coop" style="display:none;">
|
||||||
|
<div class="coop-trend">
|
||||||
|
<div class="coop-trend-head">
|
||||||
|
<span class="coop-trend-title" id="trend-title">The co-op over time</span>
|
||||||
|
<span class="coop-trend-sub" id="trend-sub"></span>
|
||||||
|
</div>
|
||||||
|
<div class="coop-trend-controls">
|
||||||
|
<span class="trend-group">
|
||||||
|
<span class="board-note" style="margin:0;">Show</span>
|
||||||
|
<button class="mode-btn active" id="tm-members" onclick="setTrendMetric('members')">Members</button>
|
||||||
|
<button class="mode-btn" id="tm-requests" onclick="setTrendMetric('requests')">Requests</button>
|
||||||
|
<button class="mode-btn" id="tm-spend" onclick="setTrendMetric('spend')">Spend</button>
|
||||||
|
</span>
|
||||||
|
<span class="trend-group">
|
||||||
|
<span class="board-note" style="margin:0;">Period</span>
|
||||||
|
<button class="mode-btn" id="tr-30" onclick="setTrendDays(30)">30 days</button>
|
||||||
|
<button class="mode-btn active" id="tr-90" onclick="setTrendDays(90)">90 days</button>
|
||||||
|
<button class="mode-btn" id="tr-all" onclick="setTrendDays(0)">All time</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div id="trend-body" class="coop-trend-body"><div class="empty">Loading</div></div>
|
||||||
|
<p class="board-note" id="trend-note" style="margin:8px 0 0;"></p>
|
||||||
|
</div>
|
||||||
<div class="board-note">Usage across the co-op, last 30 days</div>
|
<div class="board-note">Usage across the co-op, last 30 days</div>
|
||||||
<div style="display:flex; align-items:center; gap:10px; margin-bottom:10px;">
|
<div style="display:flex; align-items:center; gap:10px; margin-bottom:10px;">
|
||||||
<span class="board-note" style="margin:0;">Sort by</span>
|
<span class="board-note" style="margin:0;">Sort by</span>
|
||||||
@@ -751,6 +799,152 @@ function renderModels(models) {
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- The co-op over time (chart) ----
|
||||||
|
// Same series the admin panel charts; the portal owns the data and serves it
|
||||||
|
// through the broker as aggregate, non-personal numbers. Fetched once at the
|
||||||
|
// widest range; the toggles re-render client-side without refetching.
|
||||||
|
let trendsData = null;
|
||||||
|
let trendMetric = "members"; // members | requests | spend
|
||||||
|
let trendDays = 90; // 0 = all time
|
||||||
|
|
||||||
|
const TREND_CONF = {
|
||||||
|
members: { kind: "line", title: "The co-op over time",
|
||||||
|
note: "How many members the co-op has had, day by day. A gap means days that were never measured, not zero members." },
|
||||||
|
requests: { kind: "bar", title: "Requests the co-op served",
|
||||||
|
note: "AI requests served to members each day. Days we did not record are gaps, not zeroes." },
|
||||||
|
spend: { kind: "bar", title: "What the co-op spent",
|
||||||
|
note: "Spend charged to the co-op each day, in US dollars. Days we did not record are gaps, not zeroes." }
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadMetrics() {
|
||||||
|
if (trendsData) { renderTrends(); return; }
|
||||||
|
const host = $("trend-body");
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/metrics");
|
||||||
|
if (r.status === 401 || r.status === 403) { host.innerHTML = "<div class=empty>Sign in to see this.</div>"; return; }
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.error) { host.innerHTML = "<div class=empty>" + d.error + "</div>"; return; }
|
||||||
|
trendsData = d.series || [];
|
||||||
|
renderTrends();
|
||||||
|
} catch (e) {
|
||||||
|
host.innerHTML = "<div class=empty>Could not load the co-op chart</div>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTrendMetric(m) {
|
||||||
|
trendMetric = m;
|
||||||
|
$("tm-members").className = "mode-btn" + (m === "members" ? " active" : "");
|
||||||
|
$("tm-requests").className = "mode-btn" + (m === "requests" ? " active" : "");
|
||||||
|
$("tm-spend").className = "mode-btn" + (m === "spend" ? " active" : "");
|
||||||
|
renderTrends();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTrendDays(d) {
|
||||||
|
trendDays = d;
|
||||||
|
$("tr-30").className = "mode-btn" + (d === 30 ? " active" : "");
|
||||||
|
$("tr-90").className = "mode-btn" + (d === 90 ? " active" : "");
|
||||||
|
$("tr-all").className = "mode-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 ticks stay whole numbers;
|
||||||
|
// money uses the 1/2/5 x 10^k rule so ticks read $10.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;
|
||||||
|
}
|
||||||
|
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 fmtMoney(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);
|
||||||
|
|
||||||
|
const vals = rows.map(trendVal);
|
||||||
|
const have = vals.filter(v => v !== null && v !== undefined);
|
||||||
|
if (have.length < 2) {
|
||||||
|
host.innerHTML = "<div class=empty>Not enough history yet.</div>";
|
||||||
|
$("trend-sub").textContent = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const gaps = vals.length - have.length;
|
||||||
|
const maxV = Math.max.apply(null, have);
|
||||||
|
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 + '">');
|
||||||
|
for (let g = 0; g <= 4; g++) {
|
||||||
|
const yy = baseY - (plotH * g) / 4;
|
||||||
|
parts.push('<line x1="' + padL + '" y1="' + yy + '" x2="' + (W - padR) + '" y2="' + yy + '" stroke="var(--border, #e4e0d8)"/>');
|
||||||
|
parts.push('<text x="' + (padL - 8) + '" y="' + (yy + 4) + '" text-anchor="end" font-size="11" fill="var(--muted)">' + trendLabel(step * g) + '</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 {
|
||||||
|
// Contiguous measured runs: a day we never recorded 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];
|
||||||
|
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="' + r0.map(i => xAt(i) + "," + yAt(vals[i])).join(" ") + '" 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" : "");
|
||||||
|
$("trend-note").textContent = conf.note;
|
||||||
|
}
|
||||||
|
|
||||||
let usageMode = 'mine'; // 'mine' | 'coop'
|
let usageMode = 'mine'; // 'mine' | 'coop'
|
||||||
|
|
||||||
function setUsageMode(mode) {
|
function setUsageMode(mode) {
|
||||||
@@ -759,6 +953,8 @@ function setUsageMode(mode) {
|
|||||||
$('mode-coop').className = 'mode-btn' + (mode === 'coop' ? ' active' : '');
|
$('mode-coop').className = 'mode-btn' + (mode === 'coop' ? ' active' : '');
|
||||||
$('view-mine').style.display = mode === 'mine' ? '' : 'none';
|
$('view-mine').style.display = mode === 'mine' ? '' : 'none';
|
||||||
$('view-coop').style.display = mode === 'coop' ? '' : 'none';
|
$('view-coop').style.display = mode === 'coop' ? '' : 'none';
|
||||||
|
// The co-op chart loads the first time someone opens the co-op view.
|
||||||
|
if (mode === 'coop') loadMetrics();
|
||||||
}
|
}
|
||||||
|
|
||||||
let boardSort = 'cost'; // default: rank by cost (requests made nomic-embed dominate)
|
let boardSort = 'cost'; // default: rank by cost (requests made nomic-embed dominate)
|
||||||
|
|||||||
Reference in new issue
Block a user