app.js 50 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108
  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 = 156, NODE_H = 50, GX = 26, GY = 18, PADX = 20, PADY = 42;
  20. var DGAP_X = 18, DGAP_Y = 26;
  21. var GROUP_GAP_X = 46, GROUP_GAP_Y = 48, MAXW = 2800;
  22. // overview ("全景") tiles
  23. var TILE_W = 232, TILE_H = 116, TILE_GAP_X = 24, TILE_GAP_Y = 22;
  24. var cy = null;
  25. var dark = false;
  26. var showTerm = false;
  27. var onlyOff = false;
  28. var paused = false;
  29. var countdown = 0;
  30. var timer = null;
  31. var lastSnap = null;
  32. var viewGroup = null; // null = overview, otherwise workshop label
  33. var detailNet = null; // null/auto, 'SURVE' or 'OFFICE' inside a workshop
  34. var pathParent = {}; // child -> parent chain for hover highlighting
  35. var lockedPath = null; // switch id whose path is locked by click
  36. function el(id) { return document.getElementById(id); }
  37. function t(k, d) { return (T[k] != null) ? T[k] : (d != null ? d : k); }
  38. /* ---------------- name parsing / grouping ---------------- */
  39. function parseName(name) {
  40. var r = {workshop:'', floor:'', cab:'', u:'', area:'', role:'', ip:''};
  41. if (!name) return r;
  42. var toks = String(name).split('-');
  43. for (var i = 0; i < toks.length; i++) {
  44. var tk = toks[i];
  45. if (/^\d{1,3}(\.\d{1,3}){3}$/.test(tk)) { r.ip = tk; continue; }
  46. if (!r.workshop && /^\d{4}$/.test(tk)) { r.workshop = tk; continue; }
  47. if (!r.floor && /^\d[A-Z]\w*$/i.test(tk)) {
  48. var m = /^(\d)([A-Z])(\w+)$/i.exec(tk);
  49. if (m) { r.floor = m[1]; r.cab = m[2]; r.u = m[3]; continue; }
  50. }
  51. if (!r.area && /^(OFFICE|SURVE|GRDA|DC|IDC|LAB|PROD)$/i.test(tk)) { r.area = tk.toUpperCase(); continue; }
  52. if (!r.role && /^(Dist|Access|Core)$/i.test(tk)) { r.role = tk; }
  53. }
  54. return r;
  55. }
  56. function labelFor(n) {
  57. var p = parseName(n.name);
  58. var l1 = [];
  59. if (p.workshop) l1.push(p.workshop);
  60. if (p.area) l1.push(p.area);
  61. if (!l1.length) {
  62. l1.push((n.name || '').split('-')[0] || n.ip);
  63. }
  64. var l2 = [];
  65. if (p.floor) { var loc = p.floor + 'F' + (p.cab || '') + (p.u ? '-' + p.u + 'U' : ''); l1.push(loc); }
  66. if (p.role) l2.push(p.role);
  67. l2.push(n.ip);
  68. return l1.join(' · ') + '\n' + l2.join(' · ');
  69. }
  70. function detailLabel(n) {
  71. var s = labelFor(n);
  72. if (viewGroup) {
  73. var p = viewGroup + ' · ';
  74. if (s.indexOf(p) === 0) s = s.substring(p.length);
  75. }
  76. return s;
  77. }
  78. function rowLabel(n) {
  79. var s = String((n && n.name) || (n && n.ip) || '');
  80. s = s.replace(/^COBCO-/i, '');
  81. if (viewGroup) {
  82. try { s = s.replace(new RegExp('^' + viewGroup + '-', 'i'), ''); } catch (e) {}
  83. }
  84. return s;
  85. }
  86. function rootLabel(n) {
  87. var s = rowLabel(n);
  88. var m = /^(.+?)-(SURVE|OFFICE|OTHER)-(Dist|Access)-(.+)$/i.exec(s);
  89. if (m) return m[2].toUpperCase() + ' · ' + m[1] + '\n' + m[3] + ' · ' + m[4];
  90. return s;
  91. }
  92. function expandLabel(tpl, m) {
  93. return String(tpl).replace(/\$(\d)/g, function (_, d) { return m && m[+d] != null ? m[+d] : ''; });
  94. }
  95. function groupOf(n) {
  96. var name = n.name || n.ip || '';
  97. var rules = CFG.group_rules || [];
  98. for (var i = 0; i < rules.length; i++) {
  99. var r = rules[i];
  100. if (r.match === 'regex') {
  101. var re;
  102. try { re = new RegExp(r.pattern); } catch (e) { continue; }
  103. var m = re.exec(name);
  104. if (m) return {label: expandLabel(r.label, m) || (CFG.other_label || 'Other'), big: !!r.big};
  105. } else {
  106. if (name.toLowerCase().indexOf(String(r.pattern).toLowerCase()) >= 0) return {label: r.label || (CFG.other_label || 'Other'), big: !!r.big};
  107. }
  108. }
  109. return {label: CFG.other_label || 'Other', big: false};
  110. }
  111. /* ---------------- overview layout (workshop tiles) ---------------- */
  112. function groupStats(snap) {
  113. var map = {};
  114. var nodeById = {};
  115. (snap.nodes || []).forEach(function (n) {
  116. nodeById[n.id] = n;
  117. var g = groupOf(n);
  118. 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});
  119. s.nodes.push(n);
  120. s.offline += (+n.term_offline || 0);
  121. if (n.alarms && n.alarms.length) {
  122. s.alarms += n.alarms.length;
  123. var sev = (n.maxsev != null && +n.maxsev >= 0) ? +n.maxsev : -1;
  124. n.alarms.forEach(function (a) { if (a && a.sev != null && +a.sev > sev) sev = +a.sev; });
  125. if (sev > s.maxsev) s.maxsev = sev;
  126. }
  127. if (n.latency_ms != null && +n.latency_ms > s.latmax) s.latmax = +n.latency_ms;
  128. });
  129. (snap.edges || []).forEach(function (e) {
  130. var a = nodeById[e.source], b = nodeById[e.target];
  131. if (!a || !b) return;
  132. var ga = groupOf(a), gb = groupOf(b);
  133. if (ga.label === gb.label) return;
  134. // count links that leave this workshop towards a core switch
  135. if ((/core/i.test(a.tier || '') || /core/i.test(a.name || '')) && map[gb.label]) map[gb.label].up++;
  136. if ((/core/i.test(b.tier || '') || /core/i.test(b.name || '')) && map[ga.label]) map[ga.label].up++;
  137. });
  138. return map;
  139. }
  140. function groupRank(l) {
  141. if (/core/i.test(l)) return 0;
  142. if (/data\s*center/i.test(l)) return 1;
  143. var m = /^(\d{4})([A-Za-z]?)$/.exec(l);
  144. if (m) return 1000 + parseInt(m[1], 10) * 40 + (m[2] ? m[2].toUpperCase().charCodeAt(0) - 64 : 0);
  145. return 5000;
  146. }
  147. function computeOverview(snap) {
  148. var stats = groupStats(snap);
  149. var labels = Object.keys(stats).sort(function (a, b) { return groupRank(a) - groupRank(b); });
  150. var special = labels.filter(function (l) { return groupRank(l) < 1000; });
  151. var others = labels.filter(function (l) { return groupRank(l) >= 1000; });
  152. var COLS = Math.max(4, Math.min(7, Math.ceil(Math.sqrt(Math.max(1, others.length)))));
  153. var gridW = COLS * TILE_W + (COLS - 1) * TILE_GAP_X;
  154. var pos = {}, tiles = [], y = 0;
  155. if (special.length) {
  156. var sW = special.length * TILE_W + (special.length - 1) * TILE_GAP_X;
  157. var sx = Math.max(0, (gridW - sW) / 2);
  158. special.forEach(function (l) {
  159. tiles.push({label: l, x: sx, y: y, w: TILE_W, h: TILE_H});
  160. pos[l] = {x: sx + TILE_W / 2, y: y + TILE_H / 2};
  161. sx += TILE_W + TILE_GAP_X;
  162. });
  163. y += TILE_H + 46;
  164. }
  165. var maxX = gridW, maxY = y;
  166. for (var i = 0; i < others.length; i += COLS) {
  167. var row = others.slice(i, i + COLS);
  168. var rW = row.length * TILE_W + (row.length - 1) * TILE_GAP_X;
  169. var rx = Math.max(0, (gridW - rW) / 2);
  170. row.forEach(function (l, j) {
  171. tiles.push({label: l, x: rx + j * (TILE_W + TILE_GAP_X), y: y, w: TILE_W, h: TILE_H});
  172. pos[l] = {x: rx + j * (TILE_W + TILE_GAP_X) + TILE_W / 2, y: y + TILE_H / 2};
  173. });
  174. y += TILE_H + TILE_GAP_Y;
  175. maxY = y - TILE_GAP_Y;
  176. maxX = Math.max(maxX, rx + rW);
  177. }
  178. var root = {x: -34, y: -64, w: maxX + 68, h: maxY + 64 + 40};
  179. root.cx = root.x + root.w / 2; root.cy = root.y + root.h / 2;
  180. return {stats: stats, labels: labels, pos: pos, tiles: tiles, root: root};
  181. }
  182. function aggregateEdges(snap) {
  183. var gid = {};
  184. (snap.nodes || []).forEach(function (n) { gid[n.id] = groupOf(n).label; });
  185. var pairs = {};
  186. (snap.edges || []).forEach(function (e) {
  187. var a = gid[e.source], b = gid[e.target];
  188. if (!a || !b || a === b) return;
  189. var k = (a < b) ? (a + '\u0001' + b) : (b + '\u0001' + a);
  190. var p = pairs[k] || (pairs[k] = {a: a, b: b, count: 0, speed: 0, down: false});
  191. p.count++;
  192. if ((parseFloat(e.speed) || 0) > p.speed) p.speed = parseFloat(e.speed) || 0;
  193. if (e.state && e.state !== 'up') p.down = true;
  194. });
  195. return Object.keys(pairs).map(function (k) { return pairs[k]; });
  196. }
  197. /* ---------------- detail layout (one workshop, left-to-right tree) ---------------- */
  198. function floorInfo(n) {
  199. var toks = String((n && n.name) || '').split('-');
  200. for (var i = 0; i < toks.length; i++) {
  201. var tk = toks[i];
  202. var bm = /^B(\d+)$/i.exec(tk);
  203. if (bm) { var bn = parseInt(bm[1], 10); return {key: 'B' + bn, label: 'B' + bn, rank: -200 + bn}; }
  204. if (/^GRD/i.test(tk)) return {key: 'GRD', label: 'GRD', rank: -100};
  205. var fm = /^(\d+(?:\.\d+)?)F/i.exec(tk);
  206. if (fm) {
  207. var num = parseFloat(fm[1]);
  208. var txt = (num % 1 ? num.toFixed(1) : String(num)) + 'F';
  209. return {key: txt, label: txt, rank: num * 10};
  210. }
  211. var cm = /^(\d)([A-Z])(\w+)$/i.exec(tk);
  212. if (cm) {
  213. var n2 = parseInt(cm[1], 10);
  214. return {key: n2 + 'F', label: n2 + 'F', rank: n2 * 10};
  215. }
  216. }
  217. return {key: 'NA', label: t('l2.floor_na', 'Other'), rank: -500};
  218. }
  219. function baseNetworkOf(n) {
  220. var s = String((n && n.name) || '').toUpperCase();
  221. if (s.indexOf('SURVE') >= 0) return 'SURVE';
  222. if (s.indexOf('OFFICE') >= 0) return 'OFFICE';
  223. var ip = String((n && n.ip) || '');
  224. if (/^10\.169\./.test(ip)) return 'SURVE';
  225. if (/^10\.160\./.test(ip)) return 'OFFICE';
  226. return 'OTHER';
  227. }
  228. function networkOf(n) { return baseNetworkOf(n); }
  229. function networkLabel(key) {
  230. if (key === 'SURVE') return t('l2.net_surve', 'SURVE 监控网');
  231. if (key === 'OFFICE') return t('l2.net_office', 'OFFICE 办公网');
  232. return t('l2.net_other', '其他网络');
  233. }
  234. function computeDetail(snap, label) {
  235. var all = snap.nodes || [];
  236. var byId = {}, groupNodes = [];
  237. all.forEach(function (n) { byId[n.id] = n; if (groupOf(n).label === label) groupNodes.push(n); });
  238. // Split one workshop by physical network first: office and surveillance never mix.
  239. var gids = {}, rawAdj = {};
  240. groupNodes.forEach(function (n) { gids[n.id] = true; rawAdj[n.id] = []; });
  241. (snap.edges || []).forEach(function (e) {
  242. if (gids[e.source] && gids[e.target]) { rawAdj[e.source].push(e.target); rawAdj[e.target].push(e.source); }
  243. });
  244. var netMap = {};
  245. groupNodes.forEach(function (n) { netMap[n.id] = baseNetworkOf(n); });
  246. // Unlabelled access switches inherit the network of the distribution they hang from.
  247. for (var pass = 0; pass < 4; pass++) {
  248. groupNodes.forEach(function (n) {
  249. if (netMap[n.id] !== 'OTHER') return;
  250. var votes = {};
  251. rawAdj[n.id].forEach(function (id) {
  252. var k = netMap[id];
  253. if (k && k !== 'OTHER') votes[k] = (votes[k] || 0) + 1;
  254. });
  255. var best = null, bestN = 0;
  256. Object.keys(votes).forEach(function (k) { if (votes[k] > bestN) { best = k; bestN = votes[k]; } });
  257. if (best) netMap[n.id] = best;
  258. });
  259. }
  260. var counts = {}, nets = [];
  261. groupNodes.forEach(function (n) {
  262. var k = netMap[n.id];
  263. if (!counts[k]) { counts[k] = 0; nets.push(k); }
  264. counts[k]++;
  265. });
  266. nets.sort(function (a, b) {
  267. if (a === 'SURVE') return -1;
  268. if (b === 'SURVE') return 1;
  269. if (a === 'OFFICE') return -1;
  270. if (b === 'OFFICE') return 1;
  271. return counts[b] - counts[a];
  272. });
  273. var activeNet = (detailNet && counts[detailNet]) ? detailNet : (nets.length ? nets[0] : 'OTHER');
  274. var nodes = groupNodes.filter(function (n) { return netMap[n.id] === activeNet; });
  275. var ids = {};
  276. nodes.forEach(function (n) { ids[n.id] = true; });
  277. var internal = [], external = [];
  278. (snap.edges || []).forEach(function (e) {
  279. var a = ids[e.source], b = ids[e.target];
  280. if (a && b) internal.push(e);
  281. else if (a || b) external.push(e);
  282. });
  283. var adj = {};
  284. nodes.forEach(function (n) { adj[n.id] = []; });
  285. internal.forEach(function (e) { adj[e.source].push(e.target); adj[e.target].push(e.source); });
  286. // Distribution switches are the left-hand roots of the workshop tree.
  287. var dists = nodes.filter(function (n) { return /dist/i.test(n.tier || '') && /dist/i.test(n.name || ''); })
  288. .sort(function (a, b) { return (adj[b.id].length - adj[a.id].length) || String(a.name).localeCompare(String(b.name)); });
  289. if (!dists.length) {
  290. var cand = nodes.slice().sort(function (a, b) { return adj[b.id].length - adj[a.id].length; })[0];
  291. if (cand) dists = [cand];
  292. }
  293. // One physical root per network tab; peer/redundant distribution switches are listed below it.
  294. if (dists.length > 1) dists = [dists[0]];
  295. // Group all non-distribution switches into floor buckets (GRD lowest, then 1F/2F/3F...).
  296. var floorMap = {};
  297. nodes.forEach(function (n) {
  298. if (dists.indexOf(n) >= 0) return;
  299. if (/core/i.test(n.tier || '')) return;
  300. var fi = floorInfo(n);
  301. var f = floorMap[fi.key] || (floorMap[fi.key] = {key: fi.key, label: fi.label, rank: fi.rank, nodes: []});
  302. f.nodes.push(n);
  303. });
  304. var floors = Object.keys(floorMap).map(function (k) { return floorMap[k]; });
  305. floors.forEach(function (f) {
  306. f.nodes.sort(function (a, b) {
  307. var pa = parseName(a.name), pb = parseName(b.name);
  308. var ca = (pa.cab || '') + (pa.u || ''), cb = (pb.cab || '') + (pb.u || '');
  309. if (ca !== cb) return ca.localeCompare(cb);
  310. return String(a.name).localeCompare(String(b.name));
  311. });
  312. });
  313. // Highest floor at the top, GRD at the bottom.
  314. floors.sort(function (a, b) { return b.rank - a.rank; });
  315. var WORK_L = 300, FLOOR_X = 520, DIST_FRAME_W = 190, TOP_OFF = 34;
  316. var ROW_W = 380, ROW_H = 24, ROW_GAP = 8, FLOOR_HEAD = 28, FLOOR_GAP = 12;
  317. var floorW = ROW_W + PADX * 2 + 110;
  318. floors.forEach(function (f) {
  319. f.cols = 1;
  320. f.rows = f.nodes.length;
  321. f.innerW = ROW_W;
  322. f.h = FLOOR_HEAD + f.rows * ROW_H + Math.max(0, f.rows - 1) * ROW_GAP + 10;
  323. });
  324. var workRight = FLOOR_X + floorW + 14;
  325. var workW = workRight - WORK_L;
  326. var pos = {}, top = 44 + TOP_OFF;
  327. floors.forEach(function (f) {
  328. f.frame = {x: FLOOR_X, y: top, w: floorW, h: f.h};
  329. f.nodes.forEach(function (n, i) {
  330. pos[n.id] = {
  331. x: FLOOR_X + PADX + ROW_W / 2,
  332. y: top + FLOOR_HEAD + i * (ROW_H + ROW_GAP) + ROW_H / 2
  333. };
  334. });
  335. top += f.h + FLOOR_GAP;
  336. });
  337. var workH = Math.max(220, top + 6);
  338. var distFrame = dists.length ? {x: WORK_L + 8, y: 44 + TOP_OFF, w: DIST_FRAME_W, h: workH - (44 + TOP_OFF) - 14} : null;
  339. var distX = WORK_L + 8 + DIST_FRAME_W / 2;
  340. if (dists.length) {
  341. var childYs = floors.reduce(function (acc, f) {
  342. return acc.concat(f.nodes.map(function (n) { return pos[n.id] ? pos[n.id].y : 0; }));
  343. }, []);
  344. var cy = childYs.length ? (Math.min.apply(null, childYs) + Math.max.apply(null, childYs)) / 2 : distFrame.y + distFrame.h / 2;
  345. pos[dists[0].id] = {x: distX, y: cy};
  346. }
  347. var missing = nodes.filter(function (n) { return !pos[n.id]; });
  348. missing.forEach(function (n, i) {
  349. pos[n.id] = {x: distX, y: (distFrame ? distFrame.y + 30 : 80) + i * (NODE_H + 12)};
  350. });
  351. // One ghost core per external core→distribution uplink, aligned left of its distribution switch.
  352. var ghosts = [], ghostSeen = {};
  353. external.forEach(function (e) {
  354. var inside = ids[e.source] ? e.source : e.target;
  355. var outside = ids[e.source] ? e.target : e.source;
  356. if (!pos[inside] || !byId[outside]) return;
  357. // Only a real core uplink gets a ghost; other cross-group edges are not drawn as cores.
  358. if (!/core/i.test(byId[outside].tier || '') && !/core/i.test(byId[outside].name || '')) return;
  359. var key = outside + '|' + inside;
  360. if (ghostSeen[key]) return;
  361. var g = {id: 'gh_' + outside + '_' + inside, to: inside, node: byId[outside], speed: e.speed, state: e.state};
  362. ghostSeen[key] = g;
  363. ghosts.push(g);
  364. });
  365. var ghostAt = {};
  366. ghosts.forEach(function (g) {
  367. var base = pos[g.to];
  368. var k = ghostAt[g.to] || 0;
  369. ghostAt[g.to] = k + 1;
  370. g.x = 92;
  371. g.y = base.y + (k ? (k % 2 ? 1 : -1) * 46 * Math.ceil(k / 2) : 0);
  372. });
  373. // Root each tree at the distribution switch; remember every child's parent for path highlighting.
  374. var parentOf = {}, ghostOfDist = {};
  375. ghosts.forEach(function (g) { if (!ghostOfDist[g.to]) ghostOfDist[g.to] = g.id; });
  376. var seen = {};
  377. dists.forEach(function (d) {
  378. if (seen[d.id]) return;
  379. seen[d.id] = true;
  380. parentOf[d.id] = ghostOfDist[d.id] || null;
  381. var queue = [d.id];
  382. while (queue.length) {
  383. var cur = queue.shift();
  384. adj[cur].forEach(function (nb) {
  385. if (seen[nb]) return;
  386. seen[nb] = true;
  387. parentOf[nb] = cur;
  388. queue.push(nb);
  389. });
  390. }
  391. });
  392. nodes.forEach(function (n) { if (!seen[n.id]) seen[n.id] = true; });
  393. var workshop = {x: WORK_L, y: 0, w: workW, h: workH, label: label + ' · ' + networkLabel(activeNet) + ' · ' + nodes.length + ' ' + t('l2.unit_sw', 'sw')};
  394. var distFrameFull = distFrame ? {
  395. x: distFrame.x, y: distFrame.y, w: distFrame.w, h: distFrame.h,
  396. label: t('l2.dist', 'Distribution') + ' · ' + dists.length
  397. } : null;
  398. floors.forEach(function (f) {
  399. f.frame.label = f.label + ' · ' + f.nodes.length + ' ' + t('l2.unit_sw', 'sw');
  400. });
  401. var root = {x: -20, y: -48, w: workRight + 40, h: workH + 80};
  402. root.cx = root.x + root.w / 2; root.cy = root.y + root.h / 2;
  403. return {
  404. nodes: nodes, byId: byId, pos: pos, ghosts: ghosts, parentOf: parentOf,
  405. dists: dists, floors: floors, frames: {workshop: workshop, dist: distFrameFull},
  406. root: root, totalW: workRight, ids: ids, internal: internal,
  407. nets: nets.map(function (k) { return {key: k, count: counts[k]}; }), activeNet: activeNet, totalNodes: groupNodes.length
  408. };
  409. }
  410. /* ---------------- speed colour ---------------- */
  411. function speedColor(speed, state) {
  412. if (state && state !== 'up') return colors.down;
  413. var s = parseFloat(speed);
  414. if (!isFinite(s) || s <= 0) return colors.unknown;
  415. if (s <= 100) return colors['100'];
  416. if (s <= 1000) return colors['1000'];
  417. if (s <= 10000) return colors['10000'];
  418. return colors['20000'];
  419. }
  420. function speedWidth(speed) {
  421. var s = parseFloat(speed) || 0;
  422. if (s >= 20000) return 4.5;
  423. if (s >= 10000) return 3.6;
  424. if (s >= 1000) return 2.6;
  425. return 1.8;
  426. }
  427. function speedText(speed) {
  428. var s = parseFloat(speed) || 0;
  429. if (!s) return '?';
  430. if (s >= 1000) return (s / 1000) + 'G';
  431. return s + 'M';
  432. }
  433. /* ---------------- style ---------------- */
  434. function styleFor(d) {
  435. var bg = d ? TIER_BG_DARK : TIER_BG;
  436. return [
  437. {selector: 'node', style: {
  438. 'width': 'data(w)', 'height': 'data(h)', 'shape': 'round-rectangle',
  439. 'background-color': 'data(bg)', 'border-width': 2, 'border-color': 'data(bd)',
  440. 'label': 'data(label)', 'color': d ? '#eaf1fb' : '#ffffff',
  441. 'font-size': 11.5, 'font-weight': 600, 'text-valign': 'center', 'text-halign': 'center',
  442. 'text-wrap': 'wrap', 'text-max-width': NODE_W - 16, 'padding': '2px',
  443. 'overlay-opacity': 0, 'z-index-compare': 'manual', 'z-index': 20
  444. }},
  445. {selector: 'node[type="frame"]', style: {
  446. 'background-opacity': d ? 0.05 : 0.035, 'background-color': d ? '#5b7bb0' : '#8fa6c4',
  447. 'border-width': 1.6, 'border-style': 'dashed', 'border-color': d ? '#3f5377' : '#c2ccdb',
  448. 'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center',
  449. 'font-size': 'data(fs)', 'font-weight': 800, 'color': d ? '#aebfd8' : '#526178',
  450. 'text-margin-y': 8, 'padding': '0px', 'events': 'no', 'z-index-compare': 'manual', 'z-index': 2
  451. }},
  452. {selector: 'node[type="wsframe"]', style: {
  453. 'background-opacity': d ? 0.025 : 0.018, 'background-color': d ? '#6f8fc4' : '#8fa6c4',
  454. 'border-width': 2.2, 'border-style': 'solid', 'border-color': d ? '#4f6790' : '#9fb0c6',
  455. 'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center', 'text-wrap': 'none',
  456. 'font-size': 19, 'font-weight': 900, 'color': d ? '#d7e6ff' : '#2f4560',
  457. 'text-margin-x': 0, 'text-margin-y': 8, 'padding': '0px', 'events': 'no',
  458. 'z-index-compare': 'manual', 'z-index': 1
  459. }},
  460. {selector: 'node[type="floorframe"]', style: {
  461. 'background-opacity': d ? 0.055 : 0.035, 'background-color': d ? '#5b7bb0' : '#8fa6c4',
  462. 'border-width': 1.2, 'border-style': 'dashed', 'border-color': d ? '#3f5377' : '#b9c6d8',
  463. 'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center', 'text-wrap': 'none',
  464. 'font-size': 13, 'font-weight': 800, 'color': d ? '#a9c1e3' : '#425a78',
  465. 'text-margin-x': 0, 'text-margin-y': 7, 'padding': '0px', 'events': 'no',
  466. 'z-index-compare': 'manual', 'z-index': 2
  467. }},
  468. {selector: 'node[type="root"]', style: {
  469. 'background-opacity': d ? 0.02 : 0.012, 'background-color': d ? '#7aa0d8' : '#8ea6c2',
  470. 'border-width': 2.2, 'border-style': 'solid', 'border-color': d ? '#476492' : '#9fb0c6',
  471. 'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center',
  472. 'font-size': 28, 'font-weight': 900, 'color': d ? '#cfe0ff' : '#33465f',
  473. 'text-margin-y': 10, 'events': 'no', 'z-index-compare': 'manual', 'z-index': 0
  474. }},
  475. {selector: 'node[type="tile"]', style: {
  476. 'width': 'data(w)', 'height': 'data(h)', 'shape': 'round-rectangle',
  477. 'background-color': 'data(bg)', 'background-opacity': 0.97,
  478. 'border-width': 2.5, 'border-color': 'data(bd)',
  479. 'label': 'data(label)', 'color': 'data(fg)',
  480. 'font-size': 16, 'font-weight': 700, 'text-valign': 'center', 'text-halign': 'center',
  481. 'text-wrap': 'wrap', 'text-max-width': TILE_W - 26, 'line-height': 1.45,
  482. 'padding': '6px', 'overlay-opacity': 0, 'z-index-compare': 'manual', 'z-index': 20
  483. }},
  484. {selector: 'node[type="tile"].alarm', style: {'border-width': 5}},
  485. {selector: 'node[type="tile"].core', style: {'border-width': 3.5}},
  486. {selector: 'node[type="ghost"]', style: {
  487. 'width': 190, 'height': 40, 'shape': 'round-rectangle',
  488. 'background-color': d ? '#52667f' : '#c8d3e2', 'background-opacity': 0.55,
  489. 'border-width': 1.2, 'border-style': 'dashed', 'border-color': d ? '#7d93ad' : '#8fa3bc',
  490. 'label': 'data(label)', 'color': d ? '#dce8fa' : '#3b4f68',
  491. 'font-size': 11, 'font-weight': 600, 'text-wrap': 'wrap', 'text-max-width': 176,
  492. 'z-index-compare': 'manual', 'z-index': 15
  493. }},
  494. {selector: 'node[type="edgepoint"]', style: {
  495. 'width': 1, 'height': 1, 'opacity': 0, 'events': 'no'
  496. }},
  497. {selector: 'node[type="term"]', style: {
  498. 'width': 12, 'height': 12, 'shape': 'ellipse', 'background-color': 'data(bg)',
  499. 'border-width': 1, 'border-color': d ? '#2a3648' : '#cfd8e3', 'label': '',
  500. 'z-index-compare': 'manual', 'z-index': 30
  501. }},
  502. {selector: 'edge[type="tilelink"]', style: {
  503. 'width': 'data(w)', 'line-color': 'data(color)', 'opacity': 0.55,
  504. 'curve-style': 'bezier', 'target-arrow-shape': 'none',
  505. 'z-index-compare': 'manual', 'z-index': 5
  506. }},
  507. {selector: 'edge[type="ghost"]', style: {
  508. 'width': 1.2, 'line-color': d ? '#6b809b' : '#a9b6c8', 'line-style': 'dashed', 'opacity': 0.7,
  509. 'curve-style': 'bezier', 'target-arrow-shape': 'none',
  510. 'z-index-compare': 'manual', 'z-index': 8
  511. }},
  512. {selector: 'edge[type="uplink"]', style: {
  513. 'width': 'data(w)', 'line-color': 'data(color)', 'line-style': 'data(ls)', 'opacity': 0.95,
  514. 'curve-style': 'bezier', 'target-arrow-shape': 'none',
  515. 'label': 'data(lbl)', 'font-size': 10, 'font-weight': 700,
  516. 'color': d ? '#dce8fa' : '#25405f',
  517. 'text-background-color': d ? '#0d1b2a' : '#ffffff', 'text-background-opacity': 0.88,
  518. 'text-background-padding': '2px', 'text-rotation': 'autorotate',
  519. 'z-index-compare': 'manual', 'z-index': 40
  520. }},
  521. {selector: 'edge', style: {
  522. 'width': 'data(w)', 'line-color': 'data(color)', 'line-style': 'data(ls)',
  523. 'curve-style': 'bezier', 'opacity': 0.92, 'target-arrow-shape': 'none',
  524. 'z-index-compare': 'manual', 'z-index': 12
  525. }},
  526. {selector: 'edge[type="term"]', style: {
  527. 'width': 0.7, 'line-color': d ? '#3b4a5e' : '#c5cfdb', 'opacity': 0.55,
  528. 'curve-style': 'bezier', 'z-index-compare': 'manual', 'z-index': 11
  529. }},
  530. {selector: 'node.alarm', style: {'border-width': 6}},
  531. {selector: 'node.dim', style: {'opacity': 0.12}},
  532. {selector: 'edge.dim', style: {'opacity': 0.08}},
  533. {selector: 'edge.hl', style: {'width': 5, 'line-color': '#FFD54A', 'opacity': 1, 'z-index-compare': 'manual', 'z-index': 45}},
  534. {selector: 'node.lat-warn', style: {'border-color': '#F39C12'}},
  535. {selector: 'node.lat-bad', style: {'border-color': '#E74C3C', 'border-width': 6}},
  536. {selector: 'node.hl', style: {'border-color': '#ff0', 'border-width': 7, 'overlay-color': '#ff0', 'overlay-opacity': 0.25, 'overlay-padding': 6}},
  537. {selector: 'node.pathhl', style: {'border-width': 5, 'border-color': '#FFD54A', 'overlay-color': '#FFD54A', 'overlay-opacity': 0.2, 'overlay-padding': 4}},
  538. {selector: 'edge.pathhl', style: {'width': 5.5, 'line-color': '#FFD54A', 'opacity': 1}},
  539. {selector: 'edge.detail', style: {'opacity': 0.72, 'curve-style': 'straight', 'source-endpoint': '0% 50%', 'target-endpoint': '0% 50%'}},
  540. {selector: 'node[type="switch"]', style: {
  541. 'background-color': function (e) { return e.data('bg'); },
  542. 'text-max-width': 'data(tw)', 'text-wrap': 'none',
  543. 'text-halign': 'center', 'font-size': 10.5
  544. }},
  545. {selector: 'node[type="switch"].root', style: {'text-wrap': 'wrap'}}
  546. ];
  547. }
  548. /* ---------------- render ---------------- */
  549. function applyElements(els) {
  550. if (!cy) {
  551. cy = cytoscape({
  552. container: el('l2-cy'),
  553. elements: els,
  554. style: styleFor(dark),
  555. layout: {name: 'preset', fit: true, padding: 40},
  556. wheelSensitivity: 0.2,
  557. minZoom: 0.15, maxZoom: 4
  558. });
  559. bindCy();
  560. } else {
  561. cy.elements().remove();
  562. cy.add(els);
  563. cy.style(styleFor(dark));
  564. cy.layout({name: 'preset', fit: true, padding: 40}).run();
  565. }
  566. }
  567. function render(snap) {
  568. if (!snap || !snap.nodes) {
  569. el('l2-cy').innerHTML = '<div class="l2-empty">' + t('l2.no_data', 'No data') + '</div>';
  570. return;
  571. }
  572. lastSnap = snap;
  573. if (viewGroup) {
  574. var exists = (snap.nodes || []).some(function (n) { return groupOf(n).label === viewGroup; });
  575. if (!exists) viewGroup = null;
  576. }
  577. if (viewGroup) renderDetail(snap); else renderOverview(snap);
  578. if (el('l2-back')) el('l2-back').style.display = viewGroup ? '' : 'none';
  579. updateStats(snap);
  580. buildLegend();
  581. buildAlarms(snap);
  582. buildOffline(snap);
  583. }
  584. function renderOverview(snap) {
  585. var O = computeOverview(snap), els = [], idx = {};
  586. var nb = el('l2-net-tabs'); if (nb) { nb.style.display = 'none'; nb.innerHTML = ''; }
  587. if (O.root) {
  588. els.push({group: 'nodes', data: {id: '__root', type: 'root', label: CFG.root_label || 'COBCO', w: O.root.w, h: O.root.h},
  589. position: {x: O.root.cx, y: O.root.cy}, selectable: false, grabbable: false});
  590. }
  591. O.tiles.forEach(function (tile, i) {
  592. var s = O.stats[tile.label] || {nodes: [], alarms: 0, offline: 0, maxsev: -1};
  593. idx[tile.label] = 'grp_' + i;
  594. var isCore = /core/i.test(tile.label) || /data\s*center/i.test(tile.label);
  595. var bd = s.alarms ? '#E45959' : (dark ? '#41597c' : '#b9c8dc');
  596. var bg = isCore ? TIER_BG.core : (dark ? '#22344e' : '#eef3fb');
  597. var fg = isCore ? '#ffffff' : (dark ? '#dfe9fb' : '#1e3352');
  598. var lbl = tile.label + '\n' + s.nodes.length + ' ' + t('l2.unit_sw', 'sw') +
  599. (s.up ? ' ↑ ' + s.up : '') + (s.alarms ? ' ⚠ ' + s.alarms : '') + (s.offline ? ' ◌ ' + s.offline : '');
  600. var cls = [];
  601. if (s.alarms) cls.push('alarm');
  602. if (isCore) cls.push('core');
  603. els.push({group: 'nodes', data: {
  604. id: idx[tile.label], type: 'tile', group: tile.label,
  605. n: s.nodes.length, alarms: s.alarms, offline: s.offline, maxsev: s.maxsev, up: s.up,
  606. label: lbl, bg: bg, fg: fg, bd: bd, w: TILE_W, h: TILE_H
  607. }, position: {x: tile.x + tile.w / 2, y: tile.y + tile.h / 2}, classes: cls.join(' ')});
  608. });
  609. applyElements(els);
  610. }
  611. function renderNetTabs(D) {
  612. var box = el('l2-net-tabs');
  613. if (!box) return;
  614. box.innerHTML = '';
  615. if (!D.nets || D.nets.length < 2) { box.style.display = 'none'; return; }
  616. box.style.display = 'flex';
  617. D.nets.forEach(function (n) {
  618. var b = document.createElement('button');
  619. b.type = 'button';
  620. b.className = 'l2-net-tab' + (n.key === D.activeNet ? ' active' : '');
  621. b.textContent = networkLabel(n.key) + ' · ' + n.count + ' ' + t('l2.unit_sw', 'sw');
  622. b.addEventListener('click', function () { detailNet = n.key; render(lastSnap); });
  623. box.appendChild(b);
  624. });
  625. }
  626. function renderDetail(snap) {
  627. var D = computeDetail(snap, viewGroup), els = [];
  628. renderNetTabs(D);
  629. function frameEl(id, type, fr) {
  630. els.push({group: 'nodes', data: {id: id, type: type, label: fr.label, w: fr.w, h: fr.h},
  631. position: {x: fr.x + fr.w / 2, y: fr.y + fr.h / 2}, selectable: false, grabbable: false});
  632. }
  633. 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});
  634. if (D.frames.dist) frameEl('__dist', 'floorframe', D.frames.dist);
  635. D.floors.forEach(function (f, i) { frameEl('__floor_' + i, 'floorframe', f.frame); });
  636. (D.ghosts || []).forEach(function (g) {
  637. var nm = (g.node && g.node.name) || g.id;
  638. var ip = (g.node && g.node.ip) || '';
  639. var tail = String(nm).split(/[-\s]+/).filter(Boolean).slice(-1)[0] || '';
  640. var short = (tail ? tail + ' · ' : '') + ip;
  641. els.push({group: 'nodes', data: {id: g.id, type: 'ghost', label: short}, position: {x: g.x, y: g.y}, selectable: false, grabbable: false});
  642. els.push({group: 'edges', data: {
  643. id: 'ghe_' + g.id, type: 'uplink', source: g.id, target: g.to,
  644. speed: g.speed, state: g.state, lbl: speedText(g.speed),
  645. color: speedColor(g.speed, g.state), ls: (g.state && g.state !== 'up') ? 'dashed' : 'solid',
  646. w: Math.max(2.6, speedWidth(g.speed))
  647. }});
  648. });
  649. var switchIds = {};
  650. D.nodes.forEach(function (n, ni) {
  651. switchIds[n.id] = true;
  652. var p = D.pos[n.id] || {x: 0, y: 0};
  653. var tier = n.tier || 'access';
  654. var tkey = (tier.indexOf('dist') === 0) ? 'dist' : (tier.indexOf('core') === 0 ? 'core' : 'access');
  655. var isRoot = D.dists && D.dists.length && n.id === D.dists[0].id;
  656. var cls = [];
  657. var bd = (dark ? TIER_BG_DARK[tkey] : TIER_BG[tkey]) || '#555';
  658. var bg = (dark ? TIER_BG_DARK[tkey] : TIER_BG[tkey]) || '#777';
  659. if (!isRoot && ni % 2 === 1) bg = dark ? '#4f8a3a' : '#6ca75a';
  660. if (n.alarms && n.alarms.length) {
  661. cls.push('alarm');
  662. bd = '#E45959';
  663. }
  664. if (n.latency_ms != null) {
  665. if (+n.latency_ms > LATB) cls.push('lat-bad');
  666. else if (+n.latency_ms > LATW) cls.push('lat-warn');
  667. }
  668. if (isRoot) cls.push('root');
  669. els.push({group: 'nodes', data: {
  670. id: n.id, type: 'switch', name: n.name, ip: n.ip, tier: tier, depth: n.depth,
  671. hostid: n.hostid, oper: n.oper, maxsev: n.maxsev, alarms: n.alarms || [],
  672. term_online: n.term_online, term_offline: n.term_offline, latency_ms: n.latency_ms,
  673. label: isRoot ? rootLabel(n) : rowLabel(n), tw: isRoot ? 150 : 360,
  674. bg: bg, bd: isRoot ? bd : '#25501e',
  675. w: isRoot ? 170 : 380, h: isRoot ? 50 : 24, group: viewGroup
  676. }, position: p, classes: cls.join(' ')});
  677. });
  678. D.internal.forEach(function (e, i) {
  679. var root = D.dists && D.dists[0];
  680. if (root && (e.source === root.id || e.target === root.id)) {
  681. var childId = e.source === root.id ? e.target : e.source;
  682. var pp = D.pos[root.id], cp = D.pos[childId];
  683. if (pp && cp) {
  684. var sp = 'sp_' + i, tp = 'tp_' + i;
  685. // Exact left-edge midpoints, straight line. The parent label is
  686. // allowed to cover the line where it exits the parent.
  687. els.push({group: 'nodes', data: {id: sp, type: 'edgepoint'}, position: {x: pp.x - 86, y: pp.y}, selectable: false, grabbable: false});
  688. els.push({group: 'nodes', data: {id: tp, type: 'edgepoint'}, position: {x: cp.x - 191, y: cp.y}, selectable: false, grabbable: false});
  689. els.push({group: 'edges', data: {
  690. id: 'e_' + i, type: 'detail', source: sp, target: tp, p: root.id, c: childId,
  691. speed: e.speed, state: e.state, a_port: e.a_port, b_port: e.b_port,
  692. color: speedColor(e.speed, e.state), ls: (e.state && e.state !== 'up') ? 'dashed' : 'solid',
  693. w: Math.min(2.4, speedWidth(e.speed))
  694. }, classes: 'detail'});
  695. return;
  696. }
  697. }
  698. els.push({group: 'edges', data: {
  699. id: 'e_' + i, source: e.source, target: e.target, speed: e.speed, state: e.state,
  700. a_port: e.a_port, b_port: e.b_port,
  701. color: speedColor(e.speed, e.state), ls: (e.state && e.state !== 'up') ? 'dashed' : 'solid',
  702. w: Math.min(2.4, speedWidth(e.speed))
  703. }, classes: 'detail'});
  704. });
  705. if (showTerm) {
  706. var bySw = {};
  707. (snap.terminals || []).forEach(function (tm) {
  708. if (!switchIds[tm.sw_ip]) return;
  709. if (onlyOff && tm.online) return;
  710. (bySw[tm.sw_ip] = bySw[tm.sw_ip] || []).push(tm);
  711. });
  712. Object.keys(bySw).forEach(function (sw) {
  713. var base = D.pos[sw]; if (!base) return;
  714. bySw[sw].sort(function (a, b) { return (a.online === b.online) ? 0 : (a.online ? -1 : 1); });
  715. bySw[sw].forEach(function (tm, k) {
  716. var col = k % 9, row = Math.floor(k / 9);
  717. var x = base.x - 54 + col * 13;
  718. var y = base.y + NODE_H / 2 + 16 + row * 13;
  719. 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},
  720. position: {x: x, y: y}, selectable: false});
  721. els.push({group: 'edges', data: {id: 'te_' + tm.mac, type: 'term', source: sw, target: 't_' + tm.mac}});
  722. });
  723. });
  724. }
  725. pathParent = D.parentOf || {};
  726. lockedPath = null;
  727. applyElements(els);
  728. }
  729. /* ---------------- side panels & stats ---------------- */
  730. function updateStats(snap) {
  731. var s = snap.stats || {};
  732. var txt = '● ' + (s.switches || 0) + ' | ⇄ ' + (s.edges || 0) + ' | ' +
  733. (s.term_total || 0) + '(' + (s.term_online || 0) + '/' + (s.term_offline || 0) + ') | ⚠ ' + (s.alarms || 0);
  734. var up = snap.generated_iso ? (' · ' + t('l2.updated', 'Updated') + ' ' + snap.generated_iso) : '';
  735. el('l2-stats').textContent = txt + up;
  736. }
  737. function buildLegend() {
  738. var h = '';
  739. 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>'; }
  740. h += '<div class="lg-title">' + t('l2.legend_speed', 'Link speed') + '</div>';
  741. h += sw(colors['100'], '100M') + sw(colors['1000'], '1G') + sw(colors['10000'], '10G') + sw(colors['20000'], '20G+');
  742. h += sw(colors.unknown, t('lbl.color_unknown', 'Unknown'));
  743. h += sw(colors.down, t('lbl.color_down', 'Down'), true);
  744. h += '<div class="lg-title">' + t('l2.legend_tier', 'Tier') + '</div>';
  745. h += '<div class="lg-row"><span class="lg-box" style="background:' + TIER_BG.core + '"></span>' + t('l2.core', 'Core') +
  746. '<span class="lg-box" style="background:' + TIER_BG.dist + ';margin-left:8px"></span>' + t('l2.dist', 'Distribution') +
  747. '<span class="lg-box" style="background:' + TIER_BG.access + ';margin-left:8px"></span>' + t('l2.access', 'Access') + '</div>';
  748. h += '<div class="lg-title">' + t('l2.legend_lat', 'Latency') + '</div>';
  749. h += '<div class="lg-row"><span class="lg-box" style="background:#F39C12;border-radius:2px"></span>&gt;' + LATW + 'ms' +
  750. '<span class="lg-box" style="background:#E74C3C;border-radius:2px;margin-left:8px"></span>&gt;' + LATB + 'ms</div>';
  751. h += '<div class="lg-row"><span class="lg-box" style="background:#E45959;border-radius:2px"></span>' + t('l2.alarms', 'Alarms') + '</div>';
  752. h += '<div class="lg-row"><span class="lg-box" style="background:#2ECC71;border-radius:50%"></span>' + t('l2.term_on', 'Online') +
  753. '<span class="lg-box" style="background:#95a5a6;border-radius:50%;margin-left:8px"></span>' + t('l2.term_off', 'Offline') + '</div>';
  754. h += '<div class="lg-row" style="opacity:.72;margin-top:4px">' + t('l2.click_hint', 'Hover a switch to highlight its full upstream path') + '</div>';
  755. el('l2-legend').innerHTML = h;
  756. }
  757. function buildAlarms(snap) {
  758. var items = [];
  759. (snap.nodes || []).forEach(function (n) {
  760. if (n.maxsev != null && n.maxsev >= 0 && n.alarms && n.alarms.length) {
  761. n.alarms.forEach(function (a) {
  762. items.push({sev: n.maxsev, title: (a.name || a.description || ''), host: n.name, ip: n.ip, id: n.id, hostid: n.hostid});
  763. });
  764. }
  765. });
  766. el('l2-alarm-count').textContent = items.length;
  767. if (!items.length) { el('l2-alarm-list').innerHTML = '<div class="panel-empty">' + t('l2.alarm_empty', 'No active alarms.') + '</div>'; return; }
  768. items.sort(function (a, b) { return b.sev - a.sev; });
  769. var h = '';
  770. items.forEach(function (it) {
  771. var col = SEV_COLORS[Math.max(0, Math.min(5, it.sev))] || '#999';
  772. h += '<div class="alarm-row" data-id="' + (it.id || '') + '" data-hostid="' + (it.hostid || '') + '">' +
  773. '<span class="alarm-dot" style="background:' + col + '"></span>' +
  774. '<div class="alarm-main"><div class="alarm-title">' + esc(it.title) + '</div>' +
  775. '<div class="alarm-sub">' + esc(it.host || '') + (it.ip ? ' · ' + esc(it.ip) : '') + '</div></div></div>';
  776. });
  777. var box = el('l2-alarm-list');
  778. box.innerHTML = h;
  779. box.querySelectorAll('.alarm-row').forEach(function (r) {
  780. r.addEventListener('click', function () {
  781. var id = r.getAttribute('data-id');
  782. if (id) revealById(id);
  783. else { var hid = r.getAttribute('data-hostid'); if (hid) window.open(META.zabbixHostUrl.replace('__HOSTID__', hid), '_blank'); }
  784. });
  785. });
  786. }
  787. function buildOffline(snap) {
  788. var off = (snap.terminals || []).filter(function (tm) { return !tm.online; });
  789. el('l2-offline-count').textContent = off.length;
  790. if (!off.length) { el('l2-offline-list').innerHTML = '<div class="panel-empty">' + t('l2.offline_empty', 'No offline terminals.') + '</div>'; return; }
  791. var h = '';
  792. off.forEach(function (tm) {
  793. h += '<div class="off-row" data-mac="' + esc(tm.mac) + '">' +
  794. '<div class="off-main"><div class="off-mac">' + esc(tm.mac) + '</div>' +
  795. '<div class="off-sub">' + esc(tm.sw_name || tm.sw_ip) + ' · ' + esc(tm.port || '') + '</div></div></div>';
  796. });
  797. var box = el('l2-offline-list');
  798. box.innerHTML = h;
  799. box.querySelectorAll('.off-row').forEach(function (r) {
  800. r.addEventListener('click', function () {
  801. var mac = r.getAttribute('data-mac');
  802. revealById('t_' + mac);
  803. });
  804. });
  805. }
  806. function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]; }); }
  807. /* ---------------- interactions ---------------- */
  808. function focusNode(id) {
  809. if (!cy) return;
  810. var n = cy.getElementById(id);
  811. if (!n || n.empty() || n.length === 0) return;
  812. cy.elements().removeClass('hl');
  813. n.addClass('hl');
  814. cy.animate({center: {eles: n}, zoom: Math.max(cy.zoom(), 1.1), duration: 380});
  815. setTimeout(function () { n.removeClass('hl'); }, 2600);
  816. }
  817. function groupOfNodeId(id) {
  818. var hit = (lastSnap && lastSnap.nodes || []).filter(function (n) { return n.id === id; })[0];
  819. return hit ? groupOf(hit).label : null;
  820. }
  821. // Make sure a switch/terminal is visible (auto-opens its workshop from overview).
  822. function revealById(id) {
  823. if (!lastSnap) return;
  824. id = String(id);
  825. if (id.indexOf('t_') === 0) {
  826. var tm = (lastSnap.terminals || []).filter(function (x) { return 't_' + x.mac === id; })[0];
  827. if (!tm) return;
  828. var g = groupOfNodeId(tm.sw_ip);
  829. if (g) viewGroup = g;
  830. var swHit = (lastSnap.nodes || []).filter(function (n) { return n.id === tm.sw_ip; })[0];
  831. if (swHit) detailNet = networkOf(swHit);
  832. showTerm = true;
  833. if (el('l2-showterm')) el('l2-showterm').checked = true;
  834. render(lastSnap);
  835. setTimeout(function () { focusNode(id); }, 90);
  836. } else {
  837. var g2 = groupOfNodeId(id);
  838. if (g2 && viewGroup !== g2) { viewGroup = g2; detailNet = null; render(lastSnap); }
  839. var hit2 = (lastSnap.nodes || []).filter(function (n) { return n.id === id; })[0];
  840. if (hit2) detailNet = networkOf(hit2);
  841. render(lastSnap);
  842. setTimeout(function () { focusNode(id); }, 90);
  843. }
  844. }
  845. function clearPath() {
  846. if (!cy) return;
  847. cy.elements().removeClass('dim pathhl');
  848. lockedPath = null;
  849. }
  850. function highlightPath(nodeId) {
  851. if (!cy) return;
  852. var chain = [], id = nodeId, guard = 0;
  853. while (id && guard++ < 40) {
  854. chain.unshift(id);
  855. id = pathParent[id] || null;
  856. }
  857. cy.elements('node[type="switch"], node[type="ghost"], edge').addClass('dim').removeClass('pathhl');
  858. var nodes = cy.collection();
  859. chain.forEach(function (cid) { var n = cy.getElementById(cid); if (n && n.length) nodes = nodes.union(n); });
  860. nodes.removeClass('dim').addClass('pathhl');
  861. var edges = cy.edges().filter(function (e) {
  862. var p = e.data('p'), c = e.data('c');
  863. if (p && c) return chain.indexOf(p) >= 0 && chain.indexOf(c) >= 0;
  864. var s = e.data('source'), t = e.data('target');
  865. return chain.indexOf(s) >= 0 && chain.indexOf(t) >= 0;
  866. });
  867. edges.removeClass('dim').addClass('pathhl');
  868. }
  869. function bindCy() {
  870. cy.on('mouseover', 'node[type="switch"]', function (e) {
  871. if (viewGroup) highlightPath(e.target.id());
  872. var d = e.target.data();
  873. var arr = d.alarms || [];
  874. var sev = (d.maxsev != null && d.maxsev >= 0) ? ('<span style="color:' + SEV_COLORS[d.maxsev] + '">● ' + SEV_NAMES[d.maxsev] + '</span>') : '—';
  875. var lat = (d.latency_ms != null) ? (d.latency_ms + ' ms') : '—';
  876. var html = '<b>' + esc(d.name) + '</b><br>' + esc(d.ip) + ' · ' + esc(d.group || '') + '<br>' +
  877. 'Tier: ' + esc(d.tier) + ' · ' + (d.oper || '') + '<br>' +
  878. 'Latency: ' + lat + '<br>' +
  879. 'Terminals: ' + (d.term_online || 0) + ' on / ' + (d.term_offline || 0) + ' off<br>' +
  880. 'Alarm: ' + sev;
  881. if (arr.length) html += '<hr>' + arr.map(function (a) { return esc(a.name || a.description || ''); }).join('<br>');
  882. showTip(e, html);
  883. });
  884. cy.on('mouseout', 'node[type="switch"]', function () {
  885. if (!lockedPath) clearPath();
  886. hideTip();
  887. });
  888. cy.on('mouseover', 'node[type="tile"]', function (e) {
  889. var d = e.target.data();
  890. var html = '<b>' + esc(d.group) + '</b><br>' + d.n + ' ' + t('l2.unit_sw', 'sw') + '<br>' +
  891. '↑ Core: ' + (d.up || 0) + ' · ⚠ ' + (d.alarms || 0) + ' · ◌ ' + (d.offline || 0) +
  892. '<br><span style="opacity:.7">' + t('l2.tile_hint', 'Click to open this workshop') + '</span>';
  893. showTip(e, html);
  894. });
  895. cy.on('mouseout', 'node[type="tile"]', hideTip);
  896. cy.on('tap', 'node[type="tile"]', function (e) {
  897. var g = e.target.data('group');
  898. if (g) { viewGroup = g; detailNet = null; render(lastSnap); }
  899. });
  900. cy.on('mouseover', 'edge:not([type="term"])', function (e) {
  901. var d = e.target.data();
  902. var sp = (parseFloat(d.speed) || 0) >= 1000 ? ((parseFloat(d.speed) / 1000) + 'G') : (d.speed + 'M');
  903. showTip(e, 'Link ' + esc(d.a_port || '') + ' ↔ ' + esc(d.b_port || '') + '<br>' + sp + ' · ' + esc(d.state || ''));
  904. });
  905. cy.on('mouseout', 'edge', hideTip);
  906. cy.on('tap', 'node[type="switch"]', function (e) {
  907. lockedPath = e.target.id();
  908. highlightPath(lockedPath);
  909. });
  910. cy.on('tap', function (e) {
  911. if (e.target === cy) clearPath();
  912. });
  913. cy.on('dbltap', 'node[type="switch"]', function (e) {
  914. var hid = e.target.data('hostid');
  915. if (hid) window.open(META.zabbixHostUrl.replace('__HOSTID__', hid), '_blank');
  916. });
  917. }
  918. var SEV_NAMES = ['未分类','信息','警告','一般严重','严重','灾难'];
  919. function showTip(e, html) {
  920. var tip = el('l2-tip');
  921. tip.innerHTML = html;
  922. tip.style.display = 'block';
  923. var p = e.renderedPosition || {x: 0, y: 0};
  924. var box = el('l2-cy').getBoundingClientRect();
  925. var x = box.left + p.x + 16, y = box.top + p.y + 16;
  926. tip.style.left = x + 'px'; tip.style.top = y + 'px';
  927. }
  928. function hideTip() { el('l2-tip').style.display = 'none'; }
  929. /* ---------------- search ---------------- */
  930. function buildIndex(snap) {
  931. var idx = [];
  932. (snap.nodes || []).forEach(function (n) { idx.push({id: n.id, label: n.name, sub: n.ip, kind: 'sw'}); });
  933. (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}); });
  934. return idx;
  935. }
  936. var INDEX = [];
  937. function doSearch(q) {
  938. var box = el('l2-sug');
  939. q = (q || '').trim().toLowerCase();
  940. if (!q) { box.style.display = 'none'; box.innerHTML = ''; return; }
  941. var hits = [];
  942. for (var i = 0; i < INDEX.length && hits.length < 14; i++) {
  943. var it = INDEX[i];
  944. if ((it.label && it.label.toLowerCase().indexOf(q) >= 0) || (it.sub && it.sub.toLowerCase().indexOf(q) >= 0)) hits.push(it);
  945. }
  946. if (!hits.length) { box.innerHTML = '<div class="sug-empty">—</div>'; box.style.display = 'block'; return; }
  947. box.innerHTML = hits.map(function (h) {
  948. return '<div class="sug-row" data-id="' + esc(h.id) + '"><span class="sug-kind ' + h.kind + '">' + (h.kind === 'sw' ? 'SW' : 'PC') + '</span>' +
  949. '<span class="sug-lbl">' + esc(h.label) + '</span><span class="sug-sub">' + esc(h.sub || '') + '</span></div>';
  950. }).join('');
  951. box.style.display = 'block';
  952. box.querySelectorAll('.sug-row').forEach(function (r) {
  953. r.addEventListener('click', function () {
  954. var id = r.getAttribute('data-id');
  955. revealById(id);
  956. box.style.display = 'none';
  957. });
  958. });
  959. }
  960. /* ---------------- toolbar wiring ---------------- */
  961. function setupControls() {
  962. dark = el('l2-wrap').classList.contains('l2-dark');
  963. el('l2-dark').checked = dark;
  964. var back = el('l2-back');
  965. if (back) {
  966. back.textContent = t('l2.back_overview', '← Overview');
  967. back.addEventListener('click', function () { viewGroup = null; detailNet = null; render(lastSnap); });
  968. }
  969. el('l2-dark').addEventListener('change', function () {
  970. dark = el('l2-dark').checked;
  971. el('l2-wrap').classList.toggle('l2-dark', dark);
  972. render(lastSnap);
  973. });
  974. el('l2-showterm').addEventListener('change', function () { showTerm = el('l2-showterm').checked; render(lastSnap); });
  975. el('l2-onlyoff').addEventListener('change', function () {
  976. onlyOff = el('l2-onlyoff').checked;
  977. if (onlyOff && !showTerm) { showTerm = true; el('l2-showterm').checked = true; }
  978. render(lastSnap);
  979. });
  980. el('l2-zin').addEventListener('click', function () { if (cy) cy.zoom({level: cy.zoom() * 1.25, renderedPosition: {x: cy.width() / 2, y: cy.height() / 2}}); });
  981. el('l2-zout').addEventListener('click', function () { if (cy) cy.zoom({level: cy.zoom() / 1.25, renderedPosition: {x: cy.width() / 2, y: cy.height() / 2}}); });
  982. el('l2-fit').addEventListener('click', function () { if (cy) cy.fit(undefined, 50); });
  983. el('l2-refresh-btn').addEventListener('click', function () { reload(true); });
  984. var s = el('l2-search');
  985. s.addEventListener('input', function () { doSearch(s.value); });
  986. document.addEventListener('click', function (ev) {
  987. if (!ev.target.closest('.l2-search-wrap')) el('l2-sug').style.display = 'none';
  988. });
  989. el('l2-ts').addEventListener('change', function () {
  990. var v = el('l2-ts').value;
  991. var u = '/zabbix.php?action=l2.topology.view';
  992. if (v) u += '&ts=' + encodeURIComponent(v);
  993. window.location = u;
  994. });
  995. el('l2-pause').addEventListener('click', function () {
  996. paused = !paused;
  997. el('l2-pause').classList.toggle('on', paused);
  998. el('l2-pause').textContent = paused ? '▶' : '❚❚';
  999. el('l2-pause').title = paused ? t('l2.resume', 'Resume') : t('l2.pause', 'Pause');
  1000. });
  1001. }
  1002. function reload(manual) {
  1003. if (!META.isLive) return;
  1004. fetch(META.snapshotUrl + '?t=' + Date.now(), {cache: 'no-store'})
  1005. .then(function (r) { return r.json(); })
  1006. .then(function (j) { if (j && j.nodes) { SNAP = j; INDEX = buildIndex(j); render(j); } })
  1007. .catch(function () {})
  1008. .then(function () { if (manual) resetCountdown(); });
  1009. }
  1010. function resetCountdown() { countdown = META.refreshSeconds || 300; }
  1011. function startTimer() {
  1012. if (!META.isLive) return;
  1013. resetCountdown();
  1014. timer = setInterval(function () {
  1015. if (paused) { el('l2-countdown').textContent = '⏸'; return; }
  1016. countdown--;
  1017. if (countdown <= 0) { countdown = META.refreshSeconds || 300; reload(false); }
  1018. var m = Math.floor(countdown / 60), s = countdown % 60;
  1019. el('l2-countdown').textContent = '↻ ' + m + ':' + (s < 10 ? '0' + s : s);
  1020. }, 1000);
  1021. }
  1022. /* ---------------- boot ---------------- */
  1023. function boot() {
  1024. setupControls();
  1025. try {
  1026. var qs = new URLSearchParams(window.location.search);
  1027. var qg = qs.get('g');
  1028. if (qg) viewGroup = qg;
  1029. var qn = qs.get('net');
  1030. if (qn) detailNet = qn;
  1031. } catch (e) {}
  1032. if (!SNAP || !SNAP.nodes) {
  1033. el('l2-cy').innerHTML = '<div class="l2-empty">' + t('l2.no_data', 'No data') + '</div>';
  1034. } else {
  1035. INDEX = buildIndex(SNAP);
  1036. render(SNAP);
  1037. }
  1038. if (META.isLive) startTimer();
  1039. else { el('l2-pause').disabled = true; el('l2-countdown').textContent = t('l2.snapshot', 'Snapshot'); }
  1040. }
  1041. if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
  1042. else boot();
  1043. })();