diff --git a/src/env_doctor/server/database.py b/src/env_doctor/server/database.py index 34fe3c7..69354b2 100644 --- a/src/env_doctor/server/database.py +++ b/src/env_doctor/server/database.py @@ -1,7 +1,8 @@ """SQLAlchemy async database engine and session management.""" from pathlib import Path -from sqlalchemy import event +from sqlalchemy import event, text +from sqlalchemy.exc import OperationalError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase @@ -23,16 +24,34 @@ class Base(DeclarativeBase): async def init_db(): - """Create all tables and enable WAL mode.""" + """Create all tables, run lightweight migrations, and enable WAL mode.""" _DB_PATH.parent.mkdir(exist_ok=True) from . import models # noqa: F401 — ensure models are registered async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) - await conn.execute( - __import__("sqlalchemy").text("PRAGMA journal_mode=WAL") - ) + await conn.execute(text("PRAGMA journal_mode=WAL")) + await _run_lightweight_migrations(conn) + + +async def _run_lightweight_migrations(conn): + """Idempotent ALTER statements for columns added after the initial schema. + + SQLite's ``CREATE TABLE`` is no-op when a table exists, so new columns on + existing tables need explicit ``ALTER TABLE``. We run each statement and + swallow ``OperationalError`` (raised when the column/index already exists). + """ + statements = [ + "ALTER TABLE machines ADD COLUMN group_name VARCHAR(64)", + "CREATE INDEX IF NOT EXISTS ix_machines_group_name ON machines(group_name)", + ] + for stmt in statements: + try: + await conn.execute(text(stmt)) + except OperationalError: + # Column or index already exists — expected on every run after the first. + pass async def get_session(): diff --git a/src/env_doctor/server/models.py b/src/env_doctor/server/models.py index bbfc804..067c5f5 100644 --- a/src/env_doctor/server/models.py +++ b/src/env_doctor/server/models.py @@ -44,6 +44,7 @@ class Machine(Base): python_version = Column(String, nullable=True) latest_status = Column(String, nullable=True) # "pass"/"warning"/"fail" latest_snapshot_id = Column(Integer, nullable=True) + group_name = Column(String(64), nullable=True, index=True) first_seen = Column(DateTime, default=lambda: datetime.now(timezone.utc)) last_seen = Column(DateTime, default=lambda: datetime.now(timezone.utc)) @@ -60,6 +61,7 @@ def to_dict(self): "platform": self.platform, "python_version": self.python_version, "latest_status": self.latest_status, + "group_name": self.group_name, "first_seen": self.first_seen.isoformat() if self.first_seen else None, "last_seen": self.last_seen.isoformat() if self.last_seen else None, } diff --git a/src/env_doctor/server/routes.py b/src/env_doctor/server/routes.py index 7617926..c9d2346 100644 --- a/src/env_doctor/server/routes.py +++ b/src/env_doctor/server/routes.py @@ -1,12 +1,13 @@ """API route handlers for the dashboard.""" import json import os +import re from datetime import datetime, timezone from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel -from sqlalchemy import select +from pydantic import BaseModel, Field +from sqlalchemy import case, func, select from sqlalchemy.ext.asyncio import AsyncSession from .database import get_session @@ -29,6 +30,40 @@ def _seconds_since(when: Optional[datetime]) -> Optional[float]: return (datetime.now(timezone.utc) - when).total_seconds() +# --------------------------------------------------------------------------- +# Group name validation +# --------------------------------------------------------------------------- + +# Groups must be human-readable identifiers safe for URLs, file paths, and +# topology labels. Allow alphanumerics, hyphen, underscore, dot, and space. +_GROUP_NAME_PATTERN = re.compile(r"^[\w\-. ]+$") +_UNGROUPED_LABEL = "ungrouped" + + +def _clean_group_name(raw: Optional[str]) -> Optional[str]: + """Normalise a group name. Returns None for empty/whitespace input. + + Raises HTTPException(400) on invalid characters so callers do not have to + validate separately. + """ + if raw is None: + return None + cleaned = raw.strip() + if not cleaned: + return None + if len(cleaned) > 64: + raise HTTPException(status_code=400, detail="group_name must be 64 characters or fewer") + if not _GROUP_NAME_PATTERN.match(cleaned): + raise HTTPException( + status_code=400, + detail="group_name may only contain letters, numbers, spaces, '-', '_', '.'", + ) + if cleaned.lower() == _UNGROUPED_LABEL: + # Reserved synthetic label used by GET /api/groups for NULL machines. + raise HTTPException(status_code=400, detail=f"'{_UNGROUPED_LABEL}' is a reserved name") + return cleaned + + # --------------------------------------------------------------------------- # Pydantic request/response models # --------------------------------------------------------------------------- @@ -40,6 +75,7 @@ class MachineInfo(BaseModel): platform_release: Optional[str] = None python_version: Optional[str] = None reported_at: Optional[str] = None + group_name: Optional[str] = None # Optional self-tag from CLI; dashboard PATCH overrides. class ReportPayload(BaseModel): @@ -111,6 +147,7 @@ async def receive_report( first_seen=now, last_seen=now, latest_status=payload.status, + group_name=_clean_group_name(payload.machine.group_name), ) session.add(machine) else: @@ -119,6 +156,11 @@ async def receive_report( machine.python_version = payload.machine.python_version machine.last_seen = now machine.latest_status = payload.status + # Only honour CLI self-tag when no group was set via the dashboard PATCH. + # Dashboard-assigned groups are the source of truth and must not be + # silently overwritten by every check-in. + if machine.group_name is None and payload.machine.group_name: + machine.group_name = _clean_group_name(payload.machine.group_name) # Create snapshot fields = _extract_fields(payload) @@ -228,6 +270,102 @@ async def get_machine( return result +# --------------------------------------------------------------------------- +# PATCH /api/machines/{id} (mutate machine metadata — currently group_name) +# --------------------------------------------------------------------------- + +class MachineUpdate(BaseModel): + # Use Field(...) sentinel so we can distinguish "set to null" from "field omitted". + group_name: Optional[str] = Field(default=None, max_length=64) + + +@router.patch("/machines/{machine_id}") +async def update_machine( + machine_id: str, + body: MachineUpdate, + session: AsyncSession = Depends(get_session), +): + """Update mutable machine fields. Currently supports group_name. + + Pass ``{"group_name": "training-east"}`` to assign, or ``{"group_name": ""}`` + / ``{"group_name": null}`` to ungroup. + """ + machine = await session.get(Machine, machine_id) + if not machine: + raise HTTPException(status_code=404, detail="Machine not found") + + # _clean_group_name raises 400 on invalid characters. + machine.group_name = _clean_group_name(body.group_name) + await session.commit() + await session.refresh(machine) + + result = machine.to_dict() + elapsed = _seconds_since(machine.last_seen) + result["last_seen_seconds"] = elapsed + result["stale"] = elapsed is not None and elapsed > _STALE_AFTER_SECONDS + if machine.latest_snapshot_id: + snap = await session.get(Snapshot, machine.latest_snapshot_id) + if snap: + # Match GET /api/machines/{id} shape so callers (MachineDetail) can + # safely setMachine(patchResponse) without losing diagnostics. + result["latest_report"] = json.loads(snap.report_json) + result["gpu_name"] = snap.gpu_name + result["driver_version"] = snap.driver_version + result["cuda_version"] = snap.cuda_version + result["torch_version"] = snap.torch_version + return result + + +# --------------------------------------------------------------------------- +# GET /api/groups +# --------------------------------------------------------------------------- + +@router.get("/groups") +async def list_groups(session: AsyncSession = Depends(get_session)): + """Return all distinct group names with member counts and status breakdown. + + NULL group_name values are aggregated under the synthetic "ungrouped" entry + and listed last; named groups are sorted alphabetically. + """ + pass_sum = func.sum(case((Machine.latest_status == "pass", 1), else_=0)) + warn_sum = func.sum(case((Machine.latest_status == "warning", 1), else_=0)) + fail_sum = func.sum(case((Machine.latest_status == "fail", 1), else_=0)) + + query = ( + select( + Machine.group_name, + func.count(Machine.id), + pass_sum, + warn_sum, + fail_sum, + ) + .group_by(Machine.group_name) + ) + result = await session.execute(query) + + named: list[dict] = [] + ungrouped: Optional[dict] = None + for group_name, count, n_pass, n_warn, n_fail in result.all(): + entry = { + "name": group_name if group_name else _UNGROUPED_LABEL, + "machine_count": int(count or 0), + "status_breakdown": { + "pass": int(n_pass or 0), + "warning": int(n_warn or 0), + "fail": int(n_fail or 0), + }, + } + if group_name: + named.append(entry) + else: + ungrouped = entry + + named.sort(key=lambda g: g["name"].lower()) + if ungrouped is not None: + named.append(ungrouped) + return named + + # --------------------------------------------------------------------------- # GET /api/machines/{id}/history # --------------------------------------------------------------------------- diff --git a/web/src/api.ts b/web/src/api.ts index 4446e3b..455ad36 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -3,6 +3,7 @@ import type { CommandActivityRow, CommandRecord, MachineDetail, + MachineGroup, MachineListItem, SnapshotSummary, } from "./types"; @@ -104,6 +105,32 @@ export function getCommands(machineId: string): Promise { return fetchJson(`${BASE}/machines/${machineId}/commands`); } +export function getGroups(): Promise { + return fetchJson(`${BASE}/groups`); +} + +export async function updateMachineGroup( + id: string, + group_name: string | null +): Promise { + const res = await apiFetch(`${BASE}/machines/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ group_name }), + }); + if (!res.ok) { + let detail = `HTTP ${res.status}`; + try { + const body = await res.json(); + if (body && typeof body.detail === "string") detail = body.detail; + } catch { + /* ignore */ + } + throw new Error(detail); + } + return res.json(); +} + export function getCommandActivity( filters: CommandActivityFilters = {} ): Promise { diff --git a/web/src/components/GroupPicker.tsx b/web/src/components/GroupPicker.tsx new file mode 100644 index 0000000..614907b --- /dev/null +++ b/web/src/components/GroupPicker.tsx @@ -0,0 +1,202 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { MachineGroup } from "../types"; + +interface Props { + value: string | null; + onChange: (next: string | null) => void; + groups: MachineGroup[]; + /** Focus the input on mount. Default true. */ + autoFocus?: boolean; + /** Called when the user dismisses without selecting (Esc, click outside). */ + onClose?: () => void; + placeholder?: string; +} + +const UNGROUPED_LABEL = "ungrouped"; + +const inputStyle: React.CSSProperties = { + width: "100%", + padding: "8px 10px", + background: "#0d1117", + border: "1px solid rgba(88,166,255,0.5)", + borderRadius: 6, + color: "#e6edf3", + fontSize: 13, + outline: "none", + boxSizing: "border-box", +}; + +const dropdownStyle: React.CSSProperties = { + position: "absolute", + top: "calc(100% + 4px)", + left: 0, + right: 0, + minWidth: 200, + background: "#161b22", + border: "1px solid rgba(255,255,255,0.12)", + borderRadius: 6, + maxHeight: 240, + overflowY: "auto", + zIndex: 50, + boxShadow: "0 4px 16px rgba(0,0,0,0.4)", +}; + +const itemStyle = (active: boolean): React.CSSProperties => ({ + padding: "8px 12px", + fontSize: 13, + color: "#e6edf3", + cursor: "pointer", + background: active ? "rgba(88,166,255,0.15)" : "transparent", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 8, +}); + +export default function GroupPicker({ + value, + onChange, + groups, + autoFocus = true, + onClose, + placeholder = "Group name…", +}: Props) { + const [text, setText] = useState(value ?? ""); + const [highlight, setHighlight] = useState(0); + const rootRef = useRef(null); + + // Filter out the synthetic "ungrouped" entry — it's not a real group. + const realGroups = useMemo( + () => groups.filter(g => g.name.toLowerCase() !== UNGROUPED_LABEL), + [groups] + ); + + const trimmed = text.trim(); + const lower = trimmed.toLowerCase(); + + const filtered = useMemo(() => { + if (!lower) return realGroups; + return realGroups.filter(g => g.name.toLowerCase().includes(lower)); + }, [realGroups, lower]); + + const exactMatch = filtered.some(g => g.name.toLowerCase() === lower); + const showCreate = trimmed.length > 0 && !exactMatch; + const showUngroup = value !== null; + + const optionCount = filtered.length + (showCreate ? 1 : 0) + (showUngroup ? 1 : 0); + + // Reset highlight whenever the filter list changes shape. + useEffect(() => { setHighlight(0); }, [text, realGroups.length]); + + // Click-outside dismissal. + useEffect(() => { + if (!onClose) return; + const handler = (e: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) { + onClose(); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [onClose]); + + const commitAt = (index: number) => { + if (index < filtered.length) { + onChange(filtered[index].name); + return; + } + const offset = index - filtered.length; + if (showCreate && offset === 0) { + onChange(trimmed); + return; + } + if (showUngroup) { + onChange(null); + } + }; + + const handleKey = (e: React.KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + setHighlight(h => (optionCount === 0 ? 0 : Math.min(h + 1, optionCount - 1))); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setHighlight(h => Math.max(h - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + if (optionCount === 0) return; + commitAt(highlight); + } else if (e.key === "Escape") { + e.preventDefault(); + onClose?.(); + } + }; + + return ( +
+ setText(e.target.value)} + onKeyDown={handleKey} + placeholder={placeholder} + style={inputStyle} + aria-label="Group name" + /> +
+ {filtered.length === 0 && !showCreate && !showUngroup && ( +
+ No groups yet — type a name to create one. +
+ )} + {filtered.map((g, i) => ( +
setHighlight(i)} + onMouseDown={(e) => { e.preventDefault(); commitAt(i); }} + style={itemStyle(highlight === i)} + > + {g.name} + + {g.machine_count} + +
+ ))} + {showCreate && ( +
setHighlight(filtered.length)} + onMouseDown={(e) => { e.preventDefault(); commitAt(filtered.length); }} + style={{ + ...itemStyle(highlight === filtered.length), + borderTop: filtered.length > 0 ? "1px solid rgba(255,255,255,0.06)" : "none", + color: "#58a6ff", + }} + > + + Create “{trimmed}” +
+ )} + {showUngroup && ( +
setHighlight(filtered.length + (showCreate ? 1 : 0))} + onMouseDown={(e) => { e.preventDefault(); commitAt(filtered.length + (showCreate ? 1 : 0)); }} + style={{ + ...itemStyle(highlight === filtered.length + (showCreate ? 1 : 0)), + borderTop: "1px solid rgba(255,255,255,0.06)", + color: "rgba(255,255,255,0.55)", + fontSize: 12, + }} + > + Ungroup +
+ )} +
+
+ ); +} diff --git a/web/src/pages/FleetOverview.tsx b/web/src/pages/FleetOverview.tsx index 6e30e31..3abd08d 100644 --- a/web/src/pages/FleetOverview.tsx +++ b/web/src/pages/FleetOverview.tsx @@ -325,9 +325,20 @@ export default function FleetOverview() { display: "inline-block", transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)", }}>▶ - + e.stopPropagation()} + style={{ + color: m.stale ? "rgba(230,237,243,0.55)" : "#e6edf3", + textDecoration: "none", + fontWeight: 600, + }} + onMouseEnter={e => (e.currentTarget.style.color = "#58a6ff")} + onMouseLeave={e => (e.currentTarget.style.color = m.stale ? "rgba(230,237,243,0.55)" : "#e6edf3")} + title="Open full details page" + > {m.hostname} - + {m.stale && ( ) : ( <> + {/* Top action bar — primary navigation to MachineDetail */} +
+
+ Quick view · use full details for command runner, history, and group editor +
+ e.stopPropagation()} + style={{ + padding: "8px 14px", + background: "#1f6feb", + color: "#fff", + borderRadius: 6, + fontSize: 13, + fontWeight: 600, + textDecoration: "none", + display: "inline-flex", + alignItems: "center", + gap: 6, + transition: "background .15s", + }} + onMouseEnter={e => (e.currentTarget.style.background = "#388bfd")} + onMouseLeave={e => (e.currentTarget.style.background = "#1f6feb")} + > + Open full details → + +
+ {/* Gauge + Commands row */}
{/* Left: Gauge */} @@ -423,23 +469,8 @@ export default function FleetOverview() { {/* Diagnostics */} {detail.latest_report?.checks && (
-
-
- Diagnostics -
- - Full details + history → - +
+ Diagnostics
diff --git a/web/src/pages/MachineDetail.tsx b/web/src/pages/MachineDetail.tsx index 2feb181..7906e0b 100644 --- a/web/src/pages/MachineDetail.tsx +++ b/web/src/pages/MachineDetail.tsx @@ -1,10 +1,11 @@ import { useEffect, useState } from "react"; import { useParams, Link } from "react-router-dom"; -import { getMachine, getMachineHistory } from "../api"; -import type { MachineDetail, SnapshotSummary } from "../types"; +import { getGroups, getMachine, getMachineHistory, updateMachineGroup } from "../api"; +import type { MachineDetail, MachineGroup, SnapshotSummary } from "../types"; import StatusBadge from "../components/StatusBadge"; import DiagnosticCard from "../components/DiagnosticCard"; import CustomCommandBox from "../components/CustomCommandBox"; +import GroupPicker from "../components/GroupPicker"; function timeAgo(iso: string | null): string { if (!iso) return "never"; @@ -28,15 +29,48 @@ export default function MachineDetailPage() { const [machine, setMachine] = useState(null); const [history, setHistory] = useState([]); const [error, setError] = useState(null); + const [groups, setGroups] = useState([]); + const [editingGroup, setEditingGroup] = useState(false); + const [groupError, setGroupError] = useState(null); + const [groupSaving, setGroupSaving] = useState(false); const refresh = () => { if (!id) return; getMachine(id).then(setMachine).catch((e) => setError(e.message)); getMachineHistory(id).then(setHistory).catch(console.error); + getGroups().then(setGroups).catch(() => {}); }; useEffect(() => { refresh(); }, [id]); + const handleGroupChange = async (next: string | null) => { + if (!id || !machine) return; + if ((machine.group_name ?? null) === (next ?? null)) { + // No change — just close the editor. + setEditingGroup(false); + return; + } + const previous = machine.group_name; + setGroupSaving(true); + setGroupError(null); + // Optimistic update so the UI reflects the choice instantly. + setMachine({ ...machine, group_name: next }); + setEditingGroup(false); + try { + const updated = await updateMachineGroup(id, next); + setMachine(updated); + // Re-fetch groups so counts reflect the move. + getGroups().then(setGroups).catch(() => {}); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : "Failed to update group"; + setGroupError(msg); + // Revert. + setMachine({ ...machine, group_name: previous }); + } finally { + setGroupSaving(false); + } + }; + if (error) { return (
@@ -111,7 +145,55 @@ export default function MachineDetailPage() { {/* Summary row */} {report && ( -
+
+ {/* Group card — clickable, opens inline GroupPicker. */} +
{ if (!editingGroup) setEditingGroup(true); }} + title={editingGroup ? undefined : "Click to assign or change group"} + > +
+ Group + {!editingGroup && ( + + )} +
+ {editingGroup ? ( +
e.stopPropagation()}> + { setEditingGroup(false); setGroupError(null); }} + /> +
+ ) : ( +
+ {groupSaving ? "Saving…" : (machine.group_name ?? "—")} +
+ )} +
{[ { label: "GPU", value: machine.gpu_name }, { label: "Driver", value: machine.driver_version }, @@ -127,6 +209,20 @@ export default function MachineDetailPage() {
)} + {groupError && ( +
+ Group update failed: {groupError} +
+ )} + {/* Diagnostic Cards */} {checks && (
diff --git a/web/src/pages/TopologyView.tsx b/web/src/pages/TopologyView.tsx index 2205c4e..022b250 100644 --- a/web/src/pages/TopologyView.tsx +++ b/web/src/pages/TopologyView.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, useCallback } from "react"; +import { Link } from "react-router-dom"; import { getMachines, getMachine } from "../api"; import type { MachineListItem, MachineDetail, CheckResult } from "../types"; @@ -674,31 +675,58 @@ export default function TopologyView() { padding: "20px 16px", overflowY: "auto", color: TEXT_COL, animation: "slideR .25s ease-out", }}> -
+
{detail.latest_status ?? "unknown"} + {report && ( + + · {report.summary.issues_count} {report.summary.issues_count === 1 ? "issue" : "issues"} + + )} + (e.currentTarget.style.background = "#388bfd")} + onMouseLeave={e => (e.currentTarget.style.background = "#1f6feb")} + title="Open the full details page (history, command runner, group editor)" + > + Open full details → +
- {report && ( - <> - -
- {[ - { k: "Driver", v: report.summary.driver }, - { k: "CUDA", v: report.summary.cuda }, - { k: "cuDNN", v: report.summary.cudnn }, - { k: "Issues", v: String(report.summary.issues_count) }, - ].map(i => )} -
- - )} - {checks && ( <> diff --git a/web/src/types.ts b/web/src/types.ts index aaaf9d3..9e75f9f 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -4,6 +4,7 @@ export interface MachineListItem { platform: string | null; python_version: string | null; latest_status: string | null; + group_name: string | null; first_seen: string | null; last_seen: string | null; last_seen_seconds: number | null; @@ -14,6 +15,16 @@ export interface MachineListItem { torch_version: string | null; } +export interface MachineGroup { + name: string; + machine_count: number; + status_breakdown: { + pass: number; + warning: number; + fail: number; + }; +} + export interface MachineDetail extends MachineListItem { latest_report: Report | null; }