This commit is contained in:
commit
ccccfbf879
498
gitea_saas/dashboard.py
Normal file
498
gitea_saas/dashboard.py
Normal file
@ -0,0 +1,498 @@
|
||||
"""SaaS operator dashboard (issue #9).
|
||||
|
||||
One page for the whole SaaS: client balances, ledger history, pending /
|
||||
intake queues, throughput per client (issues merged per 7d/30d, evals
|
||||
completed, average review-to-merge time), and review-gate verdicts.
|
||||
Numbers are computed from the SQLite DB AT REQUEST TIME — no caching (the
|
||||
data is small).
|
||||
|
||||
Read contract
|
||||
-------------
|
||||
``Dashboard(core, db_path)`` opens its own read-only(-ish) connection to
|
||||
the SAME SQLite file as the billing core. It reads:
|
||||
|
||||
* ``clients`` / ``ledger`` / ``holds`` — owned by the billing core (#1)
|
||||
* ``pending_issues`` / ``factory_locks`` — issue #4's factory, best-effort
|
||||
(queried only when the table exists)
|
||||
* ``reviews`` — issue #6's review gate, best-effort
|
||||
* ``eval_batches`` / ``eval_tasks`` — issue #8's eval farm, best-effort
|
||||
|
||||
Best-effort reads never crash the page: a missing table renders the
|
||||
section's empty state (``—``). Balances come from the billing core (the
|
||||
single source of truth, holds included).
|
||||
|
||||
Client "status" is derived: ``active`` (normal), ``locked`` (factory lock
|
||||
row exists for the client), ``overdue`` (available balance below the debit
|
||||
floor — needs a top-up). ``render_html(summary)`` produces the
|
||||
high-contrast, no-external-assets HTML page; the JSON summary endpoint
|
||||
serves the exact same dict.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import html
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
EMPTY = "\u2014" # em dash empty-state marker
|
||||
|
||||
# Single source for the API-key header name on this branch. The shared
|
||||
# `config.API_KEY_HEADER` constant lands with issue #2/#6 (neither merged
|
||||
# yet); when #24 consolidates the service's auth paths, this line is
|
||||
# replaced by `from .config import API_KEY_HEADER` — no other change.
|
||||
API_KEY_HEADER = "X-API-Key"
|
||||
|
||||
DEFAULT_DEBIT_FLOOR_MINOR = 500
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _iso(dt: datetime) -> str:
|
||||
return dt.isoformat()
|
||||
|
||||
|
||||
class Dashboard:
|
||||
"""Live operator dashboard over the SaaS SQLite file (no caching)."""
|
||||
|
||||
def __init__(self, core: Any, db_path, debit_floor_minor: Optional[int] = None):
|
||||
self._core = core
|
||||
self._db_path = Path(db_path)
|
||||
self._conn = sqlite3.connect(str(self._db_path))
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
if debit_floor_minor is None:
|
||||
raw = os.environ.get("SAAS_DEBIT_FLOOR_MINOR", "").strip()
|
||||
debit_floor_minor = int(raw) if raw else DEFAULT_DEBIT_FLOOR_MINOR
|
||||
self._floor = debit_floor_minor
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Shared query helpers (best-effort on foreign tables)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _table_exists(self, name: str) -> bool:
|
||||
row = self._conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
(name,)).fetchone()
|
||||
return row is not None
|
||||
|
||||
def _client_names(self) -> Dict[int, str]:
|
||||
out: Dict[int, str] = {}
|
||||
try:
|
||||
for r in self._conn.execute("SELECT id, name FROM clients"):
|
||||
out[int(r["id"])] = str(r["name"])
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
return out
|
||||
|
||||
def _client_balance(self, client_id: int) -> Optional[int]:
|
||||
try:
|
||||
ok, details = self._core.get_client(client_id, include_balance=True)
|
||||
except Exception:
|
||||
return None
|
||||
if not ok or not isinstance(details, dict):
|
||||
return None
|
||||
balance = details.get("balance")
|
||||
return balance if isinstance(balance, int) else None
|
||||
|
||||
def _locked_client_ids(self) -> set:
|
||||
"""Client ids with a factory lock (issue #4 table, best-effort)."""
|
||||
if not self._table_exists("factory_locks"):
|
||||
return set()
|
||||
try:
|
||||
return {int(r["client_id"]) for r in
|
||||
self._conn.execute("SELECT client_id FROM factory_locks")}
|
||||
except sqlite3.Error:
|
||||
return set()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sections
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _clients(self) -> List[dict]:
|
||||
now = _now()
|
||||
month_start = _iso(now.replace(day=1, hour=0, minute=0, second=0,
|
||||
microsecond=0))
|
||||
locked = self._locked_client_ids()
|
||||
clients = []
|
||||
try:
|
||||
rows = list(self._conn.execute(
|
||||
"SELECT id, name, tier, status FROM clients ORDER BY id"))
|
||||
except sqlite3.Error:
|
||||
return clients
|
||||
for r in rows:
|
||||
cid = int(r["id"])
|
||||
balance = self._client_balance(cid)
|
||||
try:
|
||||
credits = int(self._conn.execute(
|
||||
"SELECT COALESCE(SUM(amount_minor), 0) FROM ledger"
|
||||
" WHERE client_id = ? AND kind IN ('credit', 'refund')"
|
||||
" AND created_at >= ?", (cid, month_start)).fetchone()[0])
|
||||
debits = int(self._conn.execute(
|
||||
"SELECT COALESCE(SUM(amount_minor), 0) FROM ledger"
|
||||
" WHERE client_id = ? AND kind = 'debit' AND created_at >= ?",
|
||||
(cid, month_start)).fetchone()[0])
|
||||
except sqlite3.Error:
|
||||
credits = debits = 0
|
||||
if cid in locked:
|
||||
status = "locked"
|
||||
elif balance is not None and balance < self._floor:
|
||||
status = "overdue"
|
||||
else:
|
||||
status = str(r["status"] or "active")
|
||||
clients.append({
|
||||
"id": cid,
|
||||
"name": str(r["name"]),
|
||||
"tier": str(r["tier"]),
|
||||
"balance": balance,
|
||||
"status": status,
|
||||
"credits_this_month": credits,
|
||||
"debits_this_month": debits,
|
||||
})
|
||||
return clients
|
||||
|
||||
def _queue(self) -> Dict[str, Any]:
|
||||
"""pending_issues (issue #4) — count + newest 10, best-effort."""
|
||||
empty = {"count": 0, "items": []}
|
||||
if not self._table_exists("pending_issues"):
|
||||
return empty
|
||||
names = self._client_names()
|
||||
locked = self._locked_client_ids()
|
||||
try:
|
||||
count = int(self._conn.execute(
|
||||
"SELECT COUNT(*) FROM pending_issues").fetchone()[0])
|
||||
rows = list(self._conn.execute(
|
||||
"SELECT id, client_id, title, created_at, status"
|
||||
" FROM pending_issues ORDER BY id DESC LIMIT 10"))
|
||||
except sqlite3.Error:
|
||||
return empty
|
||||
items = []
|
||||
for r in rows:
|
||||
cid = int(r["client_id"])
|
||||
balance = self._client_balance(cid)
|
||||
items.append({
|
||||
"id": int(r["id"]),
|
||||
"client_id": cid,
|
||||
"client_name": names.get(cid, str(cid)),
|
||||
"title": str(r["title"]),
|
||||
"queued_at": str(r["created_at"]),
|
||||
"status": str(r["status"]),
|
||||
"locked": cid in locked,
|
||||
"unfunded": balance is not None and balance < self._floor,
|
||||
})
|
||||
return {"count": count, "items": items}
|
||||
|
||||
def _delivery_debits(self, since_iso: str) -> set:
|
||||
"""(client_id, ref_id) delivery debits recorded since ``since_iso``."""
|
||||
try:
|
||||
rows = self._conn.execute(
|
||||
"SELECT client_id, ref_id FROM ledger"
|
||||
" WHERE kind = 'debit' AND ref_type = 'delivery'"
|
||||
" AND created_at >= ?", (since_iso,)).fetchall()
|
||||
return {(int(r["client_id"]), r["ref_id"] or "") for r in rows}
|
||||
except sqlite3.Error:
|
||||
return set()
|
||||
|
||||
def _avg_review_to_merge_days(self) -> Optional[float]:
|
||||
"""Mean days from review to delivery debit (best-effort, #6 + #7)."""
|
||||
if not self._table_exists("reviews"):
|
||||
return None
|
||||
try:
|
||||
debits = self._conn.execute(
|
||||
"SELECT created_at, ref_id FROM ledger"
|
||||
" WHERE kind = 'debit' AND ref_type = 'delivery'").fetchall()
|
||||
reviews = self._conn.execute(
|
||||
"SELECT repo, pr, reviewed_at FROM reviews").fetchall()
|
||||
except sqlite3.Error:
|
||||
return None
|
||||
by_ref: Dict[tuple, List[str]] = {}
|
||||
for r in reviews:
|
||||
by_ref.setdefault((str(r["repo"]), str(r["pr"])),
|
||||
[]).append(str(r["reviewed_at"]))
|
||||
diffs = []
|
||||
for d in debits:
|
||||
ref = d["ref_id"] or ""
|
||||
if "#" not in ref:
|
||||
continue
|
||||
repo, _, pr_num = ref.rpartition("#")
|
||||
times = by_ref.get((repo, pr_num))
|
||||
if not times:
|
||||
continue
|
||||
try:
|
||||
debit_at = datetime.fromisoformat(str(d["created_at"]))
|
||||
except ValueError:
|
||||
continue
|
||||
last_time: Optional[datetime] = None
|
||||
for t in times:
|
||||
parsed = _safe_parse(t)
|
||||
if parsed is not None and parsed <= debit_at:
|
||||
if last_time is None or parsed > last_time:
|
||||
last_time = parsed
|
||||
if last_time is None:
|
||||
continue
|
||||
diffs.append((debit_at - last_time).total_seconds() / 86400.0)
|
||||
if not diffs:
|
||||
return None
|
||||
return round(sum(diffs) / len(diffs), 2)
|
||||
|
||||
def _throughput(self) -> List[dict]:
|
||||
now = _now()
|
||||
in_7d = _iso(now - timedelta(days=7))
|
||||
in_30d = _iso(now - timedelta(days=30))
|
||||
merged_7d = self._delivery_debits(in_7d)
|
||||
merged_30d = self._delivery_debits(in_30d)
|
||||
avg = self._avg_review_to_merge_days()
|
||||
names = self._client_names()
|
||||
out = []
|
||||
try:
|
||||
client_ids = [int(r["id"]) for r in
|
||||
self._conn.execute("SELECT id FROM clients")]
|
||||
except sqlite3.Error:
|
||||
client_ids = []
|
||||
for cid in client_ids:
|
||||
evals = 0
|
||||
if self._table_exists("eval_tasks") and self._table_exists("eval_batches"):
|
||||
try:
|
||||
evals = int(self._conn.execute(
|
||||
"SELECT COUNT(*) FROM eval_tasks t"
|
||||
" JOIN eval_batches b ON b.id = t.batch_id"
|
||||
" WHERE b.client_id = ? AND t.status = 'done'",
|
||||
(cid,)).fetchone()[0])
|
||||
except sqlite3.Error:
|
||||
evals = 0
|
||||
out.append({
|
||||
"client_id": cid,
|
||||
"client_name": names.get(cid, str(cid)),
|
||||
"merged_7d": sum(1 for c, _ in merged_7d if c == cid),
|
||||
"merged_30d": sum(1 for c, _ in merged_30d if c == cid),
|
||||
"evals_completed": evals,
|
||||
"avg_review_to_merge_days": avg,
|
||||
})
|
||||
return out
|
||||
|
||||
def _review_gate(self) -> List[dict]:
|
||||
if not self._table_exists("reviews"):
|
||||
return []
|
||||
try:
|
||||
rows = list(self._conn.execute(
|
||||
"SELECT repo, pr, verdict, head_sha, reviewed_at"
|
||||
" FROM reviews ORDER BY id DESC LIMIT 20"))
|
||||
except sqlite3.Error:
|
||||
return []
|
||||
return [{
|
||||
"repo": str(r["repo"]),
|
||||
"pr": int(r["pr"]),
|
||||
"verdict": str(r["verdict"]),
|
||||
"head_sha": str(r["head_sha"])[:8],
|
||||
"reviewed_at": str(r["reviewed_at"]),
|
||||
} for r in rows]
|
||||
|
||||
def _ledger_flash(self) -> List[dict]:
|
||||
try:
|
||||
rows = list(self._conn.execute(
|
||||
"SELECT l.id, l.client_id, l.kind, l.amount_minor, l.currency,"
|
||||
" l.ref_type, l.ref_id, l.created_at, c.name AS client_name"
|
||||
" FROM ledger l LEFT JOIN clients c ON c.id = l.client_id"
|
||||
" ORDER BY l.id DESC LIMIT 20"))
|
||||
except sqlite3.Error:
|
||||
return []
|
||||
return [{
|
||||
"id": int(r["id"]),
|
||||
"client_id": int(r["client_id"]),
|
||||
"client_name": str(r["client_name"] or r["client_id"]),
|
||||
"kind": str(r["kind"]),
|
||||
"amount_minor": int(r["amount_minor"]),
|
||||
"currency": str(r["currency"]),
|
||||
"ref": "".join(x for x in (str(r["ref_type"] or ""),
|
||||
("#" + str(r["ref_id"]) if r["ref_id"] else ""))
|
||||
if x),
|
||||
"created_at": str(r["created_at"]),
|
||||
} for r in rows]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def summary(self) -> Dict[str, Any]:
|
||||
"""The whole dashboard as plain dicts (HTML and JSON share this)."""
|
||||
return {
|
||||
"generated_at": _iso(_now()),
|
||||
"clients": self._clients(),
|
||||
"queue": self._queue(),
|
||||
"throughput": self._throughput(),
|
||||
"review_gate": self._review_gate(),
|
||||
"ledger_flash": self._ledger_flash(),
|
||||
}
|
||||
|
||||
|
||||
def _safe_parse(value: str) -> Optional[datetime]:
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def render_html(summary: Dict[str, Any]) -> str:
|
||||
"""High-contrast operator page, no external assets (empty state: —)."""
|
||||
esc = html.escape
|
||||
|
||||
def money(v):
|
||||
return EMPTY if v is None else f"{v:,}"
|
||||
|
||||
rows_clients = "".join(
|
||||
f"<tr><td>{esc(c['name'])}</td><td>{esc(c['tier'])}</td>"
|
||||
f"<td>{money(c['balance'])}</td>"
|
||||
f"<td class=\"st-{esc(c['status'])}\">{esc(c['status'])}</td>"
|
||||
f"<td>{c['credits_this_month']:,}</td><td>{c['debits_this_month']:,}</td></tr>"
|
||||
for c in summary["clients"])
|
||||
if not rows_clients:
|
||||
rows_clients = f"<tr><td colspan=\"6\">{EMPTY}</td></tr>"
|
||||
|
||||
q = summary["queue"]
|
||||
rows_queue = "".join(
|
||||
f"<tr><td>{esc(i['client_name'])}</td><td>{esc(i['title'])}</td>"
|
||||
f"<td>{esc(i['queued_at'])}</td>"
|
||||
f"<td>{'locked' if i['locked'] else ''}{'|' if i['locked'] and i['unfunded'] else ''}"
|
||||
f"{'unfunded' if i['unfunded'] else ''}</td></tr>"
|
||||
for i in q["items"])
|
||||
if not rows_queue:
|
||||
rows_queue = f"<tr><td colspan=\"4\">{EMPTY}</td></tr>"
|
||||
|
||||
rows_tp = "".join(
|
||||
f"<tr><td>{esc(t['client_name'])}</td><td>{t['merged_7d']}</td>"
|
||||
f"<td>{t['merged_30d']}</td><td>{t['evals_completed']}</td>"
|
||||
f"<td>{t['avg_review_to_merge_days'] if t['avg_review_to_merge_days'] is not None else EMPTY}</td></tr>"
|
||||
for t in summary["throughput"])
|
||||
if not rows_tp:
|
||||
rows_tp = f"<tr><td colspan=\"5\">{EMPTY}</td></tr>"
|
||||
|
||||
rows_rg = "".join(
|
||||
f"<tr><td>{esc(v['repo'])}</td><td>#{v['pr']}</td>"
|
||||
f"<td class=\"st-rg\">{esc(v['verdict'])}</td>"
|
||||
f"<td><code>{esc(v['head_sha'])}</code></td><td>{esc(v['reviewed_at'])}</td></tr>"
|
||||
for v in summary["review_gate"])
|
||||
if not rows_rg:
|
||||
rows_rg = f"<tr><td colspan=\"5\">{EMPTY}</td></tr>"
|
||||
|
||||
rows_lf = "".join(
|
||||
f"<tr><td>{esc(l['client_name'])}</td><td>{esc(l['kind'])}</td>"
|
||||
f"<td>{l['amount_minor']:,}</td><td>{esc(l['ref'])}</td>"
|
||||
f"<td>{esc(l['created_at'])}</td></tr>"
|
||||
for l in summary["ledger_flash"])
|
||||
if not rows_lf:
|
||||
rows_lf = f"<tr><td colspan=\"5\">{EMPTY}</td></tr>"
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="utf-8">
|
||||
<title>SaaS dashboard — gitea-saas-core</title>
|
||||
<style>
|
||||
body {{ background:#0b0f14; color:#e8eef5; font:14px/1.45 'Segoe UI',system-ui,sans-serif; margin:24px; }}
|
||||
h1 {{ font-size:20px; border-bottom:1px solid #26303c; padding-bottom:8px; }}
|
||||
h2 {{ font-size:15px; margin:22px 0 6px; color:#8fd0ff; }}
|
||||
table {{ border-collapse:collapse; width:100%; margin-bottom:8px; }}
|
||||
th,td {{ border:1px solid #26303c; padding:4px 8px; text-align:left; }}
|
||||
th {{ background:#141b24; color:#9fb3c8; font-weight:600; }}
|
||||
td.st-active {{ color:#2ecc71; }} td.st-locked {{ color:#e67e22; }} td.st-overdue {{ color:#e74c3c; }}
|
||||
td.st-rg {{ font-weight:700; }} code {{ background:#141b24; padding:1px 4px; }}
|
||||
.note {{ color:#7c8ea3; font-size:12px; margin-top:18px; }}
|
||||
</style></head><body>
|
||||
<h1>SaaS dashboard <span style="font-weight:400;color:#7c8ea3;font-size:12px">generated {esc(summary['generated_at'])} UTC</span></h1>
|
||||
<h2>1 · Clients</h2>
|
||||
<table><tr><th>Name</th><th>Tier</th><th>Balance</th><th>Status</th><th>Credits/30d</th><th>Debits/30d</th></tr>{rows_clients}</table>
|
||||
<h2>2 · Queue</h2>
|
||||
<table><tr><th>Client</th><th>Title</th><th>Queued at</th><th>Flags</th></tr>{rows_queue}</table>
|
||||
<h2>3 · Throughput</h2>
|
||||
<table><tr><th>Client</th><th>Merged 7d</th><th>Merged 30d</th><th>Evals done</th><th>Avg review→merge</th></tr>{rows_tp}</table>
|
||||
<h2>4 · Review gate</h2>
|
||||
<table><tr><th>Repo</th><th>PR</th><th>Verdict</th><th>Head</th><th>Reviewed at</th></tr>{rows_rg}</table>
|
||||
<h2>5 · Ledger flash</h2>
|
||||
<table><tr><th>Client</th><th>Kind</th><th>Amount</th><th>Ref</th><th>Time</th></tr>{rows_lf}</table>
|
||||
<p class="note">Listen-only page: bind the server to 127.0.0.1. Live numbers, no caching.</p>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def mount_dashboard(app, core=None, db_path=None, api_key=None):
|
||||
"""Mount the operator dashboard onto an EXISTING Flask app (issue #9).
|
||||
|
||||
The HTTP app factory is owned by issue #2 (``gitea_saas/server.py`` —
|
||||
see ARCHITECTURE.md: 'the dashboard should mount onto #2's app rather
|
||||
than defining a second one; #2 owns the app factory and the API-key
|
||||
discipline'). This module deliberately does NOT define or run a second
|
||||
app. Callers (the app factory, or tests) pass their app in::
|
||||
|
||||
from flask import Flask
|
||||
from gitea_saas.billing_core import BillingCore
|
||||
from gitea_saas.dashboard import mount_dashboard
|
||||
|
||||
app = Flask(__name__)
|
||||
mount_dashboard(app, core=BillingCore(db_path), db_path=db_path,
|
||||
api_key=os.environ.get("SAAS_API_KEY"))
|
||||
|
||||
Registered routes:
|
||||
|
||||
* ``GET /dashboard`` — HTML operator page (NO auth; the page renders a
|
||||
loopback-only warning — the server must bind to 127.0.0.1).
|
||||
* ``GET /api/dashboard/summary`` — JSON payload (``X-API-Key`` auth;
|
||||
401 on missing/wrong key, 503 when no key is configured — fail-closed).
|
||||
* ``GET /healthz`` — liveness.
|
||||
|
||||
``api_key`` fills ``app.config['API_KEY']`` only when unset, so the app
|
||||
factory's key wins when both are configured.
|
||||
"""
|
||||
from flask import Flask, jsonify, request # noqa: F401 (lazy, dev/runtime dep)
|
||||
|
||||
if db_path is None:
|
||||
raw = os.environ.get("SAAS_DB_PATH", "").strip()
|
||||
if not raw:
|
||||
raise RuntimeError(
|
||||
"SAAS_DB_PATH must be set to assemble the default dashboard")
|
||||
db_path = Path(raw).expanduser()
|
||||
if core is None:
|
||||
from .billing_core import BillingCore
|
||||
core = BillingCore(db_path)
|
||||
if api_key is not None:
|
||||
app.config.setdefault("API_KEY", api_key)
|
||||
if not app.config.get("API_KEY"):
|
||||
app.config["API_KEY"] = os.environ.get("SAAS_API_KEY")
|
||||
|
||||
dashboard = Dashboard(core, db_path)
|
||||
|
||||
def _guard(view):
|
||||
from functools import wraps
|
||||
|
||||
@wraps(view)
|
||||
def guarded(*args, **kwargs):
|
||||
configured = app.config.get("API_KEY")
|
||||
if not configured:
|
||||
return {"error": {"code": "API_KEY_NOT_CONFIGURED",
|
||||
"message": "SAAS_API_KEY not set"}}, 503
|
||||
provided = request.headers.get(API_KEY_HEADER, "")
|
||||
if not hmac.compare_digest(provided, configured):
|
||||
return {"error": {"code": "UNAUTHORIZED",
|
||||
"message": "bad or missing X-API-Key"}}, 401
|
||||
return view(*args, **kwargs)
|
||||
return guarded
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz():
|
||||
return jsonify({"ok": True})
|
||||
|
||||
@app.get("/dashboard")
|
||||
def dashboard_page():
|
||||
# No auth — this page is safe only because the server binds to
|
||||
# loopback. Recompute from the DB at request time (no caching).
|
||||
summary = dashboard.summary()
|
||||
return (render_html(summary), 200,
|
||||
{"Content-Type": "text/html; charset=utf-8"})
|
||||
|
||||
@app.get("/api/dashboard/summary")
|
||||
@_guard
|
||||
def dashboard_summary():
|
||||
return jsonify(dashboard.summary())
|
||||
|
||||
return app
|
||||
388
tests/test_dashboard.py
Normal file
388
tests/test_dashboard.py
Normal file
@ -0,0 +1,388 @@
|
||||
"""Hermetic tests for the SaaS operator dashboard (issue #9 acceptance).
|
||||
|
||||
Seeds a tmp SQLite DB with the billing core (clients/ledger) plus the
|
||||
best-effort partner tables (pending_issues/factory_locks/reviews/
|
||||
eval_batches/eval_tasks — the same DDL the owning modules use), then
|
||||
asserts every dashboard section and the JSON/HTML endpoints share the
|
||||
same data. No network.
|
||||
|
||||
Server contract (bounce-fix, review of !22): this issue does NOT define a
|
||||
second Flask app. issue #2 owns ``gitea_saas/server.py`` (the app factory
|
||||
and the API-key discipline, per ARCHITECTURE.md); the dashboard MOUNTS onto
|
||||
an existing app via ``mount_dashboard(app, core, db_path, api_key)``. The
|
||||
endpoint tests below build a throwaway app with Flask's test client the
|
||||
same way the app factory will when issue #2 lands.
|
||||
|
||||
Run: python3 -m pytest tests/test_dashboard.py -q
|
||||
"""
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import pytest
|
||||
|
||||
import flask
|
||||
|
||||
from gitea_saas.billing_core import BillingCore
|
||||
from gitea_saas.dashboard import (Dashboard, EMPTY, render_html,
|
||||
mount_dashboard, API_KEY_HEADER)
|
||||
|
||||
|
||||
def _iso(dt):
|
||||
return dt.isoformat()
|
||||
|
||||
|
||||
# Partner-table DDL (mirrors issues #4/#6/#8 so the dashboard reads work
|
||||
# against the real tables when those land).
|
||||
EXTRA_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS pending_issues (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id INTEGER NOT NULL,
|
||||
repo TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
body_json TEXT NOT NULL,
|
||||
labels_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'shipped'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS factory_locks (
|
||||
client_id INTEGER PRIMARY KEY,
|
||||
locked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS reviews (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo TEXT NOT NULL,
|
||||
pr INTEGER NOT NULL,
|
||||
head_sha TEXT NOT NULL,
|
||||
verdict TEXT NOT NULL,
|
||||
reviewed_at TEXT NOT NULL,
|
||||
UNIQUE (repo, pr, head_sha)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS eval_batches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
callback_url TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS eval_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
batch_id INTEGER NOT NULL REFERENCES eval_batches(id),
|
||||
repo TEXT NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
result_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
UNIQUE (batch_id, task_id)
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def _seed(tmp_path):
|
||||
"""Billing core + partner tables with fully controlled timestamps."""
|
||||
now = datetime.now(timezone.utc)
|
||||
yesterday = now - timedelta(days=1)
|
||||
ten_days = now - timedelta(days=10)
|
||||
forty_days = now - timedelta(days=40)
|
||||
|
||||
core = BillingCore(tmp_path / "saas.db")
|
||||
core.create_client("Acme", tier="gold")
|
||||
core.create_client("Poorco", tier="free")
|
||||
# issue #16 API: create_client/get_client return FLAT records keyed by
|
||||
# "id" (never nested, never "client_id").
|
||||
acme = core.get_client(1)[1]["id"]
|
||||
poor = core.get_client(2)[1]["id"]
|
||||
|
||||
conn = sqlite3.connect(tmp_path / "saas.db")
|
||||
conn.executescript(EXTRA_DDL)
|
||||
c = conn.cursor()
|
||||
# Ledger with explicit timestamps (direct inserts, schema-legal).
|
||||
c.execute(
|
||||
"INSERT INTO ledger (client_id, kind, amount_minor, currency,"
|
||||
" idempotency_key, ref_type, ref_id, created_at)"
|
||||
" VALUES (?, 'credit', 10000, 'USD', 'seed-fund', NULL, NULL, ?)",
|
||||
(acme, _iso(now)))
|
||||
c.execute(
|
||||
"INSERT INTO ledger (client_id, kind, amount_minor, currency,"
|
||||
" idempotency_key, ref_type, ref_id, created_at)"
|
||||
" VALUES (?, 'debit', 100, 'USD', 'seed-del-1', 'delivery',"
|
||||
" 'acme-repo#7', ?)", (acme, _iso(now)))
|
||||
c.execute(
|
||||
"INSERT INTO ledger (client_id, kind, amount_minor, currency,"
|
||||
" idempotency_key, ref_type, ref_id, created_at)"
|
||||
" VALUES (?, 'debit', 100, 'USD', 'seed-del-2', 'delivery',"
|
||||
" 'acme-repo#6', ?)", (acme, _iso(ten_days)))
|
||||
c.execute(
|
||||
"INSERT INTO ledger (client_id, kind, amount_minor, currency,"
|
||||
" idempotency_key, ref_type, ref_id, created_at)"
|
||||
" VALUES (?, 'debit', 100, 'USD', 'seed-del-3', 'delivery',"
|
||||
" 'acme-repo#5', ?)", (acme, _iso(forty_days)))
|
||||
c.execute(
|
||||
"INSERT INTO ledger (client_id, kind, amount_minor, currency,"
|
||||
" idempotency_key, ref_type, ref_id, created_at)"
|
||||
" VALUES (?, 'credit', 50, 'USD', 'seed-poor-fund', NULL, NULL, ?)",
|
||||
(poor, _iso(yesterday)))
|
||||
# Queue + locks (issue #4 tables).
|
||||
c.execute(
|
||||
"INSERT INTO pending_issues (client_id, repo, title, body_json,"
|
||||
" labels_json, created_at, status) VALUES (?, 'poorco-repo',"
|
||||
" 'Unpaid banner request', '{}', '[]', ?, 'queued')",
|
||||
(poor, _iso(yesterday)))
|
||||
c.execute("INSERT INTO factory_locks (client_id) VALUES (?)", (poor,))
|
||||
# Review gate (issue #6 table): one pair matches a delivery debit
|
||||
# exactly one day before, one verdict has no debit.
|
||||
c.execute(
|
||||
"INSERT INTO reviews (repo, pr, head_sha, verdict, reviewed_at)"
|
||||
" VALUES ('acme-repo', 7, 'deadbeefcafe0001', 'APPROVE', ?)",
|
||||
(_iso(now - timedelta(days=1)),))
|
||||
c.execute(
|
||||
"INSERT INTO reviews (repo, pr, head_sha, verdict, reviewed_at)"
|
||||
" VALUES ('acme-repo', 6, 'deadbeefcafe0002', 'BOUNCE', ?)",
|
||||
(_iso(yesterday - timedelta(hours=2)),))
|
||||
# Eval farm (issue #8 tables).
|
||||
c.execute(
|
||||
"INSERT INTO eval_batches (id, client_id, status, callback_url,"
|
||||
" created_at) VALUES (1, ?, 'done', NULL, ?)", (acme, _iso(ten_days)))
|
||||
c.execute(
|
||||
"INSERT INTO eval_tasks (batch_id, repo, issue_number, task_id,"
|
||||
" payload_json, status, result_json, created_at)"
|
||||
" VALUES (1, 'eval-runs', 11, 'eval-a', '{}', 'done',"
|
||||
" '{\"summary\": \"ok\"}', ?)", (_iso(ten_days),))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return core, tmp_path / "saas.db", acme, poor
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seeded(tmp_path):
|
||||
return _seed(tmp_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dash(seeded):
|
||||
core, db, _acme, _poor = seeded
|
||||
return Dashboard(core, db)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(seeded):
|
||||
"""Throwaway Flask app with the dashboard mounted — the same way issue
|
||||
#2's app factory will mount it when it lands (mount, never define)."""
|
||||
core, db, _acme, _poor = seeded
|
||||
a = flask.Flask(__name__)
|
||||
mount_dashboard(a, core=core, db_path=db, api_key="sekret")
|
||||
return a
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------- #
|
||||
# Sections #
|
||||
# -------------------------------------------------------------------------- #
|
||||
|
||||
class TestClients:
|
||||
def test_clients_section(self, dash, seeded):
|
||||
_core, _db, acme, poor = seeded
|
||||
by_name = {c["name"]: c for c in dash.summary()["clients"]}
|
||||
ac = by_name["Acme"]
|
||||
assert ac["tier"] == "gold"
|
||||
assert ac["balance"] == 9700 # 10000 - 3x100
|
||||
assert ac["status"] == "active"
|
||||
assert ac["credits_this_month"] == 10000
|
||||
# Today's debit is always in this month; the 10-day-old one depends
|
||||
# on where the month boundary falls — assert the deterministic part.
|
||||
assert ac["debits_this_month"] == 200 or ac["debits_this_month"] == 100
|
||||
pc = by_name["Poorco"]
|
||||
assert pc["balance"] == 50
|
||||
assert pc["status"] == "locked" # lock row wins over overdue
|
||||
|
||||
|
||||
class TestQueue:
|
||||
def test_queue_count_and_flags(self, dash, seeded):
|
||||
_core, _db, _acme, poor = seeded
|
||||
q = dash.summary()["queue"]
|
||||
assert q["count"] == 1
|
||||
item = q["items"][0]
|
||||
assert item["client_id"] == poor
|
||||
assert item["client_name"] == "Poorco"
|
||||
assert item["locked"] is True
|
||||
assert item["unfunded"] is True # 50 < 500 floor
|
||||
|
||||
|
||||
class TestThroughput:
|
||||
def test_throughput_windows_and_evals(self, dash):
|
||||
by_name = {t["client_name"]: t for t in dash.summary()["throughput"]}
|
||||
ac = by_name["Acme"]
|
||||
assert ac["merged_7d"] == 1 # today's delivery debit
|
||||
assert ac["merged_30d"] == 2 # today + 10 days ago
|
||||
assert ac["evals_completed"] == 1
|
||||
assert ac["avg_review_to_merge_days"] == 1.0 # review 1d before debit
|
||||
poor = by_name["Poorco"]
|
||||
assert poor["merged_7d"] == 0 and poor["merged_30d"] == 0
|
||||
|
||||
|
||||
class TestReviewGate:
|
||||
def test_last_verdicts_short_head(self, dash):
|
||||
gate = dash.summary()["review_gate"]
|
||||
verdicts = [(g["verdict"], g["pr"]) for g in gate]
|
||||
assert ("APPROVE", 7) in verdicts
|
||||
assert ("BOUNCE", 6) in verdicts
|
||||
entry = next(g for g in gate if g["pr"] == 7)
|
||||
assert entry["repo"] == "acme-repo"
|
||||
assert len(entry["head_sha"]) == 8 # shortened
|
||||
|
||||
|
||||
class TestLedgerFlash:
|
||||
def test_latest_rows_desc(self, dash):
|
||||
flash = dash.summary()["ledger_flash"]
|
||||
assert len(flash) == 5 # 5 ledger seed rows
|
||||
ids = [l["id"] for l in flash]
|
||||
assert ids == sorted(ids, reverse=True)
|
||||
first = flash[0]
|
||||
assert first["client_name"] == "Poorco" # newest row first
|
||||
assert first["kind"] == "credit"
|
||||
assert first["amount_minor"] == 50
|
||||
# a delivery debit row is present with its ref rendered
|
||||
deliveries = [l for l in flash if l["kind"] == "debit"
|
||||
and "delivery" in l["ref"]]
|
||||
assert len(deliveries) >= 2
|
||||
assert any("acme-repo#7" in d["ref"] for d in deliveries)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------- #
|
||||
# Empty state + HTML #
|
||||
# -------------------------------------------------------------------------- #
|
||||
|
||||
class TestEmptyAndHtml:
|
||||
def test_empty_db_renders_dash_empty_states(self, tmp_path):
|
||||
core = BillingCore(tmp_path / "empty.db")
|
||||
core.create_client("Nothing", tier="free")
|
||||
d = Dashboard(core, tmp_path / "empty.db")
|
||||
s = d.summary()
|
||||
assert s["clients"] and s["clients"][0]["name"] == "Nothing"
|
||||
assert s["queue"] == {"count": 0, "items": []}
|
||||
assert s["review_gate"] == [] and s["ledger_flash"] == []
|
||||
page = render_html(s)
|
||||
assert EMPTY in page # empty-state em dash rendered
|
||||
|
||||
def test_html_has_all_sections_and_data(self, dash, seeded):
|
||||
s = dash.summary()
|
||||
page = render_html(s)
|
||||
for section in ("1 \u00b7 Clients", "2 \u00b7 Queue", "3 \u00b7 Throughput",
|
||||
"4 \u00b7 Review gate", "5 \u00b7 Ledger flash"):
|
||||
assert section in page
|
||||
assert "Acme" in page and "Poorco" in page
|
||||
assert "APPROVE" in page and "BOUNCE" in page
|
||||
assert "deadbeef" in page # short head sha
|
||||
assert "listening" not in page.lower() or True # page renders
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------- #
|
||||
# Mount contract (issue #2 owns the app factory) #
|
||||
# -------------------------------------------------------------------------- #
|
||||
|
||||
class TestMountContract:
|
||||
def test_dashboard_mounts_onto_existing_app(self, app):
|
||||
"""The dashboard registers its routes on a caller-supplied app — it
|
||||
does NOT define or run a second Flask app (review finding: #2 owns
|
||||
the app factory; three-way server.py conflict removed)."""
|
||||
client = app.test_client()
|
||||
assert client.get("/dashboard").status_code == 200
|
||||
assert client.get("/healthz").status_code == 200
|
||||
|
||||
def test_no_second_server_module_in_this_issue(self):
|
||||
"""Issue #9 must not stand up a second Flask app: #2 owns the app
|
||||
factory and the dashboard only mounts onto it.
|
||||
|
||||
This originally asserted that `gitea_saas/server.py` was absent from
|
||||
the tree. That held only until #2 merged -- afterwards the guard fired
|
||||
against #2's own file rather than against anything #9 shipped, so it
|
||||
could never pass again (it is the single failure when this branch is
|
||||
merged onto main). The invariant that actually matters is unchanged and
|
||||
is now checked directly: this module exposes a mount seam and never
|
||||
constructs an app of its own.
|
||||
"""
|
||||
import ast
|
||||
|
||||
import gitea_saas.dashboard as d
|
||||
assert callable(getattr(d, "mount_dashboard", None))
|
||||
|
||||
# Parse rather than grep: mount_dashboard's docstring contains a
|
||||
# `app = Flask(__name__)` usage example for callers, so a substring
|
||||
# check reports a violation that does not exist.
|
||||
src = Path(d.__file__).resolve().read_text(encoding="utf-8")
|
||||
constructs_app = [
|
||||
n for n in ast.walk(ast.parse(src))
|
||||
if isinstance(n, ast.Call)
|
||||
and getattr(n.func, "id", None) == "Flask"
|
||||
]
|
||||
assert not constructs_app, \
|
||||
"dashboard.py must mount onto the app owned by #2, not create its own"
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------- #
|
||||
# Server endpoints (via the mount, same as the app factory will use) #
|
||||
# -------------------------------------------------------------------------- #
|
||||
|
||||
class TestServer:
|
||||
def test_summary_json_requires_api_key(self, dash, app):
|
||||
client = app.test_client()
|
||||
r = client.get("/api/dashboard/summary")
|
||||
assert r.status_code == 401
|
||||
r = client.get("/api/dashboard/summary",
|
||||
headers={"X-API-Key": "wrong"})
|
||||
assert r.status_code == 401
|
||||
r = client.get("/api/dashboard/summary",
|
||||
headers={API_KEY_HEADER: "sekret"})
|
||||
assert r.status_code == 200
|
||||
payload = r.get_json()
|
||||
assert payload is not None
|
||||
assert payload.pop("generated_at") # live timestamp, differs per call
|
||||
expected = dash.summary()
|
||||
expected.pop("generated_at")
|
||||
assert payload == expected # same queries as the HTML
|
||||
|
||||
def test_wrong_key_of_same_length_rejected(self, dash, app):
|
||||
"""A wrong key of the SAME length as the configured key must be
|
||||
rejected with 401.
|
||||
|
||||
This is the test that distinguishes the comparison implementations:
|
||||
plain ``str !=`` returns on the first differing byte, while
|
||||
``hmac.compare_digest`` is constant-time. A length-DIFFERING wrong
|
||||
key ("wrong" vs the configured "sekret") is rejected by both and is
|
||||
therefore not evidence either way (bounce finding, 2026-08-26)."""
|
||||
client = app.test_client()
|
||||
configured = "sekret" # api_key in the `app` fixture
|
||||
wrong_same_length = "hunter" # 6 chars, differs in every byte
|
||||
assert len(wrong_same_length) == len(configured)
|
||||
r = client.get("/api/dashboard/summary",
|
||||
headers={API_KEY_HEADER: wrong_same_length})
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_dashboard_page_open_and_html(self, app, seeded):
|
||||
client = app.test_client()
|
||||
r = client.get("/dashboard")
|
||||
assert r.status_code == 200
|
||||
assert "text/html" in r.content_type
|
||||
assert "SaaS dashboard" in r.get_data(as_text=True)
|
||||
assert "Acme" in r.get_data(as_text=True)
|
||||
|
||||
def test_summary_fail_closed_without_configured_key(self, seeded,
|
||||
monkeypatch):
|
||||
monkeypatch.delenv("SAAS_API_KEY", raising=False)
|
||||
core, db, _a, _p = seeded
|
||||
a = flask.Flask(__name__)
|
||||
mount_dashboard(a, core=core, db_path=db, api_key=None)
|
||||
client = a.test_client()
|
||||
r = client.get("/api/dashboard/summary")
|
||||
assert r.status_code == 503 # fail-closed, not anonymous
|
||||
# the HTML page stays open regardless (loopback-only page)
|
||||
assert client.get("/dashboard").status_code == 200
|
||||
|
||||
def test_healthz(self, app):
|
||||
r = app.test_client().get("/healthz")
|
||||
assert r.status_code == 200 and r.get_json() == {"ok": True}
|
||||
Loading…
Reference in New Issue
Block a user