Files
foodlinkk-command-center/cockpit/static/js/ops-topology.js
T
2026-06-09 10:41:13 +00:00

223 lines
7.6 KiB
JavaScript

(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 };
})();