From 5bb34e728cf84732082bd5879390c6d523a8f063 Mon Sep 17 00:00:00 2001 From: Shawn Date: Sat, 23 May 2026 15:30:06 -0400 Subject: [PATCH] add Docker container stats endpoint + dashboard section --- index.html | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ serve.py | 64 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index 0983240..3d35126 100644 --- a/index.html +++ b/index.html @@ -64,6 +64,19 @@ .lxc-pct { font-family: 'Geist Mono', monospace; font-size: 10px; color: var(--gray-400); min-width: 32px; text-align: right; } .lxc-off { opacity: 0.4; } + /* Docker table (same as LXC, wider) */ + .docker-table { margin-top: 12px; border-top: 1px solid var(--gray-100); padding-top: 8px; } + .docker-header { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--gray-400); display: grid; grid-template-columns: 1.6fr 0.5fr 0.6fr 1.2fr 1.2fr 0.3fr; gap: 6px; padding: 2px 0; } + .docker-row { display: grid; grid-template-columns: 1.6fr 0.5fr 0.6fr 1.2fr 1.2fr 0.3fr; gap: 6px; align-items: center; font-size: 11px; padding: 3px 0; } + .docker-row + .docker-row { border-top: 1px solid var(--gray-50); } + .docker-name { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .docker-cpu { color: var(--gray-400); font-family: 'Geist Mono', monospace; font-size: 10px; } + .docker-bar-wrap { display: flex; align-items: center; gap: 4px; } + .docker-bar-bg { flex: 1; height: 5px; background: var(--gray-100); border-radius: 2px; overflow: hidden; } + .docker-bar-fill { height: 100%; border-radius: 2px; } + .docker-val { font-family: 'Geist Mono', monospace; font-size: 10px; color: var(--gray-400); } + .docker-io { font-size: 10px; color: var(--gray-400); font-family: 'Geist Mono', monospace; } + /* Service cards */ .grid-4 { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; } .svc-card { display: flex; align-items: flex-start; gap: 12px; padding: 14px 18px; background: var(--white); box-shadow: var(--ring); border-radius: 8px; text-decoration: none; color: inherit; cursor: pointer; transition: box-shadow 0.15s, transform 0.1s; } @@ -152,6 +165,17 @@ + +
+
+

🐳 Docker Containers

0
+

CT 100 (docker LXC) on primary Proxmox — CPU, memory, network, disk I/O

+
+ Loading Docker stats... +
+
+
+
@@ -414,12 +438,59 @@ async function updateHosts() { } catch(e) { console.log("proxmox fetch failed", e); } } +// ─── Docker Stats ────────────────────────────────────────────────────── +function renderDocker(containers) { + const card = document.getElementById("docker-card"); + if (!containers || containers.length === 0) { + card.innerHTML = 'No Docker containers found'; + document.getElementById("docker-count").textContent = "0"; + return; + } + + let html = `
+
+ ContainerCPURAMNetwork ↓ / ↑Disk ↓ / ↑PIDs +
`; + + for (const c of containers) { + const memPct = parseFloat(c.mem_pct) || 0; + html += `
+ ${c.name} + ${c.cpu}% +
${c.mem_used}
+ ${c.net_in} / ${c.net_out} + ${c.blk_in} / ${c.blk_out} + ${c.pids} +
`; + } + html += `
`; + card.innerHTML = html; + document.getElementById("docker-count").textContent = containers.length; +} + +async function updateDocker() { + try { + const r = await fetch("/api/docker/stats"); + const d = await r.json(); + if (d.error && !d.containers) { + document.getElementById("docker-card").innerHTML = `${d.error}`; + return; + } + renderDocker(d.containers); + } catch(e) { + console.log("docker fetch failed", e); + document.getElementById("docker-card").innerHTML = 'Docker API unreachable'; + } +} + // ─── Init ───────────────────────────────────────────────────────────── renderServices(); updateStatus(); updateHosts(); +updateDocker(); setInterval(updateStatus, 60000); setInterval(updateHosts, 30000); +setInterval(updateDocker, 30000); // ─── Dark Mode Toggle ────────────────────────────────────────────────── function toggleDark() { diff --git a/serve.py b/serve.py index d974ff5..b4c8d4e 100755 --- a/serve.py +++ b/serve.py @@ -179,6 +179,68 @@ async def health(): return {"status": "ok"} +@app.get("/api/docker/stats") +async def docker_stats(): + """Get Docker container stats from CT 100 via Proxmox SSH.""" + import subprocess + try: + # Run docker stats on CT 100 via proxmox (use raw output to avoid Go template escaping) + result = subprocess.run( + ["sshpass", "-p", _PVE_PASS, "ssh", "-o", "StrictHostKeyChecking=no", + "root@192.168.0.144", "pct", "exec", "100", "--", + "docker", "stats", "--no-stream"], + capture_output=True, text=True, timeout=15 + ) + if result.returncode != 0: + return {"error": result.stderr.strip(), "containers": []} + + containers = [] + for line in result.stdout.strip().split("\n"): + if not line.strip() or line.startswith("CONTAINER"): + continue + # Parse default docker stats output: + # CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS + parts = line.split() + if len(parts) < 8: + continue + # Account for container ID being first column + cid = parts[0] + name = parts[1] + cpu = parts[2].replace("%", "") + mem_usage = parts[3] # e.g., "311.2MiB" + mem_limit = parts[5] # e.g., "2GiB" (parts[4] is "/") + mem_pct = parts[6].replace("%", "") + # NET I/O: parts[7] and parts[9] with "/" at parts[8] + # BLOCK I/O: parts[10] and parts[12] with "/" at parts[11] + # PIDS: last element (could be at different positions depending on name length) + # More reliable: find from the right + pids = parts[-1] + # Block I/O is tricky because of spaces in column names; + # use a simpler approach: rest of line after NET I/O position + # Actually, docker stats columns are fixed-width, let's just grab what we can + net_in = parts[7] if len(parts) > 7 else "0" + net_out = parts[9] if len(parts) > 9 else "0" + blk_in = parts[10] if len(parts) > 10 else "0" + blk_out = parts[12] if len(parts) > 12 else "0" + + containers.append({ + "name": name, + "cpu": cpu, + "mem_pct": mem_pct, + "mem_used": mem_usage, + "mem_limit": mem_limit, + "net_in": net_in, + "net_out": net_out, + "blk_in": blk_in, + "blk_out": blk_out, + "pids": pids, + }) + + return {"containers": containers, "host": "CT 100 (docker)", "count": len(containers)} + except Exception as e: + return {"error": str(e)[:200], "containers": []} + + @app.get("/{path:path}") async def index(path: str = ""): """Serve static files, fallback to index.html.""" @@ -188,7 +250,7 @@ async def index(path: str = ""): file_path = STATIC_DIR / "index.html" if file_path.is_file(): return HTMLResponse(file_path.read_text()) - return HTMLResponse("

404

", 404) + return HTMLResponse("

404

\n", 404) if __name__ == "__main__":