diff --git a/README.md b/README.md index a389425..899a9bb 100644 --- a/README.md +++ b/README.md @@ -12,19 +12,28 @@ Rootstock Admin provides a central dashboard for monitoring Rootstock installati |-------------|----------|-----| | Dev | Dashboard | https://garden-ai-dev--rootstock-admin-dashboard.modal.run | | Dev | Manifest API | https://garden-ai-dev--rootstock-admin-manifest.modal.run | +| Dev | Usage API | https://garden-ai-dev--rootstock-admin-usage.modal.run | | Prod | Dashboard | https://garden-ai-prod--rootstock-admin-dashboard.modal.run | | Prod | Manifest API | https://garden-ai-prod--rootstock-admin-manifest.modal.run | +| Prod | Usage API | https://garden-ai-prod--rootstock-admin-usage.modal.run | ### Manifest API `POST` Receives and stores environment manifests from Rootstock clusters. Requires proxy authentication. **Payload:** JSON manifest containing cluster name, rootstock version, Python version, maintainer info, and environment details. +### Usage API `POST` +Receives anonymous usage rollups from Rootstock clusters ([rootstock#47](https://github.com/Garden-AI/rootstock/issues/47)). Requires proxy authentication. + +**Payload:** `{"cluster": "", "rows": [...]}` where each row is a monthly rollup row as produced by `rootstock usage report` — keyed by `month`/`cluster`/`env`/`checkpoint`/`device`/`client` with `sessions`, `n_calculations`, `duration_s`, and `unique_users` counts. Rollups are stored per cluster per month; a push replaces only the months it contains, so previously reported months persist even if the cluster's local spool has been purged. User hashes never leave the cluster — only derived counts — and any `users` field sent by mistake is dropped on ingest. + ### Dashboard `GET` Public dashboard displaying all registered clusters and their environments. Shows: - Cluster metadata (name, version, root path, maintainer) - Environment status, dependencies, source code, and checkpoints +`GET /usage` returns every stored usage rollup: `{"usage": [{"cluster": ..., "month": ..., "rows": [...]}, ...]}`. + ## Development ### Prerequisites diff --git a/modal_app.py b/modal_app.py index 0797ac0..1a61606 100644 --- a/modal_app.py +++ b/modal_app.py @@ -1,6 +1,7 @@ -import modal import os +import modal + app = modal.App("rootstock-admin") app.image = modal.Image.debian_slim().pip_install("fastapi[standard]") @@ -22,6 +23,52 @@ def manifest(manifest: dict): return manifest +@app.function(volumes={"/data": vol}) +@modal.fastapi_endpoint(method="POST", requires_proxy_auth=True) +def usage(payload: dict): + """Ingest a cluster's usage rollups: {"cluster": str, "rows": [...]}. + + Rows are the aggregated rollup rows produced by `rootstock usage report` + (keyed by month/cluster/env/checkpoint/device/client, with sessions, + n_calculations, duration_s, unique_users). Rows are stored per month and + a push only replaces the months it contains, so history survives even if + a cluster's spool loses old months (e.g. scratch purge policies). + """ + import json + import os + import re + from collections import defaultdict + + from fastapi import HTTPException + + cluster = payload.get("cluster") + rows = payload.get("rows") + if not isinstance(cluster, str) or not re.fullmatch(r"[A-Za-z0-9_-]+", cluster): + raise HTTPException(422, "invalid or missing 'cluster'") + if not isinstance(rows, list): + raise HTTPException(422, "'rows' must be a list of rollup rows") + + by_month = defaultdict(list) + for row in rows: + if not isinstance(row, dict) or not re.fullmatch( + r"\d{4}-\d{2}", str(row.get("month")) + ): + raise HTTPException(422, "every row needs a 'month' of the form YYYY-MM") + # Only derived counts ever leave a cluster; drop user hashes if a + # client ever sends them by mistake. + row.pop("users", None) + by_month[row["month"]].append(row) + + vol.reload() + os.makedirs(f"/data/usage/{cluster}", exist_ok=True) + for month, month_rows in by_month.items(): + with open(f"/data/usage/{cluster}/{month}.json", "w") as f: + json.dump({"cluster": cluster, "month": month, "rows": month_rows}, f) + + vol.commit() + return {"cluster": cluster, "months": sorted(by_month)} + + @app.function(volumes={"/data": vol}, min_containers=1) @modal.asgi_app() def dashboard(): @@ -45,4 +92,16 @@ def get_manifests(): manifests = [json.loads(p.read_text()) for p in Path("/data").glob("*.json")] return {"manifests": manifests} + @web_app.get("/usage") + def get_usage(): + import json + from pathlib import Path + + vol.reload() + rollups = [ + json.loads(p.read_text()) + for p in sorted(Path("/data/usage").glob("*/*.json")) + ] + return {"usage": rollups} + return web_app