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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
144
src/psyc/cockpit/docker_view.py
Normal file
144
src/psyc/cockpit/docker_view.py
Normal 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
|
||||
@@ -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; }
|
||||
|
||||
313
src/psyc/cockpit/static/topology.js
Normal file
313
src/psyc/cockpit/static/topology.js
Normal 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;
|
||||
});
|
||||
})();
|
||||
@@ -53,7 +53,15 @@
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h2>Docker topology</h2></div>
|
||||
<p class="page-intro">Live container + network map. <em>Wiring next via a read-only socket-proxy.</em></p>
|
||||
<div class="panel-head">
|
||||
<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>
|
||||
{% endblock %}
|
||||
|
||||
73
src/psyc/cockpit/templates/admin_docker.html
Normal file
73
src/psyc/cockpit/templates/admin_docker.html
Normal 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
124
tests/test_docker_view.py
Normal 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"] == []
|
||||
Reference in New Issue
Block a user