commit 39c625f57f84648885330cf4e269fa3bec63d4cb Author: CaffeineFueled Date: Fri Jul 10 21:38:38 2026 +0200 INIT diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ea974db --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +data/ +.venv/ +__pycache__/ +*.pyc +.env +tokens.txt +.git/ +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fe2e49a --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Copy to .env and edit. Any one of these tokens authorizes a push. +# Comma- or whitespace-separated. +METRICA_TOKENS=change-me-token-1,change-me-token-2 + +# Optional: also read tokens from a file (one per line, # for comments). +# METRICA_TOKENS_FILE=tokens.txt + +# Optional: where JSONL data files live (default: ./data). +# METRICA_DATA_DIR=data + +# Optional: how many recent points the dashboard reads/plots per script. +# METRICA_MAX_HISTORY=100 + +# Optional: bind address when running `python app.py`. +# METRICA_HOST=127.0.0.1 +# METRICA_PORT=8000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc5138b --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +data/ +.venv/ +__pycache__/ +*.pyc +.env +tokens.txt diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..57079ee --- /dev/null +++ b/Dockerfile @@ -0,0 +1,66 @@ +# syntax=docker/dockerfile:1.7 + +# ============================================================================ +# Build stage: Debian 12's Python (paths/libs match the distroless runtime) +# ============================================================================ +# Don't use python:3.11-slim-bookworm — that image installs Python to /usr/local +# with an RPATH that breaks under distroless. Debian's apt python3 lives at the +# same paths as distroless's, so symlinks and libpython resolve correctly. +FROM debian:12-slim AS builder + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + python3 python3-pip python3-venv \ + && rm -rf /var/lib/apt/lists/* + +# Default (symlink) venv. /opt/venv/bin/python ends up as a symlink to +# /usr/bin/python3, which distroless also provides. +RUN python3 -m venv /opt/venv +ENV PATH=/opt/venv/bin:$PATH + +# Install runtime deps (cached layer — only rebuilt when requirements.txt changes) +COPY requirements.txt /tmp/requirements.txt +RUN pip install -r /tmp/requirements.txt + +# Pre-compile bytecode for faster cold start on a read-only rootfs +RUN python3 -m compileall -q /opt/venv + +# Pre-create the writable data dir so the runtime rootfs can stay read-only. +# metrica appends JSONL files here (one per script, grouped into sub-dirs); +# mount a volume over it to persist metrics across container restarts. +RUN mkdir -p /app/data + +# ============================================================================ +# Runtime stage: distroless Python 3.11, non-root (uid 65532), no shell +# ============================================================================ +# gcr.io/distroless/python3-debian12 ships Python 3.11 with NO shell, NO +# package manager, and NO setuid binaries. The :nonroot tag pins USER to +# uid 65532 / gid 65532. +FROM gcr.io/distroless/python3-debian12:nonroot + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + METRICA_DATA_DIR=/app/data + +WORKDIR /app + +# Venv ships its site-packages; bin/python is a symlink resolved by distroless's +# /usr/bin/python3 and Debian's libpython under /usr/lib. +COPY --from=builder --chown=nonroot:nonroot /opt/venv /opt/venv + +# Pre-created data dir owned by the runtime user (mount a writable volume here) +COPY --from=builder --chown=nonroot:nonroot /app/data /app/data + +# Application source — its own layer so app-only changes don't bust the dep cache +COPY --chown=nonroot:nonroot app.py /app/ + +USER nonroot +EXPOSE 8000 + +ENTRYPOINT ["/opt/venv/bin/python", "-m", "uvicorn"] +CMD ["app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c97a12e --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# metrica + +AI README PLACEHOLDER + +``` +POST /api/{script}/ push JSON (requires a token) +GET /api/{script}/ read JSON (requires a token) +GET / dashboard (open) +GET /health liveness +``` + +## Quick start + +```bash +# 1. configure a token (or let the app print a temporary one on first start) +cp .env.example .env +$EDITOR .env + +# 2. run it (uv installs deps into an ephemeral env automatically) +uv run app.py +# → http://127.0.0.1:8000 +``` + +If you don't set `METRICA_TOKENS`, the app generates a temporary token and +prints it to the console on startup. + +## Pushing data + +Any valid token authorizes any script. A script is auto-created the first time +it pushes — no registration step. + +```bash +TOKEN=change-me-token-1 + +# a single number +curl -X POST http://127.0.0.1:8000/api/nightly-backup/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$(ls /var/backups | wc -l)" + +# a structured payload +curl -X POST http://127.0.0.1:8000/api/disk-check/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"files": 1423, "used_gb": 38.2, "status": "ok"}' +``` + +`X-API-Key: $TOKEN` works as an alternative to the `Authorization` header. + +The dashboard draws a sparkline from the first numeric value it finds in each +payload (so both examples above chart automatically). + +### From cron + +```cron +# count files in a directory every hour +0 * * * * curl -sf -X POST https://metrica.example.com/api/inbox-count/ \ + -H "Authorization: Bearer $METRICA_TOKEN" \ + -d "$(ls /srv/inbox | wc -l)" +``` + +## Storage + +Each script is one file: `data/{script}.jsonl`. Every push appends one line: + +```json +{"ts": "2026-07-08T16:42:00Z", "data": {"files": 1423, "used_gb": 38.2}} +``` + +Delete a script by deleting its file. Trim history with `tail`. Back it up by +copying the `data/` directory. + +## Configuration + +| Variable | Default | Purpose | +|---|---|---| +| `METRICA_TOKENS` | *(generated)* | Comma/space-separated valid push tokens | +| `METRICA_TOKENS_FILE` | – | File of tokens, one per line | +| `METRICA_DATA_DIR` | `data` | Where JSONL files are written | +| `METRICA_MAX_HISTORY` | `100` | Recent points shown per script | +| `METRICA_HOST` / `METRICA_PORT` | `127.0.0.1` / `8000` | Bind address | + +## Notes on security + +- The **push and read APIs require a token**; script names are restricted to + `[A-Za-z0-9_-]` so they can't escape the data directory. +- The **dashboard is open** (read-only). If the metrics are sensitive, keep the + service on a private network or put a reverse proxy / VPN / basic-auth in + front of `/`. +- Always run behind HTTPS in production so tokens aren't sent in clear text. diff --git a/app.py b/app.py new file mode 100644 index 0000000..4c49001 --- /dev/null +++ b/app.py @@ -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'' + f'' + ) + + +# --------------------------------------------------------------------------- # +# 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'
{num:g}
' + elif isinstance(data_latest, str) and "\n" not in data_latest and len(data_latest) <= 48: + big = f'
{html.escape(data_latest)}
' + + rows = "".join( + f'{html.escape(humanize(p.get("ts", "")))}' + f'{html.escape(_compact(p.get("data")))}' + for p in reversed(points[-8:]) + ) + + return f""" +
+
+

{html.escape(script)}

+ {html.escape(humanize(latest.get("ts", "")))} · {total} points +
+ {big} + {spark} +
latest payload
{latest_disp}
+ {rows}
+
+ """ + + +@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'

{title}' + f'{len(items)}

' + f'
{cards}
' + ) + + body = "".join(sections) or ( + '

No data yet. Push to /api/<script>/ ' + 'or /api/<group>/<script>/.

' + ) + 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 = """ + + + + + + metrica + + + +
+

metrica

+

Pushed metrics · auto-refreshes every 15s

+
+
{{BODY}}
+ + +""" + + +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")), + ) diff --git a/data/.gitkepp b/data/.gitkepp new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c8d201d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "metrica" +version = "0.1.0" +description = "Tiny push-metrics collector with a minimal dashboard (static JSONL storage)" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.110", + "uvicorn[standard]>=0.29", +] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4f169ad --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +fastapi>=0.110 +uvicorn[standard]>=0.29