This commit is contained in:
Caffeine Fueled 2026-07-10 21:38:38 +02:00
commit 39c625f57f
Signed by: cf7
GPG key ID: CA295D643074C68C
9 changed files with 679 additions and 0 deletions

482
app.py Normal file
View file

@ -0,0 +1,482 @@
"""metrica — a tiny push-metrics collector.
Scripts push arbitrary content (JSON or raw text) to /api/{script}/ or
/api/{group}/{script}/ to group them using a bearer token. Each push is
appended to a per-script JSONL file (static files, no database). A minimal
dashboard at / shows every script, grouped, with its latest value and history.
"""
import html
import json
import os
import re
import secrets
from datetime import datetime, timezone
from pathlib import Path
from threading import Lock
import uvicorn
from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse
# --------------------------------------------------------------------------- #
# Config
# --------------------------------------------------------------------------- #
def load_dotenv(path: str = ".env") -> None:
"""Minimal .env loader so we don't need an extra dependency."""
p = Path(path)
if not p.exists():
return
for line in p.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
os.environ.setdefault(key.strip(), val.strip().strip('"').strip("'"))
load_dotenv()
DATA_DIR = Path(os.environ.get("METRICA_DATA_DIR", "data"))
DATA_DIR.mkdir(parents=True, exist_ok=True)
# How many recent points the dashboard reads/plots per script.
MAX_HISTORY = int(os.environ.get("METRICA_MAX_HISTORY", "100"))
# Script names become file names, so keep them strictly safe (no path traversal).
SCRIPT_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
def load_tokens() -> set[str]:
"""Any one of these tokens authorizes a push (not tied to a script)."""
tokens: set[str] = set()
for chunk in re.split(r"[,\s]+", os.environ.get("METRICA_TOKENS", "")):
if chunk.strip():
tokens.add(chunk.strip())
token_file = os.environ.get("METRICA_TOKENS_FILE")
if token_file and Path(token_file).exists():
for line in Path(token_file).read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#"):
tokens.add(line)
return tokens
TOKENS = load_tokens()
if not TOKENS:
_generated = secrets.token_urlsafe(24)
TOKENS.add(_generated)
print("=" * 72)
print(" metrica: no METRICA_TOKENS configured — generated a temporary token")
print(f" METRICA_TOKENS={_generated}")
print(" (set METRICA_TOKENS in .env for a stable token across restarts)")
print("=" * 72)
# --------------------------------------------------------------------------- #
# Storage (append-only JSONL, one file per script)
# --------------------------------------------------------------------------- #
_locks: dict[str, Lock] = {}
_locks_guard = Lock()
def _lock_for(name: str) -> Lock:
with _locks_guard:
return _locks.setdefault(name, Lock())
def _path_for(group: str | None, script: str) -> Path:
"""A group is an optional sub-directory; ungrouped scripts sit at the top."""
if group:
return DATA_DIR / group / f"{script}.jsonl"
return DATA_DIR / f"{script}.jsonl"
def append_point(group: str | None, script: str, payload) -> dict:
entry = {"ts": _now_iso(), "data": payload}
line = json.dumps(entry, ensure_ascii=False)
path = _path_for(group, script)
with _lock_for(str(path)):
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as f:
f.write(line + "\n")
return entry
def read_points(group: str | None, script: str, limit: int = MAX_HISTORY) -> tuple[list[dict], int]:
"""Return (recent_points, total_count)."""
path = _path_for(group, script)
if not path.exists():
return [], 0
lines = [ln for ln in path.read_text(encoding="utf-8").splitlines() if ln.strip()]
points = []
for ln in lines[-limit:]:
try:
points.append(json.loads(ln))
except json.JSONDecodeError:
continue
return points, len(lines)
def list_entries() -> list[tuple[str | None, str]]:
"""All (group, script) pairs. group is None for top-level (ungrouped) scripts."""
entries: list[tuple[str | None, str]] = [(None, p.stem) for p in sorted(DATA_DIR.glob("*.jsonl"))]
entries += [(p.parent.name, p.stem) for p in sorted(DATA_DIR.glob("*/*.jsonl"))]
return entries
def delete_script(group: str | None, script: str) -> bool:
"""Remove a script's data file (and its group dir if it becomes empty)."""
path = _path_for(group, script)
with _lock_for(str(path)):
existed = path.exists()
if existed:
path.unlink()
if group: # tidy up an empty group directory
try:
if path.parent.is_dir() and not any(path.parent.iterdir()):
path.parent.rmdir()
except OSError:
pass
return existed
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
def token_valid(supplied: str) -> bool:
return any(secrets.compare_digest(supplied, t) for t in TOKENS)
def require_token(authorization: str | None, x_api_key: str | None) -> None:
supplied = None
if authorization and authorization.lower().startswith("bearer "):
supplied = authorization[7:].strip()
elif x_api_key:
supplied = x_api_key.strip()
if not supplied or not token_valid(supplied):
raise HTTPException(status_code=401, detail="invalid or missing token")
def extract_number(data) -> float | None:
"""Pick a single numeric value from a payload for the sparkline."""
if isinstance(data, bool):
return None
if isinstance(data, (int, float)):
return float(data)
if isinstance(data, dict):
for v in data.values():
if isinstance(v, (int, float)) and not isinstance(v, bool):
return float(v)
return None
def humanize(ts_iso: str) -> str:
try:
dt = datetime.fromisoformat(ts_iso.replace("Z", "+00:00"))
except (ValueError, AttributeError):
return ts_iso
secs = int((datetime.now(timezone.utc) - dt).total_seconds())
if secs < 0:
return "just now"
if secs < 60:
return f"{secs}s ago"
if secs < 3600:
return f"{secs // 60}m ago"
if secs < 86400:
return f"{secs // 3600}h ago"
return f"{secs // 86400}d ago"
def sparkline(values: list[float], width: int = 260, height: int = 44) -> str:
if len(values) < 2:
return ""
lo, hi = min(values), max(values)
span = (hi - lo) or 1.0
n = len(values)
pts = []
for i, v in enumerate(values):
x = i / (n - 1) * width
y = height - (v - lo) / span * (height - 6) - 3
pts.append(f"{x:.1f},{y:.1f}")
return (
f'<svg class="spark" viewBox="0 0 {width} {height}" preserveAspectRatio="none">'
f'<polyline points="{" ".join(pts)}" fill="none" stroke="currentColor" '
f'stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/></svg>'
)
# --------------------------------------------------------------------------- #
# App
# --------------------------------------------------------------------------- #
app = FastAPI(title="metrica", docs_url="/docs")
@app.get("/health")
def health():
return {"ok": True, "scripts": len(list_entries())}
def _validate(*names: str) -> None:
for n in names:
if not SCRIPT_RE.match(n):
raise HTTPException(400, "invalid name; use [A-Za-z0-9_-], max 64 chars")
async def _parse_body(request: Request):
body = await request.body()
if not body:
return None
text = body.decode("utf-8", errors="replace")
try:
# Valid JSON (number, string, object, array...) is stored as-is.
return json.loads(text)
except json.JSONDecodeError:
# Anything else (command output, file contents, a bare word) is stored
# as raw text — unless the caller explicitly claimed JSON.
if "application/json" in (request.headers.get("content-type") or "").lower():
raise HTTPException(400, "Content-Type is application/json but body is not valid JSON")
return text
def _read_response(group: str | None, script: str, limit: int) -> JSONResponse:
points, total = read_points(group, script, max(1, min(limit, 10_000)))
out = {"script": script, "total": total, "points": points}
if group:
out = {"group": group, **out}
return JSONResponse(out)
@app.post("/api/{group}/{script}/")
@app.post("/api/{group}/{script}")
async def push_grouped(
group: str,
script: str,
request: Request,
authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None),
):
require_token(authorization, x_api_key)
_validate(group, script)
entry = append_point(group, script, await _parse_body(request))
return {"ok": True, "group": group, "script": script, "stored": entry}
@app.post("/api/{script}/")
@app.post("/api/{script}")
async def push(
script: str,
request: Request,
authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None),
):
require_token(authorization, x_api_key)
_validate(script)
entry = append_point(None, script, await _parse_body(request))
return {"ok": True, "script": script, "stored": entry}
@app.get("/api/{group}/{script}/")
@app.get("/api/{group}/{script}")
def get_script_grouped(
group: str,
script: str,
limit: int = MAX_HISTORY,
authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None),
):
require_token(authorization, x_api_key)
_validate(group, script)
return _read_response(group, script, limit)
@app.get("/api/{script}/")
@app.get("/api/{script}")
def get_script(
script: str,
limit: int = MAX_HISTORY,
authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None),
):
require_token(authorization, x_api_key)
_validate(script)
return _read_response(None, script, limit)
@app.delete("/api/{group}/{script}/")
@app.delete("/api/{group}/{script}")
def delete_grouped(
group: str,
script: str,
authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None),
):
require_token(authorization, x_api_key)
_validate(group, script)
return {"ok": True, "group": group, "script": script, "existed": delete_script(group, script)}
@app.delete("/api/{script}/")
@app.delete("/api/{script}")
def delete_ungrouped(
script: str,
authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None),
):
require_token(authorization, x_api_key)
_validate(script)
return {"ok": True, "script": script, "existed": delete_script(None, script)}
def _render_card(script: str, points: list[dict], total: int, latest: dict) -> str:
numbers = [n for n in (extract_number(p.get("data")) for p in points) if n is not None]
spark = sparkline(numbers)
data_latest = latest.get("data")
if isinstance(data_latest, str):
latest_disp = html.escape(data_latest) # preserve raw text / newlines
else:
latest_disp = html.escape(json.dumps(data_latest, indent=2, ensure_ascii=False))
num = extract_number(data_latest)
big = ""
if num is not None:
big = f'<div class="big">{num:g}</div>'
elif isinstance(data_latest, str) and "\n" not in data_latest and len(data_latest) <= 48:
big = f'<div class="big">{html.escape(data_latest)}</div>'
rows = "".join(
f'<tr><td class="t">{html.escape(humanize(p.get("ts", "")))}</td>'
f'<td class="v">{html.escape(_compact(p.get("data")))}</td></tr>'
for p in reversed(points[-8:])
)
return f"""
<section class="card">
<header>
<h2>{html.escape(script)}</h2>
<span class="meta">{html.escape(humanize(latest.get("ts", "")))} &middot; {total} points</span>
</header>
{big}
{spark}
<details><summary>latest payload</summary><pre>{latest_disp}</pre></details>
<table class="hist"><tbody>{rows}</tbody></table>
</section>
"""
@app.get("/", response_class=HTMLResponse)
def dashboard():
groups: dict[str, list] = {}
for group, script in list_entries():
points, total = read_points(group, script)
if not points:
continue
groups.setdefault(group or "", []).append((script, points, total, points[-1]))
# Named groups first (alphabetical), ungrouped ("") last.
ordered = sorted(groups, key=lambda g: (g == "", g.lower()))
sections = []
for gkey in ordered:
items = groups[gkey]
items.sort(key=lambda s: s[3].get("ts", ""), reverse=True) # newest first
cards = "".join(_render_card(*item) for item in items)
title = html.escape(gkey) if gkey else "ungrouped"
sections.append(
f'<section class="group"><h2 class="ghead">{title}'
f'<span class="gcount">{len(items)}</span></h2>'
f'<div class="grid">{cards}</div></section>'
)
body = "".join(sections) or (
'<p class="empty">No data yet. Push to <code>/api/&lt;script&gt;/</code> '
'or <code>/api/&lt;group&gt;/&lt;script&gt;/</code>.</p>'
)
return HTMLResponse(_PAGE.replace("{{BODY}}", body))
def _compact(data) -> str:
if data is None:
return ""
if isinstance(data, str):
s = data
elif isinstance(data, (int, float, bool)):
s = str(data)
else:
s = json.dumps(data, ensure_ascii=False)
s = " ".join(s.split()) # collapse newlines/whitespace for the one-line table
return s if len(s) <= 80 else s[:79] + ""
_PAGE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="15">
<title>metrica</title>
<style>
:root { color-scheme: light dark; --bg:#f6f7f9; --card:#fff; --fg:#1a1c1e;
--muted:#6b7280; --line:#e5e7eb; --accent:#2563eb; }
@media (prefers-color-scheme: dark) {
:root { --bg:#0f1113; --card:#181b1f; --fg:#e8eaed; --muted:#9aa0a6;
--line:#2a2e33; --accent:#60a5fa; }
}
* { box-sizing: border-box; }
body { margin:0; font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
background:var(--bg); color:var(--fg); }
header.top { padding:24px 24px 8px; }
header.top h1 { margin:0; font-size:20px; letter-spacing:.5px; }
header.top p { margin:4px 0 0; color:var(--muted); font-size:13px; }
main { padding:8px 24px 40px; }
.group { margin-top:22px; }
.ghead { display:flex; align-items:center; gap:8px; margin:0 0 12px;
font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:.7px;
color:var(--muted); border-bottom:1px solid var(--line); padding-bottom:6px; }
.gcount { font-weight:400; opacity:.65; }
.grid { display:grid; gap:16px;
grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); }
.card { background:var(--card); border:1px solid var(--line); border-radius:12px;
padding:16px; }
.card header { display:flex; justify-content:space-between; align-items:baseline; gap:8px; }
.card h2 { margin:0; font-size:16px; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
.meta { color:var(--muted); font-size:12px; white-space:nowrap; }
.big { font-size:34px; font-weight:600; margin:8px 0 2px; }
.spark { width:100%; height:44px; color:var(--accent); display:block; margin:4px 0; }
details { margin:6px 0; }
summary { cursor:pointer; color:var(--muted); font-size:12px; }
pre { background:var(--bg); border:1px solid var(--line); border-radius:8px;
padding:8px; overflow:auto; font-size:12px; margin:6px 0 0; }
table.hist { width:100%; border-collapse:collapse; margin-top:8px; font-size:12px; }
table.hist td { padding:3px 0; border-top:1px solid var(--line); }
table.hist td.t { color:var(--muted); white-space:nowrap; }
table.hist td.v { text-align:right; font-family:ui-monospace,Menlo,monospace;
max-width:60%; overflow:hidden; text-overflow:ellipsis; }
.empty { padding:24px; color:var(--muted); }
code { font-family:ui-monospace,Menlo,monospace; }
</style>
</head>
<body>
<header class="top">
<h1>metrica</h1>
<p>Pushed metrics &middot; auto-refreshes every 15s</p>
</header>
<main>{{BODY}}</main>
</body>
</html>
"""
if __name__ == "__main__":
uvicorn.run(
"app:app",
host=os.environ.get("METRICA_HOST", "127.0.0.1"),
port=int(os.environ.get("METRICA_PORT", "8000")),
reload=bool(os.environ.get("METRICA_RELOAD")),
)