diff --git a/README.md b/README.md index f7d3bf7..57d7936 100644 --- a/README.md +++ b/README.md @@ -150,12 +150,14 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. ### 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. +- The dashboard now shows, **per host** (local + each registered agent, online + dot / offline notice), a **resource overview bar** (CPU cores, memory + used/total, containers, Docker version) and a **stacks-with-usage table** with + the same CPU/memory meters and inline start/stop/restart. - 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. + `/api/agents/{id}/stacks/stats`); `/agent/system` now also reports `cpu_cores`, + `mem_total` and `mem_used` so the remote resource bar and meters have a host + reference. ### Phase 16 — Volumes page (multi-host) diff --git a/backend/agent_app.py b/backend/agent_app.py index 3674e7b..849a76b 100644 --- a/backend/agent_app.py +++ b/backend/agent_app.py @@ -61,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.19.0" +AGENT_VERSION = "0.20.0" # --------------------------------------------------------------------------- # @@ -159,16 +159,25 @@ def _hostname() -> str: return os.uname().nodename -def _mem_total() -> int: +def _mem_info() -> tuple[int, int]: + """Return (total_bytes, used_bytes) from meminfo (used = total - available).""" for base in (settings.HOST_PROC_PATH, "/proc"): try: + vals: dict[str, int] = {} 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 + parts = line.split(":") + if len(parts) == 2 and parts[0] in ("MemTotal", "MemAvailable", "MemFree"): + try: + vals[parts[0]] = int(parts[1].split()[0]) * 1024 # kB -> bytes + except ValueError: + pass + total = vals.get("MemTotal", 0) + available = vals.get("MemAvailable", vals.get("MemFree", 0)) + return total, max(total - available, 0) except OSError: continue - return 0 + return 0, 0 def _system_info() -> dict: @@ -184,12 +193,14 @@ def _system_info() -> dict: total = info.get("Containers", 0) except DockerError as exc: docker_version = f"unavailable ({exc.error})" + mem_total, mem_used = _mem_info() return { "hostname": _hostname(), "docker_version": docker_version, "host_os": host_os, "cpu_cores": os.cpu_count() or 0, - "mem_total": _mem_total(), + "mem_total": mem_total, + "mem_used": mem_used, "containers_running": running, "containers_total": total, } diff --git a/backend/main.py b/backend/main.py index 87bfa6f..c41f611 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.19.0", lifespan=lifespan) +app = FastAPI(title="StackPilot", version="0.20.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, diff --git a/frontend/package.json b/frontend/package.json index 59b69f9..e60d705 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.19.0", + "version": "0.20.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/agents.ts b/frontend/src/api/agents.ts index 8912562..9ce073d 100644 --- a/frontend/src/api/agents.ts +++ b/frontend/src/api/agents.ts @@ -10,6 +10,7 @@ export interface AgentSystem { host_os: string; cpu_cores: number; mem_total: number; + mem_used: number; containers_running: number; containers_total: number; } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index cd24bcd..5dcf30e 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -51,33 +51,21 @@ export function Dashboard() { )} - {/* Local host resource bar */} -
- } label="CPU cores" value={info.data?.cpu_cores ?? "—"} /> - } - label="Memory" - value={ - info.data - ? `${formatBytes(info.data.ram.used)} / ${formatBytes(info.data.ram.total)}` - : "—" - } - /> - } - label="Containers" - value={info.data ? `${info.data.containers_running} / ${info.data.containers_total}` : "—"} - /> - } label="Docker" value={info.data?.docker_version ?? "—"} /> -
- - {/* Local stacks */} + {/* Local host */}
{hasAgents ? ( ) : ( -

Stacks

+

This host

)} + ) : ( + <> + run("restart", "Restarting", id)} emptyText="No stacks on this host." /> + )}
); @@ -418,6 +416,39 @@ function Meter({ ); } +function ResourceBar({ + cpuCores, + memUsed, + memTotal, + containersRunning, + containersTotal, + dockerVersion, +}: { + cpuCores: number; + memUsed: number; + memTotal: number; + containersRunning: number; + containersTotal: number; + dockerVersion: string; +}) { + return ( +
+ } label="CPU cores" value={cpuCores || "—"} /> + } + label="Memory" + value={memTotal ? `${formatBytes(memUsed)} / ${formatBytes(memTotal)}` : "—"} + /> + } + label="Containers" + value={`${containersRunning} / ${containersTotal}`} + /> + } label="Docker" value={dockerVersion || "—"} /> +
+ ); +} + function Stat({ icon, label,