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
50
admin/README.md
Normal file
50
admin/README.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Superadmin console
|
||||
|
||||
An **independent, internal-only** web service for the operator: user list with
|
||||
per-user history + payment status, and database usage stats. Runs in its own
|
||||
container (`admin` service) off the same image as the main app, reusing
|
||||
`app.db` + `app.models`, but it is a separate FastAPI app (`admin.main:app`)
|
||||
that **never** runs migrations or the scheduler and only ever issues `SELECT`s.
|
||||
|
||||
## Access model
|
||||
|
||||
- Bound to **`127.0.0.1:8091`** on the host — never exposed publicly, not on
|
||||
the `intranet`/NPM network. Reach it over an SSH tunnel:
|
||||
|
||||
```sh
|
||||
ssh -L 8091:localhost:8091 <vps>
|
||||
# then open http://localhost:8091
|
||||
```
|
||||
|
||||
- Gated by a single shared password, `ADMIN_CONSOLE_PASSWORD` (in `.env`).
|
||||
Empty password ⇒ every login is refused (closed by default). The login sets
|
||||
a 12-hour signed cookie (`admin_console_session`).
|
||||
|
||||
## Pages
|
||||
|
||||
- `/` — overview: totals, tier split, paid-active, signups 7/30d, sync count,
|
||||
referral conversions, newest users.
|
||||
- `/users` — full user table with email search + paging.
|
||||
- `/users/{id}` — one user: account, computed paid status, Stripe/Polar
|
||||
linkage, cloud-sync state, referrals sent, feedback votes, digest-email log,
|
||||
legal acknowledgements.
|
||||
- `/db` — per-table row estimates and on-disk size (from
|
||||
`information_schema.tables`), with each table's share of total.
|
||||
|
||||
## Deploy (this host runs prod)
|
||||
|
||||
Adding a **new** service means `up -d` (a plain `restart` won't create it).
|
||||
Do NOT run a bare `docker compose up` on this host — always pass the prod
|
||||
overlay:
|
||||
|
||||
```sh
|
||||
# 1. set ADMIN_CONSOLE_PASSWORD in .env
|
||||
# 2. build + create just the admin container (leaves app/scheduler/db running)
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build admin
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.test.yml run --rm test pytest tests/test_admin_console.py -v
|
||||
```
|
||||
9
admin/__init__.py
Normal file
9
admin/__init__.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"""Superadmin console — an independent, internal-only web service.
|
||||
|
||||
Runs in its own container (see the `admin` service in docker-compose.yml),
|
||||
bound to 127.0.0.1 on the VPS and reached over an SSH tunnel. It reuses the
|
||||
main app's ORM models and DB engine (`app.db`, `app.models`) but is a wholly
|
||||
separate FastAPI app: it never imports `app.main`, so it does NOT run Alembic
|
||||
migrations or the app's lifespan. Every DB access is a plain SELECT — the
|
||||
console never writes.
|
||||
"""
|
||||
71
admin/auth.py
Normal file
71
admin/auth.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""Password gate for the superadmin console.
|
||||
|
||||
A single shared password (``ADMIN_CONSOLE_PASSWORD``) is exchanged at
|
||||
``/login`` for a signed, time-limited session cookie. There is no user
|
||||
identity here — the console has exactly one principal, "the operator".
|
||||
|
||||
The cookie is signed with itsdangerous using ``ADMIN_CONSOLE_SESSION_SECRET``
|
||||
(falling back to the main app's session secret / token), mirroring the scheme
|
||||
in ``app.auth`` but under a distinct salt so an app session cookie can never
|
||||
be replayed against the console and vice-versa.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
SESSION_COOKIE_NAME = "admin_console_session"
|
||||
SESSION_TTL_SECONDS = 12 * 60 * 60 # 12 hours — re-auth daily-ish.
|
||||
_SALT = "admin-console-session-v1"
|
||||
|
||||
|
||||
def _serializer() -> URLSafeTimedSerializer:
|
||||
s = get_settings()
|
||||
secret = (
|
||||
s.ADMIN_CONSOLE_SESSION_SECRET
|
||||
or s.CASSANDRA_SESSION_SECRET
|
||||
or s.CASSANDRA_TOKEN
|
||||
or "dev-insecure-secret"
|
||||
)
|
||||
return URLSafeTimedSerializer(secret, salt=_SALT)
|
||||
|
||||
|
||||
def check_password(provided: str) -> bool:
|
||||
"""Constant-time compare against ``ADMIN_CONSOLE_PASSWORD``. Returns
|
||||
False when the password is unset — the console is closed by default so a
|
||||
fresh deploy can't be logged into without an explicit password."""
|
||||
expected = get_settings().ADMIN_CONSOLE_PASSWORD
|
||||
if not expected:
|
||||
return False
|
||||
return secrets.compare_digest(provided.encode(), expected.encode())
|
||||
|
||||
|
||||
def sign_session() -> str:
|
||||
"""Signed value proving a successful password login. Carries no identity
|
||||
beyond a version marker — the console has a single principal."""
|
||||
return _serializer().dumps({"v": 1})
|
||||
|
||||
|
||||
def verify_session(cookie: str) -> bool:
|
||||
try:
|
||||
data = _serializer().loads(cookie, max_age=SESSION_TTL_SECONDS)
|
||||
return data.get("v") == 1
|
||||
except (BadSignature, SignatureExpired, KeyError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def require_admin(request: Request) -> None:
|
||||
"""FastAPI dependency guarding every console page. Valid cookie → passes;
|
||||
otherwise 303 → /login (browser) so the operator lands on the form."""
|
||||
cookie = request.cookies.get(SESSION_COOKIE_NAME)
|
||||
if cookie and verify_session(cookie):
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
detail="Login required",
|
||||
headers={"Location": "/login"},
|
||||
)
|
||||
140
admin/main.py
Normal file
140
admin/main.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
"""Superadmin console FastAPI app.
|
||||
|
||||
Independent of ``app.main``: it wires only its own routes and reuses
|
||||
``app.db``'s session factory for read-only queries. No lifespan, no
|
||||
migrations, no scheduler.
|
||||
|
||||
Run: uvicorn admin.main:app --host 0.0.0.0 --port 8000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import Depends, FastAPI, Form, Query, Request
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from admin import auth, queries
|
||||
from app.db import get_session
|
||||
|
||||
TEMPLATE_DIR = Path(__file__).resolve().parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(TEMPLATE_DIR))
|
||||
|
||||
|
||||
def _fmt_bytes(n: int | None) -> str:
|
||||
if not n:
|
||||
return "0 B"
|
||||
units = ["B", "KB", "MB", "GB", "TB"]
|
||||
size = float(n)
|
||||
for u in units:
|
||||
if size < 1024 or u == units[-1]:
|
||||
return f"{size:,.0f} {u}" if u == "B" else f"{size:,.1f} {u}"
|
||||
size /= 1024
|
||||
return f"{n} B"
|
||||
|
||||
|
||||
def _fmt_dt(dt: datetime | None) -> str:
|
||||
if dt is None:
|
||||
return "—"
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.strftime("%Y-%m-%d %H:%M UTC")
|
||||
|
||||
|
||||
templates.env.filters["bytes"] = _fmt_bytes
|
||||
templates.env.filters["dt"] = _fmt_dt
|
||||
|
||||
app = FastAPI(title="read.markets superadmin console", docs_url=None, redoc_url=None)
|
||||
|
||||
|
||||
@app.get("/healthz", response_class=PlainTextResponse, include_in_schema=False)
|
||||
async def healthz() -> str:
|
||||
return "ok"
|
||||
|
||||
|
||||
@app.get("/login", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def login_form(request: Request):
|
||||
# Already signed in? Skip the form.
|
||||
cookie = request.cookies.get(auth.SESSION_COOKIE_NAME)
|
||||
if cookie and auth.verify_session(cookie):
|
||||
return RedirectResponse("/", status_code=303)
|
||||
return templates.TemplateResponse(request, "login.html", {"error": None})
|
||||
|
||||
|
||||
@app.post("/login", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def login_submit(request: Request, password: str = Form(...)):
|
||||
if not auth.check_password(password):
|
||||
return templates.TemplateResponse(
|
||||
request, "login.html", {"error": "Incorrect password."},
|
||||
status_code=401,
|
||||
)
|
||||
resp = RedirectResponse("/", status_code=303)
|
||||
resp.set_cookie(
|
||||
auth.SESSION_COOKIE_NAME,
|
||||
auth.sign_session(),
|
||||
max_age=auth.SESSION_TTL_SECONDS,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
# secure=False: the console is served over http on a localhost SSH
|
||||
# tunnel, never public TLS. A secure cookie would never be sent.
|
||||
secure=False,
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
@app.get("/logout", include_in_schema=False)
|
||||
async def logout():
|
||||
resp = RedirectResponse("/login", status_code=303)
|
||||
resp.delete_cookie(auth.SESSION_COOKIE_NAME)
|
||||
return resp
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse, include_in_schema=False,
|
||||
dependencies=[Depends(auth.require_admin)])
|
||||
async def overview(request: Request, session: AsyncSession = Depends(get_session)):
|
||||
stats = await queries.overview_stats(session)
|
||||
recent, _ = await queries.list_users(session, limit=10)
|
||||
return templates.TemplateResponse(
|
||||
request, "overview.html", {"stats": stats, "recent": recent},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/users", response_class=HTMLResponse, include_in_schema=False,
|
||||
dependencies=[Depends(auth.require_admin)])
|
||||
async def users_list(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
q: str | None = Query(default=None),
|
||||
page: int = Query(default=1, ge=1),
|
||||
):
|
||||
per_page = 200
|
||||
offset = (page - 1) * per_page
|
||||
rows, total = await queries.list_users(session, q=q, limit=per_page, offset=offset)
|
||||
return templates.TemplateResponse(
|
||||
request, "users.html",
|
||||
{"rows": rows, "total": total, "q": q or "",
|
||||
"page": page, "per_page": per_page},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/users/{user_id}", response_class=HTMLResponse, include_in_schema=False,
|
||||
dependencies=[Depends(auth.require_admin)])
|
||||
async def user_detail(
|
||||
request: Request, user_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
detail = await queries.user_detail(session, user_id)
|
||||
if detail is None:
|
||||
return templates.TemplateResponse(
|
||||
request, "not_found.html", {"user_id": user_id}, status_code=404,
|
||||
)
|
||||
return templates.TemplateResponse(request, "user_detail.html", {"d": detail})
|
||||
|
||||
|
||||
@app.get("/db", response_class=HTMLResponse, include_in_schema=False,
|
||||
dependencies=[Depends(auth.require_admin)])
|
||||
async def db_page(request: Request, session: AsyncSession = Depends(get_session)):
|
||||
stats = await queries.db_stats(session)
|
||||
return templates.TemplateResponse(request, "db.html", {"stats": stats})
|
||||
266
admin/queries.py
Normal file
266
admin/queries.py
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
"""Read-only queries backing the console. Every function issues SELECTs
|
||||
only — the console never mutates the database.
|
||||
|
||||
Payment status is computed with the app's own ``paid_status`` so the console
|
||||
never diverges from what the app considers "paid".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import func, or_, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import utcnow
|
||||
from app.models import (
|
||||
EmailSend,
|
||||
PortfolioSync,
|
||||
Referral,
|
||||
StrategicLogFeedback,
|
||||
User,
|
||||
UserAcknowledgement,
|
||||
)
|
||||
from app.services.access import paid_status
|
||||
|
||||
|
||||
async def overview_stats(session: AsyncSession) -> dict:
|
||||
"""Headline counts for the landing page: totals, tier split, growth,
|
||||
paid-active, engagement."""
|
||||
now = utcnow()
|
||||
d7 = now - timedelta(days=7)
|
||||
d30 = now - timedelta(days=30)
|
||||
|
||||
total = await session.scalar(select(func.count(User.id))) or 0
|
||||
|
||||
tier_rows = (await session.execute(
|
||||
select(User.tier, func.count(User.id)).group_by(User.tier)
|
||||
)).all()
|
||||
tiers = {tier: n for tier, n in tier_rows}
|
||||
|
||||
signups_7d = await session.scalar(
|
||||
select(func.count(User.id)).where(User.created_at >= d7)
|
||||
) or 0
|
||||
signups_30d = await session.scalar(
|
||||
select(func.count(User.id)).where(User.created_at >= d30)
|
||||
) or 0
|
||||
active_30d = await session.scalar(
|
||||
select(func.count(User.id)).where(User.last_login_at >= d30)
|
||||
) or 0
|
||||
|
||||
# Paid-active = subscribed tier OR a live credit window. The two are
|
||||
# counted disjointly (a credit user whose tier is already paid is only
|
||||
# counted once, under tier) so the sum can't double-count.
|
||||
paid_by_tier = await session.scalar(
|
||||
select(func.count(User.id)).where(User.tier.in_(("paid", "enterprise")))
|
||||
) or 0
|
||||
credit_active = await session.scalar(
|
||||
select(func.count(User.id)).where(
|
||||
User.credit_until.is_not(None),
|
||||
User.credit_until > now,
|
||||
User.tier.not_in(("paid", "enterprise")),
|
||||
)
|
||||
) or 0
|
||||
|
||||
sync_enabled = await session.scalar(
|
||||
select(func.count(PortfolioSync.user_id))
|
||||
) or 0
|
||||
|
||||
referrals_total = await session.scalar(select(func.count(Referral.id))) or 0
|
||||
referrals_converted = await session.scalar(
|
||||
select(func.count(Referral.id)).where(Referral.converted_at.is_not(None))
|
||||
) or 0
|
||||
|
||||
return {
|
||||
"total_users": total,
|
||||
"tiers": tiers,
|
||||
"free": tiers.get("free", 0),
|
||||
"paid": tiers.get("paid", 0),
|
||||
"enterprise": tiers.get("enterprise", 0),
|
||||
"paid_active": paid_by_tier + credit_active,
|
||||
"credit_active": credit_active,
|
||||
"signups_7d": signups_7d,
|
||||
"signups_30d": signups_30d,
|
||||
"active_30d": active_30d,
|
||||
"sync_enabled": sync_enabled,
|
||||
"referrals_total": referrals_total,
|
||||
"referrals_converted": referrals_converted,
|
||||
}
|
||||
|
||||
|
||||
async def list_users(
|
||||
session: AsyncSession,
|
||||
q: str | None = None,
|
||||
limit: int = 200,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""User rows for the table view, newest first. Optional ``q`` filters by
|
||||
email substring. Returns (rows, total_matching)."""
|
||||
stmt = select(User)
|
||||
count_stmt = select(func.count(User.id))
|
||||
if q:
|
||||
like = f"%{q.strip()}%"
|
||||
stmt = stmt.where(User.email.like(like))
|
||||
count_stmt = count_stmt.where(User.email.like(like))
|
||||
|
||||
total = await session.scalar(count_stmt) or 0
|
||||
users = (await session.execute(
|
||||
stmt.order_by(User.created_at.desc()).limit(limit).offset(offset)
|
||||
)).scalars().all()
|
||||
|
||||
if not users:
|
||||
return [], total
|
||||
|
||||
ids = [u.id for u in users]
|
||||
|
||||
# One grouped query each for the per-user badges, merged in Python —
|
||||
# avoids an N+1 without a wide join fan-out.
|
||||
sync_ids = set((await session.execute(
|
||||
select(PortfolioSync.user_id).where(PortfolioSync.user_id.in_(ids))
|
||||
)).scalars().all())
|
||||
|
||||
ref_counts = dict((await session.execute(
|
||||
select(Referral.referrer_user_id, func.count(Referral.id))
|
||||
.where(Referral.referrer_user_id.in_(ids))
|
||||
.group_by(Referral.referrer_user_id)
|
||||
)).all())
|
||||
|
||||
rows: list[dict] = []
|
||||
for u in users:
|
||||
ps = paid_status(u)
|
||||
rows.append({
|
||||
"id": u.id,
|
||||
"email": u.email,
|
||||
"tier": u.tier,
|
||||
"paid_active": ps.active,
|
||||
"paid_source": ps.source,
|
||||
"credit_days": ps.days_remaining,
|
||||
"created_at": u.created_at,
|
||||
"last_login_at": u.last_login_at,
|
||||
"lang": u.lang,
|
||||
"has_sync": u.id in sync_ids,
|
||||
"referrals": ref_counts.get(u.id, 0),
|
||||
"on_stripe": bool(u.stripe_customer_id),
|
||||
"on_polar": bool(u.polar_customer_id),
|
||||
"trialing": u.stripe_trial_end_at is not None,
|
||||
})
|
||||
return rows, total
|
||||
|
||||
|
||||
async def user_detail(session: AsyncSession, user_id: int) -> dict | None:
|
||||
"""Everything the console shows for one user: the row itself, computed
|
||||
payment status, billing linkage, and joined activity history."""
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
ps = paid_status(user)
|
||||
|
||||
referred_by = None
|
||||
if user.referred_by_user_id:
|
||||
ref = await session.get(User, user.referred_by_user_id)
|
||||
referred_by = {"id": ref.id, "email": ref.email} if ref else None
|
||||
|
||||
# Referrals this user sent (with conversion state).
|
||||
sent = (await session.execute(
|
||||
select(Referral, User.email)
|
||||
.join(User, User.id == Referral.referred_user_id)
|
||||
.where(Referral.referrer_user_id == user_id)
|
||||
.order_by(Referral.created_at.desc())
|
||||
)).all()
|
||||
referrals_sent = [{
|
||||
"referred_email": email,
|
||||
"created_at": r.created_at,
|
||||
"converted_at": r.converted_at,
|
||||
} for r, email in sent]
|
||||
|
||||
# Digest email delivery log (most recent first, capped).
|
||||
email_rows = (await session.execute(
|
||||
select(EmailSend).where(EmailSend.user_id == user_id)
|
||||
.order_by(EmailSend.sent_at.desc()).limit(25)
|
||||
)).scalars().all()
|
||||
emails = [{
|
||||
"kind": e.kind, "sent_at": e.sent_at,
|
||||
"status": e.status, "error": e.error,
|
||||
} for e in email_rows]
|
||||
|
||||
# Legal acknowledgements (evidentiary audit trail).
|
||||
ack_rows = (await session.execute(
|
||||
select(UserAcknowledgement).where(UserAcknowledgement.user_id == user_id)
|
||||
.order_by(UserAcknowledgement.accepted_at.desc())
|
||||
)).scalars().all()
|
||||
acks = [{
|
||||
"version": a.version, "lang": a.lang, "accepted_at": a.accepted_at,
|
||||
} for a in ack_rows]
|
||||
|
||||
# Feedback votes cast on strategic logs.
|
||||
fb_rows = (await session.execute(
|
||||
select(StrategicLogFeedback.vote, func.count(StrategicLogFeedback.id))
|
||||
.where(StrategicLogFeedback.user_id == user_id)
|
||||
.group_by(StrategicLogFeedback.vote)
|
||||
)).all()
|
||||
feedback = {vote: n for vote, n in fb_rows}
|
||||
|
||||
sync = await session.get(PortfolioSync, user_id)
|
||||
|
||||
return {
|
||||
"user": user,
|
||||
"paid": ps,
|
||||
"referred_by": referred_by,
|
||||
"referrals_sent": referrals_sent,
|
||||
"emails": emails,
|
||||
"acks": acks,
|
||||
"feedback": feedback,
|
||||
"sync": {
|
||||
"enabled": sync is not None,
|
||||
"updated_at": sync.updated_at if sync else None,
|
||||
"version": sync.version if sync else None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def db_stats(session: AsyncSession) -> dict:
|
||||
"""Per-table size and row estimates for the current schema.
|
||||
|
||||
On MariaDB this reads ``information_schema.tables`` (row counts are the
|
||||
engine's estimate for InnoDB, sizes are exact). On sqlite (the test
|
||||
backend) there is no such view, so it falls back to real ``COUNT(*)``
|
||||
per table with sizes reported as 0.
|
||||
"""
|
||||
dialect = session.bind.dialect.name
|
||||
|
||||
if dialect == "sqlite":
|
||||
from app.db import Base
|
||||
tables = []
|
||||
for tbl in Base.metadata.sorted_tables:
|
||||
n = await session.scalar(select(func.count()).select_from(tbl)) or 0
|
||||
tables.append({
|
||||
"name": tbl.name, "rows": n,
|
||||
"data_bytes": 0, "index_bytes": 0, "total_bytes": 0, "pct": 0.0,
|
||||
})
|
||||
tables.sort(key=lambda t: t["rows"], reverse=True)
|
||||
return {"tables": tables, "total_bytes": 0, "total_rows": sum(t["rows"] for t in tables)}
|
||||
|
||||
rows = (await session.execute(text(
|
||||
"SELECT table_name, table_rows, data_length, index_length "
|
||||
"FROM information_schema.tables "
|
||||
"WHERE table_schema = DATABASE() "
|
||||
"ORDER BY (data_length + index_length) DESC"
|
||||
))).all()
|
||||
|
||||
tables = []
|
||||
total_bytes = 0
|
||||
total_rows = 0
|
||||
for name, tr, data_len, idx_len in rows:
|
||||
data_len = int(data_len or 0)
|
||||
idx_len = int(idx_len or 0)
|
||||
tot = data_len + idx_len
|
||||
total_bytes += tot
|
||||
total_rows += int(tr or 0)
|
||||
tables.append({
|
||||
"name": name, "rows": int(tr or 0),
|
||||
"data_bytes": data_len, "index_bytes": idx_len, "total_bytes": tot,
|
||||
})
|
||||
for t in tables:
|
||||
t["pct"] = round(100.0 * t["total_bytes"] / total_bytes, 1) if total_bytes else 0.0
|
||||
|
||||
return {"tables": tables, "total_bytes": total_bytes, "total_rows": total_rows}
|
||||
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