/* ===================================================================== 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'}; if (!colors['2500']) colors['2500'] = '#17C3B2'; 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 pinnedTerm = null; var termPortByMac = {}; // terminal MAC -> rendered port node id var paused = false; var countdown = 0; var timer = null; var lastSnap = null; var viewGroup = null; // direct-link focus group, never hides the full chart var detailNet = null; // null/auto, 'SURVE' or 'OFFICE' inside a workshop var focusGroupLabel = null; // ?g= target, zoomed after the full chart is built var pathParent = {}; // child -> parent chain for hover highlighting var lastRoleMap = {}; // switch id -> computed display role var lockedPath = null; // switch id whose path is locked by click function el(id) { return document.getElementById(id); } function t(k, d) { return (T[k] != null) ? T[k] : (d != null ? d : k); } // PoE is a capability flag written by the collector's SNMP probe. The // lightning mark is display-only, like the topology itself. function poePrefix(n) { return (n && n.poe) ? '\u26A1\uFE0F ' : ''; } /* ---------------- 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}[A-Za-z]?$/.test(tk)) { r.workshop = tk; continue; } if (!r.floor) { var fm = /^(GRD|\d{1,2}F)([A-Z])?(\d+)?$/i.exec(tk); if (fm) { r.floor = fm[1].toUpperCase(); r.cab = fm[2] || ''; r.u = fm[3] || ''; continue; } } if (r.floor && !r.u && /^\d{1,3}$/.test(tk)) { r.u = tk; 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 poePrefix(n) + l1.join(' · ') + '\n' + l2.join(' · '); } function detailLabel(n) { var pre = poePrefix(n); var s = labelFor(n); if (pre && s.indexOf(pre) === 0) s = s.slice(pre.length); if (viewGroup) { var p = viewGroup + ' · '; if (s.indexOf(p) === 0) s = s.substring(p.length); } return pre + s; } function rowLabel(n) { var s = String((n && n.name) || (n && n.ip) || ''); s = s.replace(/^COBCO-/i, ''); if (viewGroup) { try { s = s.replace(new RegExp('^' + viewGroup + '-', 'i'), ''); } catch (e) {} } return poePrefix(n) + s; } function rootLabel(n) { var pre = poePrefix(n); var s = rowLabel(n); if (pre && s.indexOf(pre) === 0) s = s.slice(pre.length); var m = /^(.+?)-(SURVE|OFFICE|OTHER)-(Dist|Access)-(.+)$/i.exec(s); if (m) return pre + m[2].toUpperCase() + ' · ' + m[1] + '\n' + m[3] + ' · ' + m[4]; return pre + s; } 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.offline || n.oper === 'down') ? 1 : 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, left-to-right tree) ---------------- */ function floorInfo(n) { var toks = String((n && n.name) || '').split('-'); for (var i = 0; i < toks.length; i++) { var tk = toks[i]; var bm = /^B(\d+)$/i.exec(tk); if (bm) { var bn = parseInt(bm[1], 10); return {key: 'B' + bn, label: 'B' + bn, rank: -200 + bn}; } if (/^GRD/i.test(tk)) return {key: 'GRD', label: 'GRD', rank: -100}; var fm = /^(\d+(?:\.\d+)?)F/i.exec(tk); if (fm) { var num = parseFloat(fm[1]); var txt = (num % 1 ? num.toFixed(1) : String(num)) + 'F'; return {key: txt, label: txt, rank: num * 10}; } var cm = /^(\d)([A-Z])(\w+)$/i.exec(tk); if (cm) { var n2 = parseInt(cm[1], 10); return {key: n2 + 'F', label: n2 + 'F', rank: n2 * 10}; } } return {key: 'NA', label: t('l2.floor_na', 'Other'), rank: -500}; } function baseNetworkOf(n) { var s = String((n && n.name) || '').toUpperCase(); if (s.indexOf('SURVE') >= 0) return 'SURVE'; if (s.indexOf('OFFICE') >= 0) return 'OFFICE'; var ip = String((n && n.ip) || ''); if (/^10\.169\./.test(ip)) return 'SURVE'; if (/^10\.160\./.test(ip)) return 'OFFICE'; return 'OTHER'; } function networkOf(n) { return baseNetworkOf(n); } function networkLabel(key) { if (key === 'SURVE') return t('l2.net_surve', 'SURVE 监控网'); if (key === 'OFFICE') return t('l2.net_office', 'OFFICE 办公网'); return t('l2.net_other', '其他网络'); } function computeDetail(snap, label) { var all = snap.nodes || []; var byId = {}, groupNodes = []; all.forEach(function (n) { byId[n.id] = n; if (groupOf(n).label === label) groupNodes.push(n); }); // Split one workshop by physical network first: office and surveillance never mix. var gids = {}, rawAdj = {}; groupNodes.forEach(function (n) { gids[n.id] = true; rawAdj[n.id] = []; }); (snap.edges || []).forEach(function (e) { if (gids[e.source] && gids[e.target]) { rawAdj[e.source].push(e.target); rawAdj[e.target].push(e.source); } }); var netMap = {}; groupNodes.forEach(function (n) { netMap[n.id] = baseNetworkOf(n); }); // Unlabelled access switches inherit the network of the distribution they hang from. for (var pass = 0; pass < 4; pass++) { groupNodes.forEach(function (n) { if (netMap[n.id] !== 'OTHER') return; var votes = {}; rawAdj[n.id].forEach(function (id) { var k = netMap[id]; if (k && k !== 'OTHER') votes[k] = (votes[k] || 0) + 1; }); var best = null, bestN = 0; Object.keys(votes).forEach(function (k) { if (votes[k] > bestN) { best = k; bestN = votes[k]; } }); if (best) netMap[n.id] = best; }); } var counts = {}, nets = []; groupNodes.forEach(function (n) { var k = netMap[n.id]; if (!counts[k]) { counts[k] = 0; nets.push(k); } counts[k]++; }); nets.sort(function (a, b) { if (a === 'SURVE') return -1; if (b === 'SURVE') return 1; if (a === 'OFFICE') return -1; if (b === 'OFFICE') return 1; return counts[b] - counts[a]; }); var activeNet = (detailNet && counts[detailNet]) ? detailNet : (nets.length ? nets[0] : 'OTHER'); var nodes = groupNodes.filter(function (n) { return netMap[n.id] === activeNet; }); 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 = {}; nodes.forEach(function (n) { adj[n.id] = []; }); internal.forEach(function (e) { adj[e.source].push(e.target); adj[e.target].push(e.source); }); // Distribution switches are the left-hand roots of the workshop tree. var dists = nodes.filter(function (n) { return /dist/i.test(n.tier || '') && /dist/i.test(n.name || ''); }) .sort(function (a, b) { return (adj[b.id].length - adj[a.id].length) || String(a.name).localeCompare(String(b.name)); }); if (!dists.length) { var cand = nodes.slice().sort(function (a, b) { return adj[b.id].length - adj[a.id].length; })[0]; if (cand) dists = [cand]; } // One physical root per network tab; peer/redundant distribution switches are listed below it. if (dists.length > 1) dists = [dists[0]]; // Group all non-distribution switches into floor buckets (GRD lowest, then 1F/2F/3F...). var floorMap = {}; nodes.forEach(function (n) { if (dists.indexOf(n) >= 0) return; if (/core/i.test(n.tier || '')) return; var fi = floorInfo(n); var f = floorMap[fi.key] || (floorMap[fi.key] = {key: fi.key, label: fi.label, rank: fi.rank, nodes: []}); f.nodes.push(n); }); var floors = Object.keys(floorMap).map(function (k) { return floorMap[k]; }); floors.forEach(function (f) { f.nodes.sort(function (a, b) { var pa = parseName(a.name), pb = parseName(b.name); var ca = (pa.cab || '') + (pa.u || ''), cb = (pb.cab || '') + (pb.u || ''); if (ca !== cb) return ca.localeCompare(cb); return String(a.name).localeCompare(String(b.name)); }); }); // Highest floor at the top, GRD at the bottom. floors.sort(function (a, b) { return b.rank - a.rank; }); var WORK_L = 300, FLOOR_X = 520, DIST_FRAME_W = 190, TOP_OFF = 34; var ROW_W = 380, ROW_H = 24, ROW_GAP = 8, FLOOR_HEAD = 28, FLOOR_GAP = 12; var floorW = ROW_W + PADX * 2 + 110; floors.forEach(function (f) { f.cols = 1; f.rows = f.nodes.length; f.innerW = ROW_W; f.h = FLOOR_HEAD + f.rows * ROW_H + Math.max(0, f.rows - 1) * ROW_GAP + 10; }); var workRight = FLOOR_X + floorW + 14; var workW = workRight - WORK_L; var pos = {}, top = 44 + TOP_OFF; floors.forEach(function (f) { f.frame = {x: FLOOR_X, y: top, w: floorW, h: f.h}; f.nodes.forEach(function (n, i) { pos[n.id] = { x: FLOOR_X + PADX + ROW_W / 2, y: top + FLOOR_HEAD + i * (ROW_H + ROW_GAP) + ROW_H / 2 }; }); top += f.h + FLOOR_GAP; }); var workH = Math.max(220, top + 6); var distFrame = dists.length ? {x: WORK_L + 8, y: 44 + TOP_OFF, w: DIST_FRAME_W, h: workH - (44 + TOP_OFF) - 14} : null; var distX = WORK_L + 8 + DIST_FRAME_W / 2; if (dists.length) { var childYs = floors.reduce(function (acc, f) { return acc.concat(f.nodes.map(function (n) { return pos[n.id] ? pos[n.id].y : 0; })); }, []); var cy = childYs.length ? (Math.min.apply(null, childYs) + Math.max.apply(null, childYs)) / 2 : distFrame.y + distFrame.h / 2; pos[dists[0].id] = {x: distX, y: cy}; } var missing = nodes.filter(function (n) { return !pos[n.id]; }); missing.forEach(function (n, i) { pos[n.id] = {x: distX, y: (distFrame ? distFrame.y + 30 : 80) + i * (NODE_H + 12)}; }); // One ghost core per external core→distribution uplink, aligned left of its distribution switch. 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 (!pos[inside] || !byId[outside]) return; // Only a real core uplink gets a ghost; other cross-group edges are not drawn as cores. if (!/core/i.test(byId[outside].tier || '') && !/core/i.test(byId[outside].name || '')) return; var key = outside + '|' + inside; if (ghostSeen[key]) return; var g = {id: 'gh_' + outside + '_' + inside, to: inside, node: byId[outside], speed: e.speed, state: e.state}; ghostSeen[key] = g; ghosts.push(g); }); var ghostAt = {}; ghosts.forEach(function (g) { var base = pos[g.to]; var k = ghostAt[g.to] || 0; ghostAt[g.to] = k + 1; g.x = 92; g.y = base.y + (k ? (k % 2 ? 1 : -1) * 46 * Math.ceil(k / 2) : 0); }); // Root each tree at the distribution switch; remember every child's parent for path highlighting. var parentOf = {}, ghostOfDist = {}; ghosts.forEach(function (g) { if (!ghostOfDist[g.to]) ghostOfDist[g.to] = g.id; }); var seen = {}; dists.forEach(function (d) { if (seen[d.id]) return; seen[d.id] = true; parentOf[d.id] = ghostOfDist[d.id] || null; var queue = [d.id]; while (queue.length) { var cur = queue.shift(); adj[cur].forEach(function (nb) { if (seen[nb]) return; seen[nb] = true; parentOf[nb] = cur; queue.push(nb); }); } }); nodes.forEach(function (n) { if (!seen[n.id]) seen[n.id] = true; }); var workshop = {x: WORK_L, y: 0, w: workW, h: workH, label: label + ' · ' + networkLabel(activeNet) + ' · ' + nodes.length + ' ' + t('l2.unit_sw', 'sw')}; var distFrameFull = distFrame ? { x: distFrame.x, y: distFrame.y, w: distFrame.w, h: distFrame.h, label: t('l2.dist', 'Distribution') + ' · ' + dists.length } : null; floors.forEach(function (f) { f.frame.label = f.label + ' · ' + f.nodes.length + ' ' + t('l2.unit_sw', 'sw'); }); var root = {x: -20, y: -48, w: workRight + 40, h: workH + 80}; root.cx = root.x + root.w / 2; root.cy = root.y + root.h / 2; return { nodes: nodes, byId: byId, pos: pos, ghosts: ghosts, parentOf: parentOf, dists: dists, floors: floors, frames: {workshop: workshop, dist: distFrameFull}, root: root, totalW: workRight, ids: ids, internal: internal, nets: nets.map(function (k) { return {key: k, count: counts[k]}; }), activeNet: activeNet, totalNodes: groupNodes.length }; } /* ---------------- one-chart layout: fixed columns by tier ---------------- Left to right: physical core → each network's main switch → every workshop distribution switch → access switches. A workshop frame spans the distribution + access columns; each floor is a small frame inside it. Same workshop on two networks is always two separate frames. */ var CORE_X = 130, MAIN_X = 430, DIST_X = 790, ACCESS_X = 1300; var CORE_W = 200, MAIN_W = 300, NODE_W3 = 300; var WORK_LEFT = 610, WORK_RIGHT = 1530; var FLOOR_LEFT = 1130; var CHART_W = 1560; var ACCESS_STEP = 380, DIST_STEP = 86; var SEC_HEAD = 46, CORE_ROW = 92, WORK_TOP = 148; var ROW_H = 34, ROW_GAP = 14, WS_GAP = 36, SEC_GAP = 52; var FLOOR_GAP = 26; // Front-panel port colours are deliberately separate from link colours: // the ring shows the port's maximum capability, the inner fill shows the // negotiated rate. This is why a 10GE port with a 1G link is purple/green. var PORT_COLORS = { 100: '#F39C12', 1000: '#2ECC71', 2500: '#17C3B2', 5000: '#17C3B2', 10000: '#9B59B6', 20000: '#8E44AD', 40000: '#7D3C98', 100000: '#6C3483', unknown: '#95A5A6' }; function portColor(v) { var n = parseFloat(v); if (!isFinite(n) || n <= 0) return PORT_COLORS.unknown; if (n <= 100) return PORT_COLORS[100]; if (n <= 1000) return PORT_COLORS[1000]; if (n <= 2500) return PORT_COLORS[2500]; if (n <= 5000) return PORT_COLORS[5000]; if (n <= 10000) return PORT_COLORS[10000]; if (n <= 20000) return PORT_COLORS[20000]; if (n <= 40000) return PORT_COLORS[40000]; return PORT_COLORS[100000]; } function portMaxFromName(name, fallback) { var s = String(name || '').toUpperCase().replace(/\s+/g, ''); var m = s.match(/^(100GE|50GE|40GE|25GE|10GE|XGE|2\.5GE|GE|FE)/); if (m) { return { '100GE': 100000, '50GE': 50000, '40GE': 40000, '25GE': 25000, '10GE': 10000, 'XGE': 10000, '2.5GE': 2500, 'GE': 1000, 'FE': 100 }[m[1]] || fallback || 0; } if (/^GIGABITETHERNET/.test(s)) return 1000; if (/^XGIGABITETHERNET/.test(s)) return 10000; if (/^FASTETHERNET/.test(s)) return 100; return parseFloat(fallback) || 0; } function portFamily(name) { var s = String(name || '').toUpperCase().replace(/\s+/g, ''); var m = s.match(/^(100GE|50GE|40GE|25GE|10GE|XGE|2\.5GE|GE|FE)/); if (m) return m[1]; if (/^GIGABITETHERNET/.test(s)) return 'GE'; if (/^XGIGABITETHERNET/.test(s)) return 'XGE'; if (/^FASTETHERNET/.test(s)) return 'FE'; return 'OTHER'; } function portRows(ports) { var groups = {}, order = []; (ports || []).slice().sort(function (a, b) { return (parseInt(a.order, 10) || 0) - (parseInt(b.order, 10) || 0); }).forEach(function (p) { var f = portFamily(p.name); if (!groups[f]) { groups[f] = []; order.push(f); } groups[f].push(p); }); var rows = []; order.forEach(function (f) { var arr = groups[f]; // A normal access panel is two rows: 24 ports => 12+12, 48 => 24+24. // Uplink groups (usually 4-8 ports) stay on their own single row. var per = arr.length <= 12 ? arr.length : Math.ceil(arr.length / 2); for (var i = 0; i < arr.length; i += per) rows.push(arr.slice(i, i + per)); }); return rows; } function makeNetworks() { var raw = CFG.networks; if (!raw || !raw.length) { raw = [ {key: 'SURVE', label: t('l2.net_surve', 'SURVE 监控网'), keywords: ['SURVE', '10.169.'], color: '#2E75B6'}, {key: 'OFFICE', label: t('l2.net_office', 'OFFICE 办公网'), keywords: ['OFFICE', '10.160.'], color: '#548235'} ]; } return raw.map(function (n, i) { var kws = n.keywords; if (!(kws instanceof Array)) kws = String(kws || '').split(/[,,;;\s]+/); return { key: String(n.key || ('NET' + i)).trim().toUpperCase(), label: String(n.label || n.key || ('NET' + i)), keywords: kws.map(function (s) { return String(s).trim(); }).filter(Boolean), color: /^#[0-9a-fA-F]{6}$/.test(String(n.color || '')) ? n.color : '#607D8B' }; }); } var NETWORKS = makeNetworks(); function netLabel(key) { for (var i = 0; i < NETWORKS.length; i++) if (NETWORKS[i].key === key) return NETWORKS[i].label; return key === 'OTHER' ? t('l2.net_other', '其他网络') : String(key || ''); } function netColor(key) { for (var i = 0; i < NETWORKS.length; i++) if (NETWORKS[i].key === key) return NETWORKS[i].color; return '#607D8B'; } function rawNetOf(n) { var hay = (String((n && n.name) || '') + ' ' + String((n && n.ip) || '')).toUpperCase(); for (var i = 0; i < NETWORKS.length; i++) { for (var j = 0; j < NETWORKS[i].keywords.length; j++) { if (hay.indexOf(String(NETWORKS[i].keywords[j]).toUpperCase()) >= 0) return NETWORKS[i].key; } } return 'OTHER'; } // Core/root switch rules are configured from the L2 tab, one row per core. // Multiple cores in the same network are supported and all of them are // placed in the left-most core column. function coreRules() { var r = CFG.core_rules; return (r instanceof Array) ? r : []; } function coreRuleHit(rule, n) { var pattern = String(rule.pattern || ''); if (!pattern) return false; var net = String(rule.net || '').toUpperCase(); if (net) { var raw = rawNetOf(n); // A core with no recognisable network keyword may still be assigned by // the rule; a switch already known to belong to another network must // not be stolen by a broad pattern. if (raw !== 'OTHER' && raw !== net) return false; } var hay = String((n && n.name) || '') + ' ' + String((n && n.ip) || ''); if (String(rule.match || 'contains') === 'regex') { try { return new RegExp(pattern, 'i').test(hay); } catch (e) { return false; } } return hay.toLowerCase().indexOf(pattern.toLowerCase()) >= 0; } function configuredCore(n) { var rules = coreRules(); for (var i = 0; i < rules.length; i++) if (coreRuleHit(rules[i], n)) return true; return false; } function configuredCoreNet(n) { var rules = coreRules(); for (var i = 0; i < rules.length; i++) { if (coreRuleHit(rules[i], n) && String(rules[i].net || '').trim() !== '') return String(rules[i].net).toUpperCase(); } return ''; } // Network is decided first by name/IP keyword, then by the rest of the // workshop, then by graph adjacency. No switch is ever shown under the // wrong upstream network. function assignNetworks(snap) { var nodes = snap.nodes || [], byId = {}, net = {}, adj = {}; nodes.forEach(function (n) { byId[n.id] = n; net[n.id] = rawNetOf(n); adj[n.id] = []; var cn = configuredCoreNet(n); if (cn) net[n.id] = cn; }); (snap.edges || []).forEach(function (e) { if (adj[e.source] && adj[e.target]) { adj[e.source].push(e.target); adj[e.target].push(e.source); } }); var groupVote = {}; nodes.forEach(function (n) { var k = groupOf(n).label, v = net[n.id]; if (v && v !== 'OTHER') (groupVote[k] = groupVote[k] || {})[v] = true; }); nodes.forEach(function (n) { if (net[n.id] !== 'OTHER') return; var v = groupVote[groupOf(n).label], keys = v ? Object.keys(v) : []; if (keys.length === 1) net[n.id] = keys[0]; }); for (var pass = 0; pass < 8; pass++) { var changed = false; nodes.forEach(function (n) { if (net[n.id] !== 'OTHER') return; var votes = {}; adj[n.id].forEach(function (id) { if (net[id] && net[id] !== 'OTHER') votes[net[id]] = (votes[net[id]] || 0) + 1; }); var best = null, bn = 0; Object.keys(votes).forEach(function (k) { if (votes[k] > bn) { best = k; bn = votes[k]; } }); if (best) { net[n.id] = best; changed = true; } }); if (!changed) break; } return net; } function roleOf(n) { var p = parseName(n.name || ''); var r = String(p.role || '').toLowerCase(); var tier = String(n.tier || '').toLowerCase(); var childCount = parseInt(n.children, 10); if (isNaN(childCount)) childCount = -1; if (configuredCore(n)) return 'core'; // Backward-compatible fallback only when the core list is absent (old // config). An explicitly empty list means the administrator wants no // configured core column. if (!(CFG.core_rules instanceof Array) && (/^core(\s|-)/i.test(String(n.name || '')) || groupOf(n).label === 'Core')) return 'core'; // Hostname role is the display truth for Access switches. An access // switch may itself have access children (cascade/stack); it still stays // an access node and the children simply take a further-right column. // A Dist switch stays a distribution point even when it has only one child. if (r === 'access') return 'access'; if (r === 'dist') { // Old snapshots sometimes call a leaf "Dist"; if it has no children and // the collector already marked it as access, keep it an access leaf. if (tier === 'access' && childCount <= 0) return 'access'; return 'dist'; } if (r === 'core') return childCount > 0 ? 'dist' : 'main'; if (tier === 'core') { if (childCount > 0) return 'dist'; return 'main'; } if (childCount > 0) return 'dist'; if (tier === 'distribution') return 'dist'; return 'access'; } function roleName(r) { if (r === 'core') return t('l2.core', '核心'); if (r === 'main') return t('l2.main', '主交换'); if (r === 'dist') return t('l2.dist', '汇聚'); return t('l2.access', '接入'); } function shortLoc(n) { var p = parseName(n.name || ''); if (!p.floor) return ''; return p.floor + (p.cab || '') + (p.u ? '-' + p.u : ''); } function switchLabel(n, role, netKey) { var g = groupOf(n).label; var s; if (role === 'core') s = roleName('core') + ' · ' + (netKey || rawNetOf(n)) + ' · ' + (n.ip || ''); else if (role === 'main') s = roleName('main') + ' · ' + g + ' · ' + (n.ip || ''); else { var loc = shortLoc(n); s = [g, loc, roleName(role)].filter(Boolean).join(' · ') + ' · ' + (n.ip || ''); } return poePrefix(n) + s; } // A workshop is the physical area served by one distribution switch, not // the four-digit prefix printed on each access switch. The same dist can // serve access switches in several rooms/buildings; they must share one // frame, otherwise their uplink line would cross unrelated frames. function workshopKeys(snap) { var byId = {}, adj = {}, keys = {}; (snap.nodes || []).forEach(function (n) { byId[n.id] = n; adj[n.id] = []; }); (snap.edges || []).forEach(function (e) { if (adj[e.source] && adj[e.target]) { adj[e.source].push(e.target); adj[e.target].push(e.source); } }); function depthOf(n) { var d = parseInt(n && n.depth, 10); return (isFinite(d) && d >= 0 && d < 99) ? d : 999; } // Walk upstream to the highest non-core node. This is the real workshop // anchor. It keeps cascaded access (2.59 -> 2.75) in the 4401 frame while // still allowing an access switch that is itself an anchor (4504 -> .118/ // 6109 access) to own one frame. No protocol edge is changed here. function rootOf(n) { var cur = n, guard = 0; while (cur && guard++ < 80) { var cd = depthOf(cur), best = null; (adj[cur.id] || []).forEach(function (id) { var up = byId[id]; if (!up || up.id === cur.id) return; var ud = depthOf(up); if (ud < cd) { if (!best || ud < depthOf(best)) best = up; } }); if (!best) return cur; if (roleOf(best) === 'core') return cur; cur = best; } return cur || n; } (snap.nodes || []).forEach(function (n) { var r = roleOf(n); if (r === 'core' || r === 'main') return; var root = rootOf(n); keys[n.id] = groupOf(root || n).label; }); return keys; } function workshopList(snap, netMap, netKey, wsKeys) { var map = {}, order = []; (snap.nodes || []).forEach(function (n) { if (netMap[n.id] !== netKey) return; var r = roleOf(n); if (r === 'core' || r === 'main') return; var g = (wsKeys && wsKeys[n.id]) || groupOf(n).label; if (!map[g]) { map[g] = {key: g, label: g, extra: [], net: netKey, dists: [], access: [], all: []}; order.push(g); } map[g].all.push(n); if (r === 'dist') map[g].dists.push(n); else map[g].access.push(n); }); return order.map(function (g) { return map[g]; }).filter(function (w) { return w.all.length > 0; }) .map(function (w) { w.access.forEach(function (a) { var raw = groupOf(a).label; if (raw !== w.key && w.extra.indexOf(raw) < 0) w.extra.push(raw); }); w.label = w.key + (w.extra.length ? ' + ' + w.extra.slice(0, 3).join('/') + (w.extra.length > 3 ? '…' : '') : ''); return w; }); } function layoutWorkshop(w, top, snap) { var portRowMax = 0; if (showTerm) { w.all.forEach(function (n) { var rows = portRows(n.ports).length; if (!rows) rows = 2; // generic 24-port fallback if (rows > portRowMax) portRowMax = rows; }); } // Every port row is 14px high (9px circle + breathing room). Keep a // little space below the last row; when ports are unavailable the old // 24px terminal area remains. var portExtra = showTerm ? Math.max(24, portRowMax * 14 + 10) : 0; var rowH = ROW_H + ROW_GAP + portExtra; var floorMap = {}, floors = [], accessFloor = {}; w.access.forEach(function (n) { var fi = floorInfo(n); var k = fi.key; if (!floorMap[k]) { floorMap[k] = {key: k, label: fi.label, rank: (fi.key === 'NA' ? 999 : fi.rank), nodes: [], rows: []}; floors.push(floorMap[k]); } floorMap[k].nodes.push(n); accessFloor[n.id] = k; }); floors.sort(function (a, b) { return a.rank - b.rank || String(a.label).localeCompare(String(b.label)); }); // Build the access sub-columns from the real graph: direct children of a // dist are column 0, their access children column 1, and so on. No // logical parent/child is replaced by this; it only decides x positions. var byId = {}, adj = {}, member = {}; w.all.forEach(function (n) { byId[n.id] = n; adj[n.id] = []; member[n.id] = true; }); (snap.edges || []).forEach(function (e) { if (member[e.source] && member[e.target]) { adj[e.source].push(e.target); adj[e.target].push(e.source); } }); function numDepth(n) { var d = parseInt(n && n.depth, 10); return (isFinite(d) && d >= 0 && d < 99) ? d : 99; } var starts = w.dists.map(function (n) { return n.id; }); if (!starts.length) { // A frame with no Dist (e.g. 4504 when its hostname says Access) still // has a top access node. Find nodes with no upstream member. w.access.forEach(function (n) { var up = false; (adj[n.id] || []).forEach(function (id) { var other = byId[id]; if (other && numDepth(other) < numDepth(n)) up = true; }); if (!up) starts.push(n.id); }); if (!starts.length && w.access.length) starts.push(w.access[0].id); } var level = {}, queue = []; starts.forEach(function (id) { if (byId[id]) { level[id] = 0; queue.push(id); } }); while (queue.length) { var cur = queue.shift(); (adj[cur] || []).forEach(function (id) { if (!byId[id] || level[id] != null) return; level[id] = level[cur] + 1; queue.push(id); }); } var base = w.dists.length ? 1 : 0; w.access.forEach(function (n) { var lv = level[n.id]; n._col = (lv == null) ? 0 : Math.max(0, lv - base); if (n._col > 12) n._col = 12; }); var maxCol = 0; floors.forEach(function (f) { // A child fed from another floor (e.g. 1FA-27 2.59 -> GRD 2.75) is put // at the top of its own floor frame. Its straight line then stays out // of the labels of the floor's normal first column. function parentFloorKey(n) { var best = null, bestD = 999; (adj[n.id] || []).forEach(function (id) { var up = byId[id]; if (!up || !accessFloor[id]) return; var ud = numDepth(up); if (ud < numDepth(n) && ud < bestD) { best = up; bestD = ud; } }); return best ? accessFloor[best.id] : f.key; } f.nodes.sort(function (a, b) { var ca = (parentFloorKey(a) === f.key ? 1 : 0) - (parentFloorKey(b) === f.key ? 1 : 0); if (ca) return ca; var cc = (a._col || 0) - (b._col || 0); if (cc) return cc; return String(a.name).localeCompare(String(b.name)); }); f.nodes.forEach(function (n) { maxCol = Math.max(maxCol, n._col || 0); f.maxCol = Math.max(f.maxCol || 0, n._col || 0); }); f.crossFloor = f.nodes.some(function (n) { return parentFloorKey(n) !== f.key; }); f.h = 28 + f.nodes.length * rowH + 10; }); var accessH = 0; floors.forEach(function (f) { accessH += f.h; }); if (floors.length > 1) accessH += (floors.length - 1) * FLOOR_GAP; var distH = w.dists.length ? (w.dists.length * DIST_STEP + (w.dists.length - 1) * 10) : 0; var inner = Math.max(accessH, distH, 54); var h = 40 + inner + 12; w.top = top; w.h = h; w.rowH = rowH; w.right = Math.max(WORK_RIGHT, ACCESS_X + maxCol * ACCESS_STEP + NODE_W3 / 2 + 26); // Floors are stacked bottom-up: GRD lowest, then 1F → 3F → 4F → 7F. var cursor = top + h - 10; floors.forEach(function (f) { cursor -= f.h; var floorW = Math.max(340, ACCESS_X + (f.maxCol || 0) * ACCESS_STEP + NODE_W3 / 2 + 26 - FLOOR_LEFT); f.frame = {x: FLOOR_LEFT, y: cursor, w: floorW, h: f.h}; if (f.crossFloor) f.labelX = FLOOR_LEFT + 72; f.nodes.forEach(function (n, i) { f.rows.push({id: n.id, x: ACCESS_X + (n._col || 0) * ACCESS_STEP, y: cursor + 28 + rowH / 2 + i * rowH}); }); cursor -= FLOOR_GAP; }); var dstart = top + 40 + (inner - distH) / 2; w.distPos = w.dists.map(function (n, i) { return {id: n.id, x: DIST_X, y: dstart + DIST_STEP / 2 + i * (DIST_STEP + 10)}; }); w.floors = floors; w.title = w.label + ' · ' + netLabel(w.net) + ' · ' + w.all.length + ' ' + t('l2.unit_sw', '台'); // A workshop without a distribution switch only needs a box around its // access columns; keeping the full dist-column width would leave a large // empty rectangle and make the chart look sparse. var frameLeft = w.dists.length ? WORK_LEFT : FLOOR_LEFT - 40; w.frame = {x: frameLeft, y: top, w: w.right - frameLeft, h: h}; return h; } function computeFull(snap) { var nodes = snap.nodes || []; var netMap = assignNetworks(snap); var sections = [], placements = [], frames = [], roleMap = {}; var wsKeys = workshopKeys(snap); var y = 100, maxRight = CHART_W; NETWORKS.forEach(function (net) { var secNodes = nodes.filter(function (n) { return netMap[n.id] === net.key; }); if (!secNodes.length) return; var cores = [], main = null; secNodes.forEach(function (n) { roleMap[n.id] = roleOf(n); if (roleMap[n.id] === 'core') cores.push(n); if (roleMap[n.id] === 'main' && !main) main = n; }); var ws = workshopList(snap, netMap, net.key, wsKeys); var sec = { key: net.key, label: netLabel(net.key) + ' · ' + secNodes.length + ' ' + t('l2.unit_sw', '台'), x: 10, y: y, w: CHART_W - 20, h: 0, color: netColor(net.key), core: cores[0] || null, cores: cores, main: main, workshops: ws }; var cur = y + WORK_TOP; ws.forEach(function (w) { layoutWorkshop(w, cur, snap); maxRight = Math.max(maxRight, w.right || WORK_RIGHT); cur += w.h + WS_GAP; frames.push({id: 'ws_' + net.key + '_' + w.key, type: 'wsframe', x: w.frame.x, y: w.frame.y, w: w.frame.w, h: w.frame.h, label: w.title, group: w.key, net: net.key, color: net.color}); w.floors.forEach(function (f, fi) { frames.push({id: 'ff_' + net.key + '_' + w.key + '_' + fi, type: 'floorframe', x: f.frame.x, y: f.frame.y, w: f.frame.w, h: f.frame.h, label: f.label + ' · ' + f.nodes.length + ' ' + t('l2.unit_sw', '台'), labelX: f.labelX, net: net.key, color: net.color}); }); w.distPos.forEach(function (p) { var n = nodes.filter(function (x) { return x.id === p.id; })[0]; if (n) placements.push({node: n, role: 'dist', x: p.x, y: p.y, w: NODE_W3, h: 26, net: net.key, group: w.key, ws: net.key + '|' + w.key}); }); w.floors.forEach(function (f) { f.rows.forEach(function (p) { var n = nodes.filter(function (x) { return x.id === p.id; })[0]; if (n) placements.push({node: n, role: 'access', x: p.x, y: p.y, w: NODE_W3, h: 26, net: net.key, group: w.key, ws: net.key + '|' + w.key}); }); }); }); sec.h = (cur - y) + 16; // The network main switch stays at the top of its tier column (its access // children sit directly under it, as in the reference chart); the core // sits lower in the column so its uplinks fan symmetrically. sec.mainY = y + SEC_HEAD + 46; sec.coreY = y + Math.max(CORE_ROW + SEC_HEAD, Math.round(sec.h * 0.55)); if (cores.length) { var coreStep = 64; var coreTop = sec.coreY - ((cores.length - 1) * coreStep) / 2; cores.forEach(function (cn, ci) { placements.push({node: cn, role: 'core', x: CORE_X, y: coreTop + ci * coreStep, w: CORE_W, h: 54, net: net.key, group: 'Core'}); }); } if (main) placements.push({node: main, role: 'main', x: MAIN_X, y: sec.mainY, w: MAIN_W, h: 46, net: net.key, group: groupOf(main).label}); sections.push(sec); y += sec.h + SEC_GAP; }); var chartW = Math.max(CHART_W, maxRight + 40); sections.forEach(function (sec) { sec.w = chartW - 20; }); var root = {x: 0, y: 0, w: chartW, h: y + 6}; return {sections: sections, placements: placements, frames: frames, netMap: netMap, roleMap: roleMap, root: root}; } // Immediate upstream parent per switch: access -> its workshop dist; // dist -> core when directly attached, otherwise the network main switch. function buildParents(snap, L) { var byId = {}, adj = {}, edgeSpeed = {}; L.placements.forEach(function (p) { byId[p.node.id] = p; }); (snap.nodes || []).forEach(function (n) { adj[n.id] = []; }); (snap.edges || []).forEach(function (e) { if (!adj[e.source] || !adj[e.target]) return; adj[e.source].push(e.target); adj[e.target].push(e.source); edgeSpeed[e.source + '|' + e.target] = edgeSpeed[e.target + '|' + e.source] = parseFloat(e.speed) || 0; }); var parent = {}; Object.keys(byId).forEach(function (id) { var role = L.roleMap[id] || 'access'; if (role === 'core') return; var p = byId[id], cands = (adj[id] || []).map(function (x) { return {id: x, role: L.roleMap[x] || 'access', p: byId[x]}; }) .filter(function (c) { return c.p; }); var want = role === 'main' ? ['core'] : (role === 'dist' ? ['core', 'main'] : ['dist', 'main', 'core']); var best = null; for (var wi = 0; wi < want.length && !best; wi++) { var list = cands.filter(function (c) { if (c.role !== want[wi]) return false; if (role === 'access' && want[wi] === 'dist' && c.p.ws && p.ws && c.p.ws !== p.ws) return false; return true; }); if (list.length) { list.sort(function (a, b) { return (edgeSpeed[id + '|' + b.id] || 0) - (edgeSpeed[id + '|' + a.id] || 0); }); best = list[0]; } } if (best) parent[id] = best.id; }); return parent; } /* ---------------- 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 <= 2500) return colors['2500']; 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; } // Animation speed (pixels per 60Hz frame) by link rate. The visible // flow is only an overlay; the protocol/display link itself never changes. function flowRate(speed) { var s = parseFloat(speed) || 0; if (s >= 20000) return 4.6; if (s >= 10000) return 3.4; if (s >= 1000) return 2.0; if (s > 0) return 0.9; return 0.6; } function speedText(speed) { var s = parseFloat(speed) || 0; if (!s) return '?'; if (s >= 1000) return (s / 1000) + 'G'; return s + 'M'; } function terminalOnline(tm) { return tm && (tm.online === true || tm.online === 1 || tm.online === '1' || tm.online === 'true'); } function terminalText(tm) { return tm.mac + '\n' + speedText(tm.speed); } /* ---------------- 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="netframe"]', style: { 'background-opacity': d ? 0.03 : 0.02, 'background-color': d ? '#7b91b8' : '#adc0d8', 'border-width': 3, 'border-style': 'solid', 'border-color': 'data(bd)', 'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center', 'font-size': 22, 'font-weight': 900, 'color': d ? '#dbe9ff' : '#29425f', 'text-margin-y': 8, 'padding': '0px', 'events': 'no', 'z-index-compare': 'manual', 'z-index': 1 }}, {selector: 'node[type="wsframe"]', style: { 'background-opacity': d ? 0.02 : 0.014, 'background-color': d ? '#6f8fc4' : '#8fa6c4', 'border-width': 2.2, 'border-style': 'solid', 'border-color': 'data(bd)', 'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center', 'text-wrap': 'none', 'font-size': 15.5, 'font-weight': 900, 'color': d ? '#d7e6ff' : '#2f4560', 'text-margin-x': 0, 'text-margin-y': 7, 'padding': '0px', 'events': 'no', 'z-index-compare': 'manual', 'z-index': 2 }}, {selector: 'node[type="floorframe"]', style: { 'background-opacity': d ? 0.04 : 0.025, 'background-color': d ? '#5b7bb0' : '#8fa6c4', 'border-width': 1.1, 'border-style': 'dashed', 'border-color': 'data(bd)', 'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center', 'text-wrap': 'none', 'font-size': 11.5, 'font-weight': 800, 'color': d ? '#a9c1e3' : '#425a78', 'text-margin-x': 0, 'text-margin-y': 6, 'padding': '0px', 'events': 'no', 'z-index-compare': 'manual', 'z-index': 3 }}, {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="roottitle"]', style: { 'width': 'data(w)', 'height': 20, 'shape': 'rectangle', 'background-opacity': 0, 'border-width': 0, 'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'none', 'font-size': 24, 'font-weight': 900, 'color': d ? '#cfe0ff' : '#33465f', 'events': 'no', 'z-index-compare': 'manual', 'z-index': 5 }}, {selector: 'node[type="nettitle"]', style: { 'width': 'data(w)', 'height': 20, 'shape': 'rectangle', 'background-opacity': 0, 'border-width': 0, 'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'none', 'font-size': 20, 'font-weight': 900, 'color': d ? '#dbe9ff' : '#29425f', 'events': 'no', 'z-index-compare': 'manual', 'z-index': 5 }}, {selector: 'node[type="wslabel"]', style: { 'width': 'data(w)', 'height': 16, 'shape': 'rectangle', 'background-opacity': 0, 'border-width': 0, 'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'none', 'font-size': 15, 'font-weight': 900, 'color': d ? '#d7e6ff' : '#2f4560', 'events': 'no', 'z-index-compare': 'manual', 'z-index': 5 }}, {selector: 'node[type="floorlabel"]', style: { 'width': 'data(w)', 'height': 16, 'shape': 'rectangle', 'background-opacity': 0, 'border-width': 0, 'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'none', 'font-size': 11, 'font-weight': 800, 'color': d ? '#a9c1e3' : '#425a78', 'events': 'no', 'z-index-compare': 'manual', 'z-index': 5 }}, {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="edgepoint"]', style: { 'width': 1, 'height': 1, 'opacity': 0, 'events': 'no' }}, {selector: 'node[type="port"]', style: { 'width': 'data(size)', 'height': 'data(size)', 'shape': 'ellipse', 'background-color': 'data(fill)', 'background-opacity': 'data(fillOpacity)', 'border-width': 1.5, 'border-color': 'data(ring)', 'padding': '0px', 'label': '', 'overlay-opacity': 0, 'events': 'yes', 'z-index-compare': 'manual', 'z-index': 28 }}, {selector: 'node[type="term"]', style: { 'width': 7, 'height': 7, 'shape': 'ellipse', 'background-color': 'data(bg)', 'border-width': 1, 'border-color': d ? '#2a3648' : '#cfd8e3', 'label': 'data(label)', 'font-size': 9, 'font-weight': 700, 'color': d ? '#eaf1fb' : '#182c44', 'text-valign': 'bottom', 'text-halign': 'center', 'text-margin-y': 4, 'text-wrap': 'wrap', 'text-max-width': 160, 'text-background-color': d ? '#0d1b2a' : '#ffffff', 'text-background-opacity': 0.9, 'text-background-padding': '2px', '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="link"]', style: { 'width': 'data(w)', 'line-color': 'data(color)', 'line-style': 'data(ls)', 'opacity': 0.62, 'curve-style': 'straight', 'source-endpoint': '0% 50%', 'target-endpoint': '0% 50%', 'target-arrow-shape': 'none', 'z-index-compare': 'manual', 'z-index': 1 }}, // Moving data-flow overlay. The base line keeps its speed colour; this // thin dashed line runs on top and is animated downstream (core -> leaf). {selector: 'edge[type="flow"]', style: { 'width': 'data(fw)', 'line-color': d ? '#EDFFFC' : '#0E4B46', 'line-style': 'dashed', 'line-dash-pattern': [4, 10], 'line-dash-offset': 0, 'opacity': d ? 0.68 : 0.52, 'curve-style': 'straight', 'source-endpoint': '0% 50%', 'target-endpoint': '0% 50%', 'target-arrow-shape': 'none', 'events': 'no', 'z-index-compare': 'manual', 'z-index': 3 }}, {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 }}, // Latency is a secondary signal and is the only state that uses a // coloured frame. Alarm/offline are filled red at the node level. {selector: 'node.lat-warn', style: {'border-color': '#F39C12', 'border-width': 4, 'border-style': 'solid'}}, {selector: 'node.lat-bad', style: {'border-color': '#E74C3C', 'border-width': 6}}, {selector: 'node.alarm', style: {'color': '#ffffff'}}, {selector: 'node.offline', style: {'color': '#ffffff', 'opacity': 0.92}}, {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.hl', style: {'border-color': '#ff0', 'border-width': 7, 'overlay-color': '#ff0', 'overlay-opacity': 0.25, 'overlay-padding': 6}}, {selector: 'node.pathhl', style: {'border-width': 5, 'border-color': '#FFD54A', 'overlay-color': '#FFD54A', 'overlay-opacity': 0.2, 'overlay-padding': 4}}, {selector: 'edge.pathhl', style: {'width': 5.5, 'line-color': '#FFD54A', 'opacity': 1}}, {selector: 'edge.detail', style: {'opacity': 0.72, 'curve-style': 'straight', 'source-endpoint': '0% 50%', 'target-endpoint': '0% 50%'}}, {selector: 'node[type="switch"]', style: { 'background-color': function (e) { return e.data('bg'); }, 'text-max-width': 'data(tw)', 'text-wrap': 'none', 'text-halign': 'center', 'font-size': 10.5 }}, {selector: 'node[type="switch"].root', style: {'text-wrap': 'wrap'}} ]; } /* ---------------- render ---------------- */ // One tall column chart cannot be squeezed into a wide window without // wasting half the canvas. The initial view fits the chart WIDTH and // anchors at the top (core switches first); the user can pan down or press // "适应窗口" to see the entire graph. This keeps labels readable. function initialView() { if (!cy) return; var bb = cy.elements().boundingBox(); if (!bb || !bb.w || !bb.h) return; var zw = (cy.width() - 30) / bb.w; var zh = (cy.height() - 30) / bb.h; var tall = (bb.h * zw > cy.height() * 1.5); var z = tall ? zw * 0.95 : Math.min(zw, zh); cy.zoom({level: z}); if (tall) { cy.pan({x: 15 - bb.x * z, y: 28 - bb.y * z}); } else { cy.pan({x: (cy.width() - bb.w * z) / 2 - bb.x * z, y: (cy.height() - bb.h * z) / 2 - bb.y * z}); } } function applyElements(els, keepView) { if (!cy) { cy = cytoscape({ container: el('l2-cy'), elements: els, style: styleFor(dark), layout: {name: 'preset', fit: false}, wheelSensitivity: 0.2, minZoom: 0.02, maxZoom: 4 }); bindCy(); setTimeout(initialView, 0); } else { var z = cy.zoom(), p = cy.pan(); cy.elements().remove(); cy.add(els); cy.style(styleFor(dark)); cy.layout({name: 'preset', fit: false}).run(); if (keepView) { cy.zoom(z); cy.pan(p); } else { initialView(); } } startFlow(); } // Data-flow animation. Each flow overlay has its own rate (link speed), so // a 10G/20G link visibly moves faster than a 100M/1G link. This only // changes the visual dash phase; no topology/state is touched. var flowRaf = null, flowLast = 0; function startFlow() { if (flowRaf != null) return; flowRaf = requestAnimationFrame(flowTick); } function flowTick(ts) { if (!cy) { flowRaf = null; return; } var dt = flowLast ? Math.min(80, ts - flowLast) : 16; flowLast = ts; if (!document.hidden) { cy.batch(function () { cy.edges('[type="flow"]').forEach(function (e) { var rate = parseFloat(e.data('flowRate')) || 0.6; var off = (e.scratch('flowOffset') || 0) - rate * dt / 16; off = off % 14; if (off < 0) off += 14; e.scratch('flowOffset', off); e.style('line-dash-offset', off); }); }); } flowRaf = requestAnimationFrame(flowTick); } function fitGroup(label) { if (!cy || !label) return; var eles = cy.nodes().filter(function (n) { return n.data('blockGroup') === label || n.data('group') === label; }); if (eles.length) cy.fit(eles, 70); } function render(snap) { if (!snap || !snap.nodes) { el('l2-cy').innerHTML = '
' + t('l2.no_data', 'No data') + '
'; return; } lastSnap = snap; renderFull(snap); updateStats(snap); buildLegend(); buildAlarms(snap); buildOffline(snap); buildTerminals(snap); buildHistory(); } function renderFull(snap) { var L = computeFull(snap), els = []; if (!L.placements.length) { applyElements([], false); return; } lastRoleMap = L.roleMap || {}; var byId = {}; L.placements.forEach(function (p) { byId[p.node.id] = p; }); els.push({group: 'nodes', data: {id: '__root', type: 'root', label: '', w: L.root.w, h: L.root.h}, position: {x: L.root.w / 2, y: L.root.h / 2}, selectable: false, grabbable: false}); els.push({group: 'nodes', data: {id: '__roottitle', type: 'roottitle', label: CFG.root_label || 'COBCO', w: L.root.w, h: 20}, position: {x: L.root.w / 2, y: 22}, selectable: false, grabbable: false}); L.sections.forEach(function (sec, si) { els.push({group: 'nodes', data: { id: '__net_' + si, type: 'netframe', label: '', w: sec.w, h: sec.h, net: sec.key, bd: sec.color }, position: {x: sec.x + sec.w / 2, y: sec.y + sec.h / 2}, selectable: false, grabbable: false}); els.push({group: 'nodes', data: {id: '__nettitle_' + si, type: 'nettitle', label: sec.label, w: sec.w, h: 20}, position: {x: sec.x + sec.w / 2, y: sec.y + 21}, selectable: false, grabbable: false}); }); L.frames.forEach(function (f) { els.push({group: 'nodes', data: { id: f.id, type: f.type, label: '', w: f.w, h: f.h, group: f.group || '', net: f.net, bd: f.color || netColor(f.net) }, position: {x: f.x + f.w / 2, y: f.y + f.h / 2}, selectable: false, grabbable: false}); var tt = f.type === 'floorframe' ? 'floorlabel' : 'wslabel'; var ty = f.type === 'floorframe' ? 11 : 18; els.push({group: 'nodes', data: {id: 'label_' + f.id, type: tt, label: f.label, w: f.w, h: 16}, position: {x: (f.labelX != null ? f.labelX : f.x + f.w / 2), y: f.y + ty}, selectable: false, grabbable: false}); }); L.placements.forEach(function (p) { var n = p.node, cls = []; var bg = p.role === 'core' ? TIER_BG.core : (p.role === 'main' ? '#2E75B6' : (p.role === 'dist' ? TIER_BG.dist : TIER_BG.access)); var bd = bg; // Alarm and offline are node-level states: fill the whole node red. // Only latency > LATB is allowed to draw a red frame; 3-5 ms uses an // orange frame. Alarm must never be represented by a red border alone. if (n.alarms && n.alarms.length) { cls.push('alarm'); bg = '#E45959'; bd = '#E45959'; } if (n.offline || n.oper === 'down') { cls.push('offline'); bg = '#C0392B'; bd = '#C0392B'; } // Alarm/offline already owns the whole fill; do not put a latency frame // around a red alarm node (that would read as a contradictory state). if (!cls.length && n.latency_ms != null) { if (+n.latency_ms > LATB) cls.push('lat-bad'); else if (+n.latency_ms > LATW) cls.push('lat-warn'); } if (p.role === 'core') cls.push('root'); els.push({group: 'nodes', data: { id: n.id, type: 'switch', name: n.name, ip: n.ip, tier: n.tier, depth: n.depth, hostid: n.hostid, oper: n.oper, offline: !!(n.offline || n.oper === 'down'), last_online: n.last_online, maxsev: n.maxsev, alarms: n.alarms || [], term_online: n.term_online, term_offline: 0, latency_ms: n.latency_ms, poe: !!n.poe, label: switchLabel(n, p.role, p.net), tw: p.w - 12, bg: bg, bd: bd, w: p.w, h: p.h, group: p.group + ' · ' + netLabel(p.net), blockGroup: p.group, net: p.net, role: p.role }, position: {x: p.x, y: p.y}, classes: cls.join(' ')}); }); // Every protocol-discovered link = one straight segment. Cytoscape's // percentage endpoints are unreliable with preset labels, so use two // invisible anchor nodes placed exactly on the label left-edge midpoint. // The labels paint on top, so a parent line starts under the parent label // and ends exactly at the child's left edge. pathParent = buildParents(snap, L); (snap.edges || []).forEach(function (e, i) { var ps = byId[e.source], pt = byId[e.target]; if (!ps || !pt) return; var sp = 'sp_' + i, tp = 'tp_' + i; els.push({group: 'nodes', data: {id: sp, type: 'edgepoint'}, position: {x: ps.x - ps.w / 2, y: ps.y}, selectable: false, grabbable: false}); els.push({group: 'nodes', data: {id: tp, type: 'edgepoint'}, position: {x: pt.x - pt.w / 2, y: pt.y}, selectable: false, grabbable: false}); var col = speedColor(e.speed, e.state); els.push({group: 'edges', data: { id: 'e_' + i, type: 'link', source: sp, target: tp, p: e.source, c: e.target, speed: e.speed, state: e.state, a_port: e.a_port, b_port: e.b_port, color: col, ls: (e.state && e.state !== 'up') ? 'dashed' : 'solid', w: Math.min(4, speedWidth(e.speed)) }}); if (e.state === 'up') { var from = e.source, to = e.target; var sd = parseInt(ps.node && ps.node.depth, 10), td = parseInt(pt.node && pt.node.depth, 10); if (td < sd || (td === sd && pt.y < ps.y)) { from = e.target; to = e.source; } var fsp = (from === e.source) ? sp : tp, ftp = (to === e.source) ? sp : tp; els.push({group: 'edges', data: { id: 'f_' + i, type: 'flow', source: fsp, target: ftp, p: e.source, c: e.target, speed: e.speed, state: e.state, a_port: e.a_port, b_port: e.b_port, flowRate: flowRate(e.speed), fw: Math.max(0.9, Math.min(1.5, speedWidth(e.speed) * 0.35)) }}); } }); if (showTerm) addTerminals(L, byId, els); applyElements(els, true); if (focusGroupLabel) { setTimeout(function () { fitGroup(focusGroupLabel); }, 80); focusGroupLabel = null; } } function portIndexFromName(name) { var s = String(name || '').toUpperCase(); if (/^(ETH-TRUNK|VLANIF|NULL|INLOOPBACK|STACK-PORT|LOOPBACK|TUNNEL|METH|VBDIF)/.test(s)) return 0; var m = s.match(/(\d+)(?!.*\d)/); var n = m ? parseInt(m[1], 10) : 0; return n > 0 && n <= 1024 ? n : 0; } function addTerminals(L, byId, els) { termPortByMac = {}; var bySw = {}, termBySwPort = {}; function pkey(s) { return String(s || '').toUpperCase(); } ((lastSnap && lastSnap.terminals) || []).forEach(function (tm) { if (!byId[tm.sw_ip]) return; if (!terminalOnline(tm)) return; (bySw[tm.sw_ip] = bySw[tm.sw_ip] || []).push(tm); if (tm.port) termBySwPort[tm.sw_ip + '|' + pkey(tm.port)] = tm; }); Object.keys(byId).forEach(function (sw) { var p = byId[sw]; if (!p || p.role === 'core' || p.role === 'main') return; var terms = bySw[sw] || []; var rawPorts = (p.node && p.node.ports) || []; var ports = rawPorts.slice(); if (!rawPorts.length) { // The switch did not report a front-panel inventory (for example an // old history snapshot). Fall back to a generic 24-port access panel // laid out as two rows of 12. Terminals are placed into their real // suffix position when the port name allows it; the remaining ports // stay visible as unused hollow circles. var used = {}, extra = []; terms.forEach(function (tm) { var idx = portIndexFromName(tm.port); if (idx) used[idx] = tm; else extra.push(tm); }); var maxIdx = 24; Object.keys(used).forEach(function (k) { maxIdx = Math.max(maxIdx, parseInt(k, 10) || 0); }); ports = []; for (var pi = 1; pi <= maxIdx; pi++) { var ptm = used[pi]; var pspeed = ptm ? (parseFloat(ptm.speed) || 0) : 0; ports.push({ name: ptm ? (ptm.port || ('GE1/0/' + pi)) : ('GE1/0/' + pi), order: pi, speed: pspeed, max: ptm ? portMaxFromName(ptm.port, pspeed) : 1000, up: !!ptm, synthetic: true }); } extra.forEach(function (tm, k) { var espeed = parseFloat(tm.speed) || 0; ports.push({ name: tm.port || ('GE1/0/' + (maxIdx + k + 1)), order: 1000 + k, speed: espeed, max: portMaxFromName(tm.port, espeed), up: true, synthetic: true }); }); } var known = {}; ports.forEach(function (pt) { known[pkey(pt.name)] = true; }); // A terminal may sit on a logical interface (for example an Eth-Trunk) // whose physical ports are not individually listed. Keep it visible as // an extra port-like circle rather than silently dropping it. terms.forEach(function (tm) { if (tm.port && !known[pkey(tm.port)]) { ports.push({name: tm.port, order: 9999, speed: parseFloat(tm.speed) || 0, max: portMaxFromName(tm.port, parseFloat(tm.speed) || 0), up: true, synthetic: true}); known[pkey(tm.port)] = true; } }); var rows = portRows(ports), maxCols = 0; rows.forEach(function (r) { if (r.length > maxCols) maxCols = r.length; }); var step = 12.5; var startX = p.x - ((maxCols - 1) * step) / 2; var pidx = 0; rows.forEach(function (row, ri) { row.forEach(function (port, ci) { var tm = termBySwPort[sw + '|' + pkey(port.name)] || null; // The IF-MIB port speed is the negotiated physical link rate, so it // is the primary source for the filled centre. Terminal records are // only a fallback when the switch did not report a speed. var actual = parseFloat(port.speed) || 0; if (!actual) actual = parseFloat(tm && tm.speed) || 0; var max = parseFloat(port.max) || portMaxFromName(port.name, actual); var filled = !!port.up && actual > 0; var pid = 'p_' + sw + '_' + (port.order || pidx) + '_' + pidx; pidx++; els.push({group: 'nodes', data: { id: pid, type: 'port', name: port.name, port: port.name, sw_name: (p.node && p.node.name) || '', sw_ip: sw, max: max, speed: actual, up: !!port.up, filled: filled ? 1 : 0, fill: filled ? portColor(actual) : '#000000', fillOpacity: filled ? 1 : 0, ring: portColor(max), size: 9, term: tm || null, termText: tm ? terminalText(tm) : '' }, position: {x: startX + ci * step, y: p.y + 18 + ri * 14}, selectable: false}); if (tm) termPortByMac[tm.mac] = pid; }); }); }); } function renderOverview(snap) { var O = computeOverview(snap), els = [], idx = {}; var nb = el('l2-net-tabs'); if (nb) { nb.style.display = 'none'; nb.innerHTML = ''; } 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 renderNetTabs(D) { var box = el('l2-net-tabs'); if (!box) return; box.innerHTML = ''; if (!D.nets || D.nets.length < 2) { box.style.display = 'none'; return; } box.style.display = 'flex'; D.nets.forEach(function (n) { var b = document.createElement('button'); b.type = 'button'; b.className = 'l2-net-tab' + (n.key === D.activeNet ? ' active' : ''); b.textContent = networkLabel(n.key) + ' · ' + n.count + ' ' + t('l2.unit_sw', 'sw'); b.addEventListener('click', function () { detailNet = n.key; render(lastSnap); }); box.appendChild(b); }); } function renderDetail(snap) { var D = computeDetail(snap, viewGroup), els = []; renderNetTabs(D); function frameEl(id, type, fr) { els.push({group: 'nodes', data: {id: id, type: type, label: fr.label, w: fr.w, h: fr.h}, position: {x: fr.x + fr.w / 2, y: fr.y + fr.h / 2}, selectable: false, grabbable: false}); } frameEl('__ws', 'wsframe', {x: D.frames.workshop.x, y: 36, w: D.frames.workshop.w, h: D.frames.workshop.h + 36, label: D.frames.workshop.label}); if (D.frames.dist) frameEl('__dist', 'floorframe', D.frames.dist); D.floors.forEach(function (f, i) { frameEl('__floor_' + i, 'floorframe', f.frame); }); (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, ni) { 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 isRoot = D.dists && D.dists.length && n.id === D.dists[0].id; var cls = []; var bd = (dark ? TIER_BG_DARK[tkey] : TIER_BG[tkey]) || '#555'; var bg = (dark ? TIER_BG_DARK[tkey] : TIER_BG[tkey]) || '#777'; if (!isRoot && ni % 2 === 1) bg = dark ? '#4f8a3a' : '#6ca75a'; if (n.alarms && n.alarms.length) { cls.push('alarm'); bg = '#E45959'; bd = '#E45959'; } if (n.offline || n.oper === 'down') { cls.push('offline'); bg = '#C0392B'; bd = '#C0392B'; } if (!cls.length && n.latency_ms != null) { if (+n.latency_ms > LATB) cls.push('lat-bad'); else if (+n.latency_ms > LATW) cls.push('lat-warn'); } if (isRoot) cls.push('root'); 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, poe: !!n.poe, label: isRoot ? rootLabel(n) : rowLabel(n), tw: isRoot ? 150 : 360, bg: bg, bd: bd, w: isRoot ? 170 : 380, h: isRoot ? 50 : 24, group: viewGroup }, position: p, classes: cls.join(' ')}); }); D.internal.forEach(function (e, i) { var root = D.dists && D.dists[0]; if (root && (e.source === root.id || e.target === root.id)) { var childId = e.source === root.id ? e.target : e.source; var pp = D.pos[root.id], cp = D.pos[childId]; if (pp && cp) { var sp = 'sp_' + i, tp = 'tp_' + i; // Exact left-edge midpoints, straight line. The parent label is // allowed to cover the line where it exits the parent. els.push({group: 'nodes', data: {id: sp, type: 'edgepoint'}, position: {x: pp.x - 86, y: pp.y}, selectable: false, grabbable: false}); els.push({group: 'nodes', data: {id: tp, type: 'edgepoint'}, position: {x: cp.x - 191, y: cp.y}, selectable: false, grabbable: false}); els.push({group: 'edges', data: { id: 'e_' + i, type: 'detail', source: sp, target: tp, p: root.id, c: childId, 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: Math.min(2.4, speedWidth(e.speed)) }, classes: 'detail'}); if (e.state === 'up') { els.push({group: 'edges', data: { id: 'f_' + i, type: 'flow', source: sp, target: tp, p: root.id, c: childId, speed: e.speed, state: e.state, a_port: e.a_port, b_port: e.b_port, flowRate: flowRate(e.speed), fw: Math.max(0.9, Math.min(1.4, speedWidth(e.speed) * 0.35)) }}); } return; } } 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: Math.min(2.4, speedWidth(e.speed)) }, classes: 'detail'}); if (e.state === 'up') { var from = e.source, to = e.target; var ap = D.pos[e.source], bp = D.pos[e.target], po = D.parentOf || {}; if (po[e.source] === e.target) { from = e.target; to = e.source; } else if (po[e.target] === e.source) { from = e.source; to = e.target; } else if (ap && bp && bp.y < ap.y) { from = e.target; to = e.source; } els.push({group: 'edges', data: { id: 'f_' + i, type: 'flow', source: from, target: to, p: e.source, c: e.target, speed: e.speed, state: e.state, a_port: e.a_port, b_port: e.b_port, flowRate: flowRate(e.speed), fw: Math.max(0.9, Math.min(1.4, speedWidth(e.speed) * 0.35)) }}); } }); if (showTerm) { var bySw = {}; (snap.terminals || []).forEach(function (tm) { if (!switchIds[tm.sw_ip]) return; if (!terminalOnline(tm)) 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: '#2ECC71', mac: tm.mac, ip: tm.ip, online: true}, position: {x: x, y: y}, selectable: false}); els.push({group: 'edges', data: {id: 'te_' + tm.mac, type: 'term', source: sw, target: 't_' + tm.mac}}); }); }); } pathParent = D.parentOf || {}; lockedPath = null; applyElements(els); } /* ---------------- side panels & stats ---------------- */ function updateStats(snap) { var s = snap.stats || {}; var txt = '● ' + (s.switches || 0) + ' | ⇄ ' + (s.edges || 0) + ' | ⚡ ' + (s.poe_switches || 0) + ' ' + t('l2.poe', 'PoE') + ' | ' + (s.term_total || 0) + ' ' + t('l2.terminals', 'terminals') + ' | ' + (s.switches_offline || 0) + ' ' + t('l2.offline_short', 'offline SW') + ' | ⚠ ' + (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['2500'], '2.5G') + sw(colors['10000'], '10G') + sw(colors['20000'], '20G+') + sw(colors.unknown, t('lbl.color_unknown', 'Unknown')) + sw(colors.down, t('lbl.color_down', 'Down'), true) + '
'; h += '
' + t('l2.legend_tier', 'Tier') + '
'; h += '
' + t('l2.core', 'Core') + '' + t('l2.main', 'Main switch') + '' + 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.offline', 'Offline switch') + '
'; h += '
' + t('l2.click_hint', 'Hover a switch to highlight its full upstream path') + '
'; 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 fmtTs(ts) { if (!ts) return '—'; try { var d = new Date(Number(ts) * 1000); var p = function (x) { return (x < 10 ? '0' : '') + x; }; return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()); } catch (e) { return '—'; } } function buildOffline(snap) { var off = (snap.nodes || []).filter(function (n) { return n.offline || n.oper === 'down'; }); el('l2-offline-count').textContent = off.length; if (!off.length) { el('l2-offline-list').innerHTML = '
' + t('l2.offline_empty', 'No offline switches.') + '
'; return; } off.sort(function (a, b) { var ra = roleOf(a), rb = roleOf(b); if (ra !== rb) return ra === 'dist' ? -1 : 1; return String(a.name || '').localeCompare(String(b.name || '')); }); var h = ''; off.forEach(function (n) { var role = roleOf(n); var roleTxt = role === 'dist' ? t('l2.dist', 'Distribution') : t('l2.access', 'Access'); h += '
' + '' + '
' + esc(poePrefix(n) + (n.name || n.ip)) + '
' + '
' + esc(roleTxt) + ' · ' + esc(n.ip || '') + ' · ' + t('l2.last_online', 'last online') + ': ' + esc(fmtTs(n.last_online)) + '
'; }); var box = el('l2-offline-list'); box.innerHTML = h; box.querySelectorAll('.off-row').forEach(function (r) { r.addEventListener('click', function () { var id = r.getAttribute('data-id'); if (id) revealById(id); }); }); } function buildTerminals(snap) { var list = (snap.terminals || []).filter(function (tm) { return terminalOnline(tm); }); if (el('l2-term-count')) el('l2-term-count').textContent = list.length; var box = el('l2-term-list'); if (!box) return; if (!showTerm) { box.innerHTML = '
' + t('l2.show_term', 'Show terminals') + '
'; return; } if (!list.length) { box.innerHTML = '
' + t('l2.no_data', 'No data') + '
'; return; } list.sort(function (a, b) { var x = String(a.sw_name || a.sw_ip || '').localeCompare(String(b.sw_name || b.sw_ip || '')); return x || String(a.mac).localeCompare(String(b.mac)); }); var h = ''; list.forEach(function (tm) { h += '
' + '' + '
' + esc(tm.mac) + '
' + '
' + esc(speedText(tm.speed)) + ' · ' + esc(tm.sw_name || tm.sw_ip || '') + ' · ' + esc(tm.port || '') + '
'; }); box.innerHTML = h; box.querySelectorAll('.term-row').forEach(function (r) { r.addEventListener('click', function () { revealById('t_' + r.getAttribute('data-mac')); }); }); } function buildHistory() { var box = el('l2-history-list'); if (!box) return; var list = window.L2_HISTORY || []; var used = window.L2_META && window.L2_META.usedTs ? String(window.L2_META.usedTs) : ''; var h = '
' + '
' + t('l2.history_live', 'Live / latest') + '
' + '
' + t('l2.history_live_hint', 'Newest snapshot, auto refresh') + '
'; if (!list.length) { h += '
' + t('l2.history_empty', 'No history snapshots yet.') + '
'; } else { list.forEach(function (it) { var st = it.stats || {}; var ts = String(it.ts || ''); var sub = (st.switches != null ? st.switches + ' ' + t('l2.unit_sw', 'sw') : '') + (st.term_total != null ? ' · ' + st.term_total + ' ' + t('l2.terminals', 'terminals') : '') + (st.switches_offline ? ' · ' + st.switches_offline + ' ' + t('l2.offline_short', 'offline SW') : ''); h += '
' + '
' + esc(it.captured_at || fmtTs(it.ts)) + '
' + '
' + esc(sub) + '
'; }); } box.innerHTML = h; box.querySelectorAll('.hist-row').forEach(function (r) { r.addEventListener('click', function () { var ts = r.getAttribute('data-ts'); var u = new URL(window.location.href); u.searchParams.set('action', 'l2.topology.view'); if (ts) u.searchParams.set('ts', ts); else u.searchParams.delete('ts'); window.location.href = u.toString(); }); }); if (el('l2-history-count')) el('l2-history-count').textContent = list.length; // Keep the toolbar selector in sync with the same history list. The // side panel is the primary entry point; the selector is a shortcut. var sel = el('l2-ts'); if (sel) { var old = sel.value; sel.innerHTML = ''; var live = document.createElement('option'); live.value = ''; live.textContent = t('l2.time_live', 'Live'); sel.appendChild(live); list.forEach(function (it) { var opt = document.createElement('option'); opt.value = String(it.ts || ''); opt.textContent = it.captured_at || fmtTs(it.ts); sel.appendChild(opt); }); sel.value = used || old || ''; } } 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 exportPng() { if (!cy) return; var btn = el('l2-export'), oldText = btn ? btn.textContent : ''; if (btn) { btn.disabled = true; btn.textContent = t('l2.exporting', '正在导出…'); } var wasDark = dark; var flowEdges = cy.edges('[type="flow"]'); try { // The exported file must always be the day palette, even when the // operator is currently using Zabbix dark mode. The moving flow // overlay is transient screen decoration, so keep the exported static // diagram on solid speed-coloured links. cy.elements().removeClass('hl dim pathhl'); flowEdges.style('display', 'none'); cy.style(styleFor(false)); if (typeof cy.forceRender === 'function') cy.forceRender(); var data = cy.png({full: true, scale: 1, maxWidth: 2600, maxHeight: 18000, bg: '#ffffff'}); var now = new Date(); function p2(v) { return (v < 10 ? '0' : '') + v; } var suffix = META.usedTs ? ('_' + META.usedTs) : '_live'; var name = 'L2_Topology_' + now.getFullYear() + p2(now.getMonth() + 1) + p2(now.getDate()) + '_' + p2(now.getHours()) + p2(now.getMinutes()) + suffix + '.png'; var a = document.createElement('a'); a.href = data; a.download = name; document.body.appendChild(a); a.click(); document.body.removeChild(a); } catch (e) { window.alert(t('l2.export_fail', '导出失败,请重试。')); } finally { flowEdges.style('display', 'element'); cy.style(styleFor(wasDark)); if (typeof cy.forceRender === 'function') cy.forceRender(); if (btn) { btn.disabled = false; btn.textContent = oldText; } } } 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; pinnedTerm = tm.mac; showTerm = true; if (el('l2-showterm')) el('l2-showterm').checked = true; var targetId = termPortByMac[tm.mac] || id; if (!cy || cy.getElementById(targetId).empty()) render(lastSnap); targetId = termPortByMac[tm.mac] || id; setTimeout(function () { // Center the switch that the terminal hangs under, then flash the // exact physical port circle so the port location is obvious. focusNode(tm.sw_ip || id); var tn = cy && cy.getElementById(targetId); if (tn && tn.length) { tn.addClass('hl'); setTimeout(function () { tn.removeClass('hl'); }, 2600); } }, 90); } else { focusNode(id); } } function clearPath() { if (!cy) return; cy.elements().removeClass('dim pathhl'); lockedPath = null; } function upstreamGraph(nodeId) { var snap = lastSnap || {}; var nodes = snap.nodes || [], edges = snap.edges || []; var byId = {}, adj = {}; nodes.forEach(function (n) { byId[n.id] = n; adj[n.id] = []; }); edges.forEach(function (e) { if (adj[e.source] && adj[e.target]) { adj[e.source].push(e.target); adj[e.target].push(e.source); } }); var coreIds = nodes.filter(function (n) { return (lastRoleMap[n.id] || roleOf(n)) === 'core'; }).map(function (n) { return n.id; }); // If the configured core list is empty, fall back to the collector's root // tier so the upstream chain still starts at the physical core. if (!coreIds.length) { nodes.forEach(function (n) { if (String(n.tier || '').toLowerCase() === 'core' || /^core(\s|-)/i.test(String(n.name || ''))) coreIds.push(n.id); }); } var level = {}, queue = []; coreIds.forEach(function (id) { level[id] = 0; queue.push(id); }); while (queue.length) { var cur = queue.shift(); (adj[cur] || []).forEach(function (nb) { if (level[nb] == null) { level[nb] = level[cur] + 1; queue.push(nb); } }); } var nodeSet = {}, edgeSet = {}, stack = [nodeId]; nodeSet[nodeId] = true; while (stack.length) { var cur = stack.pop(); var curLv = level[cur]; if (curLv == null || curLv <= 0) continue; (adj[cur] || []).forEach(function (nb) { if (level[nb] !== curLv - 1) return; // Cytoscape edges are anchored to hidden edgepoint nodes; the real // switch pair is stored in p/c, so identify both orientations. edgeSet[cur + '|' + nb] = true; edgeSet[nb + '|' + cur] = true; if (!nodeSet[nb]) { nodeSet[nb] = true; stack.push(nb); } }); } return {nodes: nodeSet, edges: edgeSet}; } function highlightPath(nodeId) { if (!cy) return; var up = upstreamGraph(nodeId); cy.elements('node[type="switch"], node[type="ghost"], edge').addClass('dim').removeClass('pathhl'); var nodes = cy.collection(); Object.keys(up.nodes).forEach(function (id) { var n = cy.getElementById(id); if (n && n.length) nodes = nodes.union(n); }); nodes.removeClass('dim').addClass('pathhl'); var edges = cy.edges().filter(function (e) { if (e.data('type') === 'flow') return false; var p = e.data('p'), c = e.data('c'); return p && c && (up.edges[p + '|' + c] || up.edges[c + '|' + p]); }); edges.removeClass('dim').addClass('pathhl'); } function bindCy() { cy.on('mouseover', 'node[type="switch"]', function (e) { highlightPath(e.target.id()); 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 stat = (d.offline || d.oper === 'down') ? ('' + esc(t('l2.offline', 'Offline')) + '') : esc(d.oper || 'up'); var html = '' + esc(poePrefix({poe: d.poe}) + d.name) + '
' + esc(d.ip) + ' · ' + esc(d.group || '') + '
' + 'Tier: ' + esc(d.tier) + ' · ' + stat + '
' + 'Latency: ' + lat + '
' + 'Terminals: ' + (d.term_online || 0) + '
' + 'Alarm: ' + sev; if (d.offline || d.oper === 'down') html += '
' + esc(t('l2.last_online', 'last online')) + ': ' + esc(fmtTs(d.last_online)); if (arr.length) html += '
' + arr.map(function (a) { return esc(a.name || a.description || ''); }).join('
'); showTip(e, html); }); cy.on('mouseout', 'node[type="switch"]', function () { if (!lockedPath) clearPath(); hideTip(); }); 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('mouseover', 'node[type="term"]', function (e) { var d = e.target.data(); e.target.data('label', d.termText || ''); showTip(e, '' + esc(t('l2.term_title', 'Terminal')) + ' ' + esc(d.mac || '') + '
' + esc(t('l2.term_mac', 'MAC address')) + ': ' + esc(d.mac || '') + '
' + esc(t('l2.term_ip', 'IP address')) + ': ' + esc(d.ip || '—') + '
' + esc(t('l2.term_rate', 'Link rate')) + ': ' + esc(speedText(d.speed)) + '
' + esc(t('l2.term_switch', 'Switch')) + ': ' + esc(d.sw_name || '—') + '
' + esc(t('l2.term_port', 'Port')) + ': ' + esc(d.port || '—')); }); cy.on('mouseout', 'node[type="term"]', function (e) { e.target.data('label', ''); hideTip(); }); cy.on('mouseover', 'node[type="port"]', function (e) { var d = e.target.data(); var html = '' + esc(d.name || '') + '
' + esc(t('l2.term_switch', 'Switch')) + ': ' + esc(d.sw_name || d.sw_ip || '') + '
' + esc(t('l2.term_rate', 'Link rate')) + ': ' + (d.filled ? esc(speedText(d.speed)) : '—') + '
' + 'Port max: ' + esc(speedText(d.max)); if (d.term) { html += '
' + esc(t('l2.term_title', 'Terminal')) + '
' + esc(t('l2.term_mac', 'MAC address')) + ': ' + esc(d.term.mac || '') + '
' + esc(t('l2.term_ip', 'IP address')) + ': ' + esc(d.term.ip || '—'); } showTip(e, html); }); cy.on('mouseout', 'node[type="port"]', hideTip); cy.on('tap', 'node[type="switch"]', function (e) { lockedPath = e.target.id(); highlightPath(lockedPath); }); cy.on('tap', 'node[type="wsframe"]', function (e) { cy.fit(e.target, 60); }); cy.on('tap', function (e) { if (e.target === cy) clearPath(); }); 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: poePrefix(n) + n.name, sub: n.ip, kind: 'sw'}); }); (snap.terminals || []).forEach(function (tm) { if (!terminalOnline(tm)) return; idx.push({id: 't_' + tm.mac, label: tm.mac, sub: (tm.ip || '') + ' · ' + speedText(tm.speed) + ' · ' + (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 = []; var qn = q.replace(/[^a-z0-9]/g, ''); for (var i = 0; i < INDEX.length && hits.length < 14; i++) { var it = INDEX[i]; var hay = ((it.label || '') + ' ' + (it.sub || '')).toLowerCase(); var hayN = hay.replace(/[^a-z0-9]/g, ''); if (hay.indexOf(q) >= 0 || (qn && hayN.indexOf(qn) >= 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 isZabbixDark() { var html = document.documentElement; var cs = String(html.getAttribute('color-scheme') || '').toLowerCase(); if (cs === 'dark') return true; if (cs === 'light') return false; var body = document.body; var theme = [ html.getAttribute('theme'), html.getAttribute('data-theme'), html.className, body ? body.getAttribute('theme') : '', body ? body.getAttribute('data-theme') : '', body ? body.className : '' ].join(' '); return /dark/i.test(theme); } function applyTheme() { var nd = isZabbixDark(); var changed = nd !== dark; dark = nd; var wrap = el('l2-wrap'); if (wrap) wrap.classList.toggle('l2-dark', dark); if (changed && cy) cy.style(styleFor(dark)); } function fitViewport() { var wrap = el('l2-wrap'); if (!wrap) return; var top = Math.max(0, wrap.getBoundingClientRect().top); var h = Math.max(520, window.innerHeight - top - 8); wrap.style.height = h + 'px'; if (cy) cy.resize(); } function setupControls() { applyTheme(); if (window.MutationObserver) { var mo = new MutationObserver(applyTheme); mo.observe(document.documentElement, {attributes: true, attributeFilter: ['theme', 'data-theme', 'color-scheme', 'class']}); if (document.body) { mo.observe(document.body, {attributes: true, attributeFilter: ['theme', 'data-theme', 'color-scheme', 'class']}); } } var back = el('l2-back'); if (back) back.style.display = 'none'; el('l2-showterm').addEventListener('change', function () { showTerm = el('l2-showterm').checked; 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(cy.elements(), 50); }); if (el('l2-export')) el('l2-export').addEventListener('click', exportPng); el('l2-refresh-btn').addEventListener('click', function () { reload(true); }); window.addEventListener('resize', fitViewport); 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(); fitViewport(); try { var qs = new URLSearchParams(window.location.search); var qg = qs.get('g'); if (qg) focusGroupLabel = qg; } catch (e) {} if (!SNAP || !SNAP.nodes) { el('l2-cy').innerHTML = '
' + t('l2.no_data', 'No data') + '
'; } else { INDEX = buildIndex(SNAP); render(SNAP); } setTimeout(fitViewport, 60); 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(); })();