Phase 1 — Activity page (no schema change)
Goal: cross-fleet command log answering "what ran where, when, with what result."
Backend — src/env_doctor/server/routes.py
Add GET /api/commands (insert near the existing GET /machines/{id}/commands at ~line 322 to keep command routes
co-located):
- Query params: status (one of pending|running|done|failed), machine_id (UUID), since (ISO-8601 timestamp), limit
(default 50, ge=1, le=200), offset (default 0, ge=0).
- SQL: select(Command, Machine.hostname).join(Machine, Command.machine_id ==
Machine.id).order_by(Command.created_at.desc()) with conditional .where(...) for each filter.
- Response item shape: {id, machine_id, hostname, command, status, exit_code, output, created_at, executed_at,
duration_seconds} where duration_seconds = (executed_at - created_at).total_seconds() if executed_at set else null.
- Reuse the same auth dependency as every other route (already applied at the router level in app.py).
Frontend
- web/src/api.ts: add getCommandActivity(filters: { status?, machine_id?, since?, limit?, offset? }). Mirror the
existing getCommands(machineId) style.
- web/src/types.ts: add interface CommandActivityRow extends CommandRecord { hostname: string; duration_seconds:
number | null }.
- New web/src/pages/Activity.tsx:
- Table columns: Time (relative, hover→absolute) | Machine (link to /machines/{id}) | Command | Status
(<StatusBadge>) | Exit | Duration | Output (collapsible row, reuse the disclosure pattern from FleetOverview row
expansion at pages/FleetOverview.tsx:102-113).
- Top-bar filters: status pills (all | pending | running | done | failed), machine <select> (populate from
getMachines()), time range (last hour | 24h | 7d | all — translate to since).
- Auto-refresh every 10s using the standard useEffect pattern.
- web/src/main.tsx: add <Route path="activity" element={<Activity />} />.
- web/src/App.tsx: add <NavLink to="/activity"> next to existing nav links (line 108-115). Skip the sidebar reshape
until Phase 4.