223 lines
7.6 KiB
JavaScript
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 };
|
|
})();
|