From fac4377c914bf2e08ee08554f2b57c97233232b0 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:21:08 +0800 Subject: [PATCH] productize runtime map UI --- README.md | 2 + apps/web/src/App.tsx | 2 + apps/web/src/components/AppShell.tsx | 1 + apps/web/src/components/CommandPalette.tsx | 1 + apps/web/src/hooks/useSystemModel.ts | 16 +- apps/web/src/lib/demoData.ts | 176 ++++++++++ apps/web/src/lib/model.ts | 185 ++++++++++- apps/web/src/screens/Home.tsx | 29 ++ apps/web/src/screens/Runtime.tsx | 358 +++++++++++++++++++++ apps/web/src/screens/Settings.tsx | 1 + apps/web/src/styles.css | 160 +++++++++ docs/README.md | 2 + docs/deployment/DEPLOYMENT.md | 11 + docs/deployment/REVERSE_PROXY.md | 6 + docs/testing/TESTING_PLAN.md | 6 +- scripts/smoke-deploy.sh | 81 ++++- tests/e2e/dockermap.spec.ts | 1 + tests/e2e/dockermapHarness.ts | 6 +- 18 files changed, 1018 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/screens/Runtime.tsx diff --git a/README.md b/README.md index 339d086..c1300a3 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,8 @@ This starts: - See a broader runtime map when host tools are available, including systemd, cron, PM2, tmux, listening sockets, Tailscale or Headscale, reverse-proxy markers, and local DNS markers. +- Use the Runtime Map workspace to inspect provider nodes, diagnostics, and + cross-provider edges in one read-only view. - Compare what Compose says should exist with what Docker is actually running. - Use mock fallback data when Docker is not available. diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7caf3fd..1a5d232 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from "react-router-dom"; import AppShell from "./components/AppShell"; import Home from "./screens/Home"; import MapScreen from "./screens/Map"; +import RuntimeScreen from "./screens/Runtime"; import ServiceDetail from "./screens/ServiceDetail"; import Changes from "./screens/Changes"; import Copilot from "./screens/Copilot"; @@ -28,6 +29,7 @@ export function App() { }> } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index 6f77f1f..3dfdce3 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -25,6 +25,7 @@ const SPACES: { heading: string; items: NavItem[] }[] = [ items: [ { to: "/", label: "Home", icon: "home", end: true }, { to: "/map", label: "Service Map", icon: "map" }, + { to: "/runtime", label: "Runtime", icon: "layers" }, { to: "/changes", label: "Changes", icon: "history" }, { to: "/copilot", label: "Copilot", icon: "spark" } ] diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 49d3bfa..b39e17b 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -44,6 +44,7 @@ export default function CommandPalette({ const nav: Command[] = [ { id: "nav-home", label: "Home — Command Center", icon: "home", group: "Navigate", run: () => go("/") }, { id: "nav-map", label: "Service Map", icon: "map", group: "Navigate", run: () => go("/map") }, + { id: "nav-runtime", label: "Runtime Map", icon: "layers", group: "Navigate", run: () => go("/runtime") }, { id: "nav-changes", label: "Change Center", icon: "history", group: "Navigate", run: () => go("/changes") }, { id: "nav-copilot", label: "Copilot", icon: "spark", group: "Navigate", run: () => go("/copilot") }, { id: "nav-net", label: "Networking", icon: "network", group: "Navigate", run: () => go("/networking") }, diff --git a/apps/web/src/hooks/useSystemModel.ts b/apps/web/src/hooks/useSystemModel.ts index 0f96800..b361b11 100644 --- a/apps/web/src/hooks/useSystemModel.ts +++ b/apps/web/src/hooks/useSystemModel.ts @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import type { DockerSnapshot, GraphResponse } from "@dockermap/contracts"; +import type { DockerSnapshot, RuntimeMap } from "@dockermap/contracts"; import { buildModel, type SystemModel } from "../lib/model"; import { useApiResource } from "./useApiResource"; @@ -9,19 +9,19 @@ export interface SystemModelState { error: string | null; } -/** Fetches the snapshot + graph and composes them into the domain model. */ +/** Fetches the Docker snapshot + runtime map and composes them into the domain model. */ export function useSystemModel(refreshTick = 0): SystemModelState { const snapshot = useApiResource("/api/snapshot", refreshTick); - const graph = useApiResource("/api/graph", refreshTick); + const runtimeMap = useApiResource("/api/runtime/map", refreshTick); const model = useMemo(() => { - if (!snapshot.data || !graph.data) return null; - return buildModel(snapshot.data, graph.data); - }, [snapshot.data, graph.data]); + if (!snapshot.data || !runtimeMap.data) return null; + return buildModel(snapshot.data, runtimeMap.data); + }, [snapshot.data, runtimeMap.data]); return { model, - loading: snapshot.loading || graph.loading, - error: snapshot.error ?? graph.error + loading: snapshot.loading || runtimeMap.loading, + error: snapshot.error ?? runtimeMap.error }; } diff --git a/apps/web/src/lib/demoData.ts b/apps/web/src/lib/demoData.ts index 60e94ef..5f2d7d0 100644 --- a/apps/web/src/lib/demoData.ts +++ b/apps/web/src/lib/demoData.ts @@ -7,6 +7,7 @@ import type { ImageRecord, LogsResponse, NetworkRecord, + RuntimeMap, VolumeRecord } from "@dockermap/contracts"; @@ -224,6 +225,180 @@ const demoComposeScan: ComposeScan = { ] }; +const demoRuntimeMap: RuntimeMap = { + nodes: [ + { + id: "runtime_gateway", + provider: "reverse_proxy", + type: "reverse_proxy", + layer: "edge", + label: "gateway", + status: "running", + metadata: { + config: "nginx.conf", + tls: true + }, + service: { + name: "gateway", + status: "running", + dependencies: ["api"], + dependents: ["review-browser"], + health: { state: "healthy", source: "nginx", message: "Serving edge traffic" }, + logs: [{ id: "runtime_gateway_log", source: "nginx", level: "info" }], + events: [{ id: "runtime_gateway_event", kind: "reload", message: "Proxy config reloaded" }], + owner: { kind: "team", name: "platform" }, + location: { kind: "host", value: "demo-host" } + } + }, + { + id: "runtime_api", + provider: "docker", + type: "container", + layer: "container", + label: "api", + status: "running", + metadata: { + image: "python:3.11-slim", + role: "api" + }, + service: { + name: "api", + status: "running", + dependencies: ["postgres", "redis"], + dependents: ["gateway", "worker"], + health: { state: "healthy", source: "http", message: "Responding in 42ms" }, + logs: [{ id: "runtime_api_log", source: "docker logs api", level: "info" }], + events: [{ id: "runtime_api_event", kind: "deploy", message: "API revision promoted" }], + owner: { kind: "team", name: "product" }, + location: { kind: "container", value: "application" } + } + }, + { + id: "runtime_worker", + provider: "process", + type: "worker", + layer: "process", + label: "worker", + status: "running", + metadata: { + pid: 2412, + command: "python worker.py" + }, + service: { + name: "worker", + status: "running", + dependencies: ["api", "postgres"], + dependents: [], + health: { state: "degraded", source: "heartbeat", message: "Queue lag above target" }, + logs: [{ id: "runtime_worker_log", source: "worker", level: "warn" }], + events: [{ id: "runtime_worker_event", kind: "lag", message: "Queue delay crossed 30 seconds" }], + owner: { kind: "team", name: "ops" }, + location: { kind: "host", value: "demo-host" } + } + }, + { + id: "runtime_systemd_api", + provider: "systemd", + type: "systemd_service", + layer: "host", + label: "dockermap-api.service", + status: "running", + metadata: { + unit: "dockermap-api.service", + restart: "always" + }, + service: { + name: "dockermap-api.service", + status: "running", + dependencies: ["docker.service"], + dependents: ["gateway"], + health: { state: "healthy", source: "systemd", message: "Unit is active" }, + logs: [], + events: [{ id: "runtime_systemd_event", kind: "start", message: "systemd marked the unit active" }], + owner: { kind: "system", name: "systemd" }, + location: { kind: "host", value: "demo-host" } + } + }, + { + id: "runtime_npm_forge", + provider: "npm", + type: "node_application", + layer: "package", + label: "forge-ui", + status: "running", + metadata: { + framework: "vite", + path: "/srv/forge" + }, + package: { + name: "forge-ui", + manager: "npm", + version: "0.4.0", + dependencies: ["react", "vite"], + dependents: [], + update: { + currentVersion: "0.4.0", + latestVersion: null, + available: false, + advisories: [] + }, + owner: { kind: "team", name: "frontend" }, + location: { kind: "path", value: "/srv/forge" } + } + }, + { + id: "runtime_postgres", + provider: "docker", + type: "database", + layer: "container", + label: "postgres", + status: "running", + metadata: { + image: "postgres:16-alpine", + role: "database" + }, + service: { + name: "postgres", + status: "running", + dependencies: ["postgres_data"], + dependents: ["api", "worker"], + health: { state: "healthy", source: "postgres", message: "Primary is ready" }, + logs: [], + events: [{ id: "runtime_postgres_event", kind: "backup", message: "Nightly backup completed" }], + owner: { kind: "team", name: "platform" }, + location: { kind: "container", value: "data" } + } + }, + { + id: "runtime_postgres_data", + provider: "docker", + type: "docker_volume", + layer: "storage", + label: "postgres_data", + status: "attached", + metadata: { + driver: "local" + } + } + ], + edges: [ + { source: "runtime_gateway", target: "runtime_api", relationship: "proxies_to", metadata: { port: 80 } }, + { source: "runtime_api", target: "runtime_postgres", relationship: "depends_on", metadata: { source: "compose" } }, + { source: "runtime_worker", target: "runtime_api", relationship: "calls", metadata: { queue: "jobs" } }, + { source: "runtime_worker", target: "runtime_postgres", relationship: "depends_on", metadata: { source: "runtime" } }, + { source: "runtime_postgres", target: "runtime_postgres_data", relationship: "mounts", metadata: { path: "/var/lib/postgresql/data" } }, + { source: "runtime_systemd_api", target: "runtime_gateway", relationship: "exposes", metadata: { unit: "dockermap-api.service" } } + ], + diagnostics: [ + { + provider: "process", + severity: "warning", + message: "Worker heartbeat is stale enough to warrant inspection." + } + ], + lastUpdated: Date.now() +}; + function demoLogs(service: string | null): LogsResponse { const containers = service ? demoContainers.filter((c) => c.name === service) : demoContainers; const now = Date.now(); @@ -255,6 +430,7 @@ export function getDemoResponse(path: string): T { if (pathname === "/api/snapshot") return demoSnapshot as T; if (pathname === "/api/graph") return demoGraph as T; + if (pathname === "/api/runtime/map") return demoRuntimeMap as T; if (pathname === "/api/health") { return { node: { status: "ok", port: 4000 }, diff --git a/apps/web/src/lib/model.ts b/apps/web/src/lib/model.ts index 6120347..f37de0e 100644 --- a/apps/web/src/lib/model.ts +++ b/apps/web/src/lib/model.ts @@ -1,8 +1,12 @@ import type { ContainerRecord, DockerSnapshot, - GraphResponse, NetworkRecord, + RuntimeMap, + RuntimeMapDiagnostic, + RuntimeMapEdge, + RuntimeMapNode, + RuntimeProviderKind, VolumeRecord } from "@dockermap/contracts"; @@ -74,11 +78,55 @@ export interface SystemModel { relationships: Relationship[]; networks: NetworkRecord[]; volumes: VolumeRecord[]; + runtime: RuntimeModel; byId: Map; byName: Map; lastUpdated: number; } +export type RuntimeLayerId = NonNullable | "unassigned"; + +export interface RuntimeNodeRecord { + id: string; + provider: RuntimeProviderKind; + type: RuntimeMapNode["type"]; + label: string; + status: RuntimeMapNode["status"]; + layer: RuntimeLayerId; + metadata: RuntimeMapNode["metadata"]; + service?: RuntimeMapNode["service"]; + package?: RuntimeMapNode["package"]; + state: ServiceState; + incoming: RuntimeMapEdge[]; + outgoing: RuntimeMapEdge[]; +} + +export interface RuntimeBucketSummary { + id: T; + count: number; + attention: number; +} + +export interface RuntimeSummary { + totalNodes: number; + serviceNodes: number; + providers: number; + layers: number; + diagnostics: number; + attention: number; +} + +export interface RuntimeModel { + nodes: RuntimeNodeRecord[]; + edges: RuntimeMapEdge[]; + diagnostics: RuntimeMapDiagnostic[]; + byId: Map; + providerSummary: RuntimeBucketSummary[]; + layerSummary: RuntimeBucketSummary[]; + summary: RuntimeSummary; + lastUpdated: number; +} + export interface SystemSummary { total: number; healthy: number; @@ -160,7 +208,7 @@ export function hashString(value: string): number { return (h >>> 0) / 4294967295; } -export function buildModel(snapshot: DockerSnapshot, graph: GraphResponse): SystemModel { +export function buildModel(snapshot: DockerSnapshot, runtimeMap: RuntimeMap): SystemModel { const networkNameById = new Map(snapshot.networks.map((n) => [n.id, n.name])); // dependsOn references can be either container ids or names; normalise to ids. @@ -207,15 +255,17 @@ export function buildModel(snapshot: DockerSnapshot, graph: GraphResponse): Syst const byName = new Map(services.map((s) => [s.name, s])); const relationships = buildRelationships(services, snapshot, byId); + const runtime = buildRuntimeModel(runtimeMap); return { services, relationships, networks: snapshot.networks, volumes: snapshot.volumes, + runtime, byId, byName, - lastUpdated: snapshot.lastUpdated + lastUpdated: Math.max(snapshot.lastUpdated, runtime.lastUpdated) }; } @@ -325,3 +375,132 @@ function traverse(model: SystemModel, startId: string, edge: "dependsOn" | "depe visited.delete(startId); return visited; } + +function buildRuntimeModel(runtimeMap: RuntimeMap): RuntimeModel { + const incomingById = new Map(); + const outgoingById = new Map(); + + for (const edge of runtimeMap.edges) { + const outgoing = outgoingById.get(edge.source) ?? []; + outgoing.push(edge); + outgoingById.set(edge.source, outgoing); + + const incoming = incomingById.get(edge.target) ?? []; + incoming.push(edge); + incomingById.set(edge.target, incoming); + } + + const nodes: RuntimeNodeRecord[] = runtimeMap.nodes + .map((node): RuntimeNodeRecord => ({ + id: node.id, + provider: node.provider, + type: node.type, + label: node.label, + status: node.status, + layer: runtimeLayerForNode(node), + metadata: node.metadata, + service: node.service, + package: node.package, + state: runtimeStateForNode(node), + incoming: incomingById.get(node.id) ?? [], + outgoing: outgoingById.get(node.id) ?? [] + })) + .sort((left, right) => { + if (left.state !== right.state) return runtimeStateRank(left.state) - runtimeStateRank(right.state); + if (left.layer !== right.layer) return left.layer.localeCompare(right.layer); + return left.label.localeCompare(right.label); + }); + + const byId = new Map(nodes.map((node) => [node.id, node])); + const providerSummary = summarizeRuntimeBuckets(nodes, (node) => node.provider); + const layerSummary = summarizeRuntimeBuckets(nodes, (node) => node.layer); + const attention = nodes.filter((node) => needsAttention(node.state)).length; + + return { + nodes, + edges: runtimeMap.edges, + diagnostics: runtimeMap.diagnostics, + byId, + providerSummary, + layerSummary, + summary: { + totalNodes: nodes.length, + serviceNodes: nodes.filter((node) => node.service || node.package).length, + providers: providerSummary.length, + layers: layerSummary.length, + diagnostics: runtimeMap.diagnostics.length, + attention + }, + lastUpdated: runtimeMap.lastUpdated + }; +} + +function summarizeRuntimeBuckets(nodes: RuntimeNodeRecord[], pick: (node: RuntimeNodeRecord) => T): RuntimeBucketSummary[] { + const counts = new Map>(); + + for (const node of nodes) { + const id = pick(node); + const bucket = counts.get(id) ?? { id, count: 0, attention: 0 }; + bucket.count += 1; + if (needsAttention(node.state)) bucket.attention += 1; + counts.set(id, bucket); + } + + return [...counts.values()].sort((left, right) => { + if (left.attention !== right.attention) return right.attention - left.attention; + if (left.count !== right.count) return right.count - left.count; + return left.id.localeCompare(right.id); + }); +} + +function runtimeLayerForNode(node: RuntimeMapNode): RuntimeLayerId { + return (node.layer ?? "unassigned") as RuntimeLayerId; +} + +function runtimeStateForNode(node: RuntimeMapNode): ServiceState { + const healthState = node.service?.health?.state?.toLowerCase(); + if (healthState === "healthy") return "healthy"; + if (healthState === "degraded") return "degraded"; + if (healthState === "unhealthy") return "offline"; + + const candidates = [node.service?.status, node.status] + .map((value) => value?.toLowerCase()) + .filter((value): value is string => Boolean(value)); + + for (const value of candidates) { + if (value.includes("degraded") || value.includes("failed") || value.includes("error")) return "degraded"; + if ( + value.includes("offline") || + value.includes("stopped") || + value.includes("dead") || + value.includes("down") || + value.includes("exited") || + value.includes("missing") + ) { + return "offline"; + } + if (value.includes("warning") || value.includes("paused")) return "warning"; + if (value.includes("starting") || value.includes("restarting") || value.includes("pending") || value.includes("loading")) { + return "updating"; + } + if ( + value.includes("healthy") || + value.includes("running") || + value.includes("active") || + value.includes("online") || + value.includes("available") || + value.includes("attached") || + value.includes("ready") || + value.includes("connected") + ) { + return "healthy"; + } + } + + return "unknown"; +} + +function runtimeStateRank(state: ServiceState): number { + const order = { offline: 0, degraded: 1, warning: 2, updating: 3, unknown: 4, healthy: 5 }; + return order[state]; +} diff --git a/apps/web/src/screens/Home.tsx b/apps/web/src/screens/Home.tsx index a4e3349..a5adbfc 100644 --- a/apps/web/src/screens/Home.tsx +++ b/apps/web/src/screens/Home.tsx @@ -85,6 +85,35 @@ export default function Home() { {}} interactive={false} height={260} /> + + Open Runtime Map + + } + > +
+
+ {model.runtime.summary.totalNodes} + runtime nodes +
+
+ {model.runtime.summary.attention} + need attention +
+
+
+ {model.runtime.providerSummary.slice(0, 6).map((bucket) => ( + + {bucket.id} {bucket.count} + + ))} +
+
+ {changes.length === 0 ? ( diff --git a/apps/web/src/screens/Runtime.tsx b/apps/web/src/screens/Runtime.tsx new file mode 100644 index 0000000..178f033 --- /dev/null +++ b/apps/web/src/screens/Runtime.tsx @@ -0,0 +1,358 @@ +import { useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import type { RuntimeProviderKind } from "@dockermap/contracts"; +import { useApp } from "../context"; +import { needsAttention, type RuntimeLayerId, type RuntimeNodeRecord } from "../lib/model"; +import { formatRelative } from "../lib/format"; +import Icon, { type IconName } from "../components/Icon"; +import { EmptyState, ErrorState, KeyValue, Loading, Metric, Panel, StateDot, StatePill, Tag } from "../components/primitives"; + +const PROVIDER_ICON: Record = { + docker: "service", + compose: "compose", + host: "cpu", + systemd: "layers", + scheduled_job: "history", + npm: "image", + pm2: "spark", + tmux: "command", + tailscale: "network", + headscale: "network", + cloudflare: "shield", + caddy: "proxy", + reverse_proxy: "proxy", + local_dns: "network", + dns_provider: "network", + external_api: "external", + process: "worker", + network: "link", + kubernetes: "storage", + other: "service" +}; + +const LAYER_LABEL: Record = { + advisory: "Advisory", + container: "Container", + edge: "Edge", + host: "Host", + network: "Network", + package: "Package", + process: "Process", + service: "Service", + session: "Session", + storage: "Storage", + unassigned: "Unassigned" +}; + +export default function RuntimeScreen() { + const { model, loading, error } = useApp(); + const [providerFilter, setProviderFilter] = useState("all"); + const [layerFilter, setLayerFilter] = useState("all"); + const [attentionOnly, setAttentionOnly] = useState(false); + const [selectedId, setSelectedId] = useState(null); + + if (loading && !model) return ; + if (error && !model) return ; + if (!model) return ; + + const runtime = model.runtime; + + const filteredNodes = useMemo(() => { + return runtime.nodes.filter((node) => { + if (providerFilter !== "all" && node.provider !== providerFilter) return false; + if (layerFilter !== "all" && node.layer !== layerFilter) return false; + if (attentionOnly && !needsAttention(node.state)) return false; + return true; + }); + }, [attentionOnly, layerFilter, providerFilter, runtime.nodes]); + + const selected = (selectedId ? runtime.byId.get(selectedId) : null) ?? filteredNodes[0] ?? null; + const selectedDetailUrl = selected && model.byName.has(selected.label) ? `/services/${encodeURIComponent(selected.label)}` : null; + + return ( +
+
+
+
Provider-neutral topology
+

Runtime Map

+
+
+ + + {runtime.providerSummary.map((bucket) => ( + + ))} +
+
+ +
+ + + + {runtime.summary.attention}} + /> + +
+ +
+ +
+ + {runtime.layerSummary.map((bucket) => ( + + ))} +
+
+ + + {runtime.diagnostics.length === 0 ? ( + + ) : ( +
    + {runtime.diagnostics.map((diagnostic, index) => ( +
  • + + {diagnostic.provider} + + {diagnostic.message} +
  • + ))} +
+ )} +
+
+ +
+
+ + {filteredNodes.length === 0 ? ( + + ) : ( +
    + {filteredNodes.map((node) => ( +
  • + +
  • + ))} +
+ )} +
+
+ + +
+
+ ); +} + +function RelationList({ + title, + selected, + model, + edges, + direction, + onSelect +}: { + title: string; + selected: RuntimeNodeRecord; + model: NonNullable["model"]>; + edges: RuntimeNodeRecord["incoming"]; + direction: "incoming" | "outgoing"; + onSelect: (id: string) => void; +}) { + return ( +
+

{title}

+ {edges.length === 0 ? ( +

No {direction} relationships in the current snapshot.

+ ) : ( +
    + {edges.map((edge, index) => { + const targetId = direction === "outgoing" ? edge.target : edge.source; + const node = model.runtime.byId.get(targetId); + if (!node) { + return ( +
  • + {edge.relationship.replaceAll("_", " ")} + {targetId} +
  • + ); + } + + return ( +
  • + + {edge.relationship.replaceAll("_", " ")} +
  • + ); + })} +
+ )} +
+ ); +} + +function formatMetadataValue(value: string | number | boolean | null) { + if (value === null) return "null"; + return String(value); +} diff --git a/apps/web/src/screens/Settings.tsx b/apps/web/src/screens/Settings.tsx index 9babfa2..dc2941f 100644 --- a/apps/web/src/screens/Settings.tsx +++ b/apps/web/src/screens/Settings.tsx @@ -9,6 +9,7 @@ import Icon from "../components/Icon"; const ROUTE_OPTIONS: { value: string; label: string }[] = [ { value: "/", label: "Home" }, { value: "/map", label: "Service Map" }, + { value: "/runtime", label: "Runtime" }, { value: "/changes", label: "Changes" }, { value: "/copilot", label: "Copilot" }, { value: "/networking", label: "Networking" }, diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 50446aa..53c0b68 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -735,6 +735,166 @@ kbd { color: var(--accent); } +.runtime-summary-grid { + align-items: start; +} + +.diag-list, +.runtime-node-list, +.runtime-edge-list, +.runtime-evidence-list { + list-style: none; + margin: 0; + padding: 0; +} + +.diag-list, +.runtime-node-list, +.runtime-evidence-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.diag-row { + display: grid; + grid-template-columns: 140px 1fr; + gap: var(--s3); + align-items: start; + padding: var(--s3); + border-radius: var(--r-sm); + border: 1px solid var(--border); + background: var(--surface-2); +} + +.diag-row.sev-warning { + border-color: color-mix(in srgb, var(--warn) 35%, var(--border)); +} + +.diag-row.sev-error, +.diag-row.sev-blocked { + border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); +} + +.diag-provider { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted-deep); +} + +.diag-message { + font-size: 13px; + color: var(--ink-soft); +} + +.runtime-layout { + align-items: start; +} + +.runtime-node-btn, +.runtime-edge-target { + width: 100%; + border: 1px solid var(--border); + background: var(--surface); + color: inherit; + border-radius: var(--r-sm); + transition: border var(--t), background var(--t), color var(--t); +} + +.runtime-node-btn { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s3); + padding: 10px 12px; +} + +.runtime-node-btn:hover, +.runtime-edge-target:hover { + border-color: var(--border-strong); + background: var(--surface-2); +} + +.runtime-node-btn.is-active { + border-color: color-mix(in srgb, var(--accent) 46%, var(--border)); + background: var(--accent-soft); +} + +.runtime-node-main, +.runtime-node-copy { + display: flex; + align-items: center; + gap: var(--s3); +} + +.runtime-node-copy { + align-items: flex-start; + flex-direction: column; + gap: 2px; + text-align: left; +} + +.runtime-node-label { + font-weight: 600; + color: var(--ink); +} + +.runtime-node-meta { + font-size: 12px; + color: var(--muted); + text-transform: capitalize; +} + +.runtime-edge-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.runtime-edge-row { + display: flex; + align-items: center; + gap: var(--s2); +} + +.runtime-edge-target { + display: inline-flex; + align-items: center; + gap: var(--s2); + width: auto; + max-width: 100%; + padding: 7px 10px; +} + +.runtime-edge-target span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.runtime-evidence-list li { + display: flex; + align-items: center; + gap: var(--s2); + flex-wrap: wrap; + font-size: 13px; +} + +.runtime-evidence-time { + color: var(--muted); + font-size: 12px; + margin-left: auto; +} + +.runtime-meta-stack { + gap: var(--s2); +} + /* Feed -----------------------------------------------------------------------*/ .feed { list-style: none; diff --git a/docs/README.md b/docs/README.md index 3c978bb..9014bc8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,6 +35,8 @@ Working now: - Runtime-map providers for systemd, cron, PM2, tmux, listening sockets, Tailscale, Headscale, reverse-proxy markers, local DNS markers, and Docker graph nodes when those tools are available. +- A Runtime Map page in the web app that consumes `/api/runtime/map` and shows + provider diagnostics, layer filters, and cross-provider relationships. - Shared Rust and TypeScript contract fixtures. - API security tests and Playwright smoke tests. diff --git a/docs/deployment/DEPLOYMENT.md b/docs/deployment/DEPLOYMENT.md index f22577a..2e2be16 100644 --- a/docs/deployment/DEPLOYMENT.md +++ b/docs/deployment/DEPLOYMENT.md @@ -155,6 +155,15 @@ DOCKERMAP_SMOKE_URL=https://dockermap.example.com ./scripts/smoke-deploy.sh The proxy check should work without exporting `DOCKERMAP_API_TOKEN` if the proxy injects the token server-side. +The smoke script currently verifies: + +- `/api/health` returns `200`. +- Protected API routes return `401` without a token when `DOCKERMAP_API_TOKEN` + is provided locally. +- `/api/snapshot`, `/api/runtime/map`, and `/api/compose/scan` return `200` + with the expected auth path. +- `/api/events/stream` emits at least one `snapshot` SSE event. + ## Draft Deployment Definition Of Done - `dockermap-daemon` and `dockermap-api` are running under systemd. @@ -162,6 +171,8 @@ the token server-side. - The web UI loads over HTTPS. - `/api/health`, `/api/snapshot`, `/api/runtime/map`, and `/api/compose/scan` pass smoke checks through the proxy. +- `/api/events/stream` stays live through the proxy without buffering away the + event stream. - Viewer authentication is enabled at the proxy. - `DOCKERMAP_API_TOKEN` is set and non-health API routes reject direct unauthenticated requests. diff --git a/docs/deployment/REVERSE_PROXY.md b/docs/deployment/REVERSE_PROXY.md index 52e39d9..44d0484 100644 --- a/docs/deployment/REVERSE_PROXY.md +++ b/docs/deployment/REVERSE_PROXY.md @@ -113,10 +113,16 @@ After the proxy is up, check these from a browser or from `curl`: - `/api/health` returns JSON without needing a bearer token. - `/api/snapshot` works through the proxy. +- `/api/runtime/map` works through the proxy. - `/api/compose/scan` shows Compose files and mount checks. - `/api/events/stream` stays connected for live updates. - Direct access to `127.0.0.1:4100` is not possible from outside the host. +`scripts/smoke-deploy.sh` can cover the route checks above. When you export +`DOCKERMAP_API_TOKEN`, it also verifies that direct non-health API access +returns `401` without the bearer token before retrying the protected routes +with auth. + ## Do Not Do This - Do not publish the Rust daemon directly to the internet. diff --git a/docs/testing/TESTING_PLAN.md b/docs/testing/TESTING_PLAN.md index c9d4cfc..d2e8de5 100644 --- a/docs/testing/TESTING_PLAN.md +++ b/docs/testing/TESTING_PLAN.md @@ -185,7 +185,7 @@ Use this after larger changes or before a demo: 2. Open `http://127.0.0.1:3233`. 3. Check the main pages: dashboard, containers, images, networks, volumes, logs, and - Compose. + Compose, plus the Runtime Map page. 4. Check these API routes: @@ -235,6 +235,10 @@ For a review UI on another machine: More detail is in [docs/deployment/REVERSE_PROXY.md](../deployment/REVERSE_PROXY.md). +For the local host-side API path, `scripts/smoke-deploy.sh` now checks `/api/health`, +the protected JSON routes, bearer-token `401` behavior when a token is supplied, and a +live `/api/events/stream` snapshot event. + ## Gaps To Close - Broaden browser end-to-end tests beyond the current smoke flows. diff --git a/scripts/smoke-deploy.sh b/scripts/smoke-deploy.sh index c2a42f4..6fcf10e 100755 --- a/scripts/smoke-deploy.sh +++ b/scripts/smoke-deploy.sh @@ -4,37 +4,92 @@ set -euo pipefail BASE_URL="${DOCKERMAP_SMOKE_URL:-http://127.0.0.1:4000}" TOKEN="${DOCKERMAP_API_TOKEN:-}" +TMP_BODY="$(mktemp -t dockermap-smoke-body.XXXXXX)" +TMP_STREAM="$(mktemp -t dockermap-smoke-stream.XXXXXX)" -curl_json() { +cleanup() { + rm -f "$TMP_BODY" "$TMP_STREAM" +} + +trap cleanup EXIT + +require_http_200() { local path="$1" local status - status="$(curl -fsS -o /tmp/dockermap-smoke.json -w "%{http_code}" "$BASE_URL$path")" + shift + status="$(curl -fsS -o "$TMP_BODY" -w "%{http_code}" "$@" "$BASE_URL$path")" if [[ "$status" != "200" ]]; then echo "Expected 200 for $path, got $status" >&2 - cat /tmp/dockermap-smoke.json >&2 || true + cat "$TMP_BODY" >&2 || true exit 1 fi } -curl_auth_json() { +require_http_status() { local path="$1" + local expected="$2" + shift 2 local status - local args=(-fsS -o /tmp/dockermap-smoke.json -w "%{http_code}") + status="$(curl -sS -o "$TMP_BODY" -w "%{http_code}" "$@" "$BASE_URL$path")" + if [[ "$status" != "$expected" ]]; then + echo "Expected $expected for $path, got $status" >&2 + cat "$TMP_BODY" >&2 || true + exit 1 + fi +} + +require_auth_json() { + local path="$1" + local -a args=() if [[ -n "$TOKEN" ]]; then args+=(-H "Authorization: Bearer $TOKEN") fi - status="$(curl "${args[@]}" "$BASE_URL$path")" - if [[ "$status" != "200" ]]; then - echo "Expected 200 for $path, got $status" >&2 - cat /tmp/dockermap-smoke.json >&2 || true + require_http_200 "$path" "${args[@]}" +} + +check_sse() { + local path="$1" + local -a args=(--no-buffer --max-time 10 -sS) + local curl_status + if [[ -n "$TOKEN" ]]; then + args+=(-H "Authorization: Bearer $TOKEN") + fi + + set +e + curl "${args[@]}" "$BASE_URL$path" >"$TMP_STREAM" + curl_status="$?" + set -e + + if [[ "$curl_status" != "0" && "$curl_status" != "28" ]]; then + echo "Expected SSE stream for $path, curl exited with $curl_status" >&2 + cat "$TMP_STREAM" >&2 || true + exit 1 + fi + + if ! grep -q '^event: snapshot$' "$TMP_STREAM"; then + echo "Expected SSE snapshot event for $path" >&2 + cat "$TMP_STREAM" >&2 || true + exit 1 + fi + + if ! grep -q '^data: ' "$TMP_STREAM"; then + echo "Expected SSE data payload for $path" >&2 + cat "$TMP_STREAM" >&2 || true exit 1 fi } echo "[dockermap] smoke target: $BASE_URL" -curl_json "/api/health" -curl_auth_json "/api/snapshot" -curl_auth_json "/api/runtime/map" -curl_auth_json "/api/compose/scan" +require_http_200 "/api/health" + +if [[ -n "$TOKEN" ]]; then + echo "[dockermap] verifying protected routes reject unauthenticated direct access" + require_http_status "/api/snapshot" "401" +fi + +require_auth_json "/api/snapshot" +require_auth_json "/api/runtime/map" +require_auth_json "/api/compose/scan" +check_sse "/api/events/stream" echo "[dockermap] smoke checks passed" diff --git a/tests/e2e/dockermap.spec.ts b/tests/e2e/dockermap.spec.ts index 6ac267a..ecf1f76 100644 --- a/tests/e2e/dockermap.spec.ts +++ b/tests/e2e/dockermap.spec.ts @@ -29,6 +29,7 @@ test.describe("DockerMap GUI", () => { const spaces = [ ["Service Map", "/map", "Service Map"], + ["Runtime", "/runtime", "Runtime Map"], ["Changes", "/changes", "Change Center"], ["Copilot", "/copilot", "Copilot"], ["Networking", "/networking", "Networking"], diff --git a/tests/e2e/dockermapHarness.ts b/tests/e2e/dockermapHarness.ts index 0e2cd6d..5dd49e0 100644 --- a/tests/e2e/dockermapHarness.ts +++ b/tests/e2e/dockermapHarness.ts @@ -138,7 +138,11 @@ function freePort(): Promise { async function ensureDaemonBinary() { const result = spawnSync("cargo", ["build", "--manifest-path", "crates/Cargo.toml", "-p", "dockermap-daemon"], { cwd: repoRoot, - env: { ...process.env, PATH: `${process.env.HOME}/.cargo/bin:${process.env.PATH}` }, + env: { + ...process.env, + PATH: `${process.env.HOME}/.cargo/bin:${process.env.PATH}`, + CARGO_INCREMENTAL: "0" + }, encoding: "utf8" });