diff --git a/README.md b/README.md index d3c0207..f7d3bf7 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. > + Phase 9 (Networks) + Phase 10 (iGPU passthrough) + Phase 11 (Remote UX & > network attach) + Phase 12 (File browser) + Phase 13 (Multi-host networks & > images) + Phase 14 (Multi-host file browser) + Phase 15 (Dashboard stack -> resource usage) + Phase 16 (Volumes page, multi-host) complete. +> resource usage) + Phase 16 (Volumes page, multi-host) + Phase 17 (Multi-host +> dashboard) complete. ## What works today (Phase 1) @@ -147,6 +148,15 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. container or connect any container on the host (`POST /api/networks/{id}/connect` / `/disconnect`). +### Phase 17 — Multi-host dashboard + +- The dashboard now shows a **stacks-with-usage table per host** — the local host + plus a section for each registered agent (online dot, offline notice), with the + same CPU/memory meters and inline start/stop/restart as the local list. +- New agent endpoint `/agent/stacks/stats` (proxied at + `/api/agents/{id}/stacks/stats`); `/agent/system` now also reports `cpu_cores` + and `mem_total` so remote meters have a host reference. + ### Phase 16 — Volumes page (multi-host) - **New Volumes page** (sidebar) with per-host sections (local + each online diff --git a/backend/agent_app.py b/backend/agent_app.py index a2de9d0..3674e7b 100644 --- a/backend/agent_app.py +++ b/backend/agent_app.py @@ -43,6 +43,7 @@ from services import ( file_service, image_service, network_service, + stats_service, update_service, volume_service, ) @@ -60,7 +61,7 @@ def _map_docker(exc: DockerError): raise HTTPException(status_code=code, detail=exc.detail or exc.error) raise exc # falls through to the global 502 DockerError handler -AGENT_VERSION = "0.18.0" +AGENT_VERSION = "0.19.0" # --------------------------------------------------------------------------- # @@ -158,6 +159,18 @@ def _hostname() -> str: return os.uname().nodename +def _mem_total() -> int: + for base in (settings.HOST_PROC_PATH, "/proc"): + try: + with open(os.path.join(base, "meminfo"), "r", encoding="utf-8") as fh: + for line in fh: + if line.startswith("MemTotal:"): + return int(line.split()[1]) * 1024 # kB -> bytes + except OSError: + continue + return 0 + + def _system_info() -> dict: docker_version = "" host_os = "" @@ -175,6 +188,8 @@ def _system_info() -> dict: "hostname": _hostname(), "docker_version": docker_version, "host_os": host_os, + "cpu_cores": os.cpu_count() or 0, + "mem_total": _mem_total(), "containers_running": running, "containers_total": total, } @@ -207,6 +222,11 @@ def list_stacks() -> list[dict]: return [_summary(sid) for sid in compose_service.discover_stacks()] +@app.get("/agent/stacks/stats", dependencies=[Depends(verify_token)]) +def stacks_stats() -> dict: + return stats_service.stack_stats() + + @app.get("/agent/stacks/{stack_id}", dependencies=[Depends(verify_token)]) def get_stack(stack_id: str) -> dict: directory = compose_service.stack_dir(stack_id) diff --git a/backend/main.py b/backend/main.py index fbc5108..87bfa6f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -55,7 +55,7 @@ async def lifespan(app: FastAPI): schedule_task.cancel() -app = FastAPI(title="StackPilot", version="0.18.0", lifespan=lifespan) +app = FastAPI(title="StackPilot", version="0.19.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, diff --git a/backend/routers/agents.py b/backend/routers/agents.py index 77fd6fb..38412c0 100644 --- a/backend/routers/agents.py +++ b/backend/routers/agents.py @@ -213,6 +213,16 @@ async def agent_stacks( return stacks +@router.get("/{agent_id}/stacks/stats") +async def agent_stacks_stats( + agent_id: int, + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> dict: + agent = _get_or_404(session, agent_id) + return await _proxy(session, agent, "GET", "/agent/stacks/stats") + + @router.get("/{agent_id}/stacks/{stack_id}") async def agent_stack_detail( agent_id: int, diff --git a/frontend/package.json b/frontend/package.json index 494c98a..59b69f9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.18.0", + "version": "0.19.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/agents.ts b/frontend/src/api/agents.ts index edde39b..8912562 100644 --- a/frontend/src/api/agents.ts +++ b/frontend/src/api/agents.ts @@ -1,9 +1,19 @@ import api from "./client"; -import type { Agent, StackDetail, StackSummary } from "@/types"; +import type { Agent, StackDetail, StackStats, StackSummary } from "@/types"; export type RemoteStackSummary = StackSummary & { agent_id: number; agent_name: string }; export type RemoteStackDetail = StackDetail & { agent_id: number; agent_name: string }; +export interface AgentSystem { + hostname: string; + docker_version: string; + host_os: string; + cpu_cores: number; + mem_total: number; + containers_running: number; + containers_total: number; +} + export const agentsApi = { list: (refresh = true) => api.get(`/api/agents?refresh=${refresh}`).then((r) => r.data), @@ -15,8 +25,12 @@ export const agentsApi = { ping: (id: number) => api.post<{ status: string; hostname?: string }>(`/api/agents/${id}/ping`).then((r) => r.data), + system: (id: number) => + api.get(`/api/agents/${id}/system`).then((r) => r.data), stacks: (id: number) => api.get(`/api/agents/${id}/stacks`).then((r) => r.data), + stackStats: (id: number) => + api.get>(`/api/agents/${id}/stacks/stats`).then((r) => r.data), createStack: (id: number, body: { name: string; yaml: string; env?: string }) => api .post<{ id: string; name: string }>(`/api/agents/${id}/stacks`, body) diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 844f98e..cd24bcd 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,5 +1,6 @@ +import { useState } from "react"; import { Link } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Cpu, MemoryStick, @@ -11,14 +12,18 @@ import { Square, RotateCw, } from "lucide-react"; +import { toast } from "sonner"; import { Card, Spinner, StatusDot, Badge } from "@/components/ui"; +import { HostHeader } from "@/components/hosts/HostHeader"; import { stacksApi } from "@/api/stacks"; import { systemApi } from "@/api/system"; import { imagesApi } from "@/api/images"; +import { agentsApi } from "@/api/agents"; +import { apiErrorMessage } from "@/api/client"; import { formatBytes, relativeTime } from "@/lib/utils"; import { useAuthStore } from "@/store/auth"; import { useStackActions } from "@/hooks/useStackActions"; -import type { StackStats, StackSummary } from "@/types"; +import type { Agent, StackStats, StackSummary } from "@/types"; export function Dashboard() { const isAdmin = useAuthStore((s) => s.user?.role === "admin"); @@ -29,10 +34,10 @@ export function Dashboard() { const info = useQuery({ queryKey: ["system"], queryFn: systemApi.info, refetchInterval: 5000 }); const audit = useQuery({ queryKey: ["audit"], queryFn: () => systemApi.audit(10), refetchInterval: 10000 }); const updates = useQuery({ queryKey: ["image-updates"], queryFn: () => imagesApi.updates(), refetchInterval: 60000 }); + const agents = useQuery({ queryKey: ["agents"], queryFn: () => agentsApi.list(), refetchInterval: 15000 }); const updateCount = Object.values(updates.data ?? {}).filter((u) => u.update_available).length; - const hostCpus = info.data?.cpu_cores ?? 0; - const hostMem = info.data?.ram.total ?? 0; + const hasAgents = (agents.data?.length ?? 0) > 0; return (
@@ -46,7 +51,7 @@ export function Dashboard() { )} - {/* Resource bar */} + {/* Local host resource bar */}
} label="CPU cores" value={info.data?.cpu_cores ?? "—"} /> } label="Containers" - value={ - info.data - ? `${info.data.containers_running} / ${info.data.containers_total}` - : "—" - } - /> - } - label="Docker" - value={info.data?.docker_version ?? "—"} + value={info.data ? `${info.data.containers_running} / ${info.data.containers_total}` : "—"} /> + } label="Docker" value={info.data?.docker_version ?? "—"} />
- {/* Stacks list */} + {/* Local stacks */}
-

- Stacks -

- {stacks.isLoading ? ( - - ) : stacks.data && stacks.data.length > 0 ? ( - - - - - - - - {isAdmin && } - - - - {stacks.data.map((s) => ( - - ))} - -
StackCPUMemory
-
+ {hasAgents ? ( + ) : ( - -

- No stacks yet. Create one from the Stacks page. -

-
+

Stacks

)} +
+ {/* Remote hosts */} + {agents.data?.map((agent) => ( + + ))} + {/* Recent activity */}

@@ -132,13 +111,9 @@ export function Dashboard() { {a.user}{" "} {a.action}{" "} - - {a.target} - - - - {relativeTime(a.timestamp)} + {a.target} + {relativeTime(a.timestamp)} ))} @@ -151,6 +126,142 @@ export function Dashboard() { ); } +function AgentDashboardSection({ agent, isAdmin }: { agent: Agent; isAdmin: boolean }) { + const qc = useQueryClient(); + const online = agent.status === "online"; + const [busyId, setBusyId] = useState(null); + + const stacks = useQuery({ + queryKey: ["agent-stacks", agent.id], + queryFn: () => agentsApi.stacks(agent.id), + enabled: online, + refetchInterval: 8000, + }); + const stats = useQuery({ + queryKey: ["agent-stack-stats", agent.id], + queryFn: () => agentsApi.stackStats(agent.id), + enabled: online, + refetchInterval: 5000, + }); + const sys = useQuery({ + queryKey: ["agent-system", agent.id], + queryFn: () => agentsApi.system(agent.id), + enabled: online, + refetchInterval: 30000, + }); + + const run = async (action: string, label: string, id: string) => { + setBusyId(id); + const t = toast.loading(`${label} ${id} on ${agent.name}…`); + try { + await agentsApi.action(agent.id, id, action); + toast.success(`${label} ${id} ✓`, { id: t }); + qc.invalidateQueries({ queryKey: ["agent-stacks", agent.id] }); + qc.invalidateQueries({ queryKey: ["agent-stack-stats", agent.id] }); + } catch (e) { + toast.error(apiErrorMessage(e), { id: t }); + } finally { + setBusyId(null); + } + }; + + return ( +
+ + {!online ? ( + +

+ Host is {agent.status}. Check it under Settings → Remote hosts. +

+
+ ) : ( + run("start", "Starting", id)} + onStop={(id) => run("stop", "Stopping", id)} + onRestart={(id) => run("restart", "Restarting", id)} + emptyText="No stacks on this host." + /> + )} +
+ ); +} + +function StacksTable({ + stacks, + stats, + hostCpus, + hostMem, + isAdmin, + busyId, + loading, + linkBase = "/stacks", + onStart, + onStop, + onRestart, + emptyText, +}: { + stacks: StackSummary[] | undefined; + stats: Record | undefined; + hostCpus: number; + hostMem: number; + isAdmin: boolean; + busyId: string | null; + loading: boolean; + linkBase?: string; + onStart: (id: string) => void; + onStop: (id: string) => void; + onRestart: (id: string) => void; + emptyText: string; +}) { + if (loading) return ; + if (!stacks || stacks.length === 0) { + return ( + +

{emptyText}

+
+ ); + } + return ( + + + + + + + + {isAdmin && } + + + + {stacks.map((s) => ( + + ))} + +
StackCPUMemory
+
+ ); +} + function StackRow({ stack, stats, @@ -158,6 +269,7 @@ function StackRow({ hostMem, isAdmin, busy, + linkBase, onStart, onStop, onRestart, @@ -168,6 +280,7 @@ function StackRow({ hostMem: number; isAdmin: boolean; busy: boolean; + linkBase: string; onStart: (id: string) => void; onStop: (id: string) => void; onRestart: (id: string) => void; @@ -177,7 +290,7 @@ function StackRow({ return ( - + {stack.name} {stack.status}