diff --git a/docker-compose.yml b/docker-compose.yml index 1d89a5e..6a10a7a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,7 @@ services: PSYC_MOCK_CERT_URL: http://mock-cert:8770 PSYC_SOAR_URL: http://mock-cert:8770 PSYC_INFERENCE_URL: http://inference:8771 + PSYC_DOCKER_PROXY: http://docker-socket-proxy:2375 ports: - "8767:8767" # direct/debug access; the proxy serves psyc.neuronetz.ai on :80 volumes: @@ -48,6 +49,24 @@ services: timeout: 5s 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 # `--profile gpu`. Uses the psyc-trainer image (built from Dockerfile.train). inference: diff --git a/src/psyc/cockpit/app.py b/src/psyc/cockpit/app.py index 3015287..9b387e8 100644 --- a/src/psyc/cockpit/app.py +++ b/src/psyc/cockpit/app.py @@ -12,7 +12,7 @@ from fastapi.templating import Jinja2Templates from starlette.middleware.sessions import SessionMiddleware 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 ledger as ledger_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) +@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) def queue_view(request: Request, status: str = "pending") -> HTMLResponse: from psyc.models import ApprovalStatus diff --git a/src/psyc/cockpit/docker_view.py b/src/psyc/cockpit/docker_view.py new file mode 100644 index 0000000..ab1ad2d --- /dev/null +++ b/src/psyc/cockpit/docker_view.py @@ -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 diff --git a/src/psyc/cockpit/static/cockpit.css b/src/psyc/cockpit/static/cockpit.css index 0d75f10..bf57fcc 100644 --- a/src/psyc/cockpit/static/cockpit.css +++ b/src/psyc/cockpit/static/cockpit.css @@ -529,3 +529,94 @@ tr.sev-low .sev-badge { color: var(--muted); } @media (prefers-reduced-motion: reduce) { .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; } diff --git a/src/psyc/cockpit/static/topology.js b/src/psyc/cockpit/static/topology.js new file mode 100644 index 0000000..993df48 --- /dev/null +++ b/src/psyc/cockpit/static/topology.js @@ -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; + }); +})(); diff --git a/src/psyc/cockpit/templates/admin.html b/src/psyc/cockpit/templates/admin.html index 0c2872d..7660ed1 100644 --- a/src/psyc/cockpit/templates/admin.html +++ b/src/psyc/cockpit/templates/admin.html @@ -53,7 +53,15 @@
-

Docker topology

-

Live container + network map. Wiring next via a read-only socket-proxy.

+
+

Infrastructure

+ open topology → +
+
+ +

Docker topology

+

Live container roster + network map, read-only via socket-proxy. Click to open.

+
+
{% endblock %} diff --git a/src/psyc/cockpit/templates/admin_docker.html b/src/psyc/cockpit/templates/admin_docker.html new file mode 100644 index 0000000..05224dd --- /dev/null +++ b/src/psyc/cockpit/templates/admin_docker.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{% block title %}Docker topology — psyc admin{% endblock %} +{% block content %} +
+
+

Docker Topology

+ {{ topo.containers|length }} containers · {{ topo.networks|length }} networks +
+

Live read-only view of this host's Docker daemon, routed through {{ topo.proxy }}. The proxy exposes only GET on containers and networks — psyc cannot start, stop, exec into, or modify anything from here.

+

← back to admin

+ + {% if topo.error %} +
✗ Socket-proxy unreachable: {{ topo.error }}
+ {% endif %} + + {% if topo.containers %} +
+
+ network + running + exited + + drag any node · scroll to zoom +
+ + + +
+ {% endif %} + +

Networks

+
+ {% for n in topo.networks %} +
+
+
+
{{ n.name }}
+
{{ n.driver }} · {{ n.scope }}{% if n.internal %} · internal{% endif %}
+
+ {{ n.containers|length }} +
+ {% if n.containers %} +
+ {% for c in n.containers %} + {{ c.name }}{{ c.ip or '—' }} + {% endfor %} +
+ {% else %} +
(no attached containers)
+ {% endif %} +
+ {% endfor %} +
+ +

Containers

+ + + + {% for c in topo.containers %} + + + + + + + + {% endfor %} + +
NameImageStateNetworksPorts
{{ c.name }}
{{ c.id }}
{{ c.image }}{{ c.state }} + {% for net in c.networks %}{{ net.name }}{{ net.ip or '—' }}{% endfor %} + {% for p in c.ports %}{{ p }}{% if not loop.last %}
{% endif %}{% endfor %}
+
+{% endblock %} diff --git a/tests/test_docker_view.py b/tests/test_docker_view.py new file mode 100644 index 0000000..05f3c86 --- /dev/null +++ b/tests/test_docker_view.py @@ -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"] == []