chore: import upstream snapshot with attribution
Lint and Format Check / lint-and-format (push) Failing after 0s
Check Migrations / Check for duplicate migration numbers (push) Failing after 1s
CI Pre-merge Check / CI Pre-merge Check (push) Failing after 2m17s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:23:40 +08:00
commit 3a28426bf4
1399 changed files with 257375 additions and 0 deletions
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
.DS_Store
.venv/
dist/
build/
*.egg-info/
@@ -0,0 +1,212 @@
# ML Experiment Tracker
A command-line tool for logging and browsing ML experiments. Each run records hyperparameters, metrics, and notes in a PostgreSQL database managed by InsForge. A local web dashboard lets you visualize runs and compare results without leaving your machine.
> **Note:** This project was built as an example application to demonstrate using [InsForge](https://insforge.com) as a backend-as-a-service. InsForge handles auth, the database, and the REST API — this tool is a thin CLI and dashboard on top of it.
---
## Prerequisites
- **Python 3.10+**
- **Docker** (to run InsForge locally)
- **InsForge** running on `http://localhost:7130` — see the [InsForge docs](https://insforge.com/docs) for the quickstart Docker command
---
## Screenshots & Demo
The runs table lists every experiment with its hyperparameters, metrics, and timestamps in a single view.
![Dashboard — all runs](docs/dashboard-all-runs.png)
Selecting an experiment reveals a metrics-over-time chart with dual axes for accuracy and loss.
![Dashboard — metrics chart](docs/dashboard-chart.png)
The full workflow from `tracker log` to browsing results in the dashboard.
![Demo](docs/demo.gif)
---
## Installation
```bash
# From the example directory
cd examples/python-ml-experiment-tracker
pip install -e .
```
This installs the `tracker` command on your PATH.
---
## Setup
### 1. Start InsForge
InsForge must be running before any `tracker` command will work. A typical local start looks like:
```bash
docker run -p 7130:7130 insforge/insforge
```
Refer to the InsForge documentation for your exact Docker command and any environment variables (database URL, secret key, etc.).
### 2. Register an account
```bash
tracker register
# prompts for email and password
```
### 3. Log in
```bash
tracker login
# prompts for email and password
# credentials are saved to ~/.config/ml-tracker/config.json
```
### 4. Create the database tables
Table creation requires admin access in InsForge. Run `tracker init` to check whether the tables already exist:
```bash
tracker init
```
If the `experiments` and `runs` tables are missing, the command prints the SQL you need to run. To run it via the dashboard, open `http://localhost:7130` in your browser after starting InsForge, navigate to the SQL editor, and paste and execute the SQL below. Alternatively, submit it through the InsForge admin API.
```sql
CREATE TABLE IF NOT EXISTS experiments (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS runs (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
experiment_id UUID REFERENCES experiments(id) ON DELETE SET NULL,
experiment_name TEXT NOT NULL,
run_name TEXT,
status TEXT DEFAULT 'completed',
params JSONB DEFAULT '{}',
metrics JSONB DEFAULT '{}',
started_at TIMESTAMPTZ DEFAULT now(),
finished_at TIMESTAMPTZ,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
```
Run `tracker init` again to confirm the tables are accessible before logging any runs.
---
## CLI Commands
### Authentication
```bash
# Register a new account
tracker register
# Log in (saves tokens to ~/.config/ml-tracker/config.json)
tracker login
# Point at a non-default InsForge instance
tracker login --server http://my-insforge-host:7130
```
### Logging runs
```bash
# Minimal — just experiment name and metrics
tracker log -e my-model -m accuracy=0.91 -m loss=0.32
# With hyperparameters, a run label, and notes
tracker log \
-e bert-finetune \
-r "run-lr1e-4" \
-p learning_rate=1e-4 \
-p batch_size=32 \
-p epochs=5 \
-m accuracy=0.94 \
-m f1=0.93 \
-m val_loss=0.21 \
-n "Warmup schedule, no dropout"
# Provide explicit timestamps (ISO-8601)
tracker log \
-e nightly-sweep \
--started-at 2024-06-01T01:00:00Z \
--finished-at 2024-06-01T03:42:00Z \
-m rmse=0.045
```
`--params` / `-p` and `--metrics` / `-m` each accept `KEY=VALUE` and can be repeated. Numeric values are stored as floats; everything else is stored as a string.
### Viewing runs
```bash
# List the 20 most recent runs across all experiments
tracker runs list
# Filter to one experiment
tracker runs list -e bert-finetune
# Show more results or paginate
tracker runs list --limit 50 --offset 50
# Full details for a specific run (use the ID from the list)
tracker runs get a1b2c3d4
```
### Viewing experiments
```bash
# List all experiments
tracker experiments list
# Limit results
tracker experiments list --limit 10
```
### Checking the setup
```bash
# Verify that required tables exist and are accessible
tracker init
```
---
## Web Dashboard
The dashboard shows a summary of all experiments and lets you browse runs with their params and metrics.
```bash
# Start the dashboard (opens your browser automatically)
tracker serve
# Custom port
tracker serve --port 9000
# Skip opening the browser
tracker serve --no-browser
```
The dashboard runs at `http://127.0.0.1:8765` by default and proxies all API requests to InsForge using your saved credentials. It handles token refresh automatically. Stop it with `Ctrl+C`.
> You must be logged in (`tracker login`) before starting the dashboard, otherwise API requests will return an auth error.
---
## Configuration
The CLI stores its config at `~/.config/ml-tracker/config.json` (mode `0600`). You can override the InsForge server URL at login time with `--server`; the value is persisted for subsequent commands.
Default server URL: `http://localhost:7130`
Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

@@ -0,0 +1,20 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "ml-experiment-tracker"
version = "0.1.0"
description = "CLI tool for tracking ML experiments via InsForge BaaS"
requires-python = ">=3.10"
dependencies = [
"click>=8.1",
"httpx>=0.27",
"rich>=13.7",
]
[project.scripts]
tracker = "tracker.cli:cli"
[tool.hatch.build.targets.wheel]
packages = ["tracker"]
@@ -0,0 +1,3 @@
"""ML Experiment Tracker — InsForge-backed CLI."""
__version__ = "0.1.0"
@@ -0,0 +1,356 @@
from __future__ import annotations
import functools
from datetime import datetime, timezone
from typing import Any
import click
import httpx
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from tracker.client import APIError, AuthError, InsForgeClient
from tracker.config import load_config, save_config, update_tokens
console = Console()
err_console = Console(stderr=True)
REQUIRED_TABLES = ["experiments", "runs"]
# SQL for admins to run once via the InsForge dashboard or migration API
ADMIN_SETUP_SQL = """\
CREATE TABLE IF NOT EXISTS experiments (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS runs (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
experiment_id UUID REFERENCES experiments(id) ON DELETE SET NULL,
experiment_name TEXT NOT NULL,
run_name TEXT,
status TEXT DEFAULT 'completed',
params JSONB DEFAULT '{}',
metrics JSONB DEFAULT '{}',
started_at TIMESTAMPTZ DEFAULT now(),
finished_at TIMESTAMPTZ,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);\
"""
def handle_errors(fn):
"""Decorator that converts tracker exceptions into stderr messages and SystemExit(1)."""
@functools.wraps(fn)
def wrapper(*args, **kwargs):
"""Invoke the wrapped command, translating known exceptions to user-facing output."""
try:
return fn(*args, **kwargs)
except AuthError as e:
err_console.print(f"[bold red]Auth error:[/] {e}")
raise SystemExit(1)
except APIError as e:
err_console.print(f"[bold red]API error {e.status_code}:[/] {e.message}")
raise SystemExit(1)
except httpx.ConnectError:
cfg = load_config()
err_console.print(
f"[bold red]Connection refused.[/] Is InsForge running at {cfg.server_url}?"
)
raise SystemExit(1)
except httpx.TimeoutException:
err_console.print("[bold red]Request timed out.[/]")
raise SystemExit(1)
except httpx.RequestError as e:
err_console.print(f"[bold red]Network error:[/] {e}")
raise SystemExit(1)
return wrapper
def parse_kv_pairs(pairs: tuple[str, ...]) -> dict[str, Any]:
"""Parse KEY=VALUE strings into a typed dict, coercing bool/int/float where applicable."""
result: dict[str, Any] = {}
for pair in pairs:
if "=" not in pair:
raise click.BadParameter(
f"Expected KEY=VALUE, got: {pair!r}",
param_hint="--params/--metrics",
)
key, _, raw = pair.partition("=")
key = key.strip()
raw = raw.strip()
if raw.lower() == "true":
result[key] = True
elif raw.lower() == "false":
result[key] = False
else:
try:
result[key] = int(raw)
except ValueError:
try:
result[key] = float(raw)
except ValueError:
result[key] = raw
return result
def now_iso() -> str:
"""Return the current UTC time as an ISO-8601 string."""
return datetime.now(timezone.utc).isoformat()
# ── Root group ───────────────────────────────────────────────────────────
@click.group()
@click.version_option(version="0.1.0", prog_name="tracker")
def cli() -> None:
"""ML Experiment Tracker — powered by InsForge BaaS."""
from tracker.serve import cmd_serve # noqa: E402
cli.add_command(cmd_serve)
# ── tracker register ──────────────────────────────────────────────────────
@cli.command("register")
@click.option("--email", prompt="Email", help="User email address")
@click.option(
"--password",
prompt="Password",
hide_input=True,
confirmation_prompt=True,
help="Account password",
)
@click.option("--server", default=None, help="InsForge server URL")
@handle_errors
def cmd_register(email: str, password: str, server: str | None) -> None:
"""Register a new InsForge user account."""
cfg = load_config()
if server:
cfg.server_url = server
save_config(cfg)
with InsForgeClient(cfg) as client:
client.register(email, password)
console.print(Panel(f"[green]Registered[/] {email}\nRun [bold]tracker login[/] to authenticate."))
# ── tracker login ─────────────────────────────────────────────────────────
@cli.command("login")
@click.option("--email", prompt="Email", help="User email address")
@click.option("--password", prompt="Password", hide_input=True, help="Account password")
@click.option("--server", default=None, help="InsForge server URL")
@handle_errors
def cmd_login(email: str, password: str, server: str | None) -> None:
"""Authenticate and save credentials to ~/.config/ml-tracker/config.json."""
cfg = load_config()
if server:
cfg.server_url = server
save_config(cfg)
with InsForgeClient(cfg) as client:
access_token, refresh_token = client.login(email, password)
cfg.access_token = access_token
cfg.refresh_token = refresh_token
cfg.user_email = email
save_config(cfg)
console.print(Panel(f"[green]Logged in[/] as [bold]{email}[/]"))
# ── tracker init ──────────────────────────────────────────────────────────
@cli.command("init")
@handle_errors
def cmd_init() -> None:
"""Verify that the required InsForge tables exist and are accessible."""
with InsForgeClient() as client:
missing = [t for t in REQUIRED_TABLES if not client.table_exists(t)]
if not missing:
console.print(Panel(
"[green]Database ready.[/] Tables [bold]experiments[/] and [bold]runs[/] are accessible.",
))
return
missing_list = ", ".join(f"[bold]{t}[/]" for t in missing)
err_console.print(Panel(
f"[yellow]Missing tables:[/] {missing_list}\n\n"
"InsForge table creation requires admin access. Ask your project admin to run "
"the following migration via the InsForge dashboard or admin API:\n\n"
f"[dim]{ADMIN_SETUP_SQL}[/]",
title="Setup required",
border_style="yellow",
))
raise SystemExit(1)
# ── tracker log ───────────────────────────────────────────────────────────
@cli.command("log")
@click.option("--experiment", "-e", required=True, help="Experiment name (created if not found)")
@click.option("--run-name", "-r", default=None, help="Optional run label")
@click.option("--params", "-p", multiple=True, metavar="KEY=VALUE", help="Hyperparameter (repeatable)")
@click.option("--metrics", "-m", multiple=True, metavar="KEY=VALUE", help="Metric (repeatable)")
@click.option("--notes", "-n", default=None, help="Free-text notes")
@click.option("--started-at", default=None, help="ISO-8601 start time (default: now)")
@click.option("--finished-at", default=None, help="ISO-8601 finish time (default: now)")
@handle_errors
def cmd_log(
experiment: str,
run_name: str | None,
params: tuple[str, ...],
metrics: tuple[str, ...],
notes: str | None,
started_at: str | None,
finished_at: str | None,
) -> None:
"""Log a new experiment run with hyperparameters and metrics."""
parsed_params = parse_kv_pairs(params)
parsed_metrics = parse_kv_pairs(metrics)
ts = now_iso()
started_at = started_at or ts
finished_at = finished_at or ts
with InsForgeClient() as client:
exp = client.get_or_create_experiment(experiment)
run = client.create_run(
experiment_id=exp.id,
experiment_name=exp.name,
run_name=run_name,
params=parsed_params,
metrics=parsed_metrics,
notes=notes,
started_at=started_at,
finished_at=finished_at,
)
_print_run_panel(run)
# ── tracker runs ──────────────────────────────────────────────────────────
@cli.group("runs")
def runs_group() -> None:
"""Commands for managing experiment runs."""
@runs_group.command("list")
@click.option("--experiment", "-e", default=None, help="Filter by experiment name")
@click.option("--limit", "-l", default=20, show_default=True, help="Max runs to return")
@click.option("--offset", default=0, show_default=True, help="Pagination offset")
@handle_errors
def cmd_runs_list(experiment: str | None, limit: int, offset: int) -> None:
"""List recent runs, optionally filtered by experiment."""
with InsForgeClient() as client:
runs = client.list_runs(experiment_name=experiment, limit=limit, offset=offset)
if not runs:
console.print("[dim]No runs found.[/]")
return
table = Table(title="Experiment Runs", show_header=True, header_style="bold cyan")
table.add_column("ID", style="dim")
table.add_column("Experiment")
table.add_column("Run Name")
table.add_column("Status")
table.add_column("Params", justify="right")
table.add_column("Metrics", justify="right")
table.add_column("Started At")
for r in runs:
table.add_row(
r.id,
r.experiment_name,
r.run_name or "",
r.status,
str(len(r.params)),
str(len(r.metrics)),
r.started_at[:19].replace("T", " ") if r.started_at else "",
)
console.print(table)
@runs_group.command("get")
@click.argument("run_id")
@handle_errors
def cmd_runs_get(run_id: str) -> None:
"""Show full details for a specific run."""
with InsForgeClient() as client:
run = client.get_run(run_id)
_print_run_panel(run)
# ── tracker experiments ───────────────────────────────────────────────────
@cli.group("experiments")
def experiments_group() -> None:
"""Commands for managing experiments."""
@experiments_group.command("list")
@click.option("--limit", "-l", default=50, show_default=True, help="Max experiments to return")
@handle_errors
def cmd_experiments_list(limit: int) -> None:
"""List all experiments."""
with InsForgeClient() as client:
exps = client.list_experiments(limit=limit)
if not exps:
console.print("[dim]No experiments found.[/]")
return
table = Table(title="Experiments", show_header=True, header_style="bold cyan")
table.add_column("ID", style="dim", width=10)
table.add_column("Name")
table.add_column("Description")
table.add_column("Created At")
for e in exps:
table.add_row(
e.id[:8],
e.name,
e.description or "",
e.created_at[:19].replace("T", " ") if e.created_at else "",
)
console.print(table)
# ── Helpers ───────────────────────────────────────────────────────────────
def _print_run_panel(run) -> None:
"""Print a Rich-formatted panel showing run details, hyperparameters, and metrics."""
params_table = Table(show_header=True, header_style="bold", box=None, padding=(0, 1))
params_table.add_column("Key")
params_table.add_column("Value")
for k, v in run.params.items():
params_table.add_row(str(k), str(v))
metrics_table = Table(show_header=True, header_style="bold", box=None, padding=(0, 1))
metrics_table.add_column("Key")
metrics_table.add_column("Value")
for k, v in run.metrics.items():
metrics_table.add_row(str(k), str(v))
lines = [
f"[bold]Run ID:[/] {run.id}",
f"[bold]Experiment:[/] {run.experiment_name}",
f"[bold]Run Name:[/] {run.run_name or ''}",
f"[bold]Status:[/] {run.status}",
f"[bold]Started:[/] {run.started_at[:19].replace('T', ' ') if run.started_at else ''}",
f"[bold]Finished:[/] {run.finished_at[:19].replace('T', ' ') if run.finished_at else ''}",
]
if run.notes:
lines.append(f"[bold]Notes:[/] {run.notes}")
console.print(Panel("\n".join(lines), title="Run Details", border_style="cyan"))
if run.params:
console.print(Panel(params_table, title="Hyperparameters", border_style="blue"))
if run.metrics:
console.print(Panel(metrics_table, title="Metrics", border_style="green"))
@@ -0,0 +1,270 @@
from __future__ import annotations
from typing import Any
import httpx
from tracker.config import Config, clear_tokens, load_config, update_tokens
from tracker.models import Experiment, Run
class AuthError(Exception):
"""Raised when an operation requires valid credentials that are absent or expired."""
class APIError(Exception):
"""Raised when the InsForge API returns a non-2xx response."""
def __init__(self, status_code: int, code: str, message: str) -> None:
"""Store the HTTP status code, machine-readable error code, and human-readable message."""
super().__init__(message)
self.status_code = status_code
self.code = code
self.message = message
class InsForgeClient:
"""HTTP client for the InsForge BaaS API with automatic token refresh on 401."""
def __init__(self, config: Config | None = None) -> None:
"""Initialise the client, loading config from disk if not provided."""
self._config = config or load_config()
self._http = httpx.Client(
base_url=self._config.server_url,
timeout=30.0,
)
def close(self) -> None:
"""Close the underlying HTTP connection pool."""
self._http.close()
def __enter__(self) -> "InsForgeClient":
"""Support use as a context manager, returning self."""
return self
def __exit__(self, *args: Any) -> None:
"""Close the HTTP client on context-manager exit."""
self.close()
# ── Internal helpers ──────────────────────────────────────────────────
def _auth_headers(self) -> dict[str, str]:
"""Return the Authorization header dict, raising AuthError if not logged in."""
if not self._config.access_token:
raise AuthError("Not logged in. Run `tracker login` first.")
return {"Authorization": f"Bearer {self._config.access_token}"}
def _raise_for_error(self, response: httpx.Response) -> None:
"""Raise APIError if the response is not a 2xx success."""
if response.is_success:
return
try:
body = response.json()
code = body.get("error", "UNKNOWN")
message = body.get("message", response.text)
except Exception:
code = "UNKNOWN"
message = response.text or f"HTTP {response.status_code}"
raise APIError(response.status_code, code, message)
def _refresh_token(self) -> None:
"""Exchange the stored refresh token for a new access token, updating config on disk."""
if not self._config.refresh_token:
raise AuthError("Session expired. Run `tracker login` again.")
resp = self._http.post(
"/api/auth/sessions",
json={"refreshToken": self._config.refresh_token},
)
if not resp.is_success:
clear_tokens()
raise AuthError("Session expired. Run `tracker login` again.")
data = resp.json()
new_access = data.get("accessToken") or data.get("access_token", "")
if not new_access:
clear_tokens()
raise AuthError("Session expired. Run `tracker login` again.")
new_refresh = data.get("refreshToken") or data.get("refresh_token", self._config.refresh_token)
self._config.access_token = new_access
self._config.refresh_token = new_refresh
update_tokens(new_access, new_refresh)
def _request(
self,
method: str,
path: str,
*,
json: object = None,
params: dict[str, Any] | None = None,
extra_headers: dict[str, str] | None = None,
retry_on_401: bool = True,
) -> httpx.Response:
"""Send an authenticated request, transparently retrying once after a 401."""
headers = {**self._auth_headers(), **(extra_headers or {})}
resp = self._http.request(method, path, json=json, params=params, headers=headers)
if resp.status_code == 401 and retry_on_401:
self._refresh_token()
return self._request(
method, path,
json=json, params=params, extra_headers=extra_headers,
retry_on_401=False,
)
self._raise_for_error(resp)
return resp
# ── Auth ──────────────────────────────────────────────────────────────
def register(self, email: str, password: str) -> dict[str, Any]:
"""Create a new InsForge user account and return the raw response body."""
resp = self._http.post("/api/auth/users", json={"email": email, "password": password})
self._raise_for_error(resp)
return resp.json()
def login(self, email: str, password: str) -> tuple[str, str]:
"""Authenticate with email/password and return (access_token, refresh_token)."""
resp = self._http.post("/api/auth/sessions", json={"email": email, "password": password})
self._raise_for_error(resp)
data = resp.json()
access = data.get("accessToken") or data.get("access_token", "")
refresh = data.get("refreshToken") or data.get("refresh_token", "")
return access, refresh
# ── Table management ─────────────────────────────────────────────────
def table_exists(self, table: str) -> bool:
"""Return True if the table is accessible, False if it doesn't exist (404)."""
try:
self._request("GET", f"/api/database/records/{table}", params={"limit": 0})
return True
except APIError as e:
if e.status_code == 404:
return False
raise
# ── Generic CRUD helpers ─────────────────────────────────────────────
def _list_records(
self,
table: str,
filters: dict[str, str] | None = None,
limit: int | None = None,
offset: int = 0,
order: str | None = None,
select: str | None = None,
) -> list[dict[str, Any]]:
"""Fetch rows from a database table with optional filtering, ordering, and pagination."""
params: dict[str, Any] = {"offset": offset}
if limit is not None:
params["limit"] = limit
if order:
params["order"] = order
if select:
params["select"] = select
if filters:
params.update(filters)
resp = self._request("GET", f"/api/database/records/{table}", params=params)
return resp.json()
def _create_record(self, table: str, record: dict[str, Any]) -> dict[str, Any]:
"""Insert a single record into a table and return the created row."""
resp = self._request(
"POST",
f"/api/database/records/{table}",
json=[record],
extra_headers={"Prefer": "return=representation"},
)
rows = resp.json()
return rows[0] if isinstance(rows, list) else rows
# ── Experiments ──────────────────────────────────────────────────────
def get_or_create_experiment(
self, name: str, description: str | None = None
) -> Experiment:
"""Return the named experiment, creating it if it does not yet exist."""
rows = self._list_records(
"experiments",
filters={"name": f"eq.{name}"},
limit=1,
)
if rows:
return Experiment.from_dict(rows[0])
record: dict[str, Any] = {"name": name}
if description:
record["description"] = description
try:
created = self._create_record("experiments", record)
return Experiment.from_dict(created)
except APIError as e:
# UNIQUE violation: another caller created it; retry GET
if e.status_code in (409, 422) or "unique" in e.message.lower():
rows = self._list_records(
"experiments",
filters={"name": f"eq.{name}"},
limit=1,
)
if rows:
return Experiment.from_dict(rows[0])
raise
def list_experiments(self, limit: int = 50) -> list[Experiment]:
"""Return up to *limit* experiments ordered by creation time, newest first."""
rows = self._list_records("experiments", limit=limit, order="created_at.desc")
return [Experiment.from_dict(r) for r in rows]
# ── Runs ─────────────────────────────────────────────────────────────
def create_run(
self,
experiment_id: str,
experiment_name: str,
run_name: str | None,
params: dict[str, Any],
metrics: dict[str, Any],
notes: str | None,
started_at: str,
finished_at: str,
) -> Run:
"""Insert a run record and return the persisted Run model."""
record: dict[str, Any] = {
"experiment_id": experiment_id,
"experiment_name": experiment_name,
"params": params,
"metrics": metrics,
"started_at": started_at,
"finished_at": finished_at,
}
if run_name:
record["run_name"] = run_name
if notes:
record["notes"] = notes
created = self._create_record("runs", record)
return Run.from_dict(created)
def list_runs(
self,
experiment_name: str | None = None,
limit: int = 20,
offset: int = 0,
) -> list[Run]:
"""Return recent runs, optionally filtered by experiment name."""
filters: dict[str, str] = {}
if experiment_name:
filters["experiment_name"] = f"eq.{experiment_name}"
rows = self._list_records(
"runs",
filters=filters,
limit=limit,
offset=offset,
order="created_at.desc",
)
return [Run.from_dict(r) for r in rows]
def get_run(self, run_id: str) -> Run:
"""Fetch a single run by its full UUID, raising APIError(404) if missing."""
rows = self._list_records("runs", filters={"id": f"eq.{run_id}"}, limit=1)
if not rows:
raise APIError(404, "NOT_FOUND", f"Run not found: {run_id}")
return Run.from_dict(rows[0])
@@ -0,0 +1,70 @@
from __future__ import annotations
import dataclasses
import json
import os
from pathlib import Path
from typing import Any
CONFIG_DIR = Path.home() / ".config" / "ml-tracker"
CONFIG_FILE = CONFIG_DIR / "config.json"
DEFAULT_SERVER_URL = "http://localhost:7130"
@dataclasses.dataclass
class Config:
"""Persisted configuration for the tracker CLI, stored at ~/.config/ml-tracker/config.json."""
server_url: str = DEFAULT_SERVER_URL
access_token: str | None = None
refresh_token: str | None = None
user_email: str | None = None
def to_dict(self) -> dict[str, Any]:
"""Serialise the config to a plain dict suitable for JSON encoding."""
return dataclasses.asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Config":
"""Deserialise a config from a plain dict, applying defaults for missing keys."""
return cls(
server_url=data.get("server_url", DEFAULT_SERVER_URL),
access_token=data.get("access_token"),
refresh_token=data.get("refresh_token"),
user_email=data.get("user_email"),
)
def load_config() -> Config:
"""Load the config from disk, returning defaults if the file does not exist."""
if not CONFIG_FILE.exists():
return Config()
with CONFIG_FILE.open("r") as f:
data = json.load(f)
return Config.from_dict(data)
def save_config(config: Config) -> None:
"""Atomically write config to disk with 0o600 permissions, protecting token data."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
tmp = CONFIG_FILE.with_suffix(".tmp")
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
json.dump(config.to_dict(), f, indent=2)
tmp.replace(CONFIG_FILE)
def update_tokens(access_token: str, refresh_token: str) -> None:
"""Reload config from disk, replace both tokens, and save."""
cfg = load_config()
cfg.access_token = access_token
cfg.refresh_token = refresh_token
save_config(cfg)
def clear_tokens() -> None:
"""Clear stored access and refresh tokens from the config file."""
cfg = load_config()
cfg.access_token = None
cfg.refresh_token = None
save_config(cfg)
@@ -0,0 +1,58 @@
from __future__ import annotations
import dataclasses
from typing import Any
@dataclasses.dataclass
class Experiment:
"""An experiment groups related runs under a shared name."""
id: str
name: str
description: str | None
created_at: str
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Experiment":
"""Construct an Experiment from a raw API response dict."""
return cls(
id=data["id"],
name=data["name"],
description=data.get("description"),
created_at=data.get("created_at", ""),
)
@dataclasses.dataclass
class Run:
"""A single execution record belonging to an experiment."""
id: str
experiment_id: str | None
experiment_name: str
run_name: str | None
status: str
params: dict[str, Any]
metrics: dict[str, Any]
started_at: str
finished_at: str | None
notes: str | None
created_at: str
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Run":
"""Construct a Run from a raw API response dict, applying safe defaults."""
return cls(
id=data["id"],
experiment_id=data.get("experiment_id"),
experiment_name=data["experiment_name"],
run_name=data.get("run_name"),
status=data.get("status", "completed"),
params=data.get("params") or {},
metrics=data.get("metrics") or {},
started_at=data.get("started_at", ""),
finished_at=data.get("finished_at"),
notes=data.get("notes"),
created_at=data.get("created_at", ""),
)
@@ -0,0 +1,195 @@
from __future__ import annotations
import datetime
import http.server
import pathlib
import sys
import threading
import urllib.parse
import webbrowser
import click
import httpx
from tracker.config import clear_tokens, load_config, update_tokens
_STATIC_DIR = pathlib.Path(__file__).parent / "static"
def make_proxy_handler(server_url: str, token_lock: threading.Lock) -> type:
"""Build and return a BaseHTTPRequestHandler subclass bound to *server_url* and *token_lock*."""
class ProxyHandler(http.server.BaseHTTPRequestHandler):
"""HTTP handler that serves the dashboard UI and proxies /api/* calls to InsForge."""
_server_url = server_url
_lock = token_lock
def do_GET(self) -> None:
"""Route GET requests to the static dashboard or the upstream API proxy."""
parsed = urllib.parse.urlparse(self.path)
if parsed.path in ("/", ""):
self._serve_static()
elif parsed.path.startswith("/api/"):
self._proxy_api()
else:
self.send_error(404)
def _serve_static(self) -> None:
"""Respond with the dashboard index.html."""
html_path = _STATIC_DIR / "index.html"
try:
data = html_path.read_bytes()
except FileNotFoundError:
self._send_json_error(500, "STATIC_MISSING", "index.html not found")
return
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _proxy_api(self) -> None:
"""Forward an /api/* request to InsForge, handling auth and transparent token refresh."""
cfg = load_config()
if not cfg.access_token:
self._send_json_error(
401, "NOT_LOGGED_IN", "No credentials found. Run `tracker login` first."
)
return
parsed = urllib.parse.urlparse(self.path)
upstream = self._server_url + parsed.path
if parsed.query:
upstream += "?" + parsed.query
try:
resp = self._attempt_upstream(upstream, cfg.access_token)
except httpx.ConnectError:
self._send_json_error(
502, "UPSTREAM_UNAVAILABLE",
f"Cannot connect to InsForge at {self._server_url}"
)
return
except httpx.TimeoutException:
self._send_json_error(504, "UPSTREAM_TIMEOUT", "InsForge request timed out.")
return
if resp.status_code == 401:
new_token = self._do_refresh()
if new_token is None:
self._send_json_error(
401, "SESSION_EXPIRED", "Session expired. Run `tracker login` again."
)
return
try:
resp = self._attempt_upstream(upstream, new_token)
except (httpx.ConnectError, httpx.TimeoutException):
self._send_json_error(502, "UPSTREAM_UNAVAILABLE", "InsForge unreachable.")
return
body = resp.content
self.send_response(resp.status_code)
self.send_header(
"Content-Type", resp.headers.get("content-type", "application/json")
)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _attempt_upstream(self, url: str, access_token: str) -> httpx.Response:
"""Make a single authenticated GET request to the InsForge upstream URL."""
with httpx.Client(timeout=15.0) as client:
return client.get(url, headers={"Authorization": f"Bearer {access_token}"})
def _do_refresh(self) -> str | None:
"""Attempt to refresh the access token; return the new token or None on failure."""
with self._lock:
cfg = load_config()
if not cfg.refresh_token:
return None
try:
with httpx.Client(timeout=10.0) as client:
resp = client.post(
cfg.server_url + "/api/auth/sessions",
json={"refreshToken": cfg.refresh_token},
)
if not resp.is_success:
clear_tokens()
return None
data = resp.json()
new_access = data.get("accessToken") or data.get("access_token", "")
if not new_access:
clear_tokens()
return None
new_refresh = data.get("refreshToken") or data.get(
"refresh_token", cfg.refresh_token
)
update_tokens(new_access, new_refresh)
return new_access
except Exception:
return None
def _send_json_error(self, status: int, code: str, message: str) -> None:
"""Send a JSON error response with the given HTTP status, error code, and message."""
import json
body = json.dumps({"error": code, "message": message}).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt: str, *args: object) -> None:
"""Write a timestamped access-log line to stderr."""
ts = datetime.datetime.now().strftime("%H:%M:%S")
sys.stderr.write(f" [{ts}] {fmt % args}\n")
return ProxyHandler
@click.command("serve")
@click.option("--port", default=8765, show_default=True, help="Port to listen on.")
@click.option("--no-browser", is_flag=True, default=False, help="Do not open the browser automatically.")
def cmd_serve(port: int, no_browser: bool) -> None:
"""Start the local web dashboard (proxies API calls to InsForge)."""
from rich.console import Console
from rich.panel import Panel
console = Console()
err_console = Console(stderr=True)
cfg = load_config()
if not cfg.access_token:
err_console.print(Panel(
"[yellow]Warning:[/] No credentials found. "
"Run [bold]tracker login[/] first, or the dashboard will show an auth error.",
border_style="yellow",
))
token_lock = threading.Lock()
HandlerClass = make_proxy_handler(cfg.server_url, token_lock)
url = f"http://127.0.0.1:{port}"
try:
httpd = http.server.ThreadingHTTPServer(("127.0.0.1", port), HandlerClass)
except OSError as e:
err_console.print(f"[bold red]Cannot start server:[/] {e}")
raise SystemExit(1)
console.print(Panel(
f"Dashboard: [bold]{url}[/]\n"
f"Proxying to: {cfg.server_url}\n"
"Press [bold]Ctrl+C[/] to stop.",
title="tracker serve",
border_style="cyan",
))
if not no_browser:
webbrowser.open(url)
try:
httpd.serve_forever()
except KeyboardInterrupt:
console.print("\n[dim]Server stopped.[/]")
finally:
httpd.server_close()
@@ -0,0 +1,453 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ML Experiment Tracker</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
:root {
--navy: #1e293b;
--navy-text: #f8fafc;
--surface: #f1f5f9;
--card: #ffffff;
--border: #e2e8f0;
--text: #0f172a;
--muted: #64748b;
--accent: #3b82f6;
--shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.05);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--surface); color: var(--text); font-size: 14px; line-height: 1.5; }
/* ── Navbar ── */
nav {
background: var(--navy); color: var(--navy-text);
padding: 0 1.5rem; height: 56px;
display: flex; align-items: center; justify-content: space-between;
box-shadow: 0 1px 4px rgba(0,0,0,.2);
position: sticky; top: 0; z-index: 10;
}
.nav-title { font-size: 1rem; font-weight: 600; letter-spacing: .01em;
display: flex; align-items: center; gap: .5rem; }
.nav-title svg { opacity: .8; }
.nav-right { display: flex; align-items: center; gap: 1rem; }
#last-refreshed { font-size: .75rem; opacity: .6; }
#toggle-refresh {
font-size: .75rem; font-weight: 600; padding: .3rem .75rem;
border: 1px solid rgba(255,255,255,.35); border-radius: 6px;
background: transparent; color: var(--navy-text); cursor: pointer;
transition: background .15s;
}
#toggle-refresh:hover { background: rgba(255,255,255,.1); }
#toggle-refresh.off { opacity: .55; }
/* ── Main layout ── */
main { max-width: 1280px; margin: 0 auto; padding: 1.5rem; display: flex; flex-direction: column; gap: 1rem; }
/* ── Error banner ── */
#error-banner {
padding: .875rem 1rem; border-radius: 8px; font-size: .875rem;
display: flex; align-items: flex-start; gap: .625rem;
}
#error-banner[hidden] { display: none; }
#error-banner[data-type="auth"] { background: #fef3c7; color: #92400e; border: 1px solid #fde68a; }
#error-banner[data-type="connect"] { background: #ffedd5; color: #9a3412; border: 1px solid #fed7aa; }
#error-banner[data-type="generic"] { background: #fee2e2; color: #991b1b; border: 1px solid #fecaca; }
#error-banner .banner-icon { font-size: 1rem; flex-shrink: 0; }
#error-banner code { font-family: monospace; background: rgba(0,0,0,.06);
padding: .1em .35em; border-radius: 3px; }
/* ── Controls row ── */
#controls { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
#experiment-filter {
padding: .45rem .75rem; border: 1px solid var(--border); border-radius: 6px;
background: var(--card); font-size: .875rem; color: var(--text);
cursor: pointer; min-width: 220px;
}
#run-count { font-size: .8rem; color: var(--muted); }
/* ── Card ── */
.card { background: var(--card); border-radius: 10px; border: 1px solid var(--border);
box-shadow: var(--shadow); overflow: hidden; }
.card-header { padding: .875rem 1.25rem; border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between; }
.card-header h2 { font-size: .9rem; font-weight: 600; }
.card-header .subtitle { font-size: .75rem; color: var(--muted); }
/* ── Loading spinner ── */
#loading-indicator { display: flex; justify-content: center; padding: 2rem; }
.spinner {
width: 28px; height: 28px; border-radius: 50%;
border: 3px solid var(--border); border-top-color: var(--accent);
animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Table ── */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th {
text-align: left; padding: .625rem 1rem;
background: var(--surface); color: var(--muted);
font-size: .75rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em;
border-bottom: 1px solid var(--border); white-space: nowrap;
}
td {
padding: .6rem 1rem; border-bottom: 1px solid var(--border);
vertical-align: top; max-width: 260px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
td.wrap { white-space: normal; word-break: break-word; }
tr:last-child td { border-bottom: none; }
tbody tr:hover td { background: #f8fafc; }
/* ── Badges ── */
.badge {
display: inline-block; padding: .2rem .55rem; border-radius: 9999px;
font-size: .7rem; font-weight: 700; letter-spacing: .02em;
}
.badge-completed { background: #dcfce7; color: #15803d; }
.badge-running { background: #dbeafe; color: #1d4ed8; }
.badge-failed { background: #fee2e2; color: #b91c1c; }
.badge-default { background: #f1f5f9; color: #475569; }
/* ── Empty state ── */
#empty-state { padding: 3rem 1rem; text-align: center; color: var(--muted); font-size: .875rem; }
/* ── Chart ── */
#chart-section canvas { padding: 1rem 1.25rem 1.25rem; max-height: 320px; }
</style>
</head>
<body>
<nav>
<div class="nav-title">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
ML Experiment Tracker
</div>
<div class="nav-right">
<span id="last-refreshed"></span>
<button id="toggle-refresh">Auto-Refresh: ON</button>
</div>
</nav>
<main>
<div id="error-banner" hidden data-type="generic">
<span class="banner-icon"></span>
<span id="error-message"></span>
</div>
<div id="controls">
<select id="experiment-filter">
<option value="">All Experiments</option>
</select>
<span id="run-count"></span>
</div>
<div class="card">
<div class="card-header"><h2>Runs</h2></div>
<div id="loading-indicator"><div class="spinner"></div></div>
<div class="table-wrap">
<table id="runs-table" hidden>
<thead>
<tr>
<th>Experiment</th>
<th>Run Name</th>
<th>Status</th>
<th>Params</th>
<th>Metrics</th>
<th>Started At</th>
</tr>
</thead>
<tbody id="runs-tbody"></tbody>
</table>
</div>
<p id="empty-state" hidden>No runs found.</p>
</div>
<div id="chart-section" class="card" hidden>
<div class="card-header">
<h2>Metrics Over Time</h2>
<span class="subtitle" id="chart-subtitle"></span>
</div>
<canvas id="metrics-chart"></canvas>
</div>
</main>
<script>
// ── State ──────────────────────────────────────────────────────────────
let allRuns = [];
let allExperiments = [];
let filteredRuns = [];
let selectedExperiment = '';
let autoRefresh = true;
let refreshTimer = null;
let chartInstance = null;
// ── DOM refs ───────────────────────────────────────────────────────────
const errorBanner = document.getElementById('error-banner');
const errorMessage = document.getElementById('error-message');
const loadingIndicator = document.getElementById('loading-indicator');
const runsTable = document.getElementById('runs-table');
const runsTbody = document.getElementById('runs-tbody');
const emptyState = document.getElementById('empty-state');
const chartSection = document.getElementById('chart-section');
const chartSubtitle = document.getElementById('chart-subtitle');
const experimentFilter = document.getElementById('experiment-filter');
const runCount = document.getElementById('run-count');
const lastRefreshed = document.getElementById('last-refreshed');
const toggleBtn = document.getElementById('toggle-refresh');
// ── Utilities ──────────────────────────────────────────────────────────
function formatKV(obj) {
const entries = Object.entries(obj || {});
if (!entries.length) return '—';
return entries.map(([k, v]) => {
const formatted = typeof v === 'number'
? (Number.isInteger(v) ? v : v.toFixed(4))
: v;
return `${k}: ${formatted}`;
}).join(', ');
}
function badgeClass(status) {
const map = { completed: 'badge-completed', running: 'badge-running', failed: 'badge-failed' };
return map[status] || 'badge-default';
}
function fmtDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString(undefined, {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
});
}
function showError(type, msg) {
errorBanner.dataset.type = type;
errorMessage.textContent = msg;
errorBanner.hidden = false;
}
function hideError() { errorBanner.hidden = true; }
// ── Data fetching ──────────────────────────────────────────────────────
async function fetchData() {
loadingIndicator.hidden = false;
hideError();
let runsResp, expsResp;
try {
[runsResp, expsResp] = await Promise.all([
fetch('/api/database/records/runs?limit=100&order=created_at.desc'),
fetch('/api/database/records/experiments?limit=50&order=created_at.desc'),
]);
} catch (e) {
loadingIndicator.hidden = true;
showError('connect', 'Cannot reach the local proxy server. Is "tracker serve" still running?');
return;
}
if (runsResp.status === 401 || expsResp.status === 401) {
loadingIndicator.hidden = true;
showError('auth', 'Not logged in. Run "tracker login" in your terminal, then reload.');
return;
}
if (runsResp.status === 502 || expsResp.status === 502) {
loadingIndicator.hidden = true;
const body = await runsResp.json().catch(() => ({}));
showError('connect', body.message || 'Cannot connect to InsForge. Is it running?');
return;
}
if (!runsResp.ok || !expsResp.ok) {
loadingIndicator.hidden = true;
const failedResp = runsResp.ok ? expsResp : runsResp;
const body = await failedResp.json().catch(() => ({}));
showError('generic', body.message || `Unexpected error (${failedResp.status})`);
return;
}
allRuns = await runsResp.json();
allExperiments = await expsResp.json();
loadingIndicator.hidden = true;
lastRefreshed.textContent = 'Updated ' + new Date().toLocaleTimeString();
populateExperimentFilter();
applyFilter();
}
// ── Experiment filter ──────────────────────────────────────────────────
function populateExperimentFilter() {
const prev = selectedExperiment;
experimentFilter.innerHTML = '<option value="">All Experiments</option>';
const sorted = [...allExperiments].sort((a, b) => a.name.localeCompare(b.name));
sorted.forEach(exp => {
const opt = document.createElement('option');
opt.value = exp.name;
opt.textContent = exp.name;
experimentFilter.appendChild(opt);
});
// Restore previous selection if it still exists
if (prev && sorted.some(e => e.name === prev)) {
experimentFilter.value = prev;
} else {
selectedExperiment = '';
experimentFilter.value = '';
}
}
function applyFilter() {
filteredRuns = selectedExperiment
? allRuns.filter(r => r.experiment_name === selectedExperiment)
: allRuns;
runCount.textContent = `Showing ${filteredRuns.length} run${filteredRuns.length !== 1 ? 's' : ''}`;
renderTable(filteredRuns);
renderChart(filteredRuns);
}
// ── Table rendering ────────────────────────────────────────────────────
function renderTable(runs) {
runsTbody.innerHTML = '';
if (!runs.length) {
runsTable.hidden = true;
emptyState.hidden = false;
return;
}
emptyState.hidden = true;
runsTable.hidden = false;
runs.forEach(run => {
const tr = document.createElement('tr');
const paramsStr = formatKV(run.params);
const metricsStr = formatKV(run.metrics);
tr.innerHTML = `
<td title="${esc(run.experiment_name)}">${esc(run.experiment_name)}</td>
<td title="${esc(run.run_name || '—')}">${esc(run.run_name || '—')}</td>
<td><span class="badge ${badgeClass(run.status)}">${esc(run.status)}</span></td>
<td class="wrap" title="${esc(paramsStr)}">${esc(paramsStr)}</td>
<td class="wrap" title="${esc(metricsStr)}">${esc(metricsStr)}</td>
<td>${fmtDate(run.started_at)}</td>
`;
runsTbody.appendChild(tr);
});
}
function esc(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ── Chart rendering ────────────────────────────────────────────────────
function renderChart(runs) {
const eligible = runs
.filter(r => r.metrics && 'accuracy' in r.metrics && 'loss' in r.metrics)
.sort((a, b) => new Date(a.started_at) - new Date(b.started_at));
if (!selectedExperiment || eligible.length < 2) {
chartSection.hidden = true;
if (chartInstance) { chartInstance.destroy(); chartInstance = null; }
return;
}
chartSection.hidden = false;
chartSubtitle.textContent = `${eligible.length} runs · ${selectedExperiment}`;
const labels = eligible.map(r => r.run_name || r.id.slice(0, 8));
const accuracy = eligible.map(r => r.metrics.accuracy);
const loss = eligible.map(r => r.metrics.loss);
if (chartInstance) { chartInstance.destroy(); chartInstance = null; }
try {
chartInstance = new Chart(document.getElementById('metrics-chart'), {
type: 'line',
data: {
labels,
datasets: [
{
label: 'Accuracy',
data: accuracy,
borderColor: '#3b82f6',
backgroundColor: 'rgba(59,130,246,.08)',
yAxisID: 'yAcc',
tension: 0.3,
pointRadius: 4,
pointHoverRadius: 6,
},
{
label: 'Loss',
data: loss,
borderColor: '#ef4444',
backgroundColor: 'rgba(239,68,68,.08)',
yAxisID: 'yLoss',
tension: 0.3,
pointRadius: 4,
pointHoverRadius: 6,
},
],
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: 'top' },
tooltip: { usePointStyle: true },
},
scales: {
yAcc: {
type: 'linear',
position: 'left',
title: { display: true, text: 'Accuracy', color: '#3b82f6' },
grid: { color: 'rgba(0,0,0,.05)' },
},
yLoss: {
type: 'linear',
position: 'right',
title: { display: true, text: 'Loss', color: '#ef4444' },
grid: { drawOnChartArea: false },
},
},
},
});
} catch (_) {
chartSection.hidden = true;
}
}
// ── Auto-refresh ───────────────────────────────────────────────────────
function toggleAutoRefresh() {
autoRefresh = !autoRefresh;
if (autoRefresh) {
refreshTimer = setInterval(fetchData, 30000);
toggleBtn.textContent = 'Auto-Refresh: ON';
toggleBtn.classList.remove('off');
} else {
clearInterval(refreshTimer);
refreshTimer = null;
toggleBtn.textContent = 'Auto-Refresh: OFF';
toggleBtn.classList.add('off');
}
}
// ── Init ───────────────────────────────────────────────────────────────
experimentFilter.addEventListener('change', e => {
selectedExperiment = e.target.value;
applyFilter();
});
toggleBtn.addEventListener('click', toggleAutoRefresh);
fetchData();
refreshTimer = setInterval(fetchData, 30000);
</script>
</body>
</html>