admin: internal-only superadmin console (users, payments, DB stats)
New independent `admin` service (admin.main:app) on the same image, reusing app.db/app.models read-only. Never runs migrations or the scheduler; issues SELECTs only. - Password-gated (ADMIN_CONSOLE_PASSWORD) with a 12h signed cookie; closed by default when the password is empty. - Bound to 127.0.0.1:8091 (SSH-tunnel access); off the intranet/NPM network. - Pages: overview stats, user list + search, per-user history/payment detail, DB usage (information_schema size + row estimates). - Compose: base `admin` service (+prod DB-host override, test mount); Dockerfile bakes admin/ into runtime + test stages. - Tests: tests/test_admin_console.py (auth, queries, page wiring) — 12 passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
8946dee2e0
commit
411094d7b8
20 changed files with 1143 additions and 1 deletions
80
admin/templates/base.html
Normal file
80
admin/templates/base.html
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>{% block title %}superadmin{% endblock %} · read.markets</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg:#0d1117; --panel:#161b22; --border:#30363d; --fg:#c9d1d9;
|
||||
--muted:#8b949e; --accent:#58a6ff; --good:#3fb950; --warn:#d29922;
|
||||
--bad:#f85149; --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
||||
}
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; background:var(--bg); color:var(--fg);
|
||||
font:14px/1.5 var(--mono); }
|
||||
a { color:var(--accent); text-decoration:none; }
|
||||
a:hover { text-decoration:underline; }
|
||||
header { display:flex; align-items:center; gap:1.5rem; padding:.75rem 1.25rem;
|
||||
background:var(--panel); border-bottom:1px solid var(--border); }
|
||||
header .brand { font-weight:700; color:#fff; }
|
||||
header nav { display:flex; gap:1rem; }
|
||||
header .spacer { flex:1; }
|
||||
main { padding:1.25rem; max-width:1200px; margin:0 auto; }
|
||||
h1 { font-size:1.25rem; margin:0 0 1rem; }
|
||||
h2 { font-size:1rem; color:var(--muted); text-transform:uppercase;
|
||||
letter-spacing:.05em; margin:1.5rem 0 .5rem; }
|
||||
.cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(160px,1fr));
|
||||
gap:.75rem; }
|
||||
.card { background:var(--panel); border:1px solid var(--border);
|
||||
border-radius:6px; padding:.75rem 1rem; }
|
||||
.card .n { font-size:1.6rem; font-weight:700; color:#fff; }
|
||||
.card .l { color:var(--muted); font-size:.8rem; }
|
||||
table { width:100%; border-collapse:collapse; margin-top:.5rem; }
|
||||
th,td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--border);
|
||||
white-space:nowrap; }
|
||||
th { color:var(--muted); font-weight:600; font-size:.8rem;
|
||||
text-transform:uppercase; letter-spacing:.04em; }
|
||||
tr:hover td { background:#1c2230; }
|
||||
.pill { display:inline-block; padding:.05rem .5rem; border-radius:999px;
|
||||
font-size:.75rem; border:1px solid var(--border); }
|
||||
.pill.good { color:var(--good); border-color:#238636; }
|
||||
.pill.warn { color:var(--warn); border-color:#9e6a03; }
|
||||
.pill.bad { color:var(--bad); border-color:#a1281f; }
|
||||
.pill.mute { color:var(--muted); }
|
||||
.muted { color:var(--muted); }
|
||||
.bar { height:8px; background:#21262d; border-radius:4px; overflow:hidden;
|
||||
min-width:80px; }
|
||||
.bar > span { display:block; height:100%; background:var(--accent); }
|
||||
form.search { margin:.5rem 0; }
|
||||
input[type=text],input[type=password] {
|
||||
background:#0d1117; border:1px solid var(--border); color:var(--fg);
|
||||
padding:.4rem .6rem; border-radius:6px; font:inherit; }
|
||||
button { background:#238636; color:#fff; border:0; padding:.45rem .9rem;
|
||||
border-radius:6px; font:inherit; cursor:pointer; }
|
||||
button:hover { background:#2ea043; }
|
||||
.kv { display:grid; grid-template-columns:180px 1fr; gap:.25rem 1rem; }
|
||||
.kv dt { color:var(--muted); }
|
||||
.kv dd { margin:0; }
|
||||
.login { max-width:340px; margin:12vh auto; background:var(--panel);
|
||||
border:1px solid var(--border); border-radius:8px; padding:1.5rem; }
|
||||
.err { color:var(--bad); margin:.5rem 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% if show_nav|default(true) %}
|
||||
<header>
|
||||
<span class="brand">read.markets · superadmin</span>
|
||||
<nav>
|
||||
<a href="/">Overview</a>
|
||||
<a href="/users">Users</a>
|
||||
<a href="/db">Database</a>
|
||||
</nav>
|
||||
<span class="spacer"></span>
|
||||
<a href="/logout">Log out</a>
|
||||
</header>
|
||||
{% endif %}
|
||||
<main>{% block body %}{% endblock %}</main>
|
||||
</body>
|
||||
</html>
|
||||
31
admin/templates/db.html
Normal file
31
admin/templates/db.html
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}database{% endblock %}
|
||||
{% block body %}
|
||||
<h1>Database usage</h1>
|
||||
<div class="cards">
|
||||
<div class="card"><div class="n">{{ stats.total_bytes|bytes }}</div><div class="l">Total size</div></div>
|
||||
<div class="card"><div class="n">{{ "{:,}".format(stats.total_rows) }}</div><div class="l">Total rows (est.)</div></div>
|
||||
<div class="card"><div class="n">{{ stats.tables|length }}</div><div class="l">Tables</div></div>
|
||||
</div>
|
||||
|
||||
<h2>Per table</h2>
|
||||
<table>
|
||||
<thead><tr><th>Table</th><th>Rows (est.)</th><th>Data</th><th>Index</th><th>Total</th><th style="width:160px">Share</th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in stats.tables %}
|
||||
<tr>
|
||||
<td>{{ t.name }}</td>
|
||||
<td>{{ "{:,}".format(t.rows) }}</td>
|
||||
<td class="muted">{{ t.data_bytes|bytes }}</td>
|
||||
<td class="muted">{{ t.index_bytes|bytes }}</td>
|
||||
<td>{{ t.total_bytes|bytes }}</td>
|
||||
<td>
|
||||
<div class="bar"><span style="width:{{ t.pct }}%"></span></div>
|
||||
<span class="muted">{{ t.pct }}%</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted">Row counts are the storage engine's estimate for InnoDB; sizes are exact on-disk bytes.</p>
|
||||
{% endblock %}
|
||||
14
admin/templates/login.html
Normal file
14
admin/templates/login.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{% extends "base.html" %}
|
||||
{% set show_nav = false %}
|
||||
{% block title %}login{% endblock %}
|
||||
{% block body %}
|
||||
<div class="login">
|
||||
<h1>Superadmin</h1>
|
||||
{% if error %}<div class="err">{{ error }}</div>{% endif %}
|
||||
<form method="post" action="/login">
|
||||
<p><input type="password" name="password" placeholder="Password"
|
||||
autofocus autocomplete="current-password" style="width:100%"></p>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
6
admin/templates/not_found.html
Normal file
6
admin/templates/not_found.html
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}not found{% endblock %}
|
||||
{% block body %}
|
||||
<h1>User #{{ user_id }} not found</h1>
|
||||
<p><a href="/users">← back to users</a></p>
|
||||
{% endblock %}
|
||||
22
admin/templates/overview.html
Normal file
22
admin/templates/overview.html
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}overview{% endblock %}
|
||||
{% block body %}
|
||||
<h1>Overview</h1>
|
||||
<div class="cards">
|
||||
<div class="card"><div class="n">{{ stats.total_users }}</div><div class="l">Total users</div></div>
|
||||
<div class="card"><div class="n">{{ stats.paid_active }}</div><div class="l">Paid-active</div></div>
|
||||
<div class="card"><div class="n">{{ stats.free }}</div><div class="l">Free tier</div></div>
|
||||
<div class="card"><div class="n">{{ stats.paid }}</div><div class="l">Paid tier</div></div>
|
||||
<div class="card"><div class="n">{{ stats.enterprise }}</div><div class="l">Enterprise</div></div>
|
||||
<div class="card"><div class="n">{{ stats.credit_active }}</div><div class="l">On credit</div></div>
|
||||
<div class="card"><div class="n">{{ stats.signups_7d }}</div><div class="l">Signups · 7d</div></div>
|
||||
<div class="card"><div class="n">{{ stats.signups_30d }}</div><div class="l">Signups · 30d</div></div>
|
||||
<div class="card"><div class="n">{{ stats.active_30d }}</div><div class="l">Active · 30d</div></div>
|
||||
<div class="card"><div class="n">{{ stats.sync_enabled }}</div><div class="l">Cloud-sync on</div></div>
|
||||
<div class="card"><div class="n">{{ stats.referrals_converted }}/{{ stats.referrals_total }}</div><div class="l">Referrals conv.</div></div>
|
||||
</div>
|
||||
|
||||
<h2>Newest users</h2>
|
||||
{% include "partials_users_table.html" %}
|
||||
<p><a href="/users">All users →</a></p>
|
||||
{% endblock %}
|
||||
39
admin/templates/partials_users_table.html
Normal file
39
admin/templates/partials_users_table.html
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{# expects `recent` (overview) or `rows` (users list) — normalise to `rows` #}
|
||||
{% set rows = rows if rows is defined else recent %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>Email</th><th>Tier</th><th>Paid</th><th>Created</th>
|
||||
<th>Last login</th><th>Lang</th><th>Sync</th><th>Refs</th><th>Billing</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in rows %}
|
||||
<tr>
|
||||
<td>{{ u.id }}</td>
|
||||
<td><a href="/users/{{ u.id }}">{{ u.email }}</a></td>
|
||||
<td>{{ u.tier }}</td>
|
||||
<td>
|
||||
{% if u.paid_active %}
|
||||
<span class="pill good">yes{% if u.paid_source == 'credit' %} · {{ u.credit_days }}d{% endif %}</span>
|
||||
{% else %}
|
||||
<span class="pill mute">no</span>
|
||||
{% endif %}
|
||||
{% if u.trialing %}<span class="pill warn">trial</span>{% endif %}
|
||||
</td>
|
||||
<td class="muted">{{ u.created_at|dt }}</td>
|
||||
<td class="muted">{{ u.last_login_at|dt }}</td>
|
||||
<td>{{ u.lang }}</td>
|
||||
<td>{% if u.has_sync %}<span class="pill good">on</span>{% else %}<span class="muted">—</span>{% endif %}</td>
|
||||
<td>{{ u.referrals }}</td>
|
||||
<td>
|
||||
{% if u.on_stripe %}<span class="pill">stripe</span>{% endif %}
|
||||
{% if u.on_polar %}<span class="pill">polar</span>{% endif %}
|
||||
{% if not u.on_stripe and not u.on_polar %}<span class="muted">—</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="10" class="muted">No users.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
73
admin/templates/user_detail.html
Normal file
73
admin/templates/user_detail.html
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
{% extends "base.html" %}
|
||||
{% set u = d.user %}
|
||||
{% block title %}{{ u.email }}{% endblock %}
|
||||
{% block body %}
|
||||
<p class="muted"><a href="/users">← users</a></p>
|
||||
<h1>{{ u.email }} <span class="muted">#{{ u.id }}</span></h1>
|
||||
|
||||
<h2>Account</h2>
|
||||
<dl class="kv">
|
||||
<dt>Tier</dt><dd>{{ u.tier }}</dd>
|
||||
<dt>Paid status</dt><dd>
|
||||
{% if d.paid.active %}
|
||||
<span class="pill good">active</span> via {{ d.paid.source }}
|
||||
{% if d.paid.source == 'credit' %}· expires {{ d.paid.expires_at|dt }} ({{ d.paid.days_remaining }}d){% endif %}
|
||||
{% else %}<span class="pill mute">inactive</span>{% endif %}
|
||||
</dd>
|
||||
<dt>Credit until</dt><dd>{{ u.credit_until|dt }}</dd>
|
||||
<dt>Created</dt><dd>{{ u.created_at|dt }}</dd>
|
||||
<dt>Last login</dt><dd>{{ u.last_login_at|dt }}</dd>
|
||||
<dt>Language</dt><dd>{{ u.lang }}</dd>
|
||||
<dt>Digest opt-in</dt><dd>{{ 'yes' if u.email_digest_opt_in else 'no' }}{% if u.digest_tone %} · {{ u.digest_tone }}{% endif %}</dd>
|
||||
<dt>Referral code</dt><dd>{{ u.referral_code or '—' }}</dd>
|
||||
<dt>Referred by</dt><dd>{% if d.referred_by %}<a href="/users/{{ d.referred_by.id }}">{{ d.referred_by.email }}</a>{% else %}—{% endif %}</dd>
|
||||
</dl>
|
||||
|
||||
<h2>Billing linkage</h2>
|
||||
<dl class="kv">
|
||||
<dt>Stripe customer</dt><dd>{{ u.stripe_customer_id or '—' }}</dd>
|
||||
<dt>Stripe subscription</dt><dd>{{ u.stripe_subscription_id or '—' }}</dd>
|
||||
<dt>Stripe trial ends</dt><dd>{{ u.stripe_trial_end_at|dt }}</dd>
|
||||
<dt>Polar customer</dt><dd>{{ u.polar_customer_id or '—' }}</dd>
|
||||
<dt>Polar subscription</dt><dd>{{ u.polar_subscription_id or '—' }}</dd>
|
||||
</dl>
|
||||
|
||||
<h2>Cloud sync</h2>
|
||||
{% if d.sync.enabled %}
|
||||
<p><span class="pill good">enabled</span> · v{{ d.sync.version }} · updated {{ d.sync.updated_at|dt }}
|
||||
<span class="muted">(contents are end-to-end encrypted — not readable here)</span></p>
|
||||
{% else %}<p class="muted">Not enabled.</p>{% endif %}
|
||||
|
||||
<h2>Referrals sent ({{ d.referrals_sent|length }})</h2>
|
||||
{% if d.referrals_sent %}
|
||||
<table><thead><tr><th>Referred</th><th>Sent</th><th>Converted</th></tr></thead><tbody>
|
||||
{% for r in d.referrals_sent %}
|
||||
<tr><td>{{ r.referred_email }}</td><td class="muted">{{ r.created_at|dt }}</td>
|
||||
<td>{% if r.converted_at %}<span class="pill good">{{ r.converted_at|dt }}</span>{% else %}<span class="muted">pending</span>{% endif %}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody></table>
|
||||
{% else %}<p class="muted">None.</p>{% endif %}
|
||||
|
||||
<h2>Feedback votes</h2>
|
||||
<p>👍 {{ d.feedback.get('up', 0) }} · 👎 {{ d.feedback.get('down', 0) }}</p>
|
||||
|
||||
<h2>Digest emails (last 25)</h2>
|
||||
{% if d.emails %}
|
||||
<table><thead><tr><th>Kind</th><th>Sent</th><th>Status</th><th>Error</th></tr></thead><tbody>
|
||||
{% for e in d.emails %}
|
||||
<tr><td>{{ e.kind }}</td><td class="muted">{{ e.sent_at|dt }}</td>
|
||||
<td>{% if e.status == 'sent' %}<span class="pill good">sent</span>{% elif e.status == 'error' %}<span class="pill bad">error</span>{% else %}<span class="pill warn">{{ e.status }}</span>{% endif %}</td>
|
||||
<td class="muted">{{ e.error or '' }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody></table>
|
||||
{% else %}<p class="muted">None.</p>{% endif %}
|
||||
|
||||
<h2>Legal acknowledgements</h2>
|
||||
{% if d.acks %}
|
||||
<table><thead><tr><th>Version</th><th>Lang</th><th>Accepted</th></tr></thead><tbody>
|
||||
{% for a in d.acks %}
|
||||
<tr><td>v{{ a.version }}</td><td>{{ a.lang }}</td><td class="muted">{{ a.accepted_at|dt }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody></table>
|
||||
{% else %}<p class="muted">None recorded.</p>{% endif %}
|
||||
{% endblock %}
|
||||
20
admin/templates/users.html
Normal file
20
admin/templates/users.html
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}users{% endblock %}
|
||||
{% block body %}
|
||||
<h1>Users <span class="muted">({{ total }})</span></h1>
|
||||
<form class="search" method="get" action="/users">
|
||||
<input type="text" name="q" value="{{ q }}" placeholder="filter by email…">
|
||||
<button type="submit">Search</button>
|
||||
{% if q %}<a href="/users" style="margin-left:.5rem">clear</a>{% endif %}
|
||||
</form>
|
||||
{% include "partials_users_table.html" %}
|
||||
|
||||
{% set pages = (total // per_page) + (1 if total % per_page else 0) %}
|
||||
{% if pages > 1 %}
|
||||
<p class="muted">
|
||||
Page {{ page }} / {{ pages }}
|
||||
{% if page > 1 %}· <a href="/users?page={{ page-1 }}{% if q %}&q={{ q }}{% endif %}">prev</a>{% endif %}
|
||||
{% if page < pages %}· <a href="/users?page={{ page+1 }}{% if q %}&q={{ q }}{% endif %}">next</a>{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue