Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<name>", "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
Expand Down
61 changes: 60 additions & 1 deletion modal_app.py
Original file line number Diff line number Diff line change
@@ -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]")

Expand All @@ -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():
Expand All @@ -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
Loading