chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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.
|
||||
|
||||

|
||||
|
||||
Selecting an experiment reveals a metrics-over-time chart with dual axes for accuracy and loss.
|
||||
|
||||

|
||||
|
||||
The full workflow from `tracker log` to browsing results in the dashboard.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── 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>
|
||||
Reference in New Issue
Block a user