482 lines
17 KiB
Python
482 lines
17 KiB
Python
"""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", "")))} · {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/<script>/</code> '
|
|
'or <code>/api/<group>/<script>/</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 · 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")),
|
|
)
|