From c1c6e0db5362468dfe5457d068f6b748fc145e5f Mon Sep 17 00:00:00 2001 From: Mitul Garg Date: Sat, 9 May 2026 17:06:39 +0530 Subject: [PATCH] feat: activity page, cross-fleet command log, and auto-login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/commands: cross-fleet command log with status/machine/since/limit/offset filters, hostname joined in, duration_seconds computed - Activity page (/activity): table with status pills, machine dropdown, time-range filter, expandable output rows, 10s auto-refresh - Activity nav link added to sidebar - Server injects API token into served index.html so browser auto-authenticates — no login screen for self-hosted dashboard Co-Authored-By: Claude Sonnet 4.6 --- src/env_doctor/server/app.py | 12 +- src/env_doctor/server/routes.py | 68 ++++++ web/src/App.tsx | 11 + web/src/api.ts | 31 ++- web/src/main.tsx | 2 + web/src/pages/Activity.tsx | 367 ++++++++++++++++++++++++++++++++ web/src/types.ts | 13 ++ 7 files changed, 501 insertions(+), 3 deletions(-) create mode 100644 web/src/pages/Activity.tsx diff --git a/src/env_doctor/server/app.py b/src/env_doctor/server/app.py index 3c9173e..a035c7e 100644 --- a/src/env_doctor/server/app.py +++ b/src/env_doctor/server/app.py @@ -8,7 +8,7 @@ from fastapi.staticfiles import StaticFiles from . import database as _db -from .auth import require_token +from .auth import get_active_token, require_token from .routes import router as api_router @@ -58,9 +58,17 @@ async def serve_spa(full_path: str): file_path = os.path.join(_WEB_DIR, full_path) if full_path and os.path.isfile(file_path): return FileResponse(file_path) - # Fallback to index.html for client-side routing + # Fallback to index.html for client-side routing. + # Inject the API token so the browser auto-authenticates without a login form. index = os.path.join(_WEB_DIR, "index.html") if os.path.isfile(index): + token = get_active_token() + if token: + from fastapi.responses import HTMLResponse + html = open(index, encoding="utf-8").read() + snippet = f'' + html = html.replace("", f"{snippet}", 1) + return HTMLResponse(html) return FileResponse(index) return {"detail": "Frontend not built. Run 'npm run build' in web/ directory."} else: diff --git a/src/env_doctor/server/routes.py b/src/env_doctor/server/routes.py index 326a7cf..7617926 100644 --- a/src/env_doctor/server/routes.py +++ b/src/env_doctor/server/routes.py @@ -330,3 +330,71 @@ async def list_commands( ) result = await session.execute(query) return [c.to_dict() for c in result.scalars().all()] + + +# --------------------------------------------------------------------------- +# GET /api/commands (cross-fleet activity log) +# --------------------------------------------------------------------------- + +_VALID_COMMAND_STATUSES = {"pending", "running", "done", "failed"} + + +def _parse_iso(value: str) -> datetime: + # datetime.fromisoformat in 3.11+ accepts trailing "Z"; older Pythons need a swap. + if value.endswith("Z"): + value = value[:-1] + "+00:00" + return datetime.fromisoformat(value) + + +@router.get("/commands") +async def list_command_activity( + status: Optional[str] = Query(None, description="pending | running | done | failed"), + machine_id: Optional[str] = Query(None), + since: Optional[str] = Query(None, description="ISO-8601 timestamp lower bound on created_at"), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + session: AsyncSession = Depends(get_session), +): + """Cross-fleet command activity log with hostname joined in.""" + if status is not None and status not in _VALID_COMMAND_STATUSES: + raise HTTPException( + status_code=400, + detail=f"status must be one of {sorted(_VALID_COMMAND_STATUSES)}", + ) + + since_dt: Optional[datetime] = None + if since: + try: + since_dt = _parse_iso(since) + except ValueError: + raise HTTPException(status_code=400, detail="since must be an ISO-8601 timestamp") + + query = ( + select(Command, Machine.hostname) + .join(Machine, Command.machine_id == Machine.id, isouter=True) + .order_by(Command.created_at.desc()) + .limit(limit) + .offset(offset) + ) + if status: + query = query.where(Command.status == status) + if machine_id: + query = query.where(Command.machine_id == machine_id) + if since_dt is not None: + query = query.where(Command.created_at >= since_dt) + + result = await session.execute(query) + rows = result.all() + + output = [] + for cmd, hostname in rows: + item = cmd.to_dict() + item["hostname"] = hostname + if cmd.created_at and cmd.executed_at: + created = cmd.created_at if cmd.created_at.tzinfo else cmd.created_at.replace(tzinfo=timezone.utc) + executed = cmd.executed_at if cmd.executed_at.tzinfo else cmd.executed_at.replace(tzinfo=timezone.utc) + item["duration_seconds"] = (executed - created).total_seconds() + else: + item["duration_seconds"] = None + output.append(item) + return output diff --git a/web/src/App.tsx b/web/src/App.tsx index 1be97b9..e7bf9ff 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -30,6 +30,14 @@ function FleetIcon() { ); } +function ActivityIcon() { + return ( + + + + ); +} + const navStyle = (isActive: boolean): React.CSSProperties => ({ display: "flex", alignItems: "center", @@ -112,6 +120,9 @@ export default function App() { navStyle(isActive)}> Fleet + navStyle(isActive)}> + Activity +
).__ENV_DOCTOR_TOKEN__; + if (typeof injected === "string" && injected) { + try { localStorage.setItem(TOKEN_KEY, injected); } catch { /* ignore */ } + } +})(); + export function getToken(): string | null { try { return localStorage.getItem(TOKEN_KEY); @@ -88,6 +104,19 @@ export function getCommands(machineId: string): Promise { return fetchJson(`${BASE}/machines/${machineId}/commands`); } +export function getCommandActivity( + filters: CommandActivityFilters = {} +): Promise { + const params = new URLSearchParams(); + if (filters.status) params.set("status", filters.status); + if (filters.machine_id) params.set("machine_id", filters.machine_id); + if (filters.since) params.set("since", filters.since); + if (filters.limit != null) params.set("limit", String(filters.limit)); + if (filters.offset != null) params.set("offset", String(filters.offset)); + const qs = params.toString(); + return fetchJson(`${BASE}/commands${qs ? `?${qs}` : ""}`); +} + export async function verifyToken(): Promise { try { const res = await apiFetch(`${BASE}/machines`); diff --git a/web/src/main.tsx b/web/src/main.tsx index 3b79491..93c78dd 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -2,6 +2,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import App from "./App"; +import Activity from "./pages/Activity"; import FleetOverview from "./pages/FleetOverview"; import MachineDetailPage from "./pages/MachineDetail"; import TopologyView from "./pages/TopologyView"; @@ -14,6 +15,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render( } /> } /> } /> + } /> } /> diff --git a/web/src/pages/Activity.tsx b/web/src/pages/Activity.tsx new file mode 100644 index 0000000..7a3041f --- /dev/null +++ b/web/src/pages/Activity.tsx @@ -0,0 +1,367 @@ +import { useEffect, useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { getCommandActivity, getMachines } from "../api"; +import type { CommandActivityRow, MachineListItem } from "../types"; + +const REFRESH_MS = 10_000; +const PAGE_LIMIT = 100; + +type StatusFilter = "all" | "pending" | "running" | "done" | "failed"; +type RangeFilter = "1h" | "24h" | "7d" | "all"; + +const RANGE_SECONDS: Record, number> = { + "1h": 3600, + "24h": 86_400, + "7d": 604_800, +}; + +const cmdStatusColors: Record = { + pending: { bg: "rgba(139,149,163,0.18)", fg: "#b1bac4" }, + running: { bg: "rgba(88,166,255,0.18)", fg: "#58a6ff" }, + done: { bg: "rgba(35,134,54,0.22)", fg: "#3fb950" }, + failed: { bg: "rgba(218,54,51,0.22)", fg: "#f85149" }, +}; + +function CmdStatusBadge({ status }: { status: string }) { + const c = cmdStatusColors[status] ?? { bg: "rgba(255,255,255,0.12)", fg: "rgba(255,255,255,0.7)" }; + return ( + {status} + ); +} + +function timeAgo(iso: string | null): string { + if (!iso) return "—"; + const diff = Date.now() - new Date(iso).getTime(); + const s = Math.max(0, Math.floor(diff / 1000)); + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} + +function formatDuration(seconds: number | null): string { + if (seconds == null) return "—"; + if (seconds < 1) return `${Math.round(seconds * 1000)}ms`; + if (seconds < 60) return `${seconds.toFixed(1)}s`; + const m = Math.floor(seconds / 60); + const s = Math.round(seconds % 60); + return `${m}m ${s}s`; +} + +const thS: React.CSSProperties = { + padding: "10px 14px", + textAlign: "left", + fontSize: 11, + fontWeight: 600, + color: "rgba(255,255,255,0.35)", + textTransform: "uppercase", + letterSpacing: 0.8, + borderBottom: "1px solid rgba(255,255,255,0.08)", +}; +const tdS: React.CSSProperties = { + padding: "12px 14px", + fontSize: 13, + color: "#e6edf3", + borderBottom: "1px solid rgba(255,255,255,0.05)", +}; + +const filterBtnStyle = (active: boolean, color?: string): React.CSSProperties => ({ + padding: "5px 14px", + border: `1px solid ${active ? (color ?? "#58a6ff") : "rgba(255,255,255,0.12)"}`, + borderRadius: 20, + background: active ? `${color ?? "#58a6ff"}22` : "transparent", + color: active ? (color ?? "#58a6ff") : "rgba(255,255,255,0.5)", + cursor: "pointer", + fontSize: 12, + fontWeight: active ? 600 : 400, +}); + +export default function Activity() { + const [rows, setRows] = useState([]); + const [machines, setMachines] = useState([]); + const [status, setStatus] = useState("all"); + const [machineId, setMachineId] = useState(""); + const [range, setRange] = useState("24h"); + const [expandedId, setExpandedId] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Machines list for the filter dropdown — load once. + useEffect(() => { + getMachines().then(setMachines).catch(() => {}); + }, []); + + // Build the `since` ISO string from the current range. Recompute on every load so + // the rolling window stays accurate as time passes. + const sinceFor = (r: RangeFilter): string | undefined => { + if (r === "all") return undefined; + return new Date(Date.now() - RANGE_SECONDS[r] * 1000).toISOString(); + }; + + useEffect(() => { + let cancelled = false; + const load = () => { + getCommandActivity({ + status: status === "all" ? undefined : status, + machine_id: machineId || undefined, + since: sinceFor(range), + limit: PAGE_LIMIT, + }) + .then(data => { + if (cancelled) return; + setRows(data); + setError(null); + }) + .catch(err => { + if (cancelled) return; + setError(err?.message ?? "Failed to load"); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + }; + load(); + const id = setInterval(load, REFRESH_MS); + return () => { cancelled = true; clearInterval(id); }; + }, [status, machineId, range]); + + const counts = useMemo(() => ({ + all: rows.length, + pending: rows.filter(r => r.status === "pending").length, + running: rows.filter(r => r.status === "running").length, + done: rows.filter(r => r.status === "done").length, + failed: rows.filter(r => r.status === "failed").length, + }), [rows]); + + return ( +
+

+ Command Activity +

+
+ Cross-fleet log of remediation commands · refreshes every {REFRESH_MS / 1000}s +
+ + {/* Filters */} +
+ Status: + + + + + +
+ +
+ Machine: + + + Range: + {(["1h", "24h", "7d", "all"] as RangeFilter[]).map(r => ( + + ))} + + {(status !== "all" || machineId || range !== "24h") && ( + + )} +
+ + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
Loading…
+ ) : rows.length === 0 ? ( +
+
No commands match these filters.
+
+ ) : ( +
+ + + + {["Time", "Machine", "Command", "Status", "Exit", "Duration"].map(h => ( + + ))} + + + + {rows.map(r => { + const isExpanded = expandedId === r.id; + const hasOutput = !!r.output && r.output.trim().length > 0; + return ( + <> + hasOutput && setExpandedId(isExpanded ? null : r.id)} + style={{ + cursor: hasOutput ? "pointer" : "default", + background: isExpanded ? "rgba(255,255,255,0.04)" : undefined, + }} + onMouseEnter={e => { if (!isExpanded && hasOutput) e.currentTarget.style.background = "rgba(255,255,255,0.03)"; }} + onMouseLeave={e => { if (!isExpanded) e.currentTarget.style.background = ""; }} + > + + + + + + + + {isExpanded && hasOutput && ( + + + + )} + + ); + })} + +
{h}
+ + {timeAgo(r.created_at)} + + {r.hostname ? ( + e.stopPropagation()} + style={{ color: "#58a6ff", textDecoration: "none" }} + > + {r.hostname} + + ) : ( + {r.machine_id.slice(0, 8)}… + )} + + {r.command} + + {r.exit_code ?? "—"} + + {formatDuration(r.duration_seconds)} +
+
+                            {r.output}
+                          
+
+
+ )} + + {rows.length === PAGE_LIMIT && ( +
+ Showing latest {PAGE_LIMIT}. Tighten the time range or pick a machine to narrow further. +
+ )} +
+ ); +} diff --git a/web/src/types.ts b/web/src/types.ts index 5a0f83c..aaaf9d3 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -78,6 +78,19 @@ export interface CommandRecord { executed_at: string | null; } +export interface CommandActivityRow extends CommandRecord { + hostname: string | null; + duration_seconds: number | null; +} + +export interface CommandActivityFilters { + status?: string; + machine_id?: string; + since?: string; + limit?: number; + offset?: number; +} + export interface SnapshotSummary { id: number; machine_id: string;