INIT
This commit is contained in:
commit
39c625f57f
9 changed files with 679 additions and 0 deletions
8
.dockerignore
Normal file
8
.dockerignore
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
data/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
tokens.txt
|
||||
.git/
|
||||
README.md
|
||||
16
.env.example
Normal file
16
.env.example
Normal file
|
|
@ -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
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
data/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
tokens.txt
|
||||
66
Dockerfile
Normal file
66
Dockerfile
Normal file
|
|
@ -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"]
|
||||
90
README.md
Normal file
90
README.md
Normal file
|
|
@ -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.
|
||||
482
app.py
Normal file
482
app.py
Normal 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", "")))} · {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")),
|
||||
)
|
||||
0
data/.gitkepp
Normal file
0
data/.gitkepp
Normal file
9
pyproject.toml
Normal file
9
pyproject.toml
Normal file
|
|
@ -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",
|
||||
]
|
||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fastapi>=0.110
|
||||
uvicorn[standard]>=0.29
|
||||
Loading…
Add table
Add a link
Reference in a new issue