app.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578
  1. /* =====================================================================
  2. L2 Physical Topology — display-only interactive map.
  3. Groups switches into frames (COBCO outer → workshop/DataCenter/Core),
  4. colours links by speed, nodes by latency & alarm, supports dark mode,
  5. terminal show / only-offline, live + historical snapshots, MAC/IP search.
  6. ===================================================================== */
  7. (function () {
  8. 'use strict';
  9. var CFG = window.L2_CFG || {};
  10. var T = window.L2_I18N || {};
  11. var META = window.L2_META || {};
  12. var SNAP = window.L2SNAP || null;
  13. var colors = CFG.colors || {100:'#F39C12',1000:'#2ECC71',10000:'#3498DB',20000:'#9B59B6',unknown:'#E74C3C',down:'#C0392B'};
  14. var LATW = (CFG.latency_warn != null) ? +CFG.latency_warn : 3;
  15. var LATB = (CFG.latency_bad != null) ? +CFG.latency_bad : 5;
  16. var TIER_BG = {core:'#1F4E78', dist:'#2E75B6', access:'#548235'};
  17. var TIER_BG_DARK = {core:'#1B4A72', dist:'#2A5E93', access:'#3E6B2E'};
  18. var SEV_COLORS = ['#97AAB3','#7499FF','#FFC859','#FFA059','#E97659','#E45959'];
  19. var NODE_W = 190, NODE_H = 56, GX = 26, GY = 18, PADX = 20, PADY = 42;
  20. var GROUP_GAP_X = 46, GROUP_GAP_Y = 48, MAXW = 2800;
  21. var cy = null;
  22. var dark = false;
  23. var showTerm = false;
  24. var onlyOff = false;
  25. var paused = false;
  26. var countdown = 0;
  27. var timer = null;
  28. var lastSnap = null;
  29. function el(id) { return document.getElementById(id); }
  30. function t(k, d) { return (T[k] != null) ? T[k] : (d != null ? d : k); }
  31. /* ---------------- name parsing / grouping ---------------- */
  32. function parseName(name) {
  33. var r = {workshop:'', floor:'', cab:'', u:'', area:'', role:'', ip:''};
  34. if (!name) return r;
  35. var toks = String(name).split('-');
  36. for (var i = 0; i < toks.length; i++) {
  37. var tk = toks[i];
  38. if (/^\d{1,3}(\.\d{1,3}){3}$/.test(tk)) { r.ip = tk; continue; }
  39. if (!r.workshop && /^\d{4}$/.test(tk)) { r.workshop = tk; continue; }
  40. if (!r.floor && /^\d[A-Z]\w*$/i.test(tk)) {
  41. var m = /^(\d)([A-Z])(\w+)$/i.exec(tk);
  42. if (m) { r.floor = m[1]; r.cab = m[2]; r.u = m[3]; continue; }
  43. }
  44. if (!r.area && /^(OFFICE|SURVE|GRDA|DC|IDC|LAB|PROD)$/i.test(tk)) { r.area = tk.toUpperCase(); continue; }
  45. if (!r.role && /^(Dist|Access|Core)$/i.test(tk)) { r.role = tk; }
  46. }
  47. return r;
  48. }
  49. function labelFor(n) {
  50. var p = parseName(n.name);
  51. var l1 = [];
  52. if (p.workshop) l1.push(p.workshop);
  53. if (p.area) l1.push(p.area);
  54. if (!l1.length) {
  55. l1.push((n.name || '').split('-')[0] || n.ip);
  56. }
  57. var l2 = [];
  58. if (p.floor) { var loc = p.floor + 'F' + (p.cab || '') + (p.u ? '-' + p.u + 'U' : ''); l1.push(loc); }
  59. if (p.role) l2.push(p.role);
  60. l2.push(n.ip);
  61. return l1.join(' · ') + '\n' + l2.join(' · ');
  62. }
  63. function expandLabel(tpl, m) {
  64. return String(tpl).replace(/\$(\d)/g, function (_, d) { return m && m[+d] != null ? m[+d] : ''; });
  65. }
  66. function groupOf(n) {
  67. var name = n.name || n.ip || '';
  68. var rules = CFG.group_rules || [];
  69. for (var i = 0; i < rules.length; i++) {
  70. var r = rules[i];
  71. if (r.match === 'regex') {
  72. var re;
  73. try { re = new RegExp(r.pattern); } catch (e) { continue; }
  74. var m = re.exec(name);
  75. if (m) return {label: expandLabel(r.label, m) || (CFG.other_label || 'Other'), big: !!r.big};
  76. } else {
  77. if (name.indexOf(r.pattern) >= 0) return {label: r.label || (CFG.other_label || 'Other'), big: !!r.big};
  78. }
  79. }
  80. return {label: CFG.other_label || 'Other', big: false};
  81. }
  82. /* ---------------- layout ---------------- */
  83. function computeLayout(snap) {
  84. var groups = {};
  85. (snap.nodes || []).forEach(function (n) {
  86. var g = groupOf(n);
  87. if (!groups[g.label]) groups[g.label] = {label: g.label, big: g.big, nodes: []};
  88. if (g.big) groups[g.label].big = true;
  89. groups[g.label].nodes.push(n);
  90. });
  91. var order = Object.keys(groups).sort(function (a, b) {
  92. function rank(l) {
  93. if (/core/i.test(l)) return 0;
  94. if (/data\s*center/i.test(l)) return 1;
  95. if (/^\d+$/.test(l)) return 1000 + parseInt(l, 10);
  96. return 5000;
  97. }
  98. return rank(a) - rank(b);
  99. });
  100. var pos = {}, frames = [];
  101. var cursorX = 0, cursorY = 0, rowH = 0, minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
  102. order.forEach(function (label) {
  103. var g = groups[label];
  104. g.nodes.sort(function (a, b) {
  105. var da = a.depth || 0, db = b.depth || 0;
  106. if (da !== db) return da - db;
  107. return (a.name || '').localeCompare(b.name || '');
  108. });
  109. var n = g.nodes.length;
  110. var cols = Math.min(4, Math.max(1, Math.ceil(Math.sqrt(n))));
  111. var rows = Math.ceil(n / cols);
  112. var innerW = cols * NODE_W + (cols - 1) * GX;
  113. var innerH = rows * NODE_H + (rows - 1) * GY;
  114. var boxW = innerW + PADX * 2;
  115. var boxH = innerH + PADY + 12;
  116. if (cursorX > 0 && cursorX + boxW > MAXW) { cursorX = 0; cursorY += rowH + GROUP_GAP_Y; rowH = 0; }
  117. g.nodes.forEach(function (nd, i) {
  118. var rr = Math.floor(i / cols), cc = i % cols;
  119. pos[nd.id] = {
  120. x: cursorX + PADX + cc * (NODE_W + GX) + NODE_W / 2,
  121. y: cursorY + PADY + rr * (NODE_H + GY) + NODE_H / 2
  122. };
  123. });
  124. frames.push({label: label, big: g.big, x: cursorX, y: cursorY, w: boxW, h: boxH});
  125. minX = Math.min(minX, cursorX); minY = Math.min(minY, cursorY);
  126. maxX = Math.max(maxX, cursorX + boxW); maxY = Math.max(maxY, cursorY + boxH);
  127. cursorX += boxW + GROUP_GAP_X;
  128. rowH = Math.max(rowH, boxH);
  129. });
  130. var root = null;
  131. if (frames.length) {
  132. var m = 28;
  133. root = {x: minX - m, y: minY - m - 30, w: (maxX - minX) + 2 * m, h: (maxY - minY) + 2 * m + 30};
  134. root.cx = root.x + root.w / 2; root.cy = root.y + root.h / 2;
  135. }
  136. return {pos: pos, frames: frames, root: root};
  137. }
  138. /* ---------------- speed colour ---------------- */
  139. function speedColor(speed, state) {
  140. if (state && state !== 'up') return colors.down;
  141. var s = parseFloat(speed);
  142. if (!isFinite(s) || s <= 0) return colors.unknown;
  143. if (s <= 100) return colors['100'];
  144. if (s <= 1000) return colors['1000'];
  145. if (s <= 10000) return colors['10000'];
  146. return colors['20000'];
  147. }
  148. function speedWidth(speed) {
  149. var s = parseFloat(speed) || 0;
  150. if (s >= 20000) return 4.5;
  151. if (s >= 10000) return 3.6;
  152. if (s >= 1000) return 2.6;
  153. return 1.8;
  154. }
  155. /* ---------------- style ---------------- */
  156. function styleFor(d) {
  157. var bg = d ? TIER_BG_DARK : TIER_BG;
  158. return [
  159. {selector: 'node', style: {
  160. 'width': 'data(w)', 'height': 'data(h)', 'shape': 'round-rectangle',
  161. 'background-color': 'data(bg)', 'border-width': 2, 'border-color': 'data(bd)',
  162. 'label': 'data(label)', 'color': d ? '#eaf1fb' : '#ffffff',
  163. 'font-size': 11.5, 'font-weight': 600, 'text-valign': 'center', 'text-halign': 'center',
  164. 'text-wrap': 'wrap', 'text-max-width': NODE_W - 16, 'padding': '2px',
  165. 'overlay-opacity': 0, 'z-index-compare': 'manual', 'z-index': 20
  166. }},
  167. {selector: 'node[type="frame"]', style: {
  168. 'background-opacity': d ? 0.05 : 0.035, 'background-color': d ? '#5b7bb0' : '#8fa6c4',
  169. 'border-width': 1.6, 'border-style': 'dashed', 'border-color': d ? '#3f5377' : '#c2ccdb',
  170. 'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center',
  171. 'font-size': 'data(fs)', 'font-weight': 800, 'color': d ? '#aebfd8' : '#526178',
  172. 'text-margin-y': 8, 'padding': '0px', 'events': 'no', 'z-index-compare': 'manual', 'z-index': 2
  173. }},
  174. {selector: 'node[type="root"]', style: {
  175. 'background-opacity': d ? 0.02 : 0.012, 'background-color': d ? '#7aa0d8' : '#8ea6c2',
  176. 'border-width': 2.2, 'border-style': 'solid', 'border-color': d ? '#476492' : '#9fb0c6',
  177. 'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center',
  178. 'font-size': 28, 'font-weight': 900, 'color': d ? '#cfe0ff' : '#33465f',
  179. 'text-margin-y': 10, 'events': 'no', 'z-index-compare': 'manual', 'z-index': 0
  180. }},
  181. {selector: 'node[type="term"]', style: {
  182. 'width': 12, 'height': 12, 'shape': 'ellipse', 'background-color': 'data(bg)',
  183. 'border-width': 1, 'border-color': d ? '#2a3648' : '#cfd8e3', 'label': '',
  184. 'z-index-compare': 'manual', 'z-index': 30
  185. }},
  186. {selector: 'edge', style: {
  187. 'width': 'data(w)', 'line-color': 'data(color)', 'line-style': 'data(ls)',
  188. 'curve-style': 'bezier', 'opacity': 0.92, 'target-arrow-shape': 'none',
  189. 'z-index-compare': 'manual', 'z-index': 12
  190. }},
  191. {selector: 'edge[type="term"]', style: {
  192. 'width': 0.7, 'line-color': d ? '#3b4a5e' : '#c5cfdb', 'opacity': 0.55,
  193. 'curve-style': 'bezier', 'z-index-compare': 'manual', 'z-index': 11
  194. }},
  195. {selector: 'node.alarm', style: {'border-width': 6}},
  196. {selector: 'node.lat-warn', style: {'border-color': '#F39C12'}},
  197. {selector: 'node.lat-bad', style: {'border-color': '#E74C3C', 'border-width': 6}},
  198. {selector: 'node.hl', style: {'border-color': '#ff0', 'border-width': 7, 'overlay-color': '#ff0', 'overlay-opacity': 0.25, 'overlay-padding': 6}},
  199. {selector: 'node[type="switch"]', style: {'background-color': function (e) { return e.data('bg'); }}}
  200. ];
  201. }
  202. /* ---------------- render ---------------- */
  203. function render(snap) {
  204. if (!snap || !snap.nodes) {
  205. el('l2-cy').innerHTML = '<div class="l2-empty">' + t('l2.no_data', 'No data') + '</div>';
  206. return;
  207. }
  208. lastSnap = snap;
  209. var L = computeLayout(snap);
  210. var els = [];
  211. if (L.root) {
  212. els.push({group: 'nodes', data: {id: '__root', type: 'root', label: CFG.root_label || 'COBCO', w: L.root.w, h: L.root.h},
  213. position: {x: L.root.cx, y: L.root.cy}, selectable: false, grabbable: false});
  214. }
  215. L.frames.forEach(function (f, i) {
  216. els.push({group: 'nodes', data: {id: 'grp_' + i, type: 'frame', label: f.label, w: f.w, h: f.h, fs: f.big ? 20 : 14},
  217. position: {x: f.x + f.w / 2, y: f.y + f.h / 2}, selectable: false, grabbable: false});
  218. });
  219. var switchIds = {};
  220. (snap.nodes || []).forEach(function (n) {
  221. switchIds[n.id] = true;
  222. var p = L.pos[n.id] || {x: 0, y: 0};
  223. var tier = n.tier || 'access';
  224. var tkey = (tier.indexOf('dist') === 0) ? 'dist' : (tier.indexOf('core') === 0 ? 'core' : 'access');
  225. var cls = [];
  226. var bd = (dark ? TIER_BG_DARK[tkey] : TIER_BG[tkey]) || '#555';
  227. if (n.maxsev != null && n.maxsev >= 0 && (n.alarms && n.alarms.length)) {
  228. cls.push('alarm');
  229. bd = SEV_COLORS[Math.max(0, Math.min(5, n.maxsev))];
  230. }
  231. if (n.latency_ms != null) {
  232. if (+n.latency_ms > LATB) cls.push('lat-bad');
  233. else if (+n.latency_ms > LATW) cls.push('lat-warn');
  234. }
  235. els.push({group: 'nodes', data: {
  236. id: n.id, type: 'switch', name: n.name, ip: n.ip, tier: tier, depth: n.depth,
  237. hostid: n.hostid, oper: n.oper, maxsev: n.maxsev, alarms: n.alarms || [],
  238. term_online: n.term_online, term_offline: n.term_offline, latency_ms: n.latency_ms,
  239. label: labelFor(n), bg: (dark ? TIER_BG_DARK[tkey] : TIER_BG[tkey]) || '#777',
  240. bd: bd, w: NODE_W, h: NODE_H, tw: NODE_W - 16, z: 20, group: groupOf(n).label
  241. }, position: p, classes: cls.join(' ')});
  242. });
  243. (snap.edges || []).forEach(function (e, i) {
  244. if (!switchIds[e.source] || !switchIds[e.target]) return;
  245. els.push({group: 'edges', data: {
  246. id: 'e_' + i, source: e.source, target: e.target, speed: e.speed, state: e.state,
  247. a_port: e.a_port, b_port: e.b_port,
  248. color: speedColor(e.speed, e.state), ls: (e.state && e.state !== 'up') ? 'dashed' : 'solid',
  249. w: speedWidth(e.speed)
  250. }});
  251. });
  252. // terminals
  253. if (showTerm) {
  254. var bySw = {};
  255. (snap.terminals || []).forEach(function (tm) {
  256. if (!switchIds[tm.sw_ip]) return;
  257. if (onlyOff && tm.online) return;
  258. (bySw[tm.sw_ip] = bySw[tm.sw_ip] || []).push(tm);
  259. });
  260. var cnt = 0;
  261. Object.keys(bySw).forEach(function (sw) {
  262. var base = L.pos[sw]; if (!base) return;
  263. bySw[sw].sort(function (a, b) { return (a.online === b.online) ? 0 : (a.online ? -1 : 1); });
  264. bySw[sw].forEach(function (tm, k) {
  265. var col = k % 9, row = Math.floor(k / 9);
  266. var x = base.x - 54 + col * 13;
  267. var y = base.y + NODE_H / 2 + 16 + row * 13;
  268. 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},
  269. position: {x: x, y: y}, selectable: false});
  270. els.push({group: 'edges', data: {id: 'te_' + tm.mac, type: 'term', source: sw, target: 't_' + tm.mac}});
  271. cnt++;
  272. });
  273. });
  274. }
  275. if (!cy) {
  276. cy = cytoscape({
  277. container: el('l2-cy'),
  278. elements: els,
  279. style: styleFor(dark),
  280. layout: {name: 'preset', fit: true, padding: 50},
  281. wheelSensitivity: 0.2,
  282. minZoom: 0.05, maxZoom: 4
  283. });
  284. bindCy();
  285. } else {
  286. cy.elements().remove();
  287. cy.add(els);
  288. cy.style(styleFor(dark));
  289. cy.layout({name: 'preset', fit: true, padding: 50}).run();
  290. }
  291. updateStats(snap);
  292. buildLegend();
  293. buildAlarms(snap);
  294. buildOffline(snap);
  295. }
  296. /* ---------------- side panels & stats ---------------- */
  297. function updateStats(snap) {
  298. var s = snap.stats || {};
  299. var txt = '● ' + (s.switches || 0) + ' | ⇄ ' + (s.edges || 0) + ' | ' +
  300. (s.term_total || 0) + '(' + (s.term_online || 0) + '/' + (s.term_offline || 0) + ') | ⚠ ' + (s.alarms || 0);
  301. var up = snap.generated_iso ? (' · ' + t('l2.updated', 'Updated') + ' ' + snap.generated_iso) : '';
  302. el('l2-stats').textContent = txt + up;
  303. }
  304. function buildLegend() {
  305. var h = '';
  306. function sw(c, label, dashed) { return '<div class="lg-row"><span class="lg-line' + (dashed ? ' lg-dash' : '') + '" style="border-top-color:' + c + '"></span>' + label + '</div>'; }
  307. h += '<div class="lg-title">' + t('l2.legend_speed', 'Link speed') + '</div>';
  308. h += sw(colors['100'], '100M') + sw(colors['1000'], '1G') + sw(colors['10000'], '10G') + sw(colors['20000'], '20G+');
  309. h += sw(colors.unknown, t('lbl.color_unknown', 'Unknown'));
  310. h += sw(colors.down, t('lbl.color_down', 'Down'), true);
  311. h += '<div class="lg-title">' + t('l2.legend_tier', 'Tier') + '</div>';
  312. h += '<div class="lg-row"><span class="lg-box" style="background:' + TIER_BG.core + '"></span>' + t('l2.core', 'Core') +
  313. '<span class="lg-box" style="background:' + TIER_BG.dist + ';margin-left:8px"></span>' + t('l2.dist', 'Distribution') +
  314. '<span class="lg-box" style="background:' + TIER_BG.access + ';margin-left:8px"></span>' + t('l2.access', 'Access') + '</div>';
  315. h += '<div class="lg-title">' + t('l2.legend_lat', 'Latency') + '</div>';
  316. h += '<div class="lg-row"><span class="lg-box" style="background:#F39C12;border-radius:2px"></span>&gt;' + LATW + 'ms' +
  317. '<span class="lg-box" style="background:#E74C3C;border-radius:2px;margin-left:8px"></span>&gt;' + LATB + 'ms</div>';
  318. h += '<div class="lg-row"><span class="lg-box" style="background:#E45959;border-radius:2px"></span>' + t('l2.alarms', 'Alarms') + '</div>';
  319. h += '<div class="lg-row"><span class="lg-box" style="background:#2ECC71;border-radius:50%"></span>' + t('l2.term_on', 'Online') +
  320. '<span class="lg-box" style="background:#95a5a6;border-radius:50%;margin-left:8px"></span>' + t('l2.term_off', 'Offline') + '</div>';
  321. el('l2-legend').innerHTML = h;
  322. }
  323. function buildAlarms(snap) {
  324. var items = [];
  325. (snap.nodes || []).forEach(function (n) {
  326. if (n.maxsev != null && n.maxsev >= 0 && n.alarms && n.alarms.length) {
  327. n.alarms.forEach(function (a) {
  328. items.push({sev: n.maxsev, title: (a.name || a.description || ''), host: n.name, ip: n.ip, id: n.id, hostid: n.hostid});
  329. });
  330. }
  331. });
  332. (snap.other_alarms || []).forEach(function (a) {
  333. items.push({sev: a.sev, title: a.name, host: a.host, ip: a.ip, id: null, hostid: null, other: true});
  334. });
  335. el('l2-alarm-count').textContent = items.length;
  336. if (!items.length) { el('l2-alarm-list').innerHTML = '<div class="panel-empty">' + t('l2.alarm_empty', 'No active alarms.') + '</div>'; return; }
  337. items.sort(function (a, b) { return b.sev - a.sev; });
  338. var h = '';
  339. items.forEach(function (it) {
  340. var col = SEV_COLORS[Math.max(0, Math.min(5, it.sev))] || '#999';
  341. h += '<div class="alarm-row" data-id="' + (it.id || '') + '" data-hostid="' + (it.hostid || '') + '">' +
  342. '<span class="alarm-dot" style="background:' + col + '"></span>' +
  343. '<div class="alarm-main"><div class="alarm-title">' + esc(it.title) + '</div>' +
  344. '<div class="alarm-sub">' + esc(it.host || '') + (it.ip ? ' · ' + esc(it.ip) : '') + (it.other ? ' · 拓扑外' : '') + '</div></div></div>';
  345. });
  346. var box = el('l2-alarm-list');
  347. box.innerHTML = h;
  348. box.querySelectorAll('.alarm-row').forEach(function (r) {
  349. r.addEventListener('click', function () {
  350. var id = r.getAttribute('data-id');
  351. if (id) focusNode(id);
  352. else { var hid = r.getAttribute('data-hostid'); if (hid) window.open(META.zabbixHostUrl.replace('__HOSTID__', hid), '_blank'); }
  353. });
  354. });
  355. }
  356. function buildOffline(snap) {
  357. var off = (snap.terminals || []).filter(function (tm) { return !tm.online; });
  358. el('l2-offline-count').textContent = off.length;
  359. if (!off.length) { el('l2-offline-list').innerHTML = '<div class="panel-empty">' + t('l2.offline_empty', 'No offline terminals.') + '</div>'; return; }
  360. var h = '';
  361. off.forEach(function (tm) {
  362. h += '<div class="off-row" data-mac="' + esc(tm.mac) + '">' +
  363. '<div class="off-main"><div class="off-mac">' + esc(tm.mac) + '</div>' +
  364. '<div class="off-sub">' + esc(tm.sw_name || tm.sw_ip) + ' · ' + esc(tm.port || '') + '</div></div></div>';
  365. });
  366. var box = el('l2-offline-list');
  367. box.innerHTML = h;
  368. box.querySelectorAll('.off-row').forEach(function (r) {
  369. r.addEventListener('click', function () {
  370. var mac = r.getAttribute('data-mac');
  371. if (!showTerm) { showTerm = true; el('l2-showterm').checked = true; render(lastSnap); }
  372. setTimeout(function () { focusNode('t_' + mac); }, 60);
  373. });
  374. });
  375. }
  376. function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]; }); }
  377. /* ---------------- interactions ---------------- */
  378. function focusNode(id) {
  379. if (!cy) return;
  380. var n = cy.getElementById(id);
  381. if (!n || n.empty() || n.length === 0) return;
  382. cy.elements().removeClass('hl');
  383. n.addClass('hl');
  384. cy.animate({center: {eles: n}, zoom: Math.max(cy.zoom(), 1.1), duration: 380});
  385. setTimeout(function () { n.removeClass('hl'); }, 2600);
  386. }
  387. function bindCy() {
  388. cy.on('mouseover', 'node[type="switch"]', function (e) {
  389. var d = e.target.data();
  390. var arr = d.alarms || [];
  391. var sev = (d.maxsev != null && d.maxsev >= 0) ? ('<span style="color:' + SEV_COLORS[d.maxsev] + '">● ' + SEV_NAMES[d.maxsev] + '</span>') : '—';
  392. var lat = (d.latency_ms != null) ? (d.latency_ms + ' ms') : '—';
  393. var html = '<b>' + esc(d.name) + '</b><br>' + esc(d.ip) + ' · ' + esc(d.group || '') + '<br>' +
  394. 'Tier: ' + esc(d.tier) + ' · ' + (d.oper || '') + '<br>' +
  395. 'Latency: ' + lat + '<br>' +
  396. 'Terminals: ' + (d.term_online || 0) + ' on / ' + (d.term_offline || 0) + ' off<br>' +
  397. 'Alarm: ' + sev;
  398. if (arr.length) html += '<hr>' + arr.map(function (a) { return esc(a.name || a.description || ''); }).join('<br>');
  399. showTip(e, html);
  400. });
  401. cy.on('mouseout', 'node[type="switch"]', hideTip);
  402. cy.on('mouseover', 'edge:not([type="term"])', function (e) {
  403. var d = e.target.data();
  404. var sp = (parseFloat(d.speed) || 0) >= 1000 ? ((parseFloat(d.speed) / 1000) + 'G') : (d.speed + 'M');
  405. showTip(e, 'Link ' + esc(d.a_port || '') + ' ↔ ' + esc(d.b_port || '') + '<br>' + sp + ' · ' + esc(d.state || ''));
  406. });
  407. cy.on('mouseout', 'edge', hideTip);
  408. cy.on('tap', 'node[type="switch"]', function (e) {
  409. var hid = e.target.data('hostid');
  410. if (hid) { /* single click: focus only; double handled below */ }
  411. });
  412. cy.on('dbltap', 'node[type="switch"]', function (e) {
  413. var hid = e.target.data('hostid');
  414. if (hid) window.open(META.zabbixHostUrl.replace('__HOSTID__', hid), '_blank');
  415. });
  416. }
  417. var SEV_NAMES = ['未分类','信息','警告','一般严重','严重','灾难'];
  418. function showTip(e, html) {
  419. var tip = el('l2-tip');
  420. tip.innerHTML = html;
  421. tip.style.display = 'block';
  422. var p = e.renderedPosition || {x: 0, y: 0};
  423. var box = el('l2-cy').getBoundingClientRect();
  424. var x = box.left + p.x + 16, y = box.top + p.y + 16;
  425. tip.style.left = x + 'px'; tip.style.top = y + 'px';
  426. }
  427. function hideTip() { el('l2-tip').style.display = 'none'; }
  428. /* ---------------- search ---------------- */
  429. function buildIndex(snap) {
  430. var idx = [];
  431. (snap.nodes || []).forEach(function (n) { idx.push({id: n.id, label: n.name, sub: n.ip, kind: 'sw'}); });
  432. (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}); });
  433. return idx;
  434. }
  435. var INDEX = [];
  436. function doSearch(q) {
  437. var box = el('l2-sug');
  438. q = (q || '').trim().toLowerCase();
  439. if (!q) { box.style.display = 'none'; box.innerHTML = ''; return; }
  440. var hits = [];
  441. for (var i = 0; i < INDEX.length && hits.length < 14; i++) {
  442. var it = INDEX[i];
  443. if ((it.label && it.label.toLowerCase().indexOf(q) >= 0) || (it.sub && it.sub.toLowerCase().indexOf(q) >= 0)) hits.push(it);
  444. }
  445. if (!hits.length) { box.innerHTML = '<div class="sug-empty">—</div>'; box.style.display = 'block'; return; }
  446. box.innerHTML = hits.map(function (h) {
  447. return '<div class="sug-row" data-id="' + esc(h.id) + '"><span class="sug-kind ' + h.kind + '">' + (h.kind === 'sw' ? 'SW' : 'PC') + '</span>' +
  448. '<span class="sug-lbl">' + esc(h.label) + '</span><span class="sug-sub">' + esc(h.sub || '') + '</span></div>';
  449. }).join('');
  450. box.style.display = 'block';
  451. box.querySelectorAll('.sug-row').forEach(function (r) {
  452. r.addEventListener('click', function () {
  453. var id = r.getAttribute('data-id');
  454. if (id.indexOf('t_') === 0 && !showTerm) { showTerm = true; el('l2-showterm').checked = true; render(lastSnap); setTimeout(function () { focusNode(id); }, 80); }
  455. else focusNode(id);
  456. box.style.display = 'none';
  457. });
  458. });
  459. }
  460. /* ---------------- toolbar wiring ---------------- */
  461. function setupControls() {
  462. dark = el('l2-wrap').classList.contains('l2-dark');
  463. el('l2-dark').checked = dark;
  464. el('l2-dark').addEventListener('change', function () {
  465. dark = el('l2-dark').checked;
  466. el('l2-wrap').classList.toggle('l2-dark', dark);
  467. if (cy) cy.style(styleFor(dark));
  468. buildLegend();
  469. });
  470. el('l2-showterm').addEventListener('change', function () { showTerm = el('l2-showterm').checked; render(lastSnap); });
  471. el('l2-onlyoff').addEventListener('change', function () {
  472. onlyOff = el('l2-onlyoff').checked;
  473. if (onlyOff && !showTerm) { showTerm = true; el('l2-showterm').checked = true; }
  474. render(lastSnap);
  475. });
  476. el('l2-zin').addEventListener('click', function () { if (cy) cy.zoom({level: cy.zoom() * 1.25, renderedPosition: {x: cy.width() / 2, y: cy.height() / 2}}); });
  477. el('l2-zout').addEventListener('click', function () { if (cy) cy.zoom({level: cy.zoom() / 1.25, renderedPosition: {x: cy.width() / 2, y: cy.height() / 2}}); });
  478. el('l2-fit').addEventListener('click', function () { if (cy) cy.fit(undefined, 50); });
  479. el('l2-refresh-btn').addEventListener('click', function () { reload(true); });
  480. var s = el('l2-search');
  481. s.addEventListener('input', function () { doSearch(s.value); });
  482. document.addEventListener('click', function (ev) {
  483. if (!ev.target.closest('.l2-search-wrap')) el('l2-sug').style.display = 'none';
  484. });
  485. el('l2-ts').addEventListener('change', function () {
  486. var v = el('l2-ts').value;
  487. var u = '/zabbix.php?action=l2.topology.view';
  488. if (v) u += '&ts=' + encodeURIComponent(v);
  489. window.location = u;
  490. });
  491. el('l2-pause').addEventListener('click', function () {
  492. paused = !paused;
  493. el('l2-pause').classList.toggle('on', paused);
  494. el('l2-pause').textContent = paused ? '▶' : '❚❚';
  495. el('l2-pause').title = paused ? t('l2.resume', 'Resume') : t('l2.pause', 'Pause');
  496. });
  497. }
  498. function reload(manual) {
  499. if (!META.isLive) return;
  500. fetch(META.snapshotUrl + '?t=' + Date.now(), {cache: 'no-store'})
  501. .then(function (r) { return r.json(); })
  502. .then(function (j) { if (j && j.nodes) { SNAP = j; INDEX = buildIndex(j); render(j); } })
  503. .catch(function () {})
  504. .then(function () { if (manual) resetCountdown(); });
  505. }
  506. function resetCountdown() { countdown = META.refreshSeconds || 300; }
  507. function startTimer() {
  508. if (!META.isLive) return;
  509. resetCountdown();
  510. timer = setInterval(function () {
  511. if (paused) { el('l2-countdown').textContent = '⏸'; return; }
  512. countdown--;
  513. if (countdown <= 0) { countdown = META.refreshSeconds || 300; reload(false); }
  514. var m = Math.floor(countdown / 60), s = countdown % 60;
  515. el('l2-countdown').textContent = '↻ ' + m + ':' + (s < 10 ? '0' + s : s);
  516. }, 1000);
  517. }
  518. /* ---------------- boot ---------------- */
  519. function boot() {
  520. setupControls();
  521. if (!SNAP || !SNAP.nodes) {
  522. el('l2-cy').innerHTML = '<div class="l2-empty">' + t('l2.no_data', 'No data') + '</div>';
  523. } else {
  524. INDEX = buildIndex(SNAP);
  525. render(SNAP);
  526. }
  527. if (META.isLive) startTimer();
  528. else { el('l2-pause').disabled = true; el('l2-countdown').textContent = t('l2.snapshot', 'Snapshot'); }
  529. }
  530. if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
  531. else boot();
  532. })();