Skip to content

Commit 58a1db0

Browse files
mitulgargclaude
andauthored
feat: activity page, cross-fleet command log, and auto-login (#125)
- 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 <noreply@anthropic.com>
1 parent 7cd0d76 commit 58a1db0

7 files changed

Lines changed: 501 additions & 3 deletions

File tree

src/env_doctor/server/app.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from fastapi.staticfiles import StaticFiles
99

1010
from . import database as _db
11-
from .auth import require_token
11+
from .auth import get_active_token, require_token
1212
from .routes import router as api_router
1313

1414

@@ -58,9 +58,17 @@ async def serve_spa(full_path: str):
5858
file_path = os.path.join(_WEB_DIR, full_path)
5959
if full_path and os.path.isfile(file_path):
6060
return FileResponse(file_path)
61-
# Fallback to index.html for client-side routing
61+
# Fallback to index.html for client-side routing.
62+
# Inject the API token so the browser auto-authenticates without a login form.
6263
index = os.path.join(_WEB_DIR, "index.html")
6364
if os.path.isfile(index):
65+
token = get_active_token()
66+
if token:
67+
from fastapi.responses import HTMLResponse
68+
html = open(index, encoding="utf-8").read()
69+
snippet = f'<script>window.__ENV_DOCTOR_TOKEN__="{token}"</script>'
70+
html = html.replace("</head>", f"{snippet}</head>", 1)
71+
return HTMLResponse(html)
6472
return FileResponse(index)
6573
return {"detail": "Frontend not built. Run 'npm run build' in web/ directory."}
6674
else:

src/env_doctor/server/routes.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,3 +330,71 @@ async def list_commands(
330330
)
331331
result = await session.execute(query)
332332
return [c.to_dict() for c in result.scalars().all()]
333+
334+
335+
# ---------------------------------------------------------------------------
336+
# GET /api/commands (cross-fleet activity log)
337+
# ---------------------------------------------------------------------------
338+
339+
_VALID_COMMAND_STATUSES = {"pending", "running", "done", "failed"}
340+
341+
342+
def _parse_iso(value: str) -> datetime:
343+
# datetime.fromisoformat in 3.11+ accepts trailing "Z"; older Pythons need a swap.
344+
if value.endswith("Z"):
345+
value = value[:-1] + "+00:00"
346+
return datetime.fromisoformat(value)
347+
348+
349+
@router.get("/commands")
350+
async def list_command_activity(
351+
status: Optional[str] = Query(None, description="pending | running | done | failed"),
352+
machine_id: Optional[str] = Query(None),
353+
since: Optional[str] = Query(None, description="ISO-8601 timestamp lower bound on created_at"),
354+
limit: int = Query(50, ge=1, le=200),
355+
offset: int = Query(0, ge=0),
356+
session: AsyncSession = Depends(get_session),
357+
):
358+
"""Cross-fleet command activity log with hostname joined in."""
359+
if status is not None and status not in _VALID_COMMAND_STATUSES:
360+
raise HTTPException(
361+
status_code=400,
362+
detail=f"status must be one of {sorted(_VALID_COMMAND_STATUSES)}",
363+
)
364+
365+
since_dt: Optional[datetime] = None
366+
if since:
367+
try:
368+
since_dt = _parse_iso(since)
369+
except ValueError:
370+
raise HTTPException(status_code=400, detail="since must be an ISO-8601 timestamp")
371+
372+
query = (
373+
select(Command, Machine.hostname)
374+
.join(Machine, Command.machine_id == Machine.id, isouter=True)
375+
.order_by(Command.created_at.desc())
376+
.limit(limit)
377+
.offset(offset)
378+
)
379+
if status:
380+
query = query.where(Command.status == status)
381+
if machine_id:
382+
query = query.where(Command.machine_id == machine_id)
383+
if since_dt is not None:
384+
query = query.where(Command.created_at >= since_dt)
385+
386+
result = await session.execute(query)
387+
rows = result.all()
388+
389+
output = []
390+
for cmd, hostname in rows:
391+
item = cmd.to_dict()
392+
item["hostname"] = hostname
393+
if cmd.created_at and cmd.executed_at:
394+
created = cmd.created_at if cmd.created_at.tzinfo else cmd.created_at.replace(tzinfo=timezone.utc)
395+
executed = cmd.executed_at if cmd.executed_at.tzinfo else cmd.executed_at.replace(tzinfo=timezone.utc)
396+
item["duration_seconds"] = (executed - created).total_seconds()
397+
else:
398+
item["duration_seconds"] = None
399+
output.append(item)
400+
return output

web/src/App.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ function FleetIcon() {
3030
);
3131
}
3232

33+
function ActivityIcon() {
34+
return (
35+
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
36+
<polyline points="2 9 5 9 7 4 11 14 13 9 16 9" />
37+
</svg>
38+
);
39+
}
40+
3341
const navStyle = (isActive: boolean): React.CSSProperties => ({
3442
display: "flex",
3543
alignItems: "center",
@@ -112,6 +120,9 @@ export default function App() {
112120
<NavLink to="/fleet" style={({ isActive }) => navStyle(isActive)}>
113121
<FleetIcon /> Fleet
114122
</NavLink>
123+
<NavLink to="/activity" style={({ isActive }) => navStyle(isActive)}>
124+
<ActivityIcon /> Activity
125+
</NavLink>
115126
</div>
116127

117128
<div style={{

web/src/api.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,24 @@
1-
import type { CommandRecord, MachineListItem, MachineDetail, SnapshotSummary } from "./types";
1+
import type {
2+
CommandActivityFilters,
3+
CommandActivityRow,
4+
CommandRecord,
5+
MachineDetail,
6+
MachineListItem,
7+
SnapshotSummary,
8+
} from "./types";
29

310
const BASE = "/api";
411
const TOKEN_KEY = "envDoctorToken";
512

13+
// When the server injects the token into the HTML, auto-store it so the login
14+
// screen is skipped entirely. This runs before React mounts.
15+
(function seedInjectedToken() {
16+
const injected = (window as unknown as Record<string, unknown>).__ENV_DOCTOR_TOKEN__;
17+
if (typeof injected === "string" && injected) {
18+
try { localStorage.setItem(TOKEN_KEY, injected); } catch { /* ignore */ }
19+
}
20+
})();
21+
622
export function getToken(): string | null {
723
try {
824
return localStorage.getItem(TOKEN_KEY);
@@ -88,6 +104,19 @@ export function getCommands(machineId: string): Promise<CommandRecord[]> {
88104
return fetchJson(`${BASE}/machines/${machineId}/commands`);
89105
}
90106

107+
export function getCommandActivity(
108+
filters: CommandActivityFilters = {}
109+
): Promise<CommandActivityRow[]> {
110+
const params = new URLSearchParams();
111+
if (filters.status) params.set("status", filters.status);
112+
if (filters.machine_id) params.set("machine_id", filters.machine_id);
113+
if (filters.since) params.set("since", filters.since);
114+
if (filters.limit != null) params.set("limit", String(filters.limit));
115+
if (filters.offset != null) params.set("offset", String(filters.offset));
116+
const qs = params.toString();
117+
return fetchJson(`${BASE}/commands${qs ? `?${qs}` : ""}`);
118+
}
119+
91120
export async function verifyToken(): Promise<boolean> {
92121
try {
93122
const res = await apiFetch(`${BASE}/machines`);

web/src/main.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from "react";
22
import ReactDOM from "react-dom/client";
33
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
44
import App from "./App";
5+
import Activity from "./pages/Activity";
56
import FleetOverview from "./pages/FleetOverview";
67
import MachineDetailPage from "./pages/MachineDetail";
78
import TopologyView from "./pages/TopologyView";
@@ -14,6 +15,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
1415
<Route index element={<Navigate to="/topology" replace />} />
1516
<Route path="topology" element={<TopologyView />} />
1617
<Route path="fleet" element={<FleetOverview />} />
18+
<Route path="activity" element={<Activity />} />
1719
<Route path="machines/:id" element={<MachineDetailPage />} />
1820
</Route>
1921
</Routes>

0 commit comments

Comments
 (0)