stage-26b: Docker topology in /admin — read-only socket-proxy + graph

New tecnativa/docker-socket-proxy sidecar exposes only GET on
containers/networks/info/ping; POST and DELETE are blocked. The cockpit
queries it over the backend network — /var/run/docker.sock is never
mounted into a web-facing container.

cockpit/docker_view.py normalizes the daemon view: containers carry
per-network IP/MAC + published_ports; networks carry subnet/gateway from
IPAM; host_info pulls /info (degrades gracefully). topology() returns
the combined snapshot.

/admin/docker (admin-gated): a force-directed graph (pure SVG +
vanilla JS, ~280 lines) renders the complete setup — a host node,
switch nodes with subnet labels colored by driver, container nodes
colored by state, member wires labeled with the container's IP on that
network, uplinks from non-internal switches to the host labeled with
the gateway, and dashed publish-edges from containers to the host for
their published ports. Drag to rearrange, scroll to zoom, re-settle
kicks the physics. Below the graph: containers table + grouped network
cards as a textual mirror. 12 docker_view tests; verified live (32
containers, 11 switches, real subnets + gateways).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m17hr1l
2026-05-23 03:08:39 +02:00
parent eaca27be26
commit b51a88d502
8 changed files with 783 additions and 3 deletions

View File

@@ -23,6 +23,7 @@ services:
PSYC_MOCK_CERT_URL: http://mock-cert:8770 PSYC_MOCK_CERT_URL: http://mock-cert:8770
PSYC_SOAR_URL: http://mock-cert:8770 PSYC_SOAR_URL: http://mock-cert:8770
PSYC_INFERENCE_URL: http://inference:8771 PSYC_INFERENCE_URL: http://inference:8771
PSYC_DOCKER_PROXY: http://docker-socket-proxy:2375
ports: ports:
- "8767:8767" # direct/debug access; the proxy serves psyc.neuronetz.ai on :80 - "8767:8767" # direct/debug access; the proxy serves psyc.neuronetz.ai on :80
volumes: volumes:
@@ -48,6 +49,24 @@ services:
timeout: 5s timeout: 5s
retries: 3 retries: 3
# Read-only Docker daemon proxy. The cockpit's /admin/docker view queries this
# over the backend network instead of touching /var/run/docker.sock directly,
# so a compromise of the web app can't drive the daemon. Only GET on
# containers/networks/ping is enabled — POST/DELETE/EXEC stay blocked.
docker-socket-proxy:
image: tecnativa/docker-socket-proxy:0.3
environment:
CONTAINERS: "1"
NETWORKS: "1"
PING: "1"
INFO: "1"
POST: "0"
DELETE: "0"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks: [backend]
restart: unless-stopped
# The live fine-tuned model behind the Classifier bot. GPU-only — opt in with # The live fine-tuned model behind the Classifier bot. GPU-only — opt in with
# `--profile gpu`. Uses the psyc-trainer image (built from Dockerfile.train). # `--profile gpu`. Uses the psyc-trainer image (built from Dockerfile.train).
inference: inference:

View File

@@ -12,7 +12,7 @@ from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from psyc import db, log from psyc import db, log
from psyc.cockpit import adminauth, inference, journey as journey_view from psyc.cockpit import adminauth, docker_view, inference, journey as journey_view
from psyc.lines import courier as courier_line from psyc.lines import courier as courier_line
from psyc.lines import ledger as ledger_line from psyc.lines import ledger as ledger_line
from psyc.lines import lookup as lookup_line from psyc.lines import lookup as lookup_line
@@ -232,6 +232,14 @@ def admin_logout(request: Request) -> RedirectResponse:
return RedirectResponse("/admin", status_code=303) return RedirectResponse("/admin", status_code=303)
@app.get("/admin/docker", response_class=HTMLResponse)
def admin_docker(request: Request) -> HTMLResponse:
if not _admin_ok(request):
return RedirectResponse("/admin", status_code=303)
topo = docker_view.topology()
return TEMPLATES.TemplateResponse(request, "admin_docker.html", {"topo": topo})
@app.get("/queue", response_class=HTMLResponse) @app.get("/queue", response_class=HTMLResponse)
def queue_view(request: Request, status: str = "pending") -> HTMLResponse: def queue_view(request: Request, status: str = "pending") -> HTMLResponse:
from psyc.models import ApprovalStatus from psyc.models import ApprovalStatus

View File

@@ -0,0 +1,144 @@
"""Docker topology — read-only daemon view via socket-proxy.
The cockpit never touches /var/run/docker.sock directly. It talks to a
tecnativa/docker-socket-proxy sidecar over the backend network. The proxy is
configured GET-only (CONTAINERS, NETWORKS, PING) so a web-app compromise
can't drive the daemon. Returned data is normalized for templates: a flat
list of containers and a network-grouped topology.
"""
from __future__ import annotations
import os
from typing import Any, Dict, List, Optional
import httpx
from psyc import log
_log = log.get(__name__)
PROXY_URL = os.environ.get("PSYC_DOCKER_PROXY", "http://docker-socket-proxy:2375")
HTTP_TIMEOUT = 5.0
class DockerProxyError(RuntimeError):
"""Proxy unreachable or returned an error."""
def _get(path: str) -> Any:
try:
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
resp = client.get(f"{PROXY_URL}{path}")
resp.raise_for_status()
return resp.json()
except httpx.HTTPError as exc:
_log.warning("docker_view.proxy.error", path=path, error=str(exc))
raise DockerProxyError(str(exc)) from exc
def list_containers() -> List[Dict[str, Any]]:
raw = _get("/containers/json?all=1")
out: List[Dict[str, Any]] = []
for c in raw:
names = c.get("Names") or []
name = (names[0] if names else "?").lstrip("/")
nets = []
for net_name, net_data in (c.get("NetworkSettings", {}).get("Networks", {}) or {}).items():
nets.append({
"name": net_name,
"ip": net_data.get("IPAddress") or "",
"mac": net_data.get("MacAddress") or "",
"gateway": net_data.get("Gateway") or "",
})
ports = []
published_ports = [] # outer ports only — the ones reachable from the host
for p in c.get("Ports") or []:
inner = p.get("PrivatePort")
outer = p.get("PublicPort")
proto = p.get("Type") or "tcp"
if outer:
ports.append(f"{p.get('IP', '0.0.0.0')}:{outer}->{inner}/{proto}")
published_ports.append(f"{outer}/{proto}")
elif inner:
ports.append(f"{inner}/{proto}")
# Dedupe published_ports while keeping order.
seen: set = set()
published_ports = [x for x in published_ports if not (x in seen or seen.add(x))]
out.append({
"id": (c.get("Id") or "")[:12],
"name": name,
"image": c.get("Image", ""),
"state": c.get("State", ""),
"status": c.get("Status", ""),
"networks": nets,
"ports": ports,
"published_ports": published_ports,
})
out.sort(key=lambda c: (c["state"] != "running", c["name"]))
return out
def list_networks() -> List[Dict[str, Any]]:
raw = _get("/networks")
out: List[Dict[str, Any]] = []
for n in raw:
attached = []
for cid, info in (n.get("Containers") or {}).items():
ip = (info.get("IPv4Address") or "").split("/")[0]
attached.append({
"id": (cid or "")[:12], "name": info.get("Name", ""),
"ip": ip, "mac": info.get("MacAddress") or "",
})
attached.sort(key=lambda x: x["name"])
ipam_cfgs = (n.get("IPAM") or {}).get("Config") or []
subnet = ipam_cfgs[0].get("Subnet") if ipam_cfgs else ""
gateway = ipam_cfgs[0].get("Gateway") if ipam_cfgs else ""
out.append({
"id": (n.get("Id") or "")[:12],
"name": n.get("Name", ""),
"driver": n.get("Driver", ""),
"scope": n.get("Scope", ""),
"internal": bool(n.get("Internal")),
"subnet": subnet or "",
"gateway": gateway or "",
"containers": attached,
})
_DEFAULTS = {"bridge", "host", "none"}
out.sort(key=lambda n: (n["name"] in _DEFAULTS, n["name"]))
return out
def host_info() -> Dict[str, Any]:
"""Daemon-side info for the synthetic host node. Best-effort."""
try:
info = _get("/info")
except DockerProxyError:
return {"name": "docker host", "os": "", "ncpu": None}
return {
"name": info.get("Name") or "docker host",
"os": info.get("OperatingSystem") or info.get("OSType") or "",
"ncpu": info.get("NCPU"),
"containers": info.get("Containers"),
"containers_running": info.get("ContainersRunning"),
}
def topology() -> Dict[str, Any]:
"""Combined snapshot. Either field may be [] with an 'error' key set."""
state: Dict[str, Any] = {
"containers": [], "networks": [], "host": {"name": "docker host"},
"error": None, "proxy": PROXY_URL,
}
try:
state["containers"] = list_containers()
except DockerProxyError as exc:
state["error"] = f"containers: {exc}"
return state
try:
state["networks"] = list_networks()
except DockerProxyError as exc:
state["error"] = f"networks: {exc}"
state["host"] = host_info()
return state

View File

@@ -529,3 +529,94 @@ tr.sev-low .sev-badge { color: var(--muted); }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.admin-chip, .admin-chip-dot { animation: none; } .admin-chip, .admin-chip-dot { animation: none; }
} }
/* ── docker topology view ────────────────────────────────────── */
.admin-tile-link { display: block; color: inherit; text-decoration: none; transition: all 0.15s; }
.admin-tile-link:hover { border-color: var(--accent); box-shadow: 0 0 16px var(--accent-glow); text-decoration: none; }
.net-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; margin-top: 10px; }
.net-card {
background: linear-gradient(180deg, var(--panel-2), rgba(18,22,30,0.6));
border: 1px solid var(--border); border-radius: 10px; padding: 14px;
border-left: 3px solid var(--muted);
}
.net-driver-bridge { border-left-color: var(--accent); }
.net-driver-host { border-left-color: var(--amber); }
.net-driver-overlay { border-left-color: #a78bfa; }
.net-card-head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; gap: 8px; }
.net-name { font-family: var(--font-display); font-size: 14px; font-weight: 600; color: var(--text); }
.net-meta { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
.net-count { font-family: var(--font-display); font-size: 13px; color: var(--muted); background: var(--bg); padding: 2px 9px; border-radius: 12px; border: 1px solid var(--border); }
.net-members { display: flex; flex-wrap: wrap; gap: 6px; }
.net-chip {
display: inline-flex; flex-direction: column; padding: 4px 8px;
background: var(--bg); border: 1px solid var(--border); border-radius: 5px;
font-size: 11px; line-height: 1.25;
}
.net-chip-name { color: var(--text); font-weight: 600; }
.net-chip-ip { color: var(--muted); font-family: ui-monospace, Menlo, monospace; font-size: 10px; }
.net-chip.mini { padding: 2px 6px; margin: 0 4px 4px 0; }
.net-empty { color: var(--muted); font-size: 12px; font-style: italic; }
.state-badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; background: var(--panel-2); border: 1px solid var(--border); color: var(--muted); }
.state-running { color: var(--green); border-color: color-mix(in oklab, var(--green) 45%, var(--border)); }
.state-exited, .state-dead { color: var(--red); border-color: color-mix(in oklab, var(--red) 45%, var(--border)); }
.state-paused, .state-restarting { color: var(--amber); border-color: color-mix(in oklab, var(--amber) 45%, var(--border)); }
/* ── topology graph (Docker switches + addresses) ───────────── */
.topo-stage {
position: relative; margin: 12px 0 22px;
background: radial-gradient(800px 400px at 50% 30%, rgba(30,200,255,0.06), transparent 70%), var(--bg);
border: 1px solid var(--border); border-radius: 10px; overflow: hidden;
}
.topo-toolbar {
display: flex; align-items: center; gap: 14px; padding: 10px 14px;
background: rgba(28,34,48,0.6); border-bottom: 1px solid var(--border);
font-size: 11px; color: var(--muted); font-family: var(--font-display); letter-spacing: 0.08em; text-transform: uppercase;
}
.topo-legend { display: inline-flex; align-items: center; gap: 6px; }
.lg-swatch { width: 10px; height: 10px; border-radius: 50%; display: inline-block; border: 1px solid currentColor; }
.lg-swatch.lg-net { background: rgba(30,200,255,0.25); border-color: var(--accent); }
.lg-swatch.lg-run { background: rgba(74,222,128,0.25); border-color: var(--green); }
.lg-swatch.lg-stop { background: rgba(248,113,113,0.25); border-color: var(--red); }
.topo-hint { margin-left: auto; opacity: 0.6; text-transform: none; letter-spacing: 0; }
#topology-graph { display: block; width: 100%; height: 560px; cursor: grab; }
#topology-graph:active { cursor: grabbing; }
/* edges */
.topo-edge { stroke: rgba(125,133,151,0.45); stroke-width: 1.1; fill: none; }
.topo-edge-label { fill: var(--muted); font-size: 9px; text-anchor: middle; font-family: ui-monospace, Menlo, monospace; opacity: 0.7; pointer-events: none; }
.topo-kind-wire .topo-edge { stroke: rgba(30,200,255,0.4); }
.topo-kind-wire .topo-edge-label { fill: rgba(170, 220, 255, 0.7); }
.topo-kind-uplink .topo-edge { stroke: rgba(167,139,250,0.65); stroke-width: 1.5; }
.topo-kind-uplink .topo-edge-label { fill: rgba(200, 180, 250, 0.85); font-weight: 600; }
.topo-kind-publish .topo-edge { stroke: rgba(251,191,36,0.55); stroke-dasharray: 4 3; }
.topo-kind-publish .topo-edge-label { fill: rgba(253,210,90,0.8); font-weight: 600; font-size: 10px; }
/* nodes */
.topo-node { cursor: grab; }
.topo-node.dragging { cursor: grabbing; }
.topo-node circle, .topo-node rect { transition: filter 0.15s; }
.topo-node:hover circle, .topo-node:hover rect { filter: drop-shadow(0 0 10px var(--accent)); }
.topo-host rect {
fill: rgba(167,139,250,0.12); stroke: #a78bfa; stroke-width: 2;
filter: drop-shadow(0 0 14px rgba(167,139,250,0.4));
}
.topo-host .topo-label { fill: #d4c5ff; font-weight: 700; letter-spacing: 0.08em; font-size: 12px; }
.topo-net circle { fill: rgba(30,200,255,0.10); stroke-width: 2; }
.topo-net .topo-net-inner { fill: transparent; stroke-width: 1; opacity: 0.6; }
.topo-driver-bridge circle { stroke: var(--accent); filter: drop-shadow(0 0 10px var(--accent-glow)); }
.topo-driver-host circle { stroke: var(--amber); filter: drop-shadow(0 0 10px rgba(251,191,36,0.35)); }
.topo-driver-overlay circle{ stroke: #a78bfa; filter: drop-shadow(0 0 10px rgba(167,139,250,0.35)); }
.topo-driver-null circle, .topo-driver-none circle { stroke: var(--muted); }
.topo-internal circle { stroke-dasharray: 4 3; }
.topo-net .topo-label { fill: var(--accent); font-weight: 600; font-size: 12px; }
.topo-cont circle { fill: rgba(255,255,255,0.04); stroke: var(--muted); stroke-width: 1.6; }
.topo-state-running circle { stroke: var(--green); fill: rgba(74,222,128,0.10); }
.topo-state-exited circle, .topo-state-dead circle { stroke: var(--red); fill: rgba(248,113,113,0.10); }
.topo-state-paused circle, .topo-state-restarting circle { stroke: var(--amber); fill: rgba(251,191,36,0.10); }
.topo-cont .topo-label { fill: var(--text); font-size: 10px; }
.topo-label, .topo-sublabel { text-anchor: middle; pointer-events: none; font-family: var(--font-display); }
.topo-sublabel { fill: var(--muted); font-size: 9px; font-family: ui-monospace, Menlo, monospace; letter-spacing: 0.04em; }

View File

@@ -0,0 +1,313 @@
/* psyc — Docker topology force-directed graph.
*
* Networks are hubs, containers radiate around them, edges = membership.
* Pure vanilla JS, no deps. Layout settles in ~250 ticks then runs an idle
* animation loop that only redraws while velocities matter. Drag a node to
* pin/rearrange; the "re-settle" button kicks the simulation again.
*/
(function () {
"use strict";
const dataEl = document.getElementById("topo-data");
const svg = document.getElementById("topology-graph");
if (!dataEl || !svg) return;
const data = JSON.parse(dataEl.textContent);
// Build nodes + edges from the topology snapshot.
const nodes = [];
const nodeById = Object.create(null);
// Synthetic host node — the physical machine. Networks gateway to it; published
// ports leave the container through this node to the outside world.
const host = {
id: "h:host", type: "host",
label: (data.host && data.host.name) || "docker host",
sub: data.host && data.host.os ? data.host.os : "",
r: 36, x: 0, y: 0, vx: 0, vy: 0, fixed: false,
tooltip: `host: ${(data.host && data.host.name) || "docker host"}\n${(data.host && data.host.os) || ""}`,
};
nodes.push(host); nodeById[host.id] = host;
for (const net of data.networks || []) {
const r = 26 + Math.sqrt((net.containers || []).length) * 4.5;
const n = {
id: "n:" + net.name, type: "net",
label: net.name, driver: (net.driver || "bridge").toLowerCase(),
sub: net.subnet || "",
gateway: net.gateway || "",
internal: !!net.internal,
r, x: 0, y: 0, vx: 0, vy: 0, fixed: false,
tooltip: `switch: ${net.name}\ndriver: ${net.driver} (${net.scope})${net.subnet ? "\nsubnet: " + net.subnet : ""}${net.gateway ? "\ngateway: " + net.gateway : ""}\ncontainers: ${(net.containers || []).length}`,
};
nodes.push(n); nodeById[n.id] = n;
}
for (const c of data.containers || []) {
const n = {
id: "c:" + c.name, type: "cont",
label: c.name, state: c.state || "unknown",
published_ports: c.published_ports || [],
r: 11, x: 0, y: 0, vx: 0, vy: 0, fixed: false,
tooltip: `${c.name}\n${c.image}\n${c.status}${c.published_ports && c.published_ports.length ? "\npublishes: " + c.published_ports.join(", ") : ""}`,
};
nodes.push(n); nodeById[n.id] = n;
}
const edges = [];
// 1) container -> network, labeled with the container's IP on that network
for (const c of data.containers || []) {
for (const cn of c.networks || []) {
const sid = "c:" + c.name;
const tid = "n:" + cn.name;
if (nodeById[sid] && nodeById[tid]) {
edges.push({ source: sid, target: tid, kind: "wire", label: cn.ip || "" });
}
}
}
// 2) non-internal network -> host (uplink with the gateway as label)
for (const net of data.networks || []) {
if (net.internal) continue;
const tid = "n:" + net.name;
if (nodeById[tid]) {
edges.push({ source: host.id, target: tid, kind: "uplink", label: net.gateway || "" });
}
}
// 3) container -> host for published ports (ingress paths from the world)
for (const c of data.containers || []) {
if (!c.published_ports || !c.published_ports.length) continue;
const sid = "c:" + c.name;
if (nodeById[sid]) {
edges.push({ source: sid, target: host.id, kind: "publish", label: c.published_ports.join(" ") });
}
}
// Viewport.
function viewport() {
const W = svg.clientWidth || 900;
const H = parseInt(getComputedStyle(svg).height, 10) || 560;
return { W, H };
}
let { W, H } = viewport();
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
// Seed positions in a circle around the center so the initial frame isn't a glob.
(function seed() {
const cx = W / 2, cy = H / 2;
nodes.forEach((n, i) => {
const ang = (i / nodes.length) * Math.PI * 2;
const rad = (n.type === "net" ? 60 : 180) + Math.random() * 40;
n.x = cx + rad * Math.cos(ang);
n.y = cy + rad * Math.sin(ang);
});
})();
// Force-sim parameters — tuned for ~50 nodes.
const REPULSION = 1400;
const SPRING_K = 0.045;
const SPRING_REST = 110;
const DAMP = 0.82;
const CENTER_PULL = 0.004;
function tick() {
// Repulsion (O(n^2) — fine here).
for (let i = 0; i < nodes.length; i++) {
const a = nodes[i];
for (let j = i + 1; j < nodes.length; j++) {
const b = nodes[j];
const dx = b.x - a.x, dy = b.y - a.y;
const d2 = dx * dx + dy * dy + 0.1;
const d = Math.sqrt(d2);
const f = REPULSION / d2;
const fx = (dx / d) * f, fy = (dy / d) * f;
if (!a.fixed) { a.vx -= fx; a.vy -= fy; }
if (!b.fixed) { b.vx += fx; b.vy += fy; }
}
}
// Spring attraction along edges.
for (const e of edges) {
const a = nodeById[e.source], b = nodeById[e.target];
const dx = b.x - a.x, dy = b.y - a.y;
const d = Math.sqrt(dx * dx + dy * dy) + 0.1;
const f = (d - SPRING_REST) * SPRING_K;
const fx = (dx / d) * f, fy = (dy / d) * f;
if (!a.fixed) { a.vx += fx; a.vy += fy; }
if (!b.fixed) { b.vx -= fx; b.vy -= fy; }
}
// Gentle center gravity + damping + integrate.
for (const n of nodes) {
if (n.fixed) { n.vx = 0; n.vy = 0; continue; }
n.vx += (W / 2 - n.x) * CENTER_PULL;
n.vy += (H / 2 - n.y) * CENTER_PULL;
n.vx *= DAMP; n.vy *= DAMP;
n.x += n.vx; n.y += n.vy;
n.x = Math.max(n.r, Math.min(W - n.r, n.x));
n.y = Math.max(n.r, Math.min(H - n.r, n.y));
}
}
// Pre-settle so the first frame isn't chaos.
for (let i = 0; i < 300; i++) tick();
// ---------- rendering ----------------------------------------------------
const ns = "http://www.w3.org/2000/svg";
const edgesG = document.createElementNS(ns, "g");
const nodesG = document.createElementNS(ns, "g");
edgesG.setAttribute("class", "topo-edges");
nodesG.setAttribute("class", "topo-nodes");
svg.appendChild(edgesG);
svg.appendChild(nodesG);
const edgeEls = edges.map(e => {
const grp = document.createElementNS(ns, "g");
grp.setAttribute("class", "topo-edge-grp topo-kind-" + e.kind);
const ln = document.createElementNS(ns, "line");
ln.setAttribute("class", "topo-edge");
grp.appendChild(ln);
const lbl = document.createElementNS(ns, "text");
lbl.setAttribute("class", "topo-edge-label");
lbl.textContent = e.label || "";
grp.appendChild(lbl);
edgesG.appendChild(grp);
return { line: ln, label: lbl };
});
function _classFor(n) {
if (n.type === "host") return "topo-node topo-host";
if (n.type === "net") return "topo-node topo-net topo-driver-" + n.driver + (n.internal ? " topo-internal" : "");
return "topo-node topo-cont topo-state-" + n.state;
}
const nodeEls = nodes.map(n => {
const g = document.createElementNS(ns, "g");
g.setAttribute("class", _classFor(n));
g.dataset.id = n.id;
if (n.type === "host") {
// Render the host as a rounded square so it visually reads as the "outside world" anchor.
const sz = n.r;
const rect = document.createElementNS(ns, "rect");
rect.setAttribute("x", -sz); rect.setAttribute("y", -sz);
rect.setAttribute("width", sz * 2); rect.setAttribute("height", sz * 2);
rect.setAttribute("rx", 8); rect.setAttribute("ry", 8);
g.appendChild(rect);
} else if (n.type === "net") {
// Switches: hexagon-ish double-stroke for a "device" feel.
const c = document.createElementNS(ns, "circle");
c.setAttribute("r", n.r); g.appendChild(c);
const c2 = document.createElementNS(ns, "circle");
c2.setAttribute("r", n.r - 5); c2.setAttribute("class", "topo-net-inner"); g.appendChild(c2);
} else {
const c = document.createElementNS(ns, "circle");
c.setAttribute("r", n.r); g.appendChild(c);
}
const text = document.createElementNS(ns, "text");
text.setAttribute("class", "topo-label");
text.setAttribute("dy", (n.type === "host" ? n.r + 14 : n.r + 13));
text.textContent = n.label;
g.appendChild(text);
if (n.sub) {
const sub = document.createElementNS(ns, "text");
sub.setAttribute("class", "topo-sublabel");
sub.setAttribute("dy", (n.type === "host" ? n.r + 26 : n.r + 24));
sub.textContent = n.sub;
g.appendChild(sub);
}
const title = document.createElementNS(ns, "title");
title.textContent = n.tooltip;
g.appendChild(title);
nodesG.appendChild(g);
return g;
});
function paint() {
for (let i = 0; i < edges.length; i++) {
const e = edges[i], a = nodeById[e.source], b = nodeById[e.target];
const els = edgeEls[i];
els.line.setAttribute("x1", a.x); els.line.setAttribute("y1", a.y);
els.line.setAttribute("x2", b.x); els.line.setAttribute("y2", b.y);
if (els.label.textContent) {
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
els.label.setAttribute("x", mx); els.label.setAttribute("y", my - 3);
}
}
for (let i = 0; i < nodes.length; i++) {
nodeEls[i].setAttribute("transform", `translate(${nodes[i].x},${nodes[i].y})`);
}
}
paint();
// ---------- drag --------------------------------------------------------
let dragging = null, dragOffset = { x: 0, y: 0 };
function svgPoint(clientX, clientY) {
const pt = svg.createSVGPoint(); pt.x = clientX; pt.y = clientY;
return pt.matrixTransform(svg.getScreenCTM().inverse());
}
nodeEls.forEach((g, i) => {
g.addEventListener("mousedown", ev => {
ev.preventDefault();
dragging = nodes[i];
const p = svgPoint(ev.clientX, ev.clientY);
dragOffset.x = p.x - dragging.x; dragOffset.y = p.y - dragging.y;
dragging.fixed = true;
g.classList.add("dragging");
});
});
document.addEventListener("mousemove", ev => {
if (!dragging) return;
const p = svgPoint(ev.clientX, ev.clientY);
dragging.x = p.x - dragOffset.x; dragging.y = p.y - dragOffset.y;
dragging.vx = 0; dragging.vy = 0;
energyBudget = 80; // re-energize so other nodes adapt
});
document.addEventListener("mouseup", () => {
if (!dragging) return;
const g = nodesG.querySelector(`[data-id="${CSS.escape(dragging.id)}"]`);
if (g) g.classList.remove("dragging");
dragging.fixed = false;
dragging = null;
});
// ---------- idle animation ---------------------------------------------
let energyBudget = 40;
function loop() {
let moving = false;
for (const n of nodes) {
if (Math.abs(n.vx) > 0.04 || Math.abs(n.vy) > 0.04) { moving = true; break; }
}
if (moving || energyBudget > 0 || dragging) {
tick(); paint();
if (energyBudget > 0) energyBudget--;
}
requestAnimationFrame(loop);
}
loop();
// ---------- controls + resize -------------------------------------------
const resetBtn = document.getElementById("topo-reset");
if (resetBtn) {
resetBtn.addEventListener("click", () => {
for (const n of nodes) { n.vx = (Math.random() - 0.5) * 6; n.vy = (Math.random() - 0.5) * 6; }
energyBudget = 200;
});
}
// Wheel zoom on the SVG (changes viewBox).
let zoom = 1, panX = 0, panY = 0;
svg.addEventListener("wheel", ev => {
ev.preventDefault();
const delta = ev.deltaY > 0 ? 1.1 : 0.9;
zoom = Math.max(0.3, Math.min(2.5, zoom * delta));
const vw = W / zoom, vh = H / zoom;
svg.setAttribute("viewBox", `${(W - vw) / 2 + panX} ${(H - vh) / 2 + panY} ${vw} ${vh}`);
}, { passive: false });
window.addEventListener("resize", () => {
const v = viewport();
W = v.W; H = v.H;
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
energyBudget = 60;
});
})();

View File

@@ -53,7 +53,15 @@
</section> </section>
<section class="panel"> <section class="panel">
<div class="panel-head"><h2>Docker topology</h2></div> <div class="panel-head">
<p class="page-intro">Live container + network map. <em>Wiring next via a read-only socket-proxy.</em></p> <h2>Infrastructure</h2>
<a href="/admin/docker" class="lg-sub">open topology →</a>
</div>
<div class="admin-grid">
<a href="/admin/docker" class="admin-tile admin-tile-link">
<h2>Docker topology</h2>
<p>Live container roster + network map, read-only via socket-proxy. Click to open.</p>
</a>
</div>
</section> </section>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,73 @@
{% extends "base.html" %}
{% block title %}Docker topology — psyc admin{% endblock %}
{% block content %}
<section class="panel">
<div class="panel-head">
<h1>Docker Topology</h1>
<span class="count">{{ topo.containers|length }} containers · {{ topo.networks|length }} networks</span>
</div>
<p class="page-intro">Live read-only view of this host's Docker daemon, routed through <code>{{ topo.proxy }}</code>. The proxy exposes only GET on containers and networks — psyc cannot start, stop, exec into, or modify anything from here.</p>
<p class="back"><a href="/admin">← back to admin</a></p>
{% if topo.error %}
<div class="gate-error">✗ Socket-proxy unreachable: {{ topo.error }}</div>
{% endif %}
{% if topo.containers %}
<div class="topo-stage">
<div class="topo-toolbar">
<span class="topo-legend"><span class="lg-swatch lg-net"></span>network</span>
<span class="topo-legend"><span class="lg-swatch lg-run"></span>running</span>
<span class="topo-legend"><span class="lg-swatch lg-stop"></span>exited</span>
<button type="button" id="topo-reset" class="btn">re-settle</button>
<span class="topo-hint">drag any node · scroll to zoom</span>
</div>
<svg id="topology-graph" preserveAspectRatio="xMidYMid meet"></svg>
<script id="topo-data" type="application/json">{{ topo|tojson }}</script>
<script src="/static/topology.js" defer></script>
</div>
{% endif %}
<h2 style="margin-top:18px;">Networks</h2>
<div class="net-grid">
{% for n in topo.networks %}
<div class="net-card net-driver-{{ n.driver }}">
<div class="net-card-head">
<div>
<div class="net-name">{{ n.name }}</div>
<div class="net-meta">{{ n.driver }} · {{ n.scope }}{% if n.internal %} · internal{% endif %}</div>
</div>
<span class="net-count">{{ n.containers|length }}</span>
</div>
{% if n.containers %}
<div class="net-members">
{% for c in n.containers %}
<span class="net-chip"><span class="net-chip-name">{{ c.name }}</span><span class="net-chip-ip">{{ c.ip or '—' }}</span></span>
{% endfor %}
</div>
{% else %}
<div class="net-empty">(no attached containers)</div>
{% endif %}
</div>
{% endfor %}
</div>
<h2 style="margin-top:24px;">Containers</h2>
<table class="ledger">
<thead><tr><th>Name</th><th>Image</th><th>State</th><th>Networks</th><th>Ports</th></tr></thead>
<tbody>
{% for c in topo.containers %}
<tr class="ledger-row">
<td><strong>{{ c.name }}</strong><div class="lg-sub">{{ c.id }}</div></td>
<td class="lg-dest">{{ c.image }}</td>
<td><span class="state-badge state-{{ c.state }}">{{ c.state }}</span></td>
<td>
{% for net in c.networks %}<span class="net-chip mini"><span class="net-chip-name">{{ net.name }}</span><span class="net-chip-ip">{{ net.ip or '—' }}</span></span>{% endfor %}
</td>
<td class="lg-sub">{% for p in c.ports %}{{ p }}{% if not loop.last %}<br>{% endif %}{% endfor %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

124
tests/test_docker_view.py Normal file
View File

@@ -0,0 +1,124 @@
"""Docker topology — normalization + error handling against the socket-proxy."""
from __future__ import annotations
import pytest
from psyc.cockpit import docker_view
_CONTAINERS_FIXTURE = [
{
"Id": "abcdef1234567890",
"Names": ["/psyc-cockpit-1"],
"Image": "psyc:latest",
"State": "running",
"Status": "Up 5 minutes (healthy)",
"NetworkSettings": {"Networks": {"backend": {"IPAddress": "172.20.0.5"}}},
"Ports": [{"IP": "0.0.0.0", "PrivatePort": 8767, "PublicPort": 8767, "Type": "tcp"}],
},
{
"Id": "fedcba0987654321",
"Names": ["/some-stopped"],
"Image": "alpine",
"State": "exited",
"Status": "Exited (0) 2 hours ago",
"NetworkSettings": {"Networks": {}},
"Ports": [],
},
]
_NETWORKS_FIXTURE = [
{
"Id": "n1", "Name": "backend", "Driver": "bridge", "Scope": "local", "Internal": False,
"IPAM": {"Config": [{"Subnet": "172.20.0.0/16", "Gateway": "172.20.0.1"}]},
"Containers": {
"abcdef1234567890": {
"Name": "psyc-cockpit-1", "IPv4Address": "172.20.0.5/16",
"MacAddress": "02:42:ac:14:00:05",
},
},
},
{"Id": "n2", "Name": "bridge", "Driver": "bridge", "Scope": "local", "Internal": False, "Containers": {}},
]
def _fake_get_factory(monkeypatch, payloads: dict):
"""Patch docker_view._get to return canned payloads by path.
Unknown paths raise DockerProxyError (mimicking a proxy that blocks the
endpoint), so callers like host_info() exercise their fallback paths.
"""
def fake_get(path: str):
for prefix, body in payloads.items():
if path.startswith(prefix):
return body
raise docker_view.DockerProxyError(f"blocked: {path}")
monkeypatch.setattr(docker_view, "_get", fake_get)
def test_list_containers_normalizes_fields(monkeypatch):
_fake_get_factory(monkeypatch, {"/containers/json": _CONTAINERS_FIXTURE})
out = docker_view.list_containers()
by_name = {c["name"]: c for c in out}
# running comes before exited
assert out[0]["state"] == "running"
assert out[-1]["state"] == "exited"
cockpit = by_name["psyc-cockpit-1"]
assert cockpit["id"] == "abcdef123456"
assert cockpit["image"] == "psyc:latest"
assert cockpit["networks"][0]["name"] == "backend"
assert cockpit["networks"][0]["ip"] == "172.20.0.5"
assert "0.0.0.0:8767->8767/tcp" in cockpit["ports"]
def test_list_networks_attaches_containers_with_ip(monkeypatch):
_fake_get_factory(monkeypatch, {"/networks": _NETWORKS_FIXTURE})
out = docker_view.list_networks()
backend = next(n for n in out if n["name"] == "backend")
assert backend["driver"] == "bridge"
assert backend["subnet"] == "172.20.0.0/16"
assert backend["gateway"] == "172.20.0.1"
assert backend["containers"][0]["name"] == "psyc-cockpit-1"
assert backend["containers"][0]["ip"] == "172.20.0.5"
assert backend["containers"][0]["mac"] == "02:42:ac:14:00:05"
# default networks pushed to bottom
assert out[-1]["name"] == "bridge"
def test_list_containers_extracts_published_ports(monkeypatch):
_fake_get_factory(monkeypatch, {"/containers/json": _CONTAINERS_FIXTURE})
out = docker_view.list_containers()
cockpit = next(c for c in out if c["name"] == "psyc-cockpit-1")
assert cockpit["published_ports"] == ["8767/tcp"]
# stopped container with no ports → empty
stopped = next(c for c in out if c["name"] == "some-stopped")
assert stopped["published_ports"] == []
def test_host_info_falls_back_when_proxy_blocks_info(monkeypatch):
def boom(path):
raise docker_view.DockerProxyError("info forbidden")
monkeypatch.setattr(docker_view, "_get", boom)
info = docker_view.host_info()
assert info["name"] == "docker host"
def test_topology_returns_both_with_no_error(monkeypatch):
_fake_get_factory(monkeypatch, {
"/containers/json": _CONTAINERS_FIXTURE,
"/networks": _NETWORKS_FIXTURE,
})
snap = docker_view.topology()
assert snap["error"] is None
assert len(snap["containers"]) == 2
assert len(snap["networks"]) == 2
def test_topology_surfaces_proxy_failure(monkeypatch):
def boom(path):
raise docker_view.DockerProxyError("connection refused")
monkeypatch.setattr(docker_view, "_get", boom)
snap = docker_view.topology()
assert snap["error"] is not None and "connection refused" in snap["error"]
assert snap["containers"] == [] and snap["networks"] == []