/* ===================================================================== L2 Physical Topology — display-only interactive map. Groups switches into frames (COBCO outer → workshop/DataCenter/Core), colours links by speed, nodes by latency & alarm, supports dark mode, terminal show / only-offline, live + historical snapshots, MAC/IP search. ===================================================================== */ (function () { 'use strict'; var CFG = window.L2_CFG || {}; var T = window.L2_I18N || {}; var META = window.L2_META || {}; var SNAP = window.L2SNAP || null; var colors = CFG.colors || {100:'#F39C12',1000:'#2ECC71',10000:'#3498DB',20000:'#9B59B6',unknown:'#E74C3C',down:'#C0392B'}; var LATW = (CFG.latency_warn != null) ? +CFG.latency_warn : 3; var LATB = (CFG.latency_bad != null) ? +CFG.latency_bad : 5; var TIER_BG = {core:'#1F4E78', dist:'#2E75B6', access:'#548235'}; var TIER_BG_DARK = {core:'#1B4A72', dist:'#2A5E93', access:'#3E6B2E'}; var SEV_COLORS = ['#97AAB3','#7499FF','#FFC859','#FFA059','#E97659','#E45959']; var NODE_W = 156, NODE_H = 50, GX = 26, GY = 18, PADX = 20, PADY = 42; var DGAP_X = 18, DGAP_Y = 26; var GROUP_GAP_X = 46, GROUP_GAP_Y = 48, MAXW = 2800; // overview ("全景") tiles var TILE_W = 232, TILE_H = 116, TILE_GAP_X = 24, TILE_GAP_Y = 22; var cy = null; var dark = false; var showTerm = false; var onlyOff = false; var paused = false; var countdown = 0; var timer = null; var lastSnap = null; var viewGroup = null; // null = overview, otherwise workshop label function el(id) { return document.getElementById(id); } function t(k, d) { return (T[k] != null) ? T[k] : (d != null ? d : k); } /* ---------------- name parsing / grouping ---------------- */ function parseName(name) { var r = {workshop:'', floor:'', cab:'', u:'', area:'', role:'', ip:''}; if (!name) return r; var toks = String(name).split('-'); for (var i = 0; i < toks.length; i++) { var tk = toks[i]; if (/^\d{1,3}(\.\d{1,3}){3}$/.test(tk)) { r.ip = tk; continue; } if (!r.workshop && /^\d{4}$/.test(tk)) { r.workshop = tk; continue; } if (!r.floor && /^\d[A-Z]\w*$/i.test(tk)) { var m = /^(\d)([A-Z])(\w+)$/i.exec(tk); if (m) { r.floor = m[1]; r.cab = m[2]; r.u = m[3]; continue; } } if (!r.area && /^(OFFICE|SURVE|GRDA|DC|IDC|LAB|PROD)$/i.test(tk)) { r.area = tk.toUpperCase(); continue; } if (!r.role && /^(Dist|Access|Core)$/i.test(tk)) { r.role = tk; } } return r; } function labelFor(n) { var p = parseName(n.name); var l1 = []; if (p.workshop) l1.push(p.workshop); if (p.area) l1.push(p.area); if (!l1.length) { l1.push((n.name || '').split('-')[0] || n.ip); } var l2 = []; if (p.floor) { var loc = p.floor + 'F' + (p.cab || '') + (p.u ? '-' + p.u + 'U' : ''); l1.push(loc); } if (p.role) l2.push(p.role); l2.push(n.ip); return l1.join(' · ') + '\n' + l2.join(' · '); } function expandLabel(tpl, m) { return String(tpl).replace(/\$(\d)/g, function (_, d) { return m && m[+d] != null ? m[+d] : ''; }); } function groupOf(n) { var name = n.name || n.ip || ''; var rules = CFG.group_rules || []; for (var i = 0; i < rules.length; i++) { var r = rules[i]; if (r.match === 'regex') { var re; try { re = new RegExp(r.pattern); } catch (e) { continue; } var m = re.exec(name); if (m) return {label: expandLabel(r.label, m) || (CFG.other_label || 'Other'), big: !!r.big}; } else { if (name.toLowerCase().indexOf(String(r.pattern).toLowerCase()) >= 0) return {label: r.label || (CFG.other_label || 'Other'), big: !!r.big}; } } return {label: CFG.other_label || 'Other', big: false}; } /* ---------------- overview layout (workshop tiles) ---------------- */ function groupStats(snap) { var map = {}; var nodeById = {}; (snap.nodes || []).forEach(function (n) { nodeById[n.id] = n; var g = groupOf(n); var s = map[g.label] || (map[g.label] = {label: g.label, big: g.big, nodes: [], alarms: 0, offline: 0, maxsev: -1, latmax: 0, up: 0}); s.nodes.push(n); s.offline += (+n.term_offline || 0); if (n.alarms && n.alarms.length) { s.alarms += n.alarms.length; var sev = (n.maxsev != null && +n.maxsev >= 0) ? +n.maxsev : -1; n.alarms.forEach(function (a) { if (a && a.sev != null && +a.sev > sev) sev = +a.sev; }); if (sev > s.maxsev) s.maxsev = sev; } if (n.latency_ms != null && +n.latency_ms > s.latmax) s.latmax = +n.latency_ms; }); (snap.edges || []).forEach(function (e) { var a = nodeById[e.source], b = nodeById[e.target]; if (!a || !b) return; var ga = groupOf(a), gb = groupOf(b); if (ga.label === gb.label) return; // count links that leave this workshop towards a core switch if ((/core/i.test(a.tier || '') || /core/i.test(a.name || '')) && map[gb.label]) map[gb.label].up++; if ((/core/i.test(b.tier || '') || /core/i.test(b.name || '')) && map[ga.label]) map[ga.label].up++; }); return map; } function groupRank(l) { if (/core/i.test(l)) return 0; if (/data\s*center/i.test(l)) return 1; var m = /^(\d{4})([A-Za-z]?)$/.exec(l); if (m) return 1000 + parseInt(m[1], 10) * 40 + (m[2] ? m[2].toUpperCase().charCodeAt(0) - 64 : 0); return 5000; } function computeOverview(snap) { var stats = groupStats(snap); var labels = Object.keys(stats).sort(function (a, b) { return groupRank(a) - groupRank(b); }); var special = labels.filter(function (l) { return groupRank(l) < 1000; }); var others = labels.filter(function (l) { return groupRank(l) >= 1000; }); var COLS = Math.max(4, Math.min(7, Math.ceil(Math.sqrt(Math.max(1, others.length))))); var gridW = COLS * TILE_W + (COLS - 1) * TILE_GAP_X; var pos = {}, tiles = [], y = 0; if (special.length) { var sW = special.length * TILE_W + (special.length - 1) * TILE_GAP_X; var sx = Math.max(0, (gridW - sW) / 2); special.forEach(function (l) { tiles.push({label: l, x: sx, y: y, w: TILE_W, h: TILE_H}); pos[l] = {x: sx + TILE_W / 2, y: y + TILE_H / 2}; sx += TILE_W + TILE_GAP_X; }); y += TILE_H + 46; } var maxX = gridW, maxY = y; for (var i = 0; i < others.length; i += COLS) { var row = others.slice(i, i + COLS); var rW = row.length * TILE_W + (row.length - 1) * TILE_GAP_X; var rx = Math.max(0, (gridW - rW) / 2); row.forEach(function (l, j) { tiles.push({label: l, x: rx + j * (TILE_W + TILE_GAP_X), y: y, w: TILE_W, h: TILE_H}); pos[l] = {x: rx + j * (TILE_W + TILE_GAP_X) + TILE_W / 2, y: y + TILE_H / 2}; }); y += TILE_H + TILE_GAP_Y; maxY = y - TILE_GAP_Y; maxX = Math.max(maxX, rx + rW); } var root = {x: -34, y: -64, w: maxX + 68, h: maxY + 64 + 40}; root.cx = root.x + root.w / 2; root.cy = root.y + root.h / 2; return {stats: stats, labels: labels, pos: pos, tiles: tiles, root: root}; } function aggregateEdges(snap) { var gid = {}; (snap.nodes || []).forEach(function (n) { gid[n.id] = groupOf(n).label; }); var pairs = {}; (snap.edges || []).forEach(function (e) { var a = gid[e.source], b = gid[e.target]; if (!a || !b || a === b) return; var k = (a < b) ? (a + '\u0001' + b) : (b + '\u0001' + a); var p = pairs[k] || (pairs[k] = {a: a, b: b, count: 0, speed: 0, down: false}); p.count++; if ((parseFloat(e.speed) || 0) > p.speed) p.speed = parseFloat(e.speed) || 0; if (e.state && e.state !== 'up') p.down = true; }); return Object.keys(pairs).map(function (k) { return pairs[k]; }); } /* ---------------- detail layout (one workshop, tidy tree) ---------------- */ function computeDetail(snap, label) { var all = snap.nodes || []; var byId = {}, nodes = []; all.forEach(function (n) { byId[n.id] = n; if (groupOf(n).label === label) nodes.push(n); }); var ids = {}; nodes.forEach(function (n) { ids[n.id] = true; }); var internal = [], external = []; (snap.edges || []).forEach(function (e) { var a = ids[e.source], b = ids[e.target]; if (a && b) internal.push(e); else if (a || b) external.push(e); }); var adj = {}, kids = {}; nodes.forEach(function (n) { adj[n.id] = []; kids[n.id] = []; }); internal.forEach(function (e) { adj[e.source].push(e.target); adj[e.target].push(e.source); }); function rankNode(n) { var tier = n.tier || ''; if (/core/i.test(tier)) return 0; if (/dist/i.test(tier)) return 1; return 2; } var roots = nodes.slice().sort(function (a, b) { var ra = rankNode(a), rb = rankNode(b); if (ra !== rb) return ra - rb; return adj[b.id].length - adj[a.id].length; }); var seen = {}, orderedRoots = []; roots.forEach(function (r) { if (seen[r.id]) return; orderedRoots.push(r); var queue = [r.id]; seen[r.id] = true; while (queue.length) { var cur = queue.shift(); adj[cur].forEach(function (nb) { if (seen[nb]) return; seen[nb] = true; kids[cur].push(nb); queue.push(nb); }); } }); nodes.forEach(function (n) { if (!seen[n.id]) { seen[n.id] = true; orderedRoots.push(n); } }); // Compact blocks: each dist root gets a grid of access switches underneath it. var pos = {}, blockX = 0, maxBlockH = 0; orderedRoots.forEach(function (rootN) { var d1 = kids[rootN.id] || []; var cols = Math.max(1, Math.min(5, Math.ceil(Math.sqrt(Math.max(1, d1.length) * 1.2)))); var blockW = cols * NODE_W + (cols - 1) * DGAP_X; pos[rootN.id] = {x: blockX + blockW / 2, y: 0}; d1.forEach(function (c, i) { var col = i % cols, row = Math.floor(i / cols); pos[c] = {x: blockX + col * (NODE_W + DGAP_X) + NODE_W / 2, y: (row + 1) * (NODE_H + DGAP_Y)}; }); var rows = Math.ceil(d1.length / cols); var d2 = []; d1.forEach(function (c) { (kids[c] || []).forEach(function (k) { if (k !== rootN.id) d2.push(k); }); }); var y2 = (rows + 1) * (NODE_H + DGAP_Y) + 16; d2.forEach(function (id, i) { pos[id] = {x: blockX + (i % cols) * (NODE_W + DGAP_X) + NODE_W / 2, y: y2 + Math.floor(i / cols) * (NODE_H + DGAP_Y)}; }); maxBlockH = Math.max(maxBlockH, y2 + Math.ceil(d2.length / cols) * (NODE_H + DGAP_Y)); blockX += blockW + 70; }); var missing = []; nodes.forEach(function (n) { if (!pos[n.id]) missing.push(n.id); }); missing.forEach(function (id, i) { pos[id] = {x: (i % 6) * (NODE_W + DGAP_X) + NODE_W / 2, y: maxBlockH + 70 + Math.floor(i / 6) * (NODE_H + DGAP_Y)}; }); var totalW = Math.max(NODE_W, blockX - 70); var ghosts = [], ghostSeen = {}; external.forEach(function (e) { var inside = ids[e.source] ? e.source : e.target; var outside = ids[e.source] ? e.target : e.source; if (!ghostSeen[outside]) { ghostSeen[outside] = {id: outside, to: inside, node: byId[outside], speed: e.speed, state: e.state}; ghosts.push(ghostSeen[outside]); } }); var ghostAt = {}; ghosts.forEach(function (g) { var base = pos[g.to] || {x: 80}; var k = ghostAt[g.to] || 0; ghostAt[g.to] = k + 1; g.x = base.x + (k ? (k % 2 ? 1 : -1) * 95 * Math.ceil(k / 2) : 0); g.y = -86 - Math.floor(k / 2) * 48; }); var maxY = 0, minX = 0, maxX2 = totalW; Object.keys(pos).forEach(function (id) { maxY = Math.max(maxY, pos[id].y + NODE_H); minX = Math.min(minX, pos[id].x - NODE_W / 2); maxX2 = Math.max(maxX2, pos[id].x + NODE_W / 2); }); ghosts.forEach(function (g) { minX = Math.min(minX, g.x - 95); maxX2 = Math.max(maxX2, g.x + 95); }); var top = ghosts.length ? -168 : -120; var root = {x: minX - 38, y: top, w: (maxX2 - minX) + 76, h: maxY - top + 54}; root.cx = root.x + root.w / 2; root.cy = root.y + root.h / 2; return {nodes: nodes, byId: byId, pos: pos, ghosts: ghosts, root: root, totalW: totalW, ids: ids, internal: internal}; } /* ---------------- speed colour ---------------- */ function speedColor(speed, state) { if (state && state !== 'up') return colors.down; var s = parseFloat(speed); if (!isFinite(s) || s <= 0) return colors.unknown; if (s <= 100) return colors['100']; if (s <= 1000) return colors['1000']; if (s <= 10000) return colors['10000']; return colors['20000']; } function speedWidth(speed) { var s = parseFloat(speed) || 0; if (s >= 20000) return 4.5; if (s >= 10000) return 3.6; if (s >= 1000) return 2.6; return 1.8; } function speedText(speed) { var s = parseFloat(speed) || 0; if (!s) return '?'; if (s >= 1000) return (s / 1000) + 'G'; return s + 'M'; } /* ---------------- style ---------------- */ function styleFor(d) { var bg = d ? TIER_BG_DARK : TIER_BG; return [ {selector: 'node', style: { 'width': 'data(w)', 'height': 'data(h)', 'shape': 'round-rectangle', 'background-color': 'data(bg)', 'border-width': 2, 'border-color': 'data(bd)', 'label': 'data(label)', 'color': d ? '#eaf1fb' : '#ffffff', 'font-size': 11.5, 'font-weight': 600, 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'wrap', 'text-max-width': NODE_W - 16, 'padding': '2px', 'overlay-opacity': 0, 'z-index-compare': 'manual', 'z-index': 20 }}, {selector: 'node[type="frame"]', style: { 'background-opacity': d ? 0.05 : 0.035, 'background-color': d ? '#5b7bb0' : '#8fa6c4', 'border-width': 1.6, 'border-style': 'dashed', 'border-color': d ? '#3f5377' : '#c2ccdb', 'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center', 'font-size': 'data(fs)', 'font-weight': 800, 'color': d ? '#aebfd8' : '#526178', 'text-margin-y': 8, 'padding': '0px', 'events': 'no', 'z-index-compare': 'manual', 'z-index': 2 }}, {selector: 'node[type="root"]', style: { 'background-opacity': d ? 0.02 : 0.012, 'background-color': d ? '#7aa0d8' : '#8ea6c2', 'border-width': 2.2, 'border-style': 'solid', 'border-color': d ? '#476492' : '#9fb0c6', 'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center', 'font-size': 28, 'font-weight': 900, 'color': d ? '#cfe0ff' : '#33465f', 'text-margin-y': 10, 'events': 'no', 'z-index-compare': 'manual', 'z-index': 0 }}, {selector: 'node[type="tile"]', style: { 'width': 'data(w)', 'height': 'data(h)', 'shape': 'round-rectangle', 'background-color': 'data(bg)', 'background-opacity': 0.97, 'border-width': 2.5, 'border-color': 'data(bd)', 'label': 'data(label)', 'color': 'data(fg)', 'font-size': 16, 'font-weight': 700, 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'wrap', 'text-max-width': TILE_W - 26, 'line-height': 1.45, 'padding': '6px', 'overlay-opacity': 0, 'z-index-compare': 'manual', 'z-index': 20 }}, {selector: 'node[type="tile"].alarm', style: {'border-width': 5}}, {selector: 'node[type="tile"].core', style: {'border-width': 3.5}}, {selector: 'node[type="ghost"]', style: { 'width': 190, 'height': 40, 'shape': 'round-rectangle', 'background-color': d ? '#52667f' : '#c8d3e2', 'background-opacity': 0.55, 'border-width': 1.2, 'border-style': 'dashed', 'border-color': d ? '#7d93ad' : '#8fa3bc', 'label': 'data(label)', 'color': d ? '#dce8fa' : '#3b4f68', 'font-size': 11, 'font-weight': 600, 'text-wrap': 'wrap', 'text-max-width': 176, 'z-index-compare': 'manual', 'z-index': 15 }}, {selector: 'node[type="term"]', style: { 'width': 12, 'height': 12, 'shape': 'ellipse', 'background-color': 'data(bg)', 'border-width': 1, 'border-color': d ? '#2a3648' : '#cfd8e3', 'label': '', 'z-index-compare': 'manual', 'z-index': 30 }}, {selector: 'edge[type="tilelink"]', style: { 'width': 'data(w)', 'line-color': 'data(color)', 'opacity': 0.55, 'curve-style': 'bezier', 'target-arrow-shape': 'none', 'z-index-compare': 'manual', 'z-index': 5 }}, {selector: 'edge[type="ghost"]', style: { 'width': 1.2, 'line-color': d ? '#6b809b' : '#a9b6c8', 'line-style': 'dashed', 'opacity': 0.7, 'curve-style': 'bezier', 'target-arrow-shape': 'none', 'z-index-compare': 'manual', 'z-index': 8 }}, {selector: 'edge[type="uplink"]', style: { 'width': 'data(w)', 'line-color': 'data(color)', 'line-style': 'data(ls)', 'opacity': 0.95, 'curve-style': 'bezier', 'target-arrow-shape': 'none', 'label': 'data(lbl)', 'font-size': 10, 'font-weight': 700, 'color': d ? '#dce8fa' : '#25405f', 'text-background-color': d ? '#0d1b2a' : '#ffffff', 'text-background-opacity': 0.88, 'text-background-padding': '2px', 'text-rotation': 'autorotate', 'z-index-compare': 'manual', 'z-index': 40 }}, {selector: 'edge', style: { 'width': 'data(w)', 'line-color': 'data(color)', 'line-style': 'data(ls)', 'curve-style': 'bezier', 'opacity': 0.92, 'target-arrow-shape': 'none', 'z-index-compare': 'manual', 'z-index': 12 }}, {selector: 'edge[type="term"]', style: { 'width': 0.7, 'line-color': d ? '#3b4a5e' : '#c5cfdb', 'opacity': 0.55, 'curve-style': 'bezier', 'z-index-compare': 'manual', 'z-index': 11 }}, {selector: 'node.alarm', style: {'border-width': 6}}, {selector: 'node.dim', style: {'opacity': 0.12}}, {selector: 'edge.dim', style: {'opacity': 0.08}}, {selector: 'edge.hl', style: {'width': 5, 'line-color': '#FFD54A', 'opacity': 1, 'z-index-compare': 'manual', 'z-index': 45}}, {selector: 'node.lat-warn', style: {'border-color': '#F39C12'}}, {selector: 'node.lat-bad', style: {'border-color': '#E74C3C', 'border-width': 6}}, {selector: 'node.hl', style: {'border-color': '#ff0', 'border-width': 7, 'overlay-color': '#ff0', 'overlay-opacity': 0.25, 'overlay-padding': 6}}, {selector: 'node[type="switch"]', style: {'background-color': function (e) { return e.data('bg'); }}} ]; } /* ---------------- render ---------------- */ function applyElements(els) { if (!cy) { cy = cytoscape({ container: el('l2-cy'), elements: els, style: styleFor(dark), layout: {name: 'preset', fit: true, padding: 40}, wheelSensitivity: 0.2, minZoom: 0.15, maxZoom: 4 }); bindCy(); } else { cy.elements().remove(); cy.add(els); cy.style(styleFor(dark)); cy.layout({name: 'preset', fit: true, padding: 40}).run(); } } function render(snap) { if (!snap || !snap.nodes) { el('l2-cy').innerHTML = '
' + t('l2.no_data', 'No data') + '
'; return; } lastSnap = snap; if (viewGroup) { var exists = (snap.nodes || []).some(function (n) { return groupOf(n).label === viewGroup; }); if (!exists) viewGroup = null; } if (viewGroup) renderDetail(snap); else renderOverview(snap); if (el('l2-back')) el('l2-back').style.display = viewGroup ? '' : 'none'; updateStats(snap); buildLegend(); buildAlarms(snap); buildOffline(snap); } function renderOverview(snap) { var O = computeOverview(snap), els = [], idx = {}; if (O.root) { els.push({group: 'nodes', data: {id: '__root', type: 'root', label: CFG.root_label || 'COBCO', w: O.root.w, h: O.root.h}, position: {x: O.root.cx, y: O.root.cy}, selectable: false, grabbable: false}); } O.tiles.forEach(function (tile, i) { var s = O.stats[tile.label] || {nodes: [], alarms: 0, offline: 0, maxsev: -1}; idx[tile.label] = 'grp_' + i; var isCore = /core/i.test(tile.label) || /data\s*center/i.test(tile.label); var bd = s.alarms ? '#E45959' : (dark ? '#41597c' : '#b9c8dc'); var bg = isCore ? TIER_BG.core : (dark ? '#22344e' : '#eef3fb'); var fg = isCore ? '#ffffff' : (dark ? '#dfe9fb' : '#1e3352'); var lbl = tile.label + '\n' + s.nodes.length + ' ' + t('l2.unit_sw', 'sw') + (s.up ? ' ↑ ' + s.up : '') + (s.alarms ? ' ⚠ ' + s.alarms : '') + (s.offline ? ' ◌ ' + s.offline : ''); var cls = []; if (s.alarms) cls.push('alarm'); if (isCore) cls.push('core'); els.push({group: 'nodes', data: { id: idx[tile.label], type: 'tile', group: tile.label, n: s.nodes.length, alarms: s.alarms, offline: s.offline, maxsev: s.maxsev, up: s.up, label: lbl, bg: bg, fg: fg, bd: bd, w: TILE_W, h: TILE_H }, position: {x: tile.x + tile.w / 2, y: tile.y + tile.h / 2}, classes: cls.join(' ')}); }); applyElements(els); } function renderDetail(snap) { var D = computeDetail(snap, viewGroup), els = []; if (D.root) { els.push({group: 'nodes', data: {id: '__root', type: 'root', label: (CFG.root_label || 'COBCO') + ' / ' + viewGroup, w: D.root.w, h: D.root.h}, position: {x: D.root.cx, y: D.root.cy}, selectable: false, grabbable: false}); } (D.ghosts || []).forEach(function (g) { var nm = (g.node && g.node.name) || g.id; var ip = (g.node && g.node.ip) || ''; var tail = String(nm).split(/[-\s]+/).filter(Boolean).slice(-1)[0] || ''; var short = (tail ? tail + ' · ' : '') + ip; els.push({group: 'nodes', data: {id: g.id, type: 'ghost', label: short}, position: {x: g.x, y: g.y}, selectable: false, grabbable: false}); els.push({group: 'edges', data: { id: 'ghe_' + g.id, type: 'uplink', source: g.id, target: g.to, speed: g.speed, state: g.state, lbl: speedText(g.speed), color: speedColor(g.speed, g.state), ls: (g.state && g.state !== 'up') ? 'dashed' : 'solid', w: Math.max(2.6, speedWidth(g.speed)) }}); }); var switchIds = {}; D.nodes.forEach(function (n) { switchIds[n.id] = true; var p = D.pos[n.id] || {x: 0, y: 0}; var tier = n.tier || 'access'; var tkey = (tier.indexOf('dist') === 0) ? 'dist' : (tier.indexOf('core') === 0 ? 'core' : 'access'); var cls = []; var bd = (dark ? TIER_BG_DARK[tkey] : TIER_BG[tkey]) || '#555'; if (n.alarms && n.alarms.length) { cls.push('alarm'); bd = '#E45959'; } if (n.latency_ms != null) { if (+n.latency_ms > LATB) cls.push('lat-bad'); else if (+n.latency_ms > LATW) cls.push('lat-warn'); } els.push({group: 'nodes', data: { id: n.id, type: 'switch', name: n.name, ip: n.ip, tier: tier, depth: n.depth, hostid: n.hostid, oper: n.oper, maxsev: n.maxsev, alarms: n.alarms || [], term_online: n.term_online, term_offline: n.term_offline, latency_ms: n.latency_ms, label: labelFor(n), bg: (dark ? TIER_BG_DARK[tkey] : TIER_BG[tkey]) || '#777', bd: bd, w: NODE_W, h: NODE_H, group: viewGroup }, position: p, classes: cls.join(' ')}); }); D.internal.forEach(function (e, i) { els.push({group: 'edges', data: { id: 'e_' + i, source: e.source, target: e.target, speed: e.speed, state: e.state, a_port: e.a_port, b_port: e.b_port, color: speedColor(e.speed, e.state), ls: (e.state && e.state !== 'up') ? 'dashed' : 'solid', w: speedWidth(e.speed) }}); }); if (showTerm) { var bySw = {}; (snap.terminals || []).forEach(function (tm) { if (!switchIds[tm.sw_ip]) return; if (onlyOff && tm.online) return; (bySw[tm.sw_ip] = bySw[tm.sw_ip] || []).push(tm); }); Object.keys(bySw).forEach(function (sw) { var base = D.pos[sw]; if (!base) return; bySw[sw].sort(function (a, b) { return (a.online === b.online) ? 0 : (a.online ? -1 : 1); }); bySw[sw].forEach(function (tm, k) { var col = k % 9, row = Math.floor(k / 9); var x = base.x - 54 + col * 13; var y = base.y + NODE_H / 2 + 16 + row * 13; els.push({group: 'nodes', data: {id: 't_' + tm.mac, type: 'term', bg: tm.online ? '#2ECC71' : '#95a5a6', mac: tm.mac, ip: tm.ip, online: tm.online}, position: {x: x, y: y}, selectable: false}); els.push({group: 'edges', data: {id: 'te_' + tm.mac, type: 'term', source: sw, target: 't_' + tm.mac}}); }); }); } applyElements(els); } /* ---------------- side panels & stats ---------------- */ function updateStats(snap) { var s = snap.stats || {}; var txt = '● ' + (s.switches || 0) + ' | ⇄ ' + (s.edges || 0) + ' | ' + (s.term_total || 0) + '(' + (s.term_online || 0) + '/' + (s.term_offline || 0) + ') | ⚠ ' + (s.alarms || 0); var up = snap.generated_iso ? (' · ' + t('l2.updated', 'Updated') + ' ' + snap.generated_iso) : ''; el('l2-stats').textContent = txt + up; } function buildLegend() { var h = ''; function sw(c, label, dashed) { return '
' + label + '
'; } h += '
' + t('l2.legend_speed', 'Link speed') + '
'; h += sw(colors['100'], '100M') + sw(colors['1000'], '1G') + sw(colors['10000'], '10G') + sw(colors['20000'], '20G+'); h += sw(colors.unknown, t('lbl.color_unknown', 'Unknown')); h += sw(colors.down, t('lbl.color_down', 'Down'), true); h += '
' + t('l2.legend_tier', 'Tier') + '
'; h += '
' + t('l2.core', 'Core') + '' + t('l2.dist', 'Distribution') + '' + t('l2.access', 'Access') + '
'; h += '
' + t('l2.legend_lat', 'Latency') + '
'; h += '
>' + LATW + 'ms' + '>' + LATB + 'ms
'; h += '
' + t('l2.alarms', 'Alarms') + '
'; h += '
' + t('l2.term_on', 'Online') + '' + t('l2.term_off', 'Offline') + '
'; h += '
' + t('l2.click_hint', 'Click a switch to highlight its links') + '
'; el('l2-legend').innerHTML = h; } function buildAlarms(snap) { var items = []; (snap.nodes || []).forEach(function (n) { if (n.maxsev != null && n.maxsev >= 0 && n.alarms && n.alarms.length) { n.alarms.forEach(function (a) { items.push({sev: n.maxsev, title: (a.name || a.description || ''), host: n.name, ip: n.ip, id: n.id, hostid: n.hostid}); }); } }); el('l2-alarm-count').textContent = items.length; if (!items.length) { el('l2-alarm-list').innerHTML = '
' + t('l2.alarm_empty', 'No active alarms.') + '
'; return; } items.sort(function (a, b) { return b.sev - a.sev; }); var h = ''; items.forEach(function (it) { var col = SEV_COLORS[Math.max(0, Math.min(5, it.sev))] || '#999'; h += '
' + '' + '
' + esc(it.title) + '
' + '
' + esc(it.host || '') + (it.ip ? ' · ' + esc(it.ip) : '') + '
'; }); var box = el('l2-alarm-list'); box.innerHTML = h; box.querySelectorAll('.alarm-row').forEach(function (r) { r.addEventListener('click', function () { var id = r.getAttribute('data-id'); if (id) revealById(id); else { var hid = r.getAttribute('data-hostid'); if (hid) window.open(META.zabbixHostUrl.replace('__HOSTID__', hid), '_blank'); } }); }); } function buildOffline(snap) { var off = (snap.terminals || []).filter(function (tm) { return !tm.online; }); el('l2-offline-count').textContent = off.length; if (!off.length) { el('l2-offline-list').innerHTML = '
' + t('l2.offline_empty', 'No offline terminals.') + '
'; return; } var h = ''; off.forEach(function (tm) { h += '
' + '
' + esc(tm.mac) + '
' + '
' + esc(tm.sw_name || tm.sw_ip) + ' · ' + esc(tm.port || '') + '
'; }); var box = el('l2-offline-list'); box.innerHTML = h; box.querySelectorAll('.off-row').forEach(function (r) { r.addEventListener('click', function () { var mac = r.getAttribute('data-mac'); revealById('t_' + mac); }); }); } function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]; }); } /* ---------------- interactions ---------------- */ function focusNode(id) { if (!cy) return; var n = cy.getElementById(id); if (!n || n.empty() || n.length === 0) return; cy.elements().removeClass('hl'); n.addClass('hl'); cy.animate({center: {eles: n}, zoom: Math.max(cy.zoom(), 1.1), duration: 380}); setTimeout(function () { n.removeClass('hl'); }, 2600); } function groupOfNodeId(id) { var hit = (lastSnap && lastSnap.nodes || []).filter(function (n) { return n.id === id; })[0]; return hit ? groupOf(hit).label : null; } // Make sure a switch/terminal is visible (auto-opens its workshop from overview). function revealById(id) { if (!lastSnap) return; id = String(id); if (id.indexOf('t_') === 0) { var tm = (lastSnap.terminals || []).filter(function (x) { return 't_' + x.mac === id; })[0]; if (!tm) return; var g = groupOfNodeId(tm.sw_ip); if (g) viewGroup = g; showTerm = true; if (el('l2-showterm')) el('l2-showterm').checked = true; render(lastSnap); setTimeout(function () { focusNode(id); }, 90); } else { var g2 = groupOfNodeId(id); if (g2 && viewGroup !== g2) { viewGroup = g2; render(lastSnap); } setTimeout(function () { focusNode(id); }, 90); } } function bindCy() { cy.on('mouseover', 'node[type="switch"]', function (e) { var d = e.target.data(); var arr = d.alarms || []; var sev = (d.maxsev != null && d.maxsev >= 0) ? ('● ' + SEV_NAMES[d.maxsev] + '') : '—'; var lat = (d.latency_ms != null) ? (d.latency_ms + ' ms') : '—'; var html = '' + esc(d.name) + '
' + esc(d.ip) + ' · ' + esc(d.group || '') + '
' + 'Tier: ' + esc(d.tier) + ' · ' + (d.oper || '') + '
' + 'Latency: ' + lat + '
' + 'Terminals: ' + (d.term_online || 0) + ' on / ' + (d.term_offline || 0) + ' off
' + 'Alarm: ' + sev; if (arr.length) html += '
' + arr.map(function (a) { return esc(a.name || a.description || ''); }).join('
'); showTip(e, html); }); cy.on('mouseout', 'node[type="switch"]', hideTip); cy.on('mouseover', 'node[type="tile"]', function (e) { var d = e.target.data(); var html = '' + esc(d.group) + '
' + d.n + ' ' + t('l2.unit_sw', 'sw') + '
' + '↑ Core: ' + (d.up || 0) + ' · ⚠ ' + (d.alarms || 0) + ' · ◌ ' + (d.offline || 0) + '
' + t('l2.tile_hint', 'Click to open this workshop') + ''; showTip(e, html); }); cy.on('mouseout', 'node[type="tile"]', hideTip); cy.on('tap', 'node[type="tile"]', function (e) { var g = e.target.data('group'); if (g) { viewGroup = g; render(lastSnap); } }); cy.on('mouseover', 'edge:not([type="term"])', function (e) { var d = e.target.data(); var sp = (parseFloat(d.speed) || 0) >= 1000 ? ((parseFloat(d.speed) / 1000) + 'G') : (d.speed + 'M'); showTip(e, 'Link ' + esc(d.a_port || '') + ' ↔ ' + esc(d.b_port || '') + '
' + sp + ' · ' + esc(d.state || '')); }); cy.on('mouseout', 'edge', hideTip); cy.on('tap', 'node[type="switch"]', function (e) { var n = e.target; cy.elements().removeClass('dim hl'); cy.elements().not(n).addClass('dim'); n.removeClass('dim'); var links = n.connectedEdges(); links.removeClass('dim').addClass('hl'); links.connectedNodes().removeClass('dim'); }); cy.on('tap', function (e) { if (e.target === cy) cy.elements().removeClass('dim hl'); }); cy.on('dbltap', 'node[type="switch"]', function (e) { var hid = e.target.data('hostid'); if (hid) window.open(META.zabbixHostUrl.replace('__HOSTID__', hid), '_blank'); }); } var SEV_NAMES = ['未分类','信息','警告','一般严重','严重','灾难']; function showTip(e, html) { var tip = el('l2-tip'); tip.innerHTML = html; tip.style.display = 'block'; var p = e.renderedPosition || {x: 0, y: 0}; var box = el('l2-cy').getBoundingClientRect(); var x = box.left + p.x + 16, y = box.top + p.y + 16; tip.style.left = x + 'px'; tip.style.top = y + 'px'; } function hideTip() { el('l2-tip').style.display = 'none'; } /* ---------------- search ---------------- */ function buildIndex(snap) { var idx = []; (snap.nodes || []).forEach(function (n) { idx.push({id: n.id, label: n.name, sub: n.ip, kind: 'sw'}); }); (snap.terminals || []).forEach(function (tm) { idx.push({id: 't_' + tm.mac, label: tm.ip || tm.mac, sub: tm.mac + ' · ' + (tm.sw_name || ''), kind: 'term', mac: tm.mac}); }); return idx; } var INDEX = []; function doSearch(q) { var box = el('l2-sug'); q = (q || '').trim().toLowerCase(); if (!q) { box.style.display = 'none'; box.innerHTML = ''; return; } var hits = []; for (var i = 0; i < INDEX.length && hits.length < 14; i++) { var it = INDEX[i]; if ((it.label && it.label.toLowerCase().indexOf(q) >= 0) || (it.sub && it.sub.toLowerCase().indexOf(q) >= 0)) hits.push(it); } if (!hits.length) { box.innerHTML = '
—
'; box.style.display = 'block'; return; } box.innerHTML = hits.map(function (h) { return '
' + (h.kind === 'sw' ? 'SW' : 'PC') + '' + '' + esc(h.label) + '' + esc(h.sub || '') + '
'; }).join(''); box.style.display = 'block'; box.querySelectorAll('.sug-row').forEach(function (r) { r.addEventListener('click', function () { var id = r.getAttribute('data-id'); revealById(id); box.style.display = 'none'; }); }); } /* ---------------- toolbar wiring ---------------- */ function setupControls() { dark = el('l2-wrap').classList.contains('l2-dark'); el('l2-dark').checked = dark; var back = el('l2-back'); if (back) { back.textContent = t('l2.back_overview', '← Overview'); back.addEventListener('click', function () { viewGroup = null; render(lastSnap); }); } el('l2-dark').addEventListener('change', function () { dark = el('l2-dark').checked; el('l2-wrap').classList.toggle('l2-dark', dark); render(lastSnap); }); el('l2-showterm').addEventListener('change', function () { showTerm = el('l2-showterm').checked; render(lastSnap); }); el('l2-onlyoff').addEventListener('change', function () { onlyOff = el('l2-onlyoff').checked; if (onlyOff && !showTerm) { showTerm = true; el('l2-showterm').checked = true; } render(lastSnap); }); el('l2-zin').addEventListener('click', function () { if (cy) cy.zoom({level: cy.zoom() * 1.25, renderedPosition: {x: cy.width() / 2, y: cy.height() / 2}}); }); el('l2-zout').addEventListener('click', function () { if (cy) cy.zoom({level: cy.zoom() / 1.25, renderedPosition: {x: cy.width() / 2, y: cy.height() / 2}}); }); el('l2-fit').addEventListener('click', function () { if (cy) cy.fit(undefined, 50); }); el('l2-refresh-btn').addEventListener('click', function () { reload(true); }); var s = el('l2-search'); s.addEventListener('input', function () { doSearch(s.value); }); document.addEventListener('click', function (ev) { if (!ev.target.closest('.l2-search-wrap')) el('l2-sug').style.display = 'none'; }); el('l2-ts').addEventListener('change', function () { var v = el('l2-ts').value; var u = '/zabbix.php?action=l2.topology.view'; if (v) u += '&ts=' + encodeURIComponent(v); window.location = u; }); el('l2-pause').addEventListener('click', function () { paused = !paused; el('l2-pause').classList.toggle('on', paused); el('l2-pause').textContent = paused ? '▶' : '❚❚'; el('l2-pause').title = paused ? t('l2.resume', 'Resume') : t('l2.pause', 'Pause'); }); } function reload(manual) { if (!META.isLive) return; fetch(META.snapshotUrl + '?t=' + Date.now(), {cache: 'no-store'}) .then(function (r) { return r.json(); }) .then(function (j) { if (j && j.nodes) { SNAP = j; INDEX = buildIndex(j); render(j); } }) .catch(function () {}) .then(function () { if (manual) resetCountdown(); }); } function resetCountdown() { countdown = META.refreshSeconds || 300; } function startTimer() { if (!META.isLive) return; resetCountdown(); timer = setInterval(function () { if (paused) { el('l2-countdown').textContent = '⏸'; return; } countdown--; if (countdown <= 0) { countdown = META.refreshSeconds || 300; reload(false); } var m = Math.floor(countdown / 60), s = countdown % 60; el('l2-countdown').textContent = '↻ ' + m + ':' + (s < 10 ? '0' + s : s); }, 1000); } /* ---------------- boot ---------------- */ function boot() { setupControls(); try { var qg = new URLSearchParams(window.location.search).get('g'); if (qg) viewGroup = qg; } catch (e) {} if (!SNAP || !SNAP.nodes) { el('l2-cy').innerHTML = '
' + t('l2.no_data', 'No data') + '
'; } else { INDEX = buildIndex(SNAP); render(SNAP); } if (META.isLive) startTimer(); else { el('l2-pause').disabled = true; el('l2-countdown').textContent = t('l2.snapshot', 'Snapshot'); } } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot(); })();