SysOps: deploy-all — 2026-06-09 10:41 UTC
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
(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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user