The HTML operator page served the same clients/ledger/queue/review data as the authenticated JSON summary, but with no auth at all. The loopback requirement it relied on existed only as prose (page note + docstring); it was never enforced by mount_dashboard, which does not own the app and cannot constrain the bind address. Put /dashboard behind the same _guard as /api/dashboard/summary: - no X-API-Key -> 401, no billing data - no configured key -> 503 (fail-closed) - valid key -> 200 HTML (unchanged rendering) Also drop the now-false 'loopback-only' security claims from the page note and the mount_dashboard docstring, and update the tests: - test_dashboard_page_open_and_html now sends a valid key - new test_dashboard_page_guard_blocks_anonymous_access: no key -> 401, body contains no client data (mutation check: fails if guard removed) - new test_dashboard_wrong_key_same_length_rejected: wrong same-length key -> 401 on HTML page too - test_summary_fail_closed_without_configured_key now asserts the HTML page also fails closed (503), not open - test_dashboard_mounts_onto_existing_app sends a valid key /api/dashboard/summary behaviour unchanged. Suite: 160 passed, 1 skipped (before: 158 passed, 1 skipped).
504 lines
20 KiB
Python
504 lines
20 KiB
Python
"""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">Operator page — requires the same API key as the JSON summary. 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 (same ``X-API-Key`` auth and
|
|
fail-closed 401/503 shapes as the JSON summary; never billing data
|
|
without a valid key).
|
|
* ``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")
|
|
@_guard
|
|
def dashboard_page():
|
|
# Same guard as /api/dashboard/summary (issue #25): X-API-Key auth,
|
|
# fail-closed 401/503 shapes, never billing data without a valid key.
|
|
# Loopback binding is defense-in-depth only; it was never enforced, so
|
|
# it is NOT the authorization boundary. 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
|