diff --git a/.gitignore b/.gitignore index 6fb8237..cbfe31a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ /target .cursor/ __pycache__/ + +# Dev stack runtime state created by scripts/up.sh +.run/ +config.toml +certs/ diff --git a/dashboard/.gitignore b/dashboard/.gitignore new file mode 100644 index 0000000..5a5c84b --- /dev/null +++ b/dashboard/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +test-results/ +traces-*.json diff --git a/dashboard/README.md b/dashboard/README.md new file mode 100644 index 0000000..bc0711c --- /dev/null +++ b/dashboard/README.md @@ -0,0 +1,57 @@ +# Agent Gateway Dashboard + +A standalone web server that visualizes an agent gateway deployment in real time. It ingests OpenTelemetry log records from the gateway and surrounding services, and queries the authority registry directly for snapshots of registered humans, devices, and agents. + +The dashboard is owned by this repo. The backend it visualizes (gateway, platform, registry, agents) lives elsewhere and is the contract surface described below. + +## What it does + +- Serves the dashboard UI (`public/index.html`, `public/topology.js`, `registry-panel.js`). +- Accepts OTLP/HTTP+JSON log records on `POST /v1/logs` and pushes them to connected browsers over a WebSocket. Recent events are kept in an in-memory ring buffer (`MAX_EVENTS = 1000`). +- Proxies `GET /api/registry` and `GET /api/devices` to an upstream authority registry (`REGISTRY_URL`). The browser uses these for the registry panel and the topology's user-device discovery. + +## Backend contract + +The dashboard expects the backend to provide: + +| Surface | What the dashboard does with it | +|---------|---------------------------------| +| OTLP push to `POST /v1/logs` | Live event feed: gateway decisions (`request.allowed` / `request.denied` / `auth.allowed` / `tunnel.error`), platform lifecycle (`agent.created` / `agent.terminated`), user-device actions (`agent.requested` / `prompt.sent` / `permission.change_requested` / `termination.requested`). | +| `GET /snapshot` on the registry (proxied as `/api/registry`) | Topology + registry panel: humans, agents, permission grants. | +| `GET /users` on the registry (proxied as `/api/users`) | User nodes in the topology. | + +The OTLP shape is plain HTTP/JSON (no gRPC). `translate.js` parses the standard OTLP `resourceLogs` envelope and lifts each `LogRecord`'s attributes into a flat event the browser consumes. + +## Configuration + +| Env var | Default | Purpose | +|---------|---------|---------| +| `REGISTRY_URL` | `http://authority-registry:8080` | Upstream registry. The two `/api/...` endpoints proxy to this host. | + +The server binds `0.0.0.0:3000`. + +## Running + +```bash +npm install +npm start # node server.js +npm test # node --test +``` + +Open `http://localhost:3000`. With no events flowing in and no upstream registry reachable, the topology will show only the static infrastructure nodes and the registry panel will display an error. + +## Layout + +``` +. +├── server.js # HTTP + WebSocket server, OTLP ingest, registry proxy +├── translate.js # OTLP -> flat-event translation +├── translate.test.js +├── registry-panel.js # Browser-side renderer for the registry panel +├── registry-panel.test.js +├── theme.css # Shared color tokens +├── public/ +│ ├── index.html # Dashboard shell +│ └── topology.js # Topology canvas, edge highlighting, node placement +└── package.json +``` diff --git a/dashboard/otel-collector.yaml b/dashboard/otel-collector.yaml new file mode 100644 index 0000000..8207908 --- /dev/null +++ b/dashboard/otel-collector.yaml @@ -0,0 +1,37 @@ +# OpenTelemetry Collector config used by the dashboard's docker-compose +# service. It bridges the gateway (which emits OTLP gRPC) and the dashboard +# (which accepts OTLP HTTP/JSON on /v1/traces). + +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + +processors: + batch: + # Small flush window so the dashboard sees events within ~1s of the + # gateway emitting them. + timeout: 1s + send_batch_size: 50 + +exporters: + otlphttp/dashboard: + # The collector runs in docker; the dashboard runs on the host. Reach + # it via the dockerized host alias. The exporter appends /v1/traces + # automatically, so this matches POST http://:3000/v1/traces. + endpoint: http://host.docker.internal:3000 + encoding: json + tls: + insecure: true + +service: + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [otlphttp/dashboard] + + telemetry: + logs: + level: info diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json new file mode 100644 index 0000000..979e039 --- /dev/null +++ b/dashboard/package-lock.json @@ -0,0 +1,183 @@ +{ + "name": "dashboard", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dashboard", + "version": "1.0.0", + "dependencies": { + "pg": "^8.20.0", + "ws": "^8.19.0" + } + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 0000000..3970dc0 --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,13 @@ +{ + "name": "dashboard", + "version": "1.0.0", + "private": true, + "scripts": { + "start": "node server.js", + "test": "node --test" + }, + "dependencies": { + "pg": "^8.20.0", + "ws": "^8.19.0" + } +} diff --git a/dashboard/public/index.html b/dashboard/public/index.html new file mode 100644 index 0000000..7f695a7 --- /dev/null +++ b/dashboard/public/index.html @@ -0,0 +1,924 @@ + + + + + +Agent Gateway Dashboard + + + + +
+
+ + +
+ + +
+ Organization Network +
+ +
+
+ + Agent Cluster +
+
+ +
+ Infrastructure +
+ +
+
+ The agent-platform handles lifecycle and connects each agent to the gateway. + Agent runtimes use Claude Code as the agent framework. + Inference requests tunnel through the gateway directly to api.anthropic.com. +
+
+ + +
+ + +
+ Users + + +
+ Example Services + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+
+
+
+ Dashboard Status: + CONNECTING +
+ +
+
+
+
Authority Registry
+
+
Loading…
+
+
+
+
+
Audit Log
+
+
+
+
Gateway Decisions
+
+
+
+ + + + + + + diff --git a/dashboard/public/topology.js b/dashboard/public/topology.js new file mode 100644 index 0000000..93febc3 --- /dev/null +++ b/dashboard/public/topology.js @@ -0,0 +1,632 @@ +// ============================================================ +// Topology module — extracted from index.html +// Exposes window.Topology with all topology state and functions. +// ============================================================ +(function() { + 'use strict'; + + // ============================================================ + // SVG icon paths + // ============================================================ + const ICONS = { + shield: '', + containers: { viewBox: '-1 -1 26 27', path: '' }, + robot: '', + question: '', + monitor: '', + user: '', + book: '', + chat: '', + lock: '', + document: '', + gear: { viewBox: '2 -1.5 20 19', path: '' }, + }; + + function iconSvg(name, size) { + const icon = ICONS[name]; + const isObj = typeof icon === 'object'; + const vb = isObj ? icon.viewBox : '0 0 24 24'; + const path = isObj ? icon.path : icon; + return '' + path + ''; + } + + // ============================================================ + // HTML escaping + // ============================================================ + const _escapeEl = document.createElement('span'); + function escapeHtml(str) { + _escapeEl.textContent = str; + return _escapeEl.innerHTML; + } + function esc(str) { + return escapeHtml(String(str)); + } + + // ============================================================ + // Static node definitions (non-agent-runtime nodes are always present) + // ============================================================ + const STATIC_NODE_DEFS = [ + { id: 'node-docstore', icon: 'book', label: 'docstore', classes: 'org-node example-service' }, + { id: 'node-messaging', icon: 'chat', label: 'messaging', classes: 'org-node example-service' }, + { id: 'node-agent-gateway', icon: 'shield', label: 'agent-gateway', classes: 'gateway' }, + { id: 'node-agent-platform', icon: 'gear', label: 'agent-platform', classes: 'agent' }, + { id: 'node-authority-registry', icon: 'lock', label: 'authority-registry', classes: 'infrastructure-node infra-item' }, + ]; + + // Dynamic user device nodes - tracked by device_id + // Map: device_id -> { id: dom_id, label: device_id } + const users = new Map(); + let userNodeCounter = 0; + + // Dynamic agent runtime nodes - tracked by agent_id + // Map: agent_id -> { id: dom_id, label: agent_id } + const agentRuntimes = new Map(); + let agentNodeCounter = 0; + + // State: recently active edges (for highlighting) + const activeEdges = new Set(); + const deniedEdges = new Set(); + + // ============================================================ + // Layout — named grid system + // ============================================================ + + // Canvas dimensions + const CANVAS = { width: 828, height: 520 }; + + // Zone rectangles + const ZONES = { + agentCluster: { left: 30, top: 15, width: 288, height: 390 }, + orgNetwork: { left: 358, top: 15, width: 440, height: 390 }, + infra: { left: 30, top: 420, width: 288, height: 90 }, + notes: { left: 358, top: 420, width: 440, height: 90 }, + }; + + // Named columns (x positions for node icon centers) + const COL = { + agentRuntime: 102, // left side of agent cluster + agentCluster: 222, // agent-platform + gateway: 338, // gap between zones + orgCore: 554, // user devices + exampleServices: 726, // docstore, messaging + }; + + // Named rows (y positions for node icon centers) + const ROW = { + center: 210, // gateway (org zone vertical center) + agentPlatform: 308, // agent-platform (halfway between center and zone bottom) + users: 325, // users row + exServiceTop: 105, // example services distribute range top + exServiceBottom: 325, // example services distribute range bottom + }; + + // Icon, font, and spacing sizes + const SIZES = { + icon: 56, iconRadius: 14, svgIcon: 22, + gatewayIcon: 80, gatewayRadius: 20, gatewaySvg: 34, + infraIcon: 44, infraRadius: 11, infraSvg: 18, + + labelFont: 12, + agentLabelFont: 10, + infraLabelFont: 10, + zoneLabelFont: 11, + subZoneLabelFont: 10, + externalEdgeLabelFont: 10, + + nodeGap: 8, + verticalSpacing: 140, + subZonePad: 16, + subZoneLabelGap: 16, + runtimeSubzonePad: 12, + runtimeNodeInset: 10, + userZoneWidth: 176, // round(440 * 2/5) + userVerticalOffset: 6, + externalEdgeTopOffset: 5, + }; + + // ============================================================ + // DOM creation for nodes + // ============================================================ + function createNodeElement(def) { + const icon = ICONS[def.icon]; + const isObj = typeof icon === 'object'; + const vb = isObj ? icon.viewBox : '0 0 24 24'; + const path = isObj ? icon.path : icon; + const baseAttrs = 'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'; + const div = document.createElement('div'); + div.className = 'node ' + def.classes; + div.id = def.id; + div.innerHTML = + '
' + path + '
' + + '
' + escapeHtml(def.label) + '
'; + return div; + } + + function createStaticNodes() { + const container = document.getElementById('nodes-container'); + for (let i = 0; i < STATIC_NODE_DEFS.length; i++) { + container.appendChild(createNodeElement(STATIC_NODE_DEFS[i])); + } + document.getElementById('ap-header-icon').innerHTML = iconSvg('containers', 14); + } + + function addAgentRuntimeNode(agentId) { + if (agentRuntimes.has(agentId)) return; + + agentNodeCounter++; + const domId = 'node-agent-' + agentNodeCounter; + const shortId = agentId.length > 16 ? agentId.slice(0, 16) : agentId; + + agentRuntimes.set(agentId, { id: domId, label: shortId }); + + const container = document.getElementById('nodes-container'); + const def = { id: domId, icon: 'robot', label: shortId, classes: 'agent agent-runtime' }; + const el = createNodeElement(def); + container.appendChild(el); + + // Apply size to the new node + applyNodeSize(el); + + // Recompute layout and redraw + reLayout(); + } + + function removeAgentRuntimeNode(agentId) { + const info = agentRuntimes.get(agentId); + if (!info) return; + + const el = document.getElementById(info.id); + if (el) el.remove(); + agentRuntimes.delete(agentId); + + reLayout(); + } + + function addUserNode(userId) { + if (users.has(userId)) return; + + userNodeCounter++; + const domId = 'node-' + userId; + + users.set(userId, { id: domId, label: userId }); + + const container = document.getElementById('nodes-container'); + const def = { id: domId, icon: 'user', label: userId, classes: 'org-node user-node' }; + const el = createNodeElement(def); + container.appendChild(el); + + applyNodeSize(el); + reLayout(); + } + + function removeUserNode(userId) { + const info = users.get(userId); + if (!info) return; + + const el = document.getElementById(info.id); + if (el) el.remove(); + users.delete(userId); + + reLayout(); + } + + // ============================================================ + // Sizing + // ============================================================ + function applyNodeSize(node) { + node.style.gap = SIZES.nodeGap + 'px'; + const icon = node.querySelector('.node-icon'); + const label = node.querySelector('.node-label'); + const svgEl = icon.querySelector('svg'); + + if (node.classList.contains('gateway')) { + setIconSize(icon, svgEl, SIZES.gatewayIcon, SIZES.gatewayRadius, SIZES.gatewaySvg); + label.style.fontSize = SIZES.labelFont + 'px'; + } else if (node.classList.contains('infra-item')) { + setIconSize(icon, svgEl, SIZES.infraIcon, SIZES.infraRadius, SIZES.infraSvg); + label.style.fontSize = SIZES.infraLabelFont + 'px'; + } else { + setIconSize(icon, svgEl, SIZES.icon, SIZES.iconRadius, SIZES.svgIcon); + label.style.fontSize = node.classList.contains('agent') + ? SIZES.agentLabelFont + 'px' + : SIZES.labelFont + 'px'; + } + } + + function applySizes() { + const canvas = document.getElementById('canvas'); + canvas.style.width = CANVAS.width + 'px'; + canvas.style.height = CANVAS.height + 'px'; + + const svg = document.getElementById('edges'); + svg.style.width = CANVAS.width + 'px'; + svg.style.height = CANVAS.height + 'px'; + + setRect('zone-org', ZONES.orgNetwork); + setRect('zone-ap', ZONES.agentCluster); + setRect('zone-infra', ZONES.infra); + setRect('zone-notes', ZONES.notes); + + document.querySelectorAll('.zone-label').forEach(function(el) { + el.style.fontSize = SIZES.zoneLabelFont + 'px'; + }); + + document.querySelectorAll('.node').forEach(applyNodeSize); + } + + function setIconSize(icon, svgEl, size, radius, svgSize) { + icon.style.width = size + 'px'; + icon.style.height = size + 'px'; + icon.style.borderRadius = radius + 'px'; + svgEl.setAttribute('width', svgSize); + svgEl.setAttribute('height', svgSize); + } + + function setRect(id, r) { + const el = document.getElementById(id); + el.style.left = r.left + 'px'; + el.style.top = r.top + 'px'; + el.style.width = r.width + 'px'; + el.style.height = r.height + 'px'; + } + + // ============================================================ + // Layout computation + // ============================================================ + function computeLayout() { + const ap = ZONES.agentCluster; + const infra = ZONES.infra; + + // Static node positions from the named grid + placeNodeCenter('node-agent-gateway', COL.gateway, ROW.center); + placeNodeCenter('node-agent-platform', COL.agentCluster, ROW.agentPlatform); + + // User devices subzone + const udNodes = Array.from(document.querySelectorAll('.user-node')); + const sampleUd = udNodes[0]; + const udNodeW = sampleUd ? sampleUd.offsetWidth : SIZES.icon; + const udNodeH = sampleUd ? sampleUd.offsetHeight : SIZES.icon + SIZES.nodeGap + SIZES.labelFont; + const udIconH = sampleUd ? sampleUd.querySelector('.node-icon').offsetHeight : SIZES.icon; + const udZoneW = SIZES.userZoneWidth; + const udZonePad = SIZES.subZonePad; + const udZoneH = udNodeH + udZonePad; + const udZoneLeft = COL.orgCore - udZoneW / 2; + const udZoneTop = ROW.users - udIconH / 2 - udZonePad + SIZES.userVerticalOffset; + const udSz = document.getElementById('subzone-users'); + udSz.style.left = Math.round(udZoneLeft) + 'px'; + udSz.style.top = Math.round(udZoneTop) + 'px'; + udSz.style.width = udZoneW + 'px'; + udSz.style.height = udZoneH + 'px'; + + const udNodeInset = SIZES.runtimeNodeInset; + const udLeftInset = udNodeW / 2 + udNodeInset; + const udRightInset = udNodeW / 2 + udNodeInset; + distributeHorizontally(udNodes, ROW.users, + Math.round(udZoneLeft) + udLeftInset, + Math.round(udZoneLeft) + udZoneW - udRightInset); + + const udLabel = document.getElementById('label-users'); + udLabel.style.fontSize = SIZES.subZoneLabelFont + 'px'; + udLabel.style.left = Math.round(udZoneLeft + udZoneW / 2) + 'px'; + udLabel.style.transform = 'translateX(-50%)'; + udLabel.style.top = Math.round(udZoneTop - SIZES.subZoneLabelGap) + 'px'; + + // Example services (distributed vertically) + const exampleServiceNodes = Array.from(document.querySelectorAll('.example-service')); + distributeVertically(exampleServiceNodes, COL.exampleServices, ROW.center, + ROW.exServiceTop, ROW.exServiceBottom); + positionSubZone(exampleServiceNodes, 'subzone-example-services', 'label-example-services'); + + // Agent runtime subzone + const runtimeNodes = Array.from(document.querySelectorAll('.agent-runtime')); + const sampleRuntime = runtimeNodes[0]; + const runtimeW = sampleRuntime ? sampleRuntime.offsetWidth : 60; + const apHeader = document.querySelector('#zone-ap .zone-header'); + const headerH = apHeader ? apHeader.offsetHeight : 0; + const rzPadding = SIZES.runtimeSubzonePad; + const rzTop = ap.top + headerH + rzPadding; + const rzHeight = ap.height - headerH - rzPadding * 2; + const sz = document.getElementById('subzone-agent-runtimes'); + + const szLeft = COL.agentRuntime - runtimeW / 2 - SIZES.subZonePad; + sz.style.display = ''; + sz.style.left = szLeft + 'px'; + sz.style.top = rzTop + 'px'; + sz.style.width = (runtimeW + SIZES.subZonePad * 2) + 'px'; + sz.style.height = rzHeight + 'px'; + + if (runtimeNodes.length > 0) { + const sampleIconH = sampleRuntime.querySelector('.node-icon').offsetHeight; + const sampleNodeH = sampleRuntime.offsetHeight; + const nodeInsetPad = SIZES.runtimeNodeInset; + const topInset = sampleIconH / 2 + nodeInsetPad; + const bottomInset = sampleNodeH - sampleIconH / 2 + nodeInsetPad; + distributeVertically(runtimeNodes, COL.agentRuntime, ROW.center, + rzTop + topInset, rzTop + rzHeight - bottomInset); + } + + // Infrastructure nodes + const infraNodes = Array.from(document.querySelectorAll('.infra-item')); + const infraColumnXs = [COL.agentCluster, COL.gateway]; + const sampleInfra = infraNodes[0]; + if (sampleInfra) { + const infraNodeH = sampleInfra.offsetHeight; + const infraIconH = sampleInfra.querySelector('.node-icon').offsetHeight; + const infraCY = infra.top + (infra.height - infraNodeH) / 2 + infraIconH / 2; + infraNodes.forEach(function(node, i) { + placeNodeCenter(node.id, infraColumnXs[i % infraColumnXs.length], infraCY); + }); + } + } + + function placeNodeCenter(id, cx, cy) { + const el = document.getElementById(id); + if (!el) return; + const iconH = el.querySelector('.node-icon').offsetHeight; + const nodeW = el.offsetWidth; + el.style.left = Math.round(cx - nodeW / 2) + 'px'; + el.style.top = Math.round(cy - iconH / 2) + 'px'; + } + + function distributeVertically(nodes, centerX, midY, minY, maxY) { + const n = nodes.length; + if (n === 0) return; + if (n === 1) { placeNodeCenter(nodes[0].id, centerX, midY); return; } + const span = Math.min(maxY - minY, (n - 1) * SIZES.verticalSpacing); + const startY = midY - span / 2; + const step = span / (n - 1); + for (let i = 0; i < n; i++) { + placeNodeCenter(nodes[i].id, centerX, startY + i * step); + } + } + + /** + * Distribute nodes horizontally, centered on midX at a fixed centerY. + * minX/maxX define the allowed range for node icon centers. + */ + function distributeHorizontally(nodes, centerY, minX, maxX) { + const n = nodes.length; + if (n === 0) return; + const midX = (minX + maxX) / 2; + if (n === 1) { placeNodeCenter(nodes[0].id, midX, centerY); return; } + const span = maxX - minX; + const step = span / (n - 1); + for (let i = 0; i < n; i++) { + placeNodeCenter(nodes[i].id, minX + i * step, centerY); + } + } + + function positionSubZone(nodes, subZoneId, labelId) { + if (nodes.length === 0) return; + const pad = SIZES.subZonePad; + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + nodes.forEach(function(n) { + const l = parseInt(n.style.left, 10); + const t = parseInt(n.style.top, 10); + minX = Math.min(minX, l); + maxX = Math.max(maxX, l + n.offsetWidth); + minY = Math.min(minY, t); + maxY = Math.max(maxY, t + n.offsetHeight); + }); + const szEl = document.getElementById(subZoneId); + const zoneLeft = minX - pad; + const zoneWidth = maxX - minX + pad * 2; + szEl.style.left = zoneLeft + 'px'; + szEl.style.top = (minY - pad) + 'px'; + szEl.style.width = zoneWidth + 'px'; + szEl.style.height = (maxY - minY + pad * 2) + 'px'; + + const labelEl = document.getElementById(labelId); + labelEl.style.fontSize = SIZES.subZoneLabelFont + 'px'; + labelEl.style.left = (zoneLeft + zoneWidth / 2) + 'px'; + labelEl.style.transform = 'translateX(-50%)'; + labelEl.style.top = (minY - pad - SIZES.subZoneLabelGap) + 'px'; + } + + // ============================================================ + // Edge drawing + // ============================================================ + function nodeIconRect(id) { + const el = document.getElementById(id); + if (!el) return null; + const icon = el.querySelector('.node-icon'); + return { + cx: el.offsetLeft + icon.offsetLeft + icon.offsetWidth / 2, + cy: el.offsetTop + icon.offsetTop + icon.offsetHeight / 2, + halfW: icon.offsetWidth / 2, + halfH: icon.offsetHeight / 2 + }; + } + + function rectBorderPoint(cx, cy, halfW, halfH, tx, ty) { + const dx = tx - cx, dy = ty - cy; + if (dx === 0 && dy === 0) return { x: cx, y: cy }; + const t = Math.min( + dx !== 0 ? halfW / Math.abs(dx) : Infinity, + dy !== 0 ? halfH / Math.abs(dy) : Infinity + ); + return { x: cx + dx * t, y: cy + dy * t }; + } + + function drawEdge(fromId, toId, style) { + const from = nodeIconRect(fromId); + const to = nodeIconRect(toId); + if (!from || !to) return; + const p1 = rectBorderPoint(from.cx, from.cy, from.halfW, from.halfH, to.cx, to.cy); + const p2 = rectBorderPoint(to.cx, to.cy, to.halfW, to.halfH, from.cx, from.cy); + + const prefix = (style === 'active' || style === 'denied') ? style : 'default'; + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + line.setAttribute('x1', p1.x); + line.setAttribute('y1', p1.y); + line.setAttribute('x2', p2.x); + line.setAttribute('y2', p2.y); + line.setAttribute('class', style === 'default' ? 'edge' : 'edge ' + style); + line.setAttribute('marker-start', 'url(#arrow-' + prefix + '-start)'); + line.setAttribute('marker-end', 'url(#arrow-' + prefix + '-end)'); + document.getElementById('edges').appendChild(line); + } + + function drawExternalEdge(nodeId, label, key) { + const from = nodeIconRect(nodeId); + if (!from) return; + const startY = from.cy - from.halfH; + const endY = ZONES.orgNetwork.top + SIZES.externalEdgeTopOffset; + + const svg = document.getElementById('edges'); + const style = edgeStyle(key); + const prefix = (style === 'active' || style === 'denied') ? style : 'default'; + + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + line.setAttribute('x1', from.cx); + line.setAttribute('y1', startY); + line.setAttribute('x2', from.cx); + line.setAttribute('y2', endY); + line.setAttribute('class', style === 'default' ? 'edge' : 'edge ' + style); + line.setAttribute('marker-start', 'url(#arrow-' + prefix + '-start)'); + line.setAttribute('marker-end', 'url(#arrow-' + prefix + '-end)'); + svg.appendChild(line); + + // Place the label to the left of the arrow (which, after the -90° rotation, + // reads as "above" the arrow's direction of travel). + const labelX = from.cx - 10; + const labelY = (startY + endY) / 2; + const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + text.setAttribute('x', labelX); + text.setAttribute('y', labelY); + text.setAttribute('text-anchor', 'middle'); + text.setAttribute('dominant-baseline', 'central'); + text.setAttribute('transform', 'rotate(-90 ' + labelX + ' ' + labelY + ')'); + text.setAttribute('fill', 'currentColor'); + text.setAttribute('font-size', SIZES.labelFont + 'px'); + text.setAttribute('font-family', getComputedStyle(document.body).fontFamily); + text.setAttribute('font-weight', '500'); + text.textContent = label; + svg.appendChild(text); + } + + function edgeStyle(key) { + if (deniedEdges.has(key)) return 'denied'; + if (activeEdges.has(key)) return 'active'; + return 'default'; + } + + function buildEdgeList() { + const edges = []; + + // Devices connect directly to agent-platform over mTLS. The gateway only + // mediates outbound traffic from agents. + document.querySelectorAll('.user-node').forEach(function(ud) { + const name = ud.id.replace('node-', ''); + edges.push([ud.id, 'node-agent-platform', edgeStyle(name)]); + }); + for (let entry of agentRuntimes) { + const agentId = entry[0], info = entry[1]; + // Outbound: agent-proxy talks to the gateway (mTLS CONNECT). + edges.push([info.id, 'node-agent-gateway', edgeStyle(agentId)]); + // Control: platform spawns and drives the agent via the agent-host + // supervisor. We collapse the supervisor hop visually. + edges.push(['node-agent-platform', info.id, edgeStyle('platform-' + agentId)]); + } + + document.querySelectorAll('.example-service').forEach(function(svc) { + const svcName = svc.id.replace('node-', ''); + edges.push(['node-agent-gateway', svc.id, edgeStyle(svcName)]); + }); + + document.querySelectorAll('.infra-item').forEach(function(infra) { + const infraName = infra.id.replace('node-', ''); + edges.push(['node-agent-gateway', infra.id, edgeStyle('gateway-' + infraName)]); + edges.push(['node-agent-platform', infra.id, edgeStyle('platform-' + infraName)]); + }); + + return edges; + } + + function drawAllEdges() { + const svg = document.getElementById('edges'); + svg.querySelectorAll('line, text').forEach(function(el) { el.remove(); }); + + const edgeList = buildEdgeList(); + for (let i = 0; i < edgeList.length; i++) { + drawEdge(edgeList[i][0], edgeList[i][1], edgeList[i][2]); + } + drawExternalEdge('node-agent-gateway', 'To Anthropic API', 'anthropic'); + } + + // ============================================================ + // Fit topology canvas + // ============================================================ + function fitTopologyToPanel() { + const panel = document.getElementById('panel-topology'); + const canvas = document.getElementById('canvas'); + const scaleX = panel.clientWidth / CANVAS.width; + const scaleY = panel.clientHeight / CANVAS.height; + const fit = Math.min(scaleX, scaleY); + const scaledW = CANVAS.width * fit; + const scaledH = CANVAS.height * fit; + canvas.style.position = 'absolute'; + canvas.style.left = ((panel.clientWidth - scaledW) / 2) + 'px'; + canvas.style.top = ((panel.clientHeight - scaledH) / 2) + 'px'; + canvas.style.transformOrigin = 'top left'; + canvas.style.transform = 'scale(' + fit + ')'; + } + + /** Full re-layout after topology changes (agent add/remove) */ + function reLayout() { + computeLayout(); + drawAllEdges(); + fitTopologyToPanel(); + } + + // ============================================================ + // Edge highlighting + // ============================================================ + const EDGE_HIGHLIGHT_MS = 1500; + + function highlightEdge(key, durationMs) { + activeEdges.add(key); + drawAllEdges(); + setTimeout(function() { + activeEdges.delete(key); + drawAllEdges(); + }, durationMs || 1500); + } + + function highlightEdgeDenied(key, durationMs) { + deniedEdges.add(key); + drawAllEdges(); + setTimeout(function() { + deniedEdges.delete(key); + drawAllEdges(); + }, durationMs || 1500); + } + + // ============================================================ + // Public API + // ============================================================ + window.Topology = { + // Functions + esc: esc, + createStaticNodes: createStaticNodes, + applySizes: applySizes, + computeLayout: computeLayout, + drawAllEdges: drawAllEdges, + fitTopologyToPanel: fitTopologyToPanel, + reLayout: reLayout, + addAgentRuntimeNode: addAgentRuntimeNode, + removeAgentRuntimeNode: removeAgentRuntimeNode, + addUserNode: addUserNode, + removeUserNode: removeUserNode, + highlightEdge: highlightEdge, + highlightEdgeDenied: highlightEdgeDenied, + + // State (exposed as properties for dashboard read/write access) + get agentRuntimes() { return agentRuntimes; }, + get agentNodeCounter() { return agentNodeCounter; }, + set agentNodeCounter(v) { agentNodeCounter = v; }, + get activeEdges() { return activeEdges; }, + get deniedEdges() { return deniedEdges; }, + get EDGE_HIGHLIGHT_MS() { return EDGE_HIGHLIGHT_MS; }, + }; +})(); diff --git a/dashboard/registry-panel.js b/dashboard/registry-panel.js new file mode 100644 index 0000000..f654f27 --- /dev/null +++ b/dashboard/registry-panel.js @@ -0,0 +1,64 @@ +'use strict'; + +(function () { + function esc(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function section(title, count, emptyMessage, rowsHtml) { + const header = '
' + + esc(title) + ' (' + count + ')
'; + const body = count === 0 + ? '
' + esc(emptyMessage) + '
' + : rowsHtml; + return '
' + header + body + '
'; + } + + function permsLine(perms) { + if (!perms || perms.length === 0) return '
(no permissions)
'; + return '
' + esc(perms.join(', ')) + '
'; + } + + function humanRow(h) { + return '
' + + '' + esc(h.id) + '' + + permsLine(h.permissions) + + '
'; + } + + function agentRow(a) { + const meta = a.created_by + ? '
authorized by ' + esc(a.created_by) + '
' + : ''; + return '
' + + '' + esc(a.id) + '' + + meta + + permsLine(a.permissions) + + '
'; + } + + function renderRegistryHtml(snapshot, state) { + state = state || {}; + let out = ''; + if (state.error) { + out += '
' + esc(state.error) + '
'; + } + if (snapshot) { + out += section('Humans', snapshot.humans.length, 'No humans registered', + snapshot.humans.map(humanRow).join('')); + out += section('Agents', snapshot.agents.length, 'No agents running', + snapshot.agents.map(agentRow).join('')); + } + return out; + } + + if (typeof module !== 'undefined' && module.exports) { + module.exports = { renderRegistryHtml }; + } else { + globalThis.renderRegistryHtml = renderRegistryHtml; + } +})(); diff --git a/dashboard/registry-panel.test.js b/dashboard/registry-panel.test.js new file mode 100644 index 0000000..37555d9 --- /dev/null +++ b/dashboard/registry-panel.test.js @@ -0,0 +1,54 @@ +const assert = require('node:assert'); +const { test } = require('node:test'); +const { renderRegistryHtml } = require('./registry-panel'); + +test('renders Humans and Agents sections with counts', () => { + const html = renderRegistryHtml({ + humans: [{ id: 'alice', permissions: ['docstore.example.com'] }], + agents: [ + { + id: 'research-bot', + created_by: 'alice', + permissions: ['docstore.example.com'], + }, + ], + }); + + assert.match(html, /Humans \(1\)/); + assert.match(html, /Agents \(1\)/); + assert.match(html, /alice\b/); + assert.match(html, /research-bot/); + assert.match(html, /authorized by alice/); + assert.match(html, /docstore\.example\.com/); +}); + +test('empty sections render an empty-state line', () => { + const html = renderRegistryHtml({ humans: [], agents: [] }); + assert.match(html, /Humans \(0\)/); + assert.match(html, /Agents \(0\)/); + assert.match(html, /No humans registered/); + assert.match(html, /No agents running/); +}); + +test('error state renders an error line', () => { + const html = renderRegistryHtml(null, { error: 'Registry unavailable' }); + assert.match(html, /Registry unavailable/); +}); + +test('escapes HTML in IDs and permissions', () => { + const html = renderRegistryHtml({ + humans: [{ id: '