(function () { const NS = 'http://www.w3.org/2000/svg'; function el(tag, attrs, text) { const node = document.createElementNS(NS, tag); Object.entries(attrs || {}).forEach(([k, v]) => node.setAttribute(k, String(v))); if (text != null) node.textContent = text; return node; } function clear(root) { while (root.firstChild) root.removeChild(root.firstChild); } function statusClass(status) { const s = (status || 'unknown').toLowerCase(); if (['online', 'running', 'up'].includes(s)) return 'online'; if (['offline', 'down', 'stopped'].includes(s)) return 'offline'; return 'degraded'; } function flattenTree(nodes, parentId) { const out = []; (nodes || []).forEach((n) => { out.push({ ...n, parentId: parentId || null }); out.push(...flattenTree(n.children || [], n.id)); }); return out; } function layoutNodes(flat, width) { const byType = { hardware: [], proxmox: [], vm: [], service: [] }; flat.forEach((n) => { const t = n.type || 'service'; (byType[t] || byType.service).push(n); }); const positions = {}; positions['hardware-r340'] = { x: width / 2, y: 70 }; positions['proxmox-host'] = { x: width / 2, y: 190 }; const vm = byType.vm[0]; if (vm) positions[vm.id] = { x: width / 2, y: 310 }; const services = byType.service; const count = Math.max(services.length, 1); const pad = 70; const span = width - pad * 2; services.forEach((s, i) => { positions[s.id] = { x: pad + (span * (i + 0.5)) / count, y: 430, }; }); flat.forEach((n, i) => { if (!positions[n.id]) { positions[n.id] = { x: 100 + (i % 6) * (width / 7), y: 430 }; } }); positions['mon-sysops'] = { x: width * 0.32, y: 570 }; positions['mon-research'] = { x: width * 0.68, y: 570 }; return positions; } function drawLayerBands(svg, layers, width) { const g = el('g', { class: 'ops-layers' }); (layers || []).forEach((layer) => { g.appendChild(el('line', { x1: 36, y1: layer.y - 28, x2: width - 36, y2: layer.y - 28, class: 'ops-layer-line', })); g.appendChild(el('text', { x: 14, y: layer.y - 33, class: 'ops-layer-label', }, layer.label)); }); svg.appendChild(g); } function drawEdges(svg, flat, positions) { const g = el('g', { class: 'ops-edges' }); flat.forEach((n) => { if (!n.parentId) return; const from = positions[n.parentId]; const to = positions[n.id]; if (!from || !to) return; const yOffFrom = n.parentId === 'hardware-r340' ? 38 : 32; const yOffTo = n.type === 'hardware' ? -38 : n.type === 'service' ? -22 : -30; g.appendChild(el('line', { x1: from.x, y1: from.y + yOffFrom, x2: to.x, y2: to.y + yOffTo, class: 'topology-line', })); g.appendChild(el('line', { x1: from.x, y1: from.y + yOffFrom, x2: to.x, y2: to.y + yOffTo, class: 'pulse-line', })); }); svg.appendChild(g); } function drawHardwareNode(svg, n, pos) { const cls = statusClass(n.status); const g = el('g', { class: `ops-node-group node-hardware status-${cls}`, transform: `translate(${pos.x},${pos.y})`, }); g.appendChild(el('rect', { x: -95, y: -36, width: 190, height: 72, rx: 8, class: 'ops-hardware-box' })); g.appendChild(el('rect', { x: -88, y: -28, width: 12, height: 56, rx: 2, class: 'ops-hardware-slot' })); g.appendChild(el('rect', { x: -70, y: -28, width: 12, height: 56, rx: 2, class: 'ops-hardware-slot' })); g.appendChild(el('text', { x: -48, y: -6, class: 'ops-node-title ops-title-left' }, 'Dell PowerEdge')); g.appendChild(el('text', { x: -48, y: 14, class: 'ops-node-sub ops-title-left' }, 'R340 · Bare metal')); g.appendChild(el('text', { x: -48, y: 32, class: 'ops-node-role ops-title-left' }, cls.toUpperCase())); svg.appendChild(g); } function drawNode(svg, n, pos) { if (n.type === 'hardware') { drawHardwareNode(svg, n, pos); return; } const cls = statusClass(n.status); const g = el('g', { class: `ops-node-group node-${n.type || 'service'} status-${cls}`, transform: `translate(${pos.x},${pos.y})`, }); const r = n.type === 'proxmox' ? 38 : n.type === 'vm' ? 32 : 24; g.appendChild(el('circle', { r: r, class: 'ops-node-circle' })); const title = (n.label || n.id).split('·')[0].trim(); g.appendChild(el('text', { y: -6, class: 'ops-node-title' }, title.length > 18 ? title.slice(0, 16) + '…' : title)); if (n.type === 'vm' && n.vmid) { g.appendChild(el('text', { y: 10, class: 'ops-node-sub' }, 'ID ' + n.vmid + ' · ' + cls.toUpperCase())); } else { g.appendChild(el('text', { y: 10, class: 'ops-node-sub' }, cls.toUpperCase())); } if (n.role) { const role = n.role.length > 28 ? n.role.slice(0, 26) + '…' : n.role; g.appendChild(el('text', { y: 24, class: 'ops-node-role' }, role)); } if (n.url && n.type === 'service') { const link = el('a', { href: n.url, target: '_blank', rel: 'noopener' }); link.appendChild(g); svg.appendChild(link); } else { svg.appendChild(g); } } function drawMonitorAgents(svg, agents, positions, stats) { (agents || []).forEach((a) => { const pos = positions[a.id] || { x: 200, y: 570 }; const stat = (stats || {})[a.agent_key] || {}; const health = stat.health || 'idle'; const g = el('g', { class: `ops-node-group ops-monitor status-${health}`, transform: `translate(${pos.x},${pos.y})`, }); g.appendChild(el('rect', { x: -72, y: -30, width: 144, height: 60, rx: 10, class: 'ops-monitor-box' })); g.appendChild(el('text', { y: -6, class: 'ops-node-title' }, a.label)); g.appendChild(el('text', { y: 10, class: 'ops-node-sub' }, health.toUpperCase())); g.appendChild(el('text', { y: 24, class: 'ops-node-role' }, a.role || '')); svg.appendChild(g); }); } async function loadAgentHealth() { try { const r = await fetch('/api/agents/mesh'); if (!r.ok) return {}; const data = await r.json(); const map = {}; (data.nodes || []).forEach((n) => { map[n.agent_key] = { health: n.health, events_6h: n.events_6h }; }); return map; } catch (e) { return {}; } } async function render(canvasId, metaEl) { const svg = document.getElementById(canvasId); if (!svg) return; clear(svg); const width = 1200; const height = 620; svg.setAttribute('viewBox', `0 0 ${width} ${height}`); try { const [topoRes, agentStats] = await Promise.all([ fetch('/api/ops/topology').then((r) => r.json()), loadAgentHealth(), ]); const flat = flattenTree(topoRes.nodes || []); const positions = layoutNodes(flat, width); (topoRes.layer_agents || []).forEach((a) => { if (!positions[a.id]) positions[a.id] = { x: width / 2, y: 570 }; }); drawLayerBands(svg, topoRes.layers, width); drawEdges(svg, flat, positions); flat.forEach((n) => { const pos = positions[n.id]; if (pos) drawNode(svg, n, pos); }); drawMonitorAgents(svg, topoRes.layer_agents, positions, agentStats); if (metaEl) { const online = (topoRes.meta && topoRes.meta.services_online) || 0; metaEl.textContent = 'Update: ' + (topoRes.generated_at || '—').substring(0, 19).replace('T', ' ') + ' · ' + online + ' services online'; } } catch (e) { svg.appendChild(el('text', { x: 40, y: 60, fill: '#ef4444', 'font-size': 14 }, 'Topology laden mislukt: ' + e.message)); } } window.OpsTopology = { render }; })();