Backend
src/env_doctor/server/models.py — add to Machine:
group_name = Column(String(64), nullable=True, index=True)
Base.metadata.create_all() auto-adds the column on next startup for new DBs. For existing DBs SQLite will not
retroactively add columns — add a one-shot migration in database.py:init_db() that runs ALTER TABLE machines ADD
COLUMN group_name VARCHAR(64) inside a try/except OperationalError (idempotent: the OperationalError on second run
means the column already exists).
src/env_doctor/server/routes.py — add two endpoints:
- PATCH /api/machines/{id} — body MachineUpdate(group_name: str | None). Pydantic model with group_name:
Optional[str] = Field(None, max_length=64). Empty string → None (ungroup). Sanitise: strip whitespace, reject names
containing / or control chars.
- GET /api/groups — returns [{name, machine_count, status_breakdown: {pass, warning, fail}}]. Single SQL: SELECT
group_name, COUNT(*), SUM(CASE WHEN latest_status='pass'...) GROUP BY group_name. Treat NULL as the synthetic group
"ungrouped" in the response.
POST /report ingest (routes.py ~line 100): if the inbound payload includes machine.group_name, persist it. Optional —
keeps CLI ↔ dashboard symmetric so a machine can self-tag at registration time. Don't require it.
Frontend types — web/src/types.ts
- Add group_name: string | null to both MachineListItem and MachineDetail.
- Add interface MachineGroup { name: string; machine_count: number; status_breakdown: { pass: number; warning:
number; fail: number } }.
Frontend API — web/src/api.ts
- updateMachineGroup(id: string, group_name: string | null): Promise<MachineDetail> → PATCH wrapper.
- getGroups(): Promise<MachineGroup[]>.
MachineDetail inline-edit (web/src/pages/MachineDetail.tsx)
Add a "Group" row in the summary cards block (~line 114-127). Display: current group name or "—". Click to edit →
small inline <GroupPicker> (see component below). On save → updateMachineGroup() → optimistic update + refetch. This
is the first inline-edit pattern in the app — keep it minimal: no modal, just an in-place swap between label and
picker.
New shared component — web/src/components/GroupPicker.tsx
Compact combobox (no library — <input list> won't style well in dark theme):
- Text input with a dropdown showing existing groups (from getGroups()).
- Free typing creates a new group on submit (no separate "create" step — the group exists implicitly once a machine
is assigned to it).
- Empty submit → ungroup.
- Props: { value: string | null, onChange: (next: string | null) => void, groups: MachineGroup[] }.
- Reused by MachineDetail (inline) and by Topology's floating action bar (Phase 3) and Fleet's filter chip (Phase 3).
Phase 2 — Grouping schema + assignment endpoints