Dashboard UI: 'Your membership' + identity, richer usage (tokens/requests/per-model), logout, Member Home link, footer, fix loading placeholders
This commit is contained in:
1 parent
105ff499f5
commit
aec6f5f118
1 file changed
+106
-37
+106
-37
@@ -2,10 +2,10 @@
|
||||
|
||||
Security model:
|
||||
- Authentication is done by Cloudron's proxyAuth wall (SSO). Cloudron injects
|
||||
the authenticated user's identity via the X-Forwarded-User header.
|
||||
the authenticated user's identity via the X-Remote-User header (username).
|
||||
- This app holds NO privileged credentials. It calls the member portal's
|
||||
broker endpoints, authenticated with a shared BROKER_SECRET, and scopes
|
||||
every request to the logged-in member's email.
|
||||
every request to the logged-in member.
|
||||
|
||||
Environment (all provided by Cloudron env vars):
|
||||
PORTAL_BASE — base URL of the member portal (e.g. https://portal.inference.coop)
|
||||
@@ -59,13 +59,11 @@ def get_user_identity(request: Request) -> str:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def broker_headers(identity: str) -> dict:
|
||||
# Pass the raw identity (username or email); the portal resolves it to an
|
||||
# email via its own Cloudron user list.
|
||||
return {"X-Broker-Secret": BROKER_SECRET, "X-Member-User": identity}
|
||||
|
||||
|
||||
async def broker_get(identity: str, path: str) -> dict:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
r = await client.get(f"{PORTAL_BASE}{path}", headers=broker_headers(identity))
|
||||
if r.status_code == 401:
|
||||
raise HTTPException(500, "Portal rejected broker credentials")
|
||||
@@ -77,7 +75,7 @@ async def broker_get(identity: str, path: str) -> dict:
|
||||
|
||||
|
||||
async def broker_post(identity: str, path: str, payload: dict | None = None) -> dict:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
r = await client.post(f"{PORTAL_BASE}{path}", headers=broker_headers(identity), json=payload or {})
|
||||
if r.status_code in (401, 403):
|
||||
raise HTTPException(r.status_code, r.text)
|
||||
@@ -87,7 +85,7 @@ async def broker_post(identity: str, path: str, payload: dict | None = None) ->
|
||||
|
||||
|
||||
async def broker_delete(identity: str, path: str) -> dict:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
r = await client.delete(f"{PORTAL_BASE}{path}", headers=broker_headers(identity))
|
||||
if r.status_code in (401, 403):
|
||||
raise HTTPException(r.status_code, r.text)
|
||||
@@ -160,13 +158,20 @@ async def api_revoke_key(name: str, request: Request):
|
||||
# UI (brand-matched, self-contained, no external requests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def render_page(email: str) -> str:
|
||||
return """<!DOCTYPE html>
|
||||
def _esc(s: str) -> str:
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
||||
|
||||
|
||||
def render_page(identity: str) -> str:
|
||||
return PAGE_HTML.replace("@@IDENTITY@@", _esc(identity))
|
||||
|
||||
|
||||
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 · Your account</title>
|
||||
<title>Inference Cooperative · Your membership</title>
|
||||
<style>
|
||||
:root {
|
||||
--cream: #faf8f5;
|
||||
@@ -185,32 +190,48 @@ def render_page(email: str) -> str:
|
||||
color: var(--ink);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
display: flex; flex-direction: column; min-height: 100vh;
|
||||
}
|
||||
.wrap { max-width: 720px; margin: 0 auto; padding: 40px 24px 64px; }
|
||||
header { display: flex; align-items: center; gap: 16px; margin-bottom: 8px; }
|
||||
.wrap { width: 100%; max-width: 760px; margin: 0 auto; padding: 32px 24px 24px; flex: 1; }
|
||||
header { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 8px; }
|
||||
.logo svg { display: block; }
|
||||
.brand { flex: 1; }
|
||||
.brand h1 { font-size: 20px; font-weight: 700; letter-spacing: -0.01em; }
|
||||
.brand p { color: var(--muted); font-size: 14px; }
|
||||
.brand .sub { color: var(--muted); font-size: 14px; }
|
||||
.identity { margin-top: 6px; font-size: 13px; color: var(--muted); }
|
||||
.identity code {
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
background: #f4f2ec; padding: 1px 6px; border-radius: 5px; font-size: 12px;
|
||||
}
|
||||
.header-actions { display: flex; gap: 14px; align-items: center; flex-shrink: 0; }
|
||||
a.link { color: var(--green-deep); text-decoration: none; font-size: 14px; font-weight: 600; }
|
||||
a.link:hover { text-decoration: underline; }
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 28px;
|
||||
margin-top: 24px;
|
||||
padding: 24px 28px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.card h2 { font-size: 15px; font-weight: 600; margin-bottom: 16px; }
|
||||
.balance {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.balance .spend { font-size: 34px; font-weight: 700; color: var(--green-deep); }
|
||||
.balance .of { color: var(--muted); font-size: 15px; }
|
||||
.bar { height: 10px; background: #eef0ea; border-radius: 6px; margin-top: 14px; overflow: hidden; }
|
||||
.card h2 { font-size: 15px; font-weight: 600; margin-bottom: 14px; }
|
||||
.balance { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
|
||||
.balance .big { font-size: 34px; font-weight: 700; color: var(--green-deep); }
|
||||
.balance .lbl { color: var(--muted); font-size: 15px; }
|
||||
.stats { display: flex; gap: 28px; margin-top: 12px; flex-wrap: wrap; }
|
||||
.stat .num { font-size: 18px; font-weight: 700; color: var(--ink); }
|
||||
.stat .lbl { font-size: 12px; color: var(--muted); }
|
||||
.bar { height: 10px; background: #eef0ea; border-radius: 6px; margin-top: 16px; overflow: hidden; }
|
||||
.bar .fill { height: 100%; background: var(--green-mid); border-radius: 6px; transition: width .3s ease; }
|
||||
.bar .fill.warn { background: #d99a3e; }
|
||||
.meta { color: var(--muted); font-size: 13px; margin-top: 8px; }
|
||||
.models { margin-top: 16px; }
|
||||
.model-row {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
padding: 9px 0; border-top: 1px solid #f0ede6;
|
||||
}
|
||||
.model-name { font-weight: 600; font-size: 13px; }
|
||||
.model-sub { color: var(--muted); font-size: 12px; }
|
||||
.model-spend { font-size: 13px; font-weight: 600; color: var(--ink); text-align: right; }
|
||||
.keys { margin-top: 8px; }
|
||||
.key-row {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
@@ -251,10 +272,13 @@ def render_page(email: str) -> str:
|
||||
.flash.show { display: block; }
|
||||
.flash.ok { background: #eaf3ea; color: #2f5c30; }
|
||||
.flash.err { background: #f6e9e5; color: var(--danger); }
|
||||
code.inline {
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
font-size: 12px; background: #f4f2ec; padding: 1px 5px; border-radius: 4px;
|
||||
footer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 20px 24px; margin-top: 32px;
|
||||
text-align: center; font-size: 13px; color: var(--muted);
|
||||
}
|
||||
footer a { color: var(--green-deep); text-decoration: none; margin: 0 8px; }
|
||||
footer a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -288,24 +312,35 @@ def render_page(email: str) -> str:
|
||||
</div>
|
||||
<div class="brand">
|
||||
<h1>Inference Cooperative</h1>
|
||||
<p>Your account</p>
|
||||
<div class="sub">Your membership</div>
|
||||
<div class="identity" id="identity">Signed in as <code>@@IDENTITY@@</code></div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a class="link" href="https://my.inference.coop">Member Home</a>
|
||||
<a class="link" href="/logout">Log out</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="card">
|
||||
<h2>Usage</h2>
|
||||
<div class="balance">
|
||||
<span class="spend" id="spend">—</span>
|
||||
<span class="of" id="of">of your balance used</span>
|
||||
<span class="big" id="spend">—</span>
|
||||
<span class="lbl" id="of">of your balance used</span>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="num" id="remaining">—</div><div class="lbl">remaining</div></div>
|
||||
<div class="stat"><div class="num" id="tokens">—</div><div class="lbl">tokens used</div></div>
|
||||
<div class="stat"><div class="num" id="calls">—</div><div class="lbl">requests</div></div>
|
||||
</div>
|
||||
<div class="bar"><div class="fill" id="fill" style="width:0%"></div></div>
|
||||
<div class="meta" id="meta">Loading…</div>
|
||||
<div class="meta">Shared across chat and API. Top-ups appear here automatically.</div>
|
||||
<div class="models" id="models"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>API keys</h2>
|
||||
<p class="hint">Keys let you use the co-op's AI from your own tools. They draw from the same balance as chat.</p>
|
||||
<div class="keys" id="keys"><div class="empty">Loading…</div></div>
|
||||
<div class="keys" id="keys"><div class="empty">No API keys yet.</div></div>
|
||||
<div class="new-key">
|
||||
<input id="new-name" type="text" placeholder="Key name (e.g. my-script)" maxlength="64" />
|
||||
<button class="primary" id="create-btn" onclick="createKey()">Create key</button>
|
||||
@@ -314,6 +349,13 @@ def render_page(email: str) -> str:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<a href="https://git.inference.coop/co-op/docs/src/branch/main/README.md">Docs</a> ·
|
||||
<a href="https://git.inference.coop/co-op/docs/src/branch/main/terms-of-service.md">Terms</a> ·
|
||||
<a href="https://git.inference.coop/co-op/docs/src/branch/main/privacy-policy.md">Privacy</a>
|
||||
<div style="margin-top:6px;">The Inference Cooperative · inference.coop</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
@@ -324,6 +366,8 @@ function flash(msg, ok) {
|
||||
setTimeout(() => { f.className = 'flash'; }, 6000);
|
||||
}
|
||||
|
||||
function fmtMoney(n) { return '$' + (Number(n) || 0).toFixed(2); }
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const r = await fetch('/api/usage');
|
||||
@@ -334,17 +378,42 @@ async function load() {
|
||||
const spend = d.spend ?? 0;
|
||||
const remaining = d.remaining ?? Math.max(0, balance - spend);
|
||||
const pct = balance > 0 ? Math.min(100, (spend / balance) * 100) : 0;
|
||||
$('spend').textContent = '$' + spend.toFixed(2);
|
||||
$('of').textContent = 'of $' + balance.toFixed(2) + ' used · $' + remaining.toFixed(2) + ' left';
|
||||
|
||||
$('spend').textContent = fmtMoney(spend);
|
||||
$('of').textContent = 'of ' + fmtMoney(balance) + ' used';
|
||||
$('remaining').textContent = fmtMoney(remaining);
|
||||
$('fill').style.width = pct + '%';
|
||||
$('fill').className = 'fill' + (pct > 80 ? ' warn' : '');
|
||||
$('meta').textContent = 'Shared across chat and API. Top-ups appear here automatically.';
|
||||
|
||||
if (d.email) {
|
||||
$('identity').innerHTML = 'Signed in as <code>' + escapeHtml(d.email) + '</code>';
|
||||
}
|
||||
|
||||
const tokens = d.total_tokens ?? 0;
|
||||
const calls = (d.models || []).reduce((s, m) => s + (m.calls || 0), 0);
|
||||
$('tokens').textContent = tokens.toLocaleString();
|
||||
$('calls').textContent = calls.toLocaleString();
|
||||
|
||||
renderModels(d.models || []);
|
||||
renderKeys(d.keys || []);
|
||||
} catch (e) {
|
||||
flash('Could not load usage: ' + e.message, false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderModels(models) {
|
||||
const el = $('models');
|
||||
if (!models.length) { el.innerHTML = ''; return; }
|
||||
el.innerHTML = models.map(m => {
|
||||
const sub = (m.calls || 0).toLocaleString() + ' requests · ' + (m.tokens || 0).toLocaleString() + ' tokens';
|
||||
return '<div class="model-row">' +
|
||||
'<div><div class="model-name">' + escapeHtml(m.model) + '</div>' +
|
||||
'<div class="model-sub">' + escapeHtml(sub) + '</div></div>' +
|
||||
'<div class="model-spend">' + fmtMoney(m.spend) + '</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderKeys(keys) {
|
||||
const el = $('keys');
|
||||
if (!keys.length) {
|
||||
@@ -359,7 +428,7 @@ function renderKeys(keys) {
|
||||
'<div class="key-meta">' + escapeHtml(created) + '</div>' +
|
||||
(token ? '<div class="key-token">' + escapeHtml(token) + '</div>' : '') +
|
||||
'</div>' +
|
||||
'<button class="ghost" onclick="revokeKey(\'' + escapeHtml(k.name) + '\')">Revoke</button>' +
|
||||
'<button class="ghost" data-name="' + escapeHtml(k.name) + '" onclick="revokeKey(this.dataset.name)">Revoke</button>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user