app.js 83 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807
  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 pinnedTerm = null;
  29. var paused = false;
  30. var countdown = 0;
  31. var timer = null;
  32. var lastSnap = null;
  33. var viewGroup = null; // direct-link focus group, never hides the full chart
  34. var detailNet = null; // null/auto, 'SURVE' or 'OFFICE' inside a workshop
  35. var focusGroupLabel = null; // ?g= target, zoomed after the full chart is built
  36. var pathParent = {}; // child -> parent chain for hover highlighting
  37. var lockedPath = null; // switch id whose path is locked by click
  38. function el(id) { return document.getElementById(id); }
  39. function t(k, d) { return (T[k] != null) ? T[k] : (d != null ? d : k); }
  40. /* ---------------- name parsing / grouping ---------------- */
  41. function parseName(name) {
  42. var r = {workshop:'', floor:'', cab:'', u:'', area:'', role:'', ip:''};
  43. if (!name) return r;
  44. var toks = String(name).split('-');
  45. for (var i = 0; i < toks.length; i++) {
  46. var tk = toks[i];
  47. if (/^\d{1,3}(\.\d{1,3}){3}$/.test(tk)) { r.ip = tk; continue; }
  48. if (!r.workshop && /^\d{4}[A-Za-z]?$/.test(tk)) { r.workshop = tk; continue; }
  49. if (!r.floor) {
  50. var fm = /^(GRD|\d{1,2}F)([A-Z])?(\d+)?$/i.exec(tk);
  51. if (fm) { r.floor = fm[1].toUpperCase(); r.cab = fm[2] || ''; r.u = fm[3] || ''; continue; }
  52. }
  53. if (r.floor && !r.u && /^\d{1,3}$/.test(tk)) { r.u = tk; continue; }
  54. if (!r.area && /^(OFFICE|SURVE|GRDA|DC|IDC|LAB|PROD)$/i.test(tk)) { r.area = tk.toUpperCase(); continue; }
  55. if (!r.role && /^(Dist|Access|Core)$/i.test(tk)) { r.role = tk; }
  56. }
  57. return r;
  58. }
  59. function labelFor(n) {
  60. var p = parseName(n.name);
  61. var l1 = [];
  62. if (p.workshop) l1.push(p.workshop);
  63. if (p.area) l1.push(p.area);
  64. if (!l1.length) {
  65. l1.push((n.name || '').split('-')[0] || n.ip);
  66. }
  67. var l2 = [];
  68. if (p.floor) { var loc = p.floor + 'F' + (p.cab || '') + (p.u ? '-' + p.u + 'U' : ''); l1.push(loc); }
  69. if (p.role) l2.push(p.role);
  70. l2.push(n.ip);
  71. return l1.join(' · ') + '\n' + l2.join(' · ');
  72. }
  73. function detailLabel(n) {
  74. var s = labelFor(n);
  75. if (viewGroup) {
  76. var p = viewGroup + ' · ';
  77. if (s.indexOf(p) === 0) s = s.substring(p.length);
  78. }
  79. return s;
  80. }
  81. function rowLabel(n) {
  82. var s = String((n && n.name) || (n && n.ip) || '');
  83. s = s.replace(/^COBCO-/i, '');
  84. if (viewGroup) {
  85. try { s = s.replace(new RegExp('^' + viewGroup + '-', 'i'), ''); } catch (e) {}
  86. }
  87. return s;
  88. }
  89. function rootLabel(n) {
  90. var s = rowLabel(n);
  91. var m = /^(.+?)-(SURVE|OFFICE|OTHER)-(Dist|Access)-(.+)$/i.exec(s);
  92. if (m) return m[2].toUpperCase() + ' · ' + m[1] + '\n' + m[3] + ' · ' + m[4];
  93. return s;
  94. }
  95. function expandLabel(tpl, m) {
  96. return String(tpl).replace(/\$(\d)/g, function (_, d) { return m && m[+d] != null ? m[+d] : ''; });
  97. }
  98. function groupOf(n) {
  99. var name = n.name || n.ip || '';
  100. var rules = CFG.group_rules || [];
  101. for (var i = 0; i < rules.length; i++) {
  102. var r = rules[i];
  103. if (r.match === 'regex') {
  104. var re;
  105. try { re = new RegExp(r.pattern); } catch (e) { continue; }
  106. var m = re.exec(name);
  107. if (m) return {label: expandLabel(r.label, m) || (CFG.other_label || 'Other'), big: !!r.big};
  108. } else {
  109. if (name.toLowerCase().indexOf(String(r.pattern).toLowerCase()) >= 0) return {label: r.label || (CFG.other_label || 'Other'), big: !!r.big};
  110. }
  111. }
  112. return {label: CFG.other_label || 'Other', big: false};
  113. }
  114. /* ---------------- overview layout (workshop tiles) ---------------- */
  115. function groupStats(snap) {
  116. var map = {};
  117. var nodeById = {};
  118. (snap.nodes || []).forEach(function (n) {
  119. nodeById[n.id] = n;
  120. var g = groupOf(n);
  121. 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});
  122. s.nodes.push(n);
  123. s.offline += (n.offline || n.oper === 'down') ? 1 : 0;
  124. if (n.alarms && n.alarms.length) {
  125. s.alarms += n.alarms.length;
  126. var sev = (n.maxsev != null && +n.maxsev >= 0) ? +n.maxsev : -1;
  127. n.alarms.forEach(function (a) { if (a && a.sev != null && +a.sev > sev) sev = +a.sev; });
  128. if (sev > s.maxsev) s.maxsev = sev;
  129. }
  130. if (n.latency_ms != null && +n.latency_ms > s.latmax) s.latmax = +n.latency_ms;
  131. });
  132. (snap.edges || []).forEach(function (e) {
  133. var a = nodeById[e.source], b = nodeById[e.target];
  134. if (!a || !b) return;
  135. var ga = groupOf(a), gb = groupOf(b);
  136. if (ga.label === gb.label) return;
  137. // count links that leave this workshop towards a core switch
  138. if ((/core/i.test(a.tier || '') || /core/i.test(a.name || '')) && map[gb.label]) map[gb.label].up++;
  139. if ((/core/i.test(b.tier || '') || /core/i.test(b.name || '')) && map[ga.label]) map[ga.label].up++;
  140. });
  141. return map;
  142. }
  143. function groupRank(l) {
  144. if (/core/i.test(l)) return 0;
  145. if (/data\s*center/i.test(l)) return 1;
  146. var m = /^(\d{4})([A-Za-z]?)$/.exec(l);
  147. if (m) return 1000 + parseInt(m[1], 10) * 40 + (m[2] ? m[2].toUpperCase().charCodeAt(0) - 64 : 0);
  148. return 5000;
  149. }
  150. function computeOverview(snap) {
  151. var stats = groupStats(snap);
  152. var labels = Object.keys(stats).sort(function (a, b) { return groupRank(a) - groupRank(b); });
  153. var special = labels.filter(function (l) { return groupRank(l) < 1000; });
  154. var others = labels.filter(function (l) { return groupRank(l) >= 1000; });
  155. var COLS = Math.max(4, Math.min(7, Math.ceil(Math.sqrt(Math.max(1, others.length)))));
  156. var gridW = COLS * TILE_W + (COLS - 1) * TILE_GAP_X;
  157. var pos = {}, tiles = [], y = 0;
  158. if (special.length) {
  159. var sW = special.length * TILE_W + (special.length - 1) * TILE_GAP_X;
  160. var sx = Math.max(0, (gridW - sW) / 2);
  161. special.forEach(function (l) {
  162. tiles.push({label: l, x: sx, y: y, w: TILE_W, h: TILE_H});
  163. pos[l] = {x: sx + TILE_W / 2, y: y + TILE_H / 2};
  164. sx += TILE_W + TILE_GAP_X;
  165. });
  166. y += TILE_H + 46;
  167. }
  168. var maxX = gridW, maxY = y;
  169. for (var i = 0; i < others.length; i += COLS) {
  170. var row = others.slice(i, i + COLS);
  171. var rW = row.length * TILE_W + (row.length - 1) * TILE_GAP_X;
  172. var rx = Math.max(0, (gridW - rW) / 2);
  173. row.forEach(function (l, j) {
  174. tiles.push({label: l, x: rx + j * (TILE_W + TILE_GAP_X), y: y, w: TILE_W, h: TILE_H});
  175. pos[l] = {x: rx + j * (TILE_W + TILE_GAP_X) + TILE_W / 2, y: y + TILE_H / 2};
  176. });
  177. y += TILE_H + TILE_GAP_Y;
  178. maxY = y - TILE_GAP_Y;
  179. maxX = Math.max(maxX, rx + rW);
  180. }
  181. var root = {x: -34, y: -64, w: maxX + 68, h: maxY + 64 + 40};
  182. root.cx = root.x + root.w / 2; root.cy = root.y + root.h / 2;
  183. return {stats: stats, labels: labels, pos: pos, tiles: tiles, root: root};
  184. }
  185. function aggregateEdges(snap) {
  186. var gid = {};
  187. (snap.nodes || []).forEach(function (n) { gid[n.id] = groupOf(n).label; });
  188. var pairs = {};
  189. (snap.edges || []).forEach(function (e) {
  190. var a = gid[e.source], b = gid[e.target];
  191. if (!a || !b || a === b) return;
  192. var k = (a < b) ? (a + '\u0001' + b) : (b + '\u0001' + a);
  193. var p = pairs[k] || (pairs[k] = {a: a, b: b, count: 0, speed: 0, down: false});
  194. p.count++;
  195. if ((parseFloat(e.speed) || 0) > p.speed) p.speed = parseFloat(e.speed) || 0;
  196. if (e.state && e.state !== 'up') p.down = true;
  197. });
  198. return Object.keys(pairs).map(function (k) { return pairs[k]; });
  199. }
  200. /* ---------------- detail layout (one workshop, left-to-right tree) ---------------- */
  201. function floorInfo(n) {
  202. var toks = String((n && n.name) || '').split('-');
  203. for (var i = 0; i < toks.length; i++) {
  204. var tk = toks[i];
  205. var bm = /^B(\d+)$/i.exec(tk);
  206. if (bm) { var bn = parseInt(bm[1], 10); return {key: 'B' + bn, label: 'B' + bn, rank: -200 + bn}; }
  207. if (/^GRD/i.test(tk)) return {key: 'GRD', label: 'GRD', rank: -100};
  208. var fm = /^(\d+(?:\.\d+)?)F/i.exec(tk);
  209. if (fm) {
  210. var num = parseFloat(fm[1]);
  211. var txt = (num % 1 ? num.toFixed(1) : String(num)) + 'F';
  212. return {key: txt, label: txt, rank: num * 10};
  213. }
  214. var cm = /^(\d)([A-Z])(\w+)$/i.exec(tk);
  215. if (cm) {
  216. var n2 = parseInt(cm[1], 10);
  217. return {key: n2 + 'F', label: n2 + 'F', rank: n2 * 10};
  218. }
  219. }
  220. return {key: 'NA', label: t('l2.floor_na', 'Other'), rank: -500};
  221. }
  222. function baseNetworkOf(n) {
  223. var s = String((n && n.name) || '').toUpperCase();
  224. if (s.indexOf('SURVE') >= 0) return 'SURVE';
  225. if (s.indexOf('OFFICE') >= 0) return 'OFFICE';
  226. var ip = String((n && n.ip) || '');
  227. if (/^10\.169\./.test(ip)) return 'SURVE';
  228. if (/^10\.160\./.test(ip)) return 'OFFICE';
  229. return 'OTHER';
  230. }
  231. function networkOf(n) { return baseNetworkOf(n); }
  232. function networkLabel(key) {
  233. if (key === 'SURVE') return t('l2.net_surve', 'SURVE 监控网');
  234. if (key === 'OFFICE') return t('l2.net_office', 'OFFICE 办公网');
  235. return t('l2.net_other', '其他网络');
  236. }
  237. function computeDetail(snap, label) {
  238. var all = snap.nodes || [];
  239. var byId = {}, groupNodes = [];
  240. all.forEach(function (n) { byId[n.id] = n; if (groupOf(n).label === label) groupNodes.push(n); });
  241. // Split one workshop by physical network first: office and surveillance never mix.
  242. var gids = {}, rawAdj = {};
  243. groupNodes.forEach(function (n) { gids[n.id] = true; rawAdj[n.id] = []; });
  244. (snap.edges || []).forEach(function (e) {
  245. if (gids[e.source] && gids[e.target]) { rawAdj[e.source].push(e.target); rawAdj[e.target].push(e.source); }
  246. });
  247. var netMap = {};
  248. groupNodes.forEach(function (n) { netMap[n.id] = baseNetworkOf(n); });
  249. // Unlabelled access switches inherit the network of the distribution they hang from.
  250. for (var pass = 0; pass < 4; pass++) {
  251. groupNodes.forEach(function (n) {
  252. if (netMap[n.id] !== 'OTHER') return;
  253. var votes = {};
  254. rawAdj[n.id].forEach(function (id) {
  255. var k = netMap[id];
  256. if (k && k !== 'OTHER') votes[k] = (votes[k] || 0) + 1;
  257. });
  258. var best = null, bestN = 0;
  259. Object.keys(votes).forEach(function (k) { if (votes[k] > bestN) { best = k; bestN = votes[k]; } });
  260. if (best) netMap[n.id] = best;
  261. });
  262. }
  263. var counts = {}, nets = [];
  264. groupNodes.forEach(function (n) {
  265. var k = netMap[n.id];
  266. if (!counts[k]) { counts[k] = 0; nets.push(k); }
  267. counts[k]++;
  268. });
  269. nets.sort(function (a, b) {
  270. if (a === 'SURVE') return -1;
  271. if (b === 'SURVE') return 1;
  272. if (a === 'OFFICE') return -1;
  273. if (b === 'OFFICE') return 1;
  274. return counts[b] - counts[a];
  275. });
  276. var activeNet = (detailNet && counts[detailNet]) ? detailNet : (nets.length ? nets[0] : 'OTHER');
  277. var nodes = groupNodes.filter(function (n) { return netMap[n.id] === activeNet; });
  278. var ids = {};
  279. nodes.forEach(function (n) { ids[n.id] = true; });
  280. var internal = [], external = [];
  281. (snap.edges || []).forEach(function (e) {
  282. var a = ids[e.source], b = ids[e.target];
  283. if (a && b) internal.push(e);
  284. else if (a || b) external.push(e);
  285. });
  286. var adj = {};
  287. nodes.forEach(function (n) { adj[n.id] = []; });
  288. internal.forEach(function (e) { adj[e.source].push(e.target); adj[e.target].push(e.source); });
  289. // Distribution switches are the left-hand roots of the workshop tree.
  290. var dists = nodes.filter(function (n) { return /dist/i.test(n.tier || '') && /dist/i.test(n.name || ''); })
  291. .sort(function (a, b) { return (adj[b.id].length - adj[a.id].length) || String(a.name).localeCompare(String(b.name)); });
  292. if (!dists.length) {
  293. var cand = nodes.slice().sort(function (a, b) { return adj[b.id].length - adj[a.id].length; })[0];
  294. if (cand) dists = [cand];
  295. }
  296. // One physical root per network tab; peer/redundant distribution switches are listed below it.
  297. if (dists.length > 1) dists = [dists[0]];
  298. // Group all non-distribution switches into floor buckets (GRD lowest, then 1F/2F/3F...).
  299. var floorMap = {};
  300. nodes.forEach(function (n) {
  301. if (dists.indexOf(n) >= 0) return;
  302. if (/core/i.test(n.tier || '')) return;
  303. var fi = floorInfo(n);
  304. var f = floorMap[fi.key] || (floorMap[fi.key] = {key: fi.key, label: fi.label, rank: fi.rank, nodes: []});
  305. f.nodes.push(n);
  306. });
  307. var floors = Object.keys(floorMap).map(function (k) { return floorMap[k]; });
  308. floors.forEach(function (f) {
  309. f.nodes.sort(function (a, b) {
  310. var pa = parseName(a.name), pb = parseName(b.name);
  311. var ca = (pa.cab || '') + (pa.u || ''), cb = (pb.cab || '') + (pb.u || '');
  312. if (ca !== cb) return ca.localeCompare(cb);
  313. return String(a.name).localeCompare(String(b.name));
  314. });
  315. });
  316. // Highest floor at the top, GRD at the bottom.
  317. floors.sort(function (a, b) { return b.rank - a.rank; });
  318. var WORK_L = 300, FLOOR_X = 520, DIST_FRAME_W = 190, TOP_OFF = 34;
  319. var ROW_W = 380, ROW_H = 24, ROW_GAP = 8, FLOOR_HEAD = 28, FLOOR_GAP = 12;
  320. var floorW = ROW_W + PADX * 2 + 110;
  321. floors.forEach(function (f) {
  322. f.cols = 1;
  323. f.rows = f.nodes.length;
  324. f.innerW = ROW_W;
  325. f.h = FLOOR_HEAD + f.rows * ROW_H + Math.max(0, f.rows - 1) * ROW_GAP + 10;
  326. });
  327. var workRight = FLOOR_X + floorW + 14;
  328. var workW = workRight - WORK_L;
  329. var pos = {}, top = 44 + TOP_OFF;
  330. floors.forEach(function (f) {
  331. f.frame = {x: FLOOR_X, y: top, w: floorW, h: f.h};
  332. f.nodes.forEach(function (n, i) {
  333. pos[n.id] = {
  334. x: FLOOR_X + PADX + ROW_W / 2,
  335. y: top + FLOOR_HEAD + i * (ROW_H + ROW_GAP) + ROW_H / 2
  336. };
  337. });
  338. top += f.h + FLOOR_GAP;
  339. });
  340. var workH = Math.max(220, top + 6);
  341. var distFrame = dists.length ? {x: WORK_L + 8, y: 44 + TOP_OFF, w: DIST_FRAME_W, h: workH - (44 + TOP_OFF) - 14} : null;
  342. var distX = WORK_L + 8 + DIST_FRAME_W / 2;
  343. if (dists.length) {
  344. var childYs = floors.reduce(function (acc, f) {
  345. return acc.concat(f.nodes.map(function (n) { return pos[n.id] ? pos[n.id].y : 0; }));
  346. }, []);
  347. var cy = childYs.length ? (Math.min.apply(null, childYs) + Math.max.apply(null, childYs)) / 2 : distFrame.y + distFrame.h / 2;
  348. pos[dists[0].id] = {x: distX, y: cy};
  349. }
  350. var missing = nodes.filter(function (n) { return !pos[n.id]; });
  351. missing.forEach(function (n, i) {
  352. pos[n.id] = {x: distX, y: (distFrame ? distFrame.y + 30 : 80) + i * (NODE_H + 12)};
  353. });
  354. // One ghost core per external core→distribution uplink, aligned left of its distribution switch.
  355. var ghosts = [], ghostSeen = {};
  356. external.forEach(function (e) {
  357. var inside = ids[e.source] ? e.source : e.target;
  358. var outside = ids[e.source] ? e.target : e.source;
  359. if (!pos[inside] || !byId[outside]) return;
  360. // Only a real core uplink gets a ghost; other cross-group edges are not drawn as cores.
  361. if (!/core/i.test(byId[outside].tier || '') && !/core/i.test(byId[outside].name || '')) return;
  362. var key = outside + '|' + inside;
  363. if (ghostSeen[key]) return;
  364. var g = {id: 'gh_' + outside + '_' + inside, to: inside, node: byId[outside], speed: e.speed, state: e.state};
  365. ghostSeen[key] = g;
  366. ghosts.push(g);
  367. });
  368. var ghostAt = {};
  369. ghosts.forEach(function (g) {
  370. var base = pos[g.to];
  371. var k = ghostAt[g.to] || 0;
  372. ghostAt[g.to] = k + 1;
  373. g.x = 92;
  374. g.y = base.y + (k ? (k % 2 ? 1 : -1) * 46 * Math.ceil(k / 2) : 0);
  375. });
  376. // Root each tree at the distribution switch; remember every child's parent for path highlighting.
  377. var parentOf = {}, ghostOfDist = {};
  378. ghosts.forEach(function (g) { if (!ghostOfDist[g.to]) ghostOfDist[g.to] = g.id; });
  379. var seen = {};
  380. dists.forEach(function (d) {
  381. if (seen[d.id]) return;
  382. seen[d.id] = true;
  383. parentOf[d.id] = ghostOfDist[d.id] || null;
  384. var queue = [d.id];
  385. while (queue.length) {
  386. var cur = queue.shift();
  387. adj[cur].forEach(function (nb) {
  388. if (seen[nb]) return;
  389. seen[nb] = true;
  390. parentOf[nb] = cur;
  391. queue.push(nb);
  392. });
  393. }
  394. });
  395. nodes.forEach(function (n) { if (!seen[n.id]) seen[n.id] = true; });
  396. var workshop = {x: WORK_L, y: 0, w: workW, h: workH, label: label + ' · ' + networkLabel(activeNet) + ' · ' + nodes.length + ' ' + t('l2.unit_sw', 'sw')};
  397. var distFrameFull = distFrame ? {
  398. x: distFrame.x, y: distFrame.y, w: distFrame.w, h: distFrame.h,
  399. label: t('l2.dist', 'Distribution') + ' · ' + dists.length
  400. } : null;
  401. floors.forEach(function (f) {
  402. f.frame.label = f.label + ' · ' + f.nodes.length + ' ' + t('l2.unit_sw', 'sw');
  403. });
  404. var root = {x: -20, y: -48, w: workRight + 40, h: workH + 80};
  405. root.cx = root.x + root.w / 2; root.cy = root.y + root.h / 2;
  406. return {
  407. nodes: nodes, byId: byId, pos: pos, ghosts: ghosts, parentOf: parentOf,
  408. dists: dists, floors: floors, frames: {workshop: workshop, dist: distFrameFull},
  409. root: root, totalW: workRight, ids: ids, internal: internal,
  410. nets: nets.map(function (k) { return {key: k, count: counts[k]}; }), activeNet: activeNet, totalNodes: groupNodes.length
  411. };
  412. }
  413. /* ---------------- one-chart layout: fixed columns by tier ----------------
  414. Left to right: physical core → each network's main switch → every
  415. workshop distribution switch → access switches. A workshop frame spans
  416. the distribution + access columns; each floor is a small frame inside it.
  417. Same workshop on two networks is always two separate frames. */
  418. var CORE_X = 130, MAIN_X = 430, DIST_X = 790, ACCESS_X = 1300;
  419. var CORE_W = 200, MAIN_W = 300, NODE_W3 = 300;
  420. var WORK_LEFT = 610, WORK_RIGHT = 1530;
  421. var FLOOR_LEFT = 1130, FLOOR_RIGHT = 1525;
  422. var CHART_W = 1560;
  423. var SEC_HEAD = 46, CORE_ROW = 92, WORK_TOP = 148;
  424. var ROW_H = 24, ROW_GAP = 8, WS_GAP = 20, SEC_GAP = 40;
  425. var FLOOR_GAP = 18;
  426. function makeNetworks() {
  427. var raw = CFG.networks;
  428. if (!raw || !raw.length) {
  429. raw = [
  430. {key: 'SURVE', label: t('l2.net_surve', 'SURVE 监控网'), keywords: ['SURVE', '10.169.'], color: '#2E75B6'},
  431. {key: 'OFFICE', label: t('l2.net_office', 'OFFICE 办公网'), keywords: ['OFFICE', '10.160.'], color: '#548235'}
  432. ];
  433. }
  434. return raw.map(function (n, i) {
  435. var kws = n.keywords;
  436. if (!(kws instanceof Array)) kws = String(kws || '').split(/[,,;;\s]+/);
  437. return {
  438. key: String(n.key || ('NET' + i)).trim().toUpperCase(),
  439. label: String(n.label || n.key || ('NET' + i)),
  440. keywords: kws.map(function (s) { return String(s).trim(); }).filter(Boolean),
  441. color: /^#[0-9a-fA-F]{6}$/.test(String(n.color || '')) ? n.color : '#607D8B'
  442. };
  443. });
  444. }
  445. var NETWORKS = makeNetworks();
  446. function netLabel(key) {
  447. for (var i = 0; i < NETWORKS.length; i++) if (NETWORKS[i].key === key) return NETWORKS[i].label;
  448. return key === 'OTHER' ? t('l2.net_other', '其他网络') : String(key || '');
  449. }
  450. function netColor(key) {
  451. for (var i = 0; i < NETWORKS.length; i++) if (NETWORKS[i].key === key) return NETWORKS[i].color;
  452. return '#607D8B';
  453. }
  454. function rawNetOf(n) {
  455. var hay = (String((n && n.name) || '') + ' ' + String((n && n.ip) || '')).toUpperCase();
  456. for (var i = 0; i < NETWORKS.length; i++) {
  457. for (var j = 0; j < NETWORKS[i].keywords.length; j++) {
  458. if (hay.indexOf(String(NETWORKS[i].keywords[j]).toUpperCase()) >= 0) return NETWORKS[i].key;
  459. }
  460. }
  461. return 'OTHER';
  462. }
  463. // Core/root switch rules are configured from the L2 tab, one row per core.
  464. // Multiple cores in the same network are supported and all of them are
  465. // placed in the left-most core column.
  466. function coreRules() {
  467. var r = CFG.core_rules;
  468. return (r instanceof Array) ? r : [];
  469. }
  470. function coreRuleHit(rule, n) {
  471. var pattern = String(rule.pattern || '');
  472. if (!pattern) return false;
  473. var net = String(rule.net || '').toUpperCase();
  474. if (net) {
  475. var raw = rawNetOf(n);
  476. // A core with no recognisable network keyword may still be assigned by
  477. // the rule; a switch already known to belong to another network must
  478. // not be stolen by a broad pattern.
  479. if (raw !== 'OTHER' && raw !== net) return false;
  480. }
  481. var hay = String((n && n.name) || '') + ' ' + String((n && n.ip) || '');
  482. if (String(rule.match || 'contains') === 'regex') {
  483. try { return new RegExp(pattern, 'i').test(hay); } catch (e) { return false; }
  484. }
  485. return hay.toLowerCase().indexOf(pattern.toLowerCase()) >= 0;
  486. }
  487. function configuredCore(n) {
  488. var rules = coreRules();
  489. for (var i = 0; i < rules.length; i++) if (coreRuleHit(rules[i], n)) return true;
  490. return false;
  491. }
  492. function configuredCoreNet(n) {
  493. var rules = coreRules();
  494. for (var i = 0; i < rules.length; i++) {
  495. if (coreRuleHit(rules[i], n) && String(rules[i].net || '').trim() !== '') return String(rules[i].net).toUpperCase();
  496. }
  497. return '';
  498. }
  499. // Network is decided first by name/IP keyword, then by the rest of the
  500. // workshop, then by graph adjacency. No switch is ever shown under the
  501. // wrong upstream network.
  502. function assignNetworks(snap) {
  503. var nodes = snap.nodes || [], byId = {}, net = {}, adj = {};
  504. nodes.forEach(function (n) {
  505. byId[n.id] = n; net[n.id] = rawNetOf(n); adj[n.id] = [];
  506. var cn = configuredCoreNet(n);
  507. if (cn) net[n.id] = cn;
  508. });
  509. (snap.edges || []).forEach(function (e) {
  510. if (adj[e.source] && adj[e.target]) { adj[e.source].push(e.target); adj[e.target].push(e.source); }
  511. });
  512. var groupVote = {};
  513. nodes.forEach(function (n) {
  514. var k = groupOf(n).label, v = net[n.id];
  515. if (v && v !== 'OTHER') (groupVote[k] = groupVote[k] || {})[v] = true;
  516. });
  517. nodes.forEach(function (n) {
  518. if (net[n.id] !== 'OTHER') return;
  519. var v = groupVote[groupOf(n).label], keys = v ? Object.keys(v) : [];
  520. if (keys.length === 1) net[n.id] = keys[0];
  521. });
  522. for (var pass = 0; pass < 8; pass++) {
  523. var changed = false;
  524. nodes.forEach(function (n) {
  525. if (net[n.id] !== 'OTHER') return;
  526. var votes = {};
  527. adj[n.id].forEach(function (id) { if (net[id] && net[id] !== 'OTHER') votes[net[id]] = (votes[net[id]] || 0) + 1; });
  528. var best = null, bn = 0;
  529. Object.keys(votes).forEach(function (k) { if (votes[k] > bn) { best = k; bn = votes[k]; } });
  530. if (best) { net[n.id] = best; changed = true; }
  531. });
  532. if (!changed) break;
  533. }
  534. return net;
  535. }
  536. function roleOf(n) {
  537. var p = parseName(n.name || '');
  538. var r = String(p.role || '').toLowerCase();
  539. var tier = String(n.tier || '').toLowerCase();
  540. var childCount = parseInt(n.children, 10);
  541. if (isNaN(childCount)) childCount = -1;
  542. if (configuredCore(n)) return 'core';
  543. // Backward-compatible fallback only when the core list is absent (old
  544. // config). An explicitly empty list means the administrator wants no
  545. // configured core column.
  546. if (!(CFG.core_rules instanceof Array) && (/^core(\s|-)/i.test(String(n.name || '')) || groupOf(n).label === 'Core')) return 'core';
  547. // STP topology is the truth for Dist vs Access: a switch with downstream
  548. // children is a distribution point even when its hostname says Access.
  549. // This fixes the 3301/4504 cases where the old snapshot tier was a root
  550. // tier but the device is really an aggregation switch.
  551. if (tier === 'core') {
  552. if (childCount > 0 || r === 'dist') return 'dist';
  553. return 'main';
  554. }
  555. if (childCount > 0) return 'dist';
  556. if (r === 'dist' && tier !== 'access') return 'dist';
  557. if (tier === 'distribution') return 'dist';
  558. return 'access';
  559. }
  560. function roleName(r) {
  561. if (r === 'core') return t('l2.core', '核心');
  562. if (r === 'main') return t('l2.main', '主交换');
  563. if (r === 'dist') return t('l2.dist', '汇聚');
  564. return t('l2.access', '接入');
  565. }
  566. function shortLoc(n) {
  567. var p = parseName(n.name || '');
  568. if (!p.floor) return '';
  569. return p.floor + (p.cab || '') + (p.u ? '-' + p.u : '');
  570. }
  571. function switchLabel(n, role, netKey) {
  572. var g = groupOf(n).label;
  573. if (role === 'core') return roleName('core') + ' · ' + (netKey || rawNetOf(n)) + ' · ' + (n.ip || '');
  574. if (role === 'main') return roleName('main') + ' · ' + g + ' · ' + (n.ip || '');
  575. var loc = shortLoc(n);
  576. return [g, loc, roleName(role)].filter(Boolean).join(' · ') + ' · ' + (n.ip || '');
  577. }
  578. // A workshop is the physical area served by one distribution switch, not
  579. // the four-digit prefix printed on each access switch. The same dist can
  580. // serve access switches in several rooms/buildings; they must share one
  581. // frame, otherwise their uplink line would cross unrelated frames.
  582. function workshopKeys(snap) {
  583. var byId = {}, adj = {}, keys = {};
  584. (snap.nodes || []).forEach(function (n) { byId[n.id] = n; adj[n.id] = []; });
  585. (snap.edges || []).forEach(function (e) {
  586. if (adj[e.source] && adj[e.target]) { adj[e.source].push(e.target); adj[e.target].push(e.source); }
  587. });
  588. (snap.nodes || []).forEach(function (n) {
  589. var r = roleOf(n);
  590. if (r === 'core' || r === 'main') return;
  591. if (r === 'dist') { keys[n.id] = groupOf(n).label; return; }
  592. var ds = (adj[n.id] || []).map(function (id) { return byId[id]; })
  593. .filter(function (d) { return d && roleOf(d) === 'dist'; });
  594. var same = ds.filter(function (d) { return groupOf(d).label === groupOf(n).label; });
  595. var pick = same[0] || ds[0];
  596. keys[n.id] = pick ? groupOf(pick).label : groupOf(n).label;
  597. });
  598. return keys;
  599. }
  600. function workshopList(snap, netMap, netKey, wsKeys) {
  601. var map = {}, order = [];
  602. (snap.nodes || []).forEach(function (n) {
  603. if (netMap[n.id] !== netKey) return;
  604. var r = roleOf(n);
  605. if (r === 'core' || r === 'main') return;
  606. var g = (wsKeys && wsKeys[n.id]) || groupOf(n).label;
  607. if (!map[g]) { map[g] = {key: g, label: g, extra: [], net: netKey, dists: [], access: [], all: []}; order.push(g); }
  608. map[g].all.push(n);
  609. if (r === 'dist') map[g].dists.push(n); else map[g].access.push(n);
  610. });
  611. return order.map(function (g) { return map[g]; }).filter(function (w) { return w.all.length > 0; })
  612. .map(function (w) {
  613. w.access.forEach(function (a) {
  614. var raw = groupOf(a).label;
  615. if (raw !== w.key && w.extra.indexOf(raw) < 0) w.extra.push(raw);
  616. });
  617. w.label = w.key + (w.extra.length ? ' + ' + w.extra.slice(0, 3).join('/') + (w.extra.length > 3 ? '…' : '') : '');
  618. return w;
  619. });
  620. }
  621. function layoutWorkshop(w, top) {
  622. var rowH = ROW_H + ROW_GAP + (showTerm ? 24 : 0);
  623. var floorMap = {}, floors = [];
  624. w.access.forEach(function (n) {
  625. var fi = floorInfo(n);
  626. var k = fi.key;
  627. if (!floorMap[k]) { floorMap[k] = {key: k, label: fi.label, rank: (fi.key === 'NA' ? 999 : fi.rank), nodes: [], rows: []}; floors.push(floorMap[k]); }
  628. floorMap[k].nodes.push(n);
  629. });
  630. floors.sort(function (a, b) { return a.rank - b.rank || String(a.label).localeCompare(String(b.label)); });
  631. var accessH = 0;
  632. floors.forEach(function (f) {
  633. f.nodes.sort(function (a, b) { return String(a.name).localeCompare(String(b.name)); });
  634. f.h = 24 + f.nodes.length * rowH + 8;
  635. accessH += f.h;
  636. });
  637. if (floors.length > 1) accessH += (floors.length - 1) * FLOOR_GAP;
  638. var distH = w.dists.length ? (w.dists.length * 64 + (w.dists.length - 1) * 8) : 0;
  639. var inner = Math.max(accessH, distH, 54);
  640. var h = 38 + inner + 10;
  641. w.top = top; w.h = h; w.rowH = rowH;
  642. // Floors are stacked bottom-up: GRD lowest, then 1F → 3F → 4F → 7F.
  643. var cursor = top + h - 8;
  644. floors.forEach(function (f) {
  645. cursor -= f.h;
  646. f.frame = {x: FLOOR_LEFT, y: cursor, w: FLOOR_RIGHT - FLOOR_LEFT, h: f.h};
  647. f.nodes.forEach(function (n, i) {
  648. f.rows.push({id: n.id, x: ACCESS_X, y: cursor + 22 + rowH / 2 + i * rowH});
  649. });
  650. cursor -= FLOOR_GAP;
  651. });
  652. var dstart = top + 38 + (inner - distH) / 2;
  653. w.distPos = w.dists.map(function (n, i) {
  654. return {id: n.id, x: DIST_X, y: dstart + 32 + i * 72};
  655. });
  656. w.floors = floors;
  657. w.title = w.label + ' · ' + netLabel(w.net) + ' · ' + w.all.length + ' ' + t('l2.unit_sw', '台');
  658. // A workshop without a distribution switch only needs a box around its
  659. // access column; keeping the full dist-column width would leave a large
  660. // empty rectangle and make the chart look sparse.
  661. var frameLeft = w.dists.length ? WORK_LEFT : FLOOR_LEFT - 40;
  662. w.frame = {x: frameLeft, y: top, w: WORK_RIGHT - frameLeft, h: h};
  663. return h;
  664. }
  665. function computeFull(snap) {
  666. var nodes = snap.nodes || [];
  667. var netMap = assignNetworks(snap);
  668. var sections = [], placements = [], frames = [], roleMap = {};
  669. var wsKeys = workshopKeys(snap);
  670. var y = 100;
  671. NETWORKS.forEach(function (net) {
  672. var secNodes = nodes.filter(function (n) { return netMap[n.id] === net.key; });
  673. if (!secNodes.length) return;
  674. var cores = [], main = null;
  675. secNodes.forEach(function (n) {
  676. roleMap[n.id] = roleOf(n);
  677. if (roleMap[n.id] === 'core') cores.push(n);
  678. if (roleMap[n.id] === 'main' && !main) main = n;
  679. });
  680. var ws = workshopList(snap, netMap, net.key, wsKeys);
  681. var sec = {
  682. key: net.key, label: netLabel(net.key) + ' · ' + secNodes.length + ' ' + t('l2.unit_sw', '台'),
  683. x: 10, y: y, w: CHART_W - 20, h: 0, color: netColor(net.key),
  684. core: cores[0] || null, cores: cores, main: main, workshops: ws
  685. };
  686. var cur = y + WORK_TOP;
  687. ws.forEach(function (w) {
  688. layoutWorkshop(w, cur);
  689. cur += w.h + WS_GAP;
  690. 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,
  691. label: w.title, group: w.key, net: net.key, color: net.color});
  692. w.floors.forEach(function (f, fi) {
  693. frames.push({id: 'ff_' + net.key + '_' + w.key + '_' + fi, type: 'floorframe', x: f.frame.x, y: f.frame.y,
  694. w: f.frame.w, h: f.frame.h, label: f.label + ' · ' + f.nodes.length + ' ' + t('l2.unit_sw', '台'), net: net.key, color: net.color});
  695. });
  696. w.distPos.forEach(function (p) {
  697. var n = nodes.filter(function (x) { return x.id === p.id; })[0];
  698. 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});
  699. });
  700. w.floors.forEach(function (f) {
  701. f.rows.forEach(function (p) {
  702. var n = nodes.filter(function (x) { return x.id === p.id; })[0];
  703. 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});
  704. });
  705. });
  706. });
  707. sec.h = (cur - y) + 16;
  708. // The network main switch stays at the top of its tier column (its access
  709. // children sit directly under it, as in the reference chart); the core
  710. // sits lower in the column so its uplinks fan symmetrically.
  711. sec.mainY = y + SEC_HEAD + 46;
  712. sec.coreY = y + Math.max(CORE_ROW + SEC_HEAD, Math.round(sec.h * 0.55));
  713. if (cores.length) {
  714. var coreStep = 64;
  715. var coreTop = sec.coreY - ((cores.length - 1) * coreStep) / 2;
  716. cores.forEach(function (cn, ci) {
  717. placements.push({node: cn, role: 'core', x: CORE_X, y: coreTop + ci * coreStep, w: CORE_W, h: 54, net: net.key, group: 'Core'});
  718. });
  719. }
  720. 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});
  721. sections.push(sec);
  722. y += sec.h + SEC_GAP;
  723. });
  724. var root = {x: 0, y: 0, w: CHART_W, h: y + 6};
  725. return {sections: sections, placements: placements, frames: frames, netMap: netMap, roleMap: roleMap, root: root};
  726. }
  727. // Immediate upstream parent per switch: access -> its workshop dist;
  728. // dist -> core when directly attached, otherwise the network main switch.
  729. function buildParents(snap, L) {
  730. var byId = {}, adj = {}, edgeSpeed = {};
  731. L.placements.forEach(function (p) { byId[p.node.id] = p; });
  732. (snap.nodes || []).forEach(function (n) { adj[n.id] = []; });
  733. (snap.edges || []).forEach(function (e) {
  734. if (!adj[e.source] || !adj[e.target]) return;
  735. adj[e.source].push(e.target); adj[e.target].push(e.source);
  736. edgeSpeed[e.source + '|' + e.target] = edgeSpeed[e.target + '|' + e.source] = parseFloat(e.speed) || 0;
  737. });
  738. var parent = {};
  739. Object.keys(byId).forEach(function (id) {
  740. var role = L.roleMap[id] || 'access';
  741. if (role === 'core') return;
  742. var p = byId[id], cands = (adj[id] || []).map(function (x) { return {id: x, role: L.roleMap[x] || 'access', p: byId[x]}; })
  743. .filter(function (c) { return c.p; });
  744. var want = role === 'main' ? ['core'] : (role === 'dist' ? ['core', 'main'] : ['dist', 'main', 'core']);
  745. var best = null;
  746. for (var wi = 0; wi < want.length && !best; wi++) {
  747. var list = cands.filter(function (c) {
  748. if (c.role !== want[wi]) return false;
  749. if (role === 'access' && want[wi] === 'dist' && c.p.ws && p.ws && c.p.ws !== p.ws) return false;
  750. return true;
  751. });
  752. if (list.length) {
  753. list.sort(function (a, b) { return (edgeSpeed[id + '|' + b.id] || 0) - (edgeSpeed[id + '|' + a.id] || 0); });
  754. best = list[0];
  755. }
  756. }
  757. if (best) parent[id] = best.id;
  758. });
  759. return parent;
  760. }
  761. /* ---------------- speed colour ---------------- */
  762. function speedColor(speed, state) {
  763. if (state && state !== 'up') return colors.down;
  764. var s = parseFloat(speed);
  765. if (!isFinite(s) || s <= 0) return colors.unknown;
  766. if (s <= 100) return colors['100'];
  767. if (s <= 1000) return colors['1000'];
  768. if (s <= 10000) return colors['10000'];
  769. return colors['20000'];
  770. }
  771. function speedWidth(speed) {
  772. var s = parseFloat(speed) || 0;
  773. if (s >= 20000) return 4.5;
  774. if (s >= 10000) return 3.6;
  775. if (s >= 1000) return 2.6;
  776. return 1.8;
  777. }
  778. function speedText(speed) {
  779. var s = parseFloat(speed) || 0;
  780. if (!s) return '?';
  781. if (s >= 1000) return (s / 1000) + 'G';
  782. return s + 'M';
  783. }
  784. function terminalOnline(tm) {
  785. return tm && (tm.online === true || tm.online === 1 || tm.online === '1' || tm.online === 'true');
  786. }
  787. function terminalText(tm) {
  788. return tm.mac + '\n' + speedText(tm.speed);
  789. }
  790. /* ---------------- style ---------------- */
  791. function styleFor(d) {
  792. var bg = d ? TIER_BG_DARK : TIER_BG;
  793. return [
  794. {selector: 'node', style: {
  795. 'width': 'data(w)', 'height': 'data(h)', 'shape': 'round-rectangle',
  796. 'background-color': 'data(bg)', 'border-width': 2, 'border-color': 'data(bd)',
  797. 'label': 'data(label)', 'color': d ? '#eaf1fb' : '#ffffff',
  798. 'font-size': 11.5, 'font-weight': 600, 'text-valign': 'center', 'text-halign': 'center',
  799. 'text-wrap': 'wrap', 'text-max-width': NODE_W - 16, 'padding': '2px',
  800. 'overlay-opacity': 0, 'z-index-compare': 'manual', 'z-index': 20
  801. }},
  802. {selector: 'node[type="frame"]', style: {
  803. 'background-opacity': d ? 0.05 : 0.035, 'background-color': d ? '#5b7bb0' : '#8fa6c4',
  804. 'border-width': 1.6, 'border-style': 'dashed', 'border-color': d ? '#3f5377' : '#c2ccdb',
  805. 'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center',
  806. 'font-size': 'data(fs)', 'font-weight': 800, 'color': d ? '#aebfd8' : '#526178',
  807. 'text-margin-y': 8, 'padding': '0px', 'events': 'no', 'z-index-compare': 'manual', 'z-index': 2
  808. }},
  809. {selector: 'node[type="netframe"]', style: {
  810. 'background-opacity': d ? 0.03 : 0.02, 'background-color': d ? '#7b91b8' : '#adc0d8',
  811. 'border-width': 3, 'border-style': 'solid', 'border-color': 'data(bd)',
  812. 'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center',
  813. 'font-size': 22, 'font-weight': 900, 'color': d ? '#dbe9ff' : '#29425f',
  814. 'text-margin-y': 8, 'padding': '0px', 'events': 'no',
  815. 'z-index-compare': 'manual', 'z-index': 1
  816. }},
  817. {selector: 'node[type="wsframe"]', style: {
  818. 'background-opacity': d ? 0.02 : 0.014, 'background-color': d ? '#6f8fc4' : '#8fa6c4',
  819. 'border-width': 2.2, 'border-style': 'solid', 'border-color': 'data(bd)',
  820. 'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center', 'text-wrap': 'none',
  821. 'font-size': 15.5, 'font-weight': 900, 'color': d ? '#d7e6ff' : '#2f4560',
  822. 'text-margin-x': 0, 'text-margin-y': 7, 'padding': '0px', 'events': 'no',
  823. 'z-index-compare': 'manual', 'z-index': 2
  824. }},
  825. {selector: 'node[type="floorframe"]', style: {
  826. 'background-opacity': d ? 0.04 : 0.025, 'background-color': d ? '#5b7bb0' : '#8fa6c4',
  827. 'border-width': 1.1, 'border-style': 'dashed', 'border-color': 'data(bd)',
  828. 'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center', 'text-wrap': 'none',
  829. 'font-size': 11.5, 'font-weight': 800, 'color': d ? '#a9c1e3' : '#425a78',
  830. 'text-margin-x': 0, 'text-margin-y': 6, 'padding': '0px', 'events': 'no',
  831. 'z-index-compare': 'manual', 'z-index': 3
  832. }},
  833. {selector: 'node[type="root"]', style: {
  834. 'background-opacity': d ? 0.02 : 0.012, 'background-color': d ? '#7aa0d8' : '#8ea6c2',
  835. 'border-width': 2.2, 'border-style': 'solid', 'border-color': d ? '#476492' : '#9fb0c6',
  836. 'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center',
  837. 'font-size': 28, 'font-weight': 900, 'color': d ? '#cfe0ff' : '#33465f',
  838. 'text-margin-y': 10, 'events': 'no', 'z-index-compare': 'manual', 'z-index': 0
  839. }},
  840. {selector: 'node[type="roottitle"]', style: {
  841. 'width': 'data(w)', 'height': 20, 'shape': 'rectangle', 'background-opacity': 0, 'border-width': 0,
  842. 'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'none',
  843. 'font-size': 24, 'font-weight': 900, 'color': d ? '#cfe0ff' : '#33465f', 'events': 'no',
  844. 'z-index-compare': 'manual', 'z-index': 5
  845. }},
  846. {selector: 'node[type="nettitle"]', style: {
  847. 'width': 'data(w)', 'height': 20, 'shape': 'rectangle', 'background-opacity': 0, 'border-width': 0,
  848. 'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'none',
  849. 'font-size': 20, 'font-weight': 900, 'color': d ? '#dbe9ff' : '#29425f', 'events': 'no',
  850. 'z-index-compare': 'manual', 'z-index': 5
  851. }},
  852. {selector: 'node[type="wslabel"]', style: {
  853. 'width': 'data(w)', 'height': 16, 'shape': 'rectangle', 'background-opacity': 0, 'border-width': 0,
  854. 'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'none',
  855. 'font-size': 15, 'font-weight': 900, 'color': d ? '#d7e6ff' : '#2f4560', 'events': 'no',
  856. 'z-index-compare': 'manual', 'z-index': 5
  857. }},
  858. {selector: 'node[type="floorlabel"]', style: {
  859. 'width': 'data(w)', 'height': 16, 'shape': 'rectangle', 'background-opacity': 0, 'border-width': 0,
  860. 'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'none',
  861. 'font-size': 11, 'font-weight': 800, 'color': d ? '#a9c1e3' : '#425a78', 'events': 'no',
  862. 'z-index-compare': 'manual', 'z-index': 5
  863. }},
  864. {selector: 'node[type="tile"]', style: {
  865. 'width': 'data(w)', 'height': 'data(h)', 'shape': 'round-rectangle',
  866. 'background-color': 'data(bg)', 'background-opacity': 0.97,
  867. 'border-width': 2.5, 'border-color': 'data(bd)',
  868. 'label': 'data(label)', 'color': 'data(fg)',
  869. 'font-size': 16, 'font-weight': 700, 'text-valign': 'center', 'text-halign': 'center',
  870. 'text-wrap': 'wrap', 'text-max-width': TILE_W - 26, 'line-height': 1.45,
  871. 'padding': '6px', 'overlay-opacity': 0, 'z-index-compare': 'manual', 'z-index': 20
  872. }},
  873. {selector: 'node[type="tile"].alarm', style: {'border-width': 5}},
  874. {selector: 'node[type="tile"].core', style: {'border-width': 3.5}},
  875. {selector: 'node[type="ghost"]', style: {
  876. 'width': 190, 'height': 40, 'shape': 'round-rectangle',
  877. 'background-color': d ? '#52667f' : '#c8d3e2', 'background-opacity': 0.55,
  878. 'border-width': 1.2, 'border-style': 'dashed', 'border-color': d ? '#7d93ad' : '#8fa3bc',
  879. 'label': 'data(label)', 'color': d ? '#dce8fa' : '#3b4f68',
  880. 'font-size': 11, 'font-weight': 600, 'text-wrap': 'wrap', 'text-max-width': 176,
  881. 'z-index-compare': 'manual', 'z-index': 15
  882. }},
  883. {selector: 'node[type="edgepoint"]', style: {
  884. 'width': 1, 'height': 1, 'opacity': 0, 'events': 'no'
  885. }},
  886. {selector: 'node[type="term"]', style: {
  887. 'width': 7, 'height': 7, 'shape': 'ellipse', 'background-color': 'data(bg)',
  888. 'border-width': 1, 'border-color': d ? '#2a3648' : '#cfd8e3',
  889. 'label': 'data(label)', 'font-size': 9, 'font-weight': 700,
  890. 'color': d ? '#eaf1fb' : '#182c44',
  891. 'text-valign': 'bottom', 'text-halign': 'center', 'text-margin-y': 4,
  892. 'text-wrap': 'wrap', 'text-max-width': 160,
  893. 'text-background-color': d ? '#0d1b2a' : '#ffffff', 'text-background-opacity': 0.9,
  894. 'text-background-padding': '2px',
  895. 'z-index-compare': 'manual', 'z-index': 30
  896. }},
  897. {selector: 'edge[type="tilelink"]', style: {
  898. 'width': 'data(w)', 'line-color': 'data(color)', 'opacity': 0.55,
  899. 'curve-style': 'bezier', 'target-arrow-shape': 'none',
  900. 'z-index-compare': 'manual', 'z-index': 5
  901. }},
  902. {selector: 'edge[type="ghost"]', style: {
  903. 'width': 1.2, 'line-color': d ? '#6b809b' : '#a9b6c8', 'line-style': 'dashed', 'opacity': 0.7,
  904. 'curve-style': 'bezier', 'target-arrow-shape': 'none',
  905. 'z-index-compare': 'manual', 'z-index': 8
  906. }},
  907. {selector: 'edge[type="uplink"]', style: {
  908. 'width': 'data(w)', 'line-color': 'data(color)', 'line-style': 'data(ls)', 'opacity': 0.95,
  909. 'curve-style': 'bezier', 'target-arrow-shape': 'none',
  910. 'label': 'data(lbl)', 'font-size': 10, 'font-weight': 700,
  911. 'color': d ? '#dce8fa' : '#25405f',
  912. 'text-background-color': d ? '#0d1b2a' : '#ffffff', 'text-background-opacity': 0.88,
  913. 'text-background-padding': '2px', 'text-rotation': 'autorotate',
  914. 'z-index-compare': 'manual', 'z-index': 40
  915. }},
  916. {selector: 'edge', style: {
  917. 'width': 'data(w)', 'line-color': 'data(color)', 'line-style': 'data(ls)',
  918. 'curve-style': 'bezier', 'opacity': 0.92, 'target-arrow-shape': 'none',
  919. 'z-index-compare': 'manual', 'z-index': 12
  920. }},
  921. {selector: 'edge[type="link"]', style: {
  922. 'width': 'data(w)', 'line-color': 'data(color)', 'line-style': 'data(ls)',
  923. 'opacity': 0.62, 'curve-style': 'straight',
  924. 'source-endpoint': '0% 50%', 'target-endpoint': '0% 50%',
  925. 'target-arrow-shape': 'none', 'z-index-compare': 'manual', 'z-index': 1
  926. }},
  927. {selector: 'edge[type="term"]', style: {
  928. 'width': 0.7, 'line-color': d ? '#3b4a5e' : '#c5cfdb', 'opacity': 0.55,
  929. 'curve-style': 'bezier', 'z-index-compare': 'manual', 'z-index': 11
  930. }},
  931. {selector: 'node.alarm', style: {'border-width': 6}},
  932. {selector: 'node.offline', style: {'border-color': '#C0392B', 'border-width': 6, 'border-style': 'dashed', 'opacity': 0.9}},
  933. {selector: 'node.dim', style: {'opacity': 0.12}},
  934. {selector: 'edge.dim', style: {'opacity': 0.08}},
  935. {selector: 'edge.hl', style: {'width': 5, 'line-color': '#FFD54A', 'opacity': 1, 'z-index-compare': 'manual', 'z-index': 45}},
  936. {selector: 'node.lat-warn', style: {'border-color': '#F39C12'}},
  937. {selector: 'node.lat-bad', style: {'border-color': '#E74C3C', 'border-width': 6}},
  938. {selector: 'node.hl', style: {'border-color': '#ff0', 'border-width': 7, 'overlay-color': '#ff0', 'overlay-opacity': 0.25, 'overlay-padding': 6}},
  939. {selector: 'node.pathhl', style: {'border-width': 5, 'border-color': '#FFD54A', 'overlay-color': '#FFD54A', 'overlay-opacity': 0.2, 'overlay-padding': 4}},
  940. {selector: 'edge.pathhl', style: {'width': 5.5, 'line-color': '#FFD54A', 'opacity': 1}},
  941. {selector: 'edge.detail', style: {'opacity': 0.72, 'curve-style': 'straight', 'source-endpoint': '0% 50%', 'target-endpoint': '0% 50%'}},
  942. {selector: 'node[type="switch"]', style: {
  943. 'background-color': function (e) { return e.data('bg'); },
  944. 'text-max-width': 'data(tw)', 'text-wrap': 'none',
  945. 'text-halign': 'center', 'font-size': 10.5
  946. }},
  947. {selector: 'node[type="switch"].root', style: {'text-wrap': 'wrap'}}
  948. ];
  949. }
  950. /* ---------------- render ---------------- */
  951. // One tall column chart cannot be squeezed into a wide window without
  952. // wasting half the canvas. The initial view fits the chart WIDTH and
  953. // anchors at the top (core switches first); the user can pan down or press
  954. // "适应窗口" to see the entire graph. This keeps labels readable.
  955. function initialView() {
  956. if (!cy) return;
  957. var bb = cy.elements().boundingBox();
  958. if (!bb || !bb.w || !bb.h) return;
  959. var zw = (cy.width() - 30) / bb.w;
  960. var zh = (cy.height() - 30) / bb.h;
  961. var tall = (bb.h * zw > cy.height() * 1.5);
  962. var z = tall ? zw * 0.95 : Math.min(zw, zh);
  963. cy.zoom({level: z});
  964. if (tall) {
  965. cy.pan({x: 15 - bb.x * z, y: 28 - bb.y * z});
  966. } else {
  967. cy.pan({x: (cy.width() - bb.w * z) / 2 - bb.x * z, y: (cy.height() - bb.h * z) / 2 - bb.y * z});
  968. }
  969. }
  970. function applyElements(els, keepView) {
  971. if (!cy) {
  972. cy = cytoscape({
  973. container: el('l2-cy'),
  974. elements: els,
  975. style: styleFor(dark),
  976. layout: {name: 'preset', fit: false},
  977. wheelSensitivity: 0.2,
  978. minZoom: 0.02, maxZoom: 4
  979. });
  980. bindCy();
  981. setTimeout(initialView, 0);
  982. } else {
  983. var z = cy.zoom(), p = cy.pan();
  984. cy.elements().remove();
  985. cy.add(els);
  986. cy.style(styleFor(dark));
  987. cy.layout({name: 'preset', fit: false}).run();
  988. if (keepView) { cy.zoom(z); cy.pan(p); }
  989. else { initialView(); }
  990. }
  991. }
  992. function fitGroup(label) {
  993. if (!cy || !label) return;
  994. var eles = cy.nodes().filter(function (n) {
  995. return n.data('blockGroup') === label || n.data('group') === label;
  996. });
  997. if (eles.length) cy.fit(eles, 70);
  998. }
  999. function render(snap) {
  1000. if (!snap || !snap.nodes) {
  1001. el('l2-cy').innerHTML = '<div class="l2-empty">' + t('l2.no_data', 'No data') + '</div>';
  1002. return;
  1003. }
  1004. lastSnap = snap;
  1005. renderFull(snap);
  1006. updateStats(snap);
  1007. buildLegend();
  1008. buildAlarms(snap);
  1009. buildOffline(snap);
  1010. buildTerminals(snap);
  1011. buildHistory();
  1012. }
  1013. function renderFull(snap) {
  1014. var L = computeFull(snap), els = [];
  1015. if (!L.placements.length) { applyElements([], false); return; }
  1016. var byId = {};
  1017. L.placements.forEach(function (p) { byId[p.node.id] = p; });
  1018. els.push({group: 'nodes', data: {id: '__root', type: 'root', label: '', w: L.root.w, h: L.root.h},
  1019. position: {x: L.root.w / 2, y: L.root.h / 2}, selectable: false, grabbable: false});
  1020. els.push({group: 'nodes', data: {id: '__roottitle', type: 'roottitle', label: CFG.root_label || 'COBCO', w: L.root.w, h: 20},
  1021. position: {x: L.root.w / 2, y: 22}, selectable: false, grabbable: false});
  1022. L.sections.forEach(function (sec, si) {
  1023. els.push({group: 'nodes', data: {
  1024. id: '__net_' + si, type: 'netframe', label: '', w: sec.w, h: sec.h, net: sec.key, bd: sec.color
  1025. }, position: {x: sec.x + sec.w / 2, y: sec.y + sec.h / 2}, selectable: false, grabbable: false});
  1026. els.push({group: 'nodes', data: {id: '__nettitle_' + si, type: 'nettitle', label: sec.label, w: sec.w, h: 20},
  1027. position: {x: sec.x + sec.w / 2, y: sec.y + 21}, selectable: false, grabbable: false});
  1028. });
  1029. L.frames.forEach(function (f) {
  1030. els.push({group: 'nodes', data: {
  1031. 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)
  1032. }, position: {x: f.x + f.w / 2, y: f.y + f.h / 2}, selectable: false, grabbable: false});
  1033. var tt = f.type === 'floorframe' ? 'floorlabel' : 'wslabel';
  1034. var ty = f.type === 'floorframe' ? 11 : 18;
  1035. els.push({group: 'nodes', data: {id: 'label_' + f.id, type: tt, label: f.label, w: f.w, h: 16},
  1036. position: {x: f.x + f.w / 2, y: f.y + ty}, selectable: false, grabbable: false});
  1037. });
  1038. L.placements.forEach(function (p) {
  1039. var n = p.node, cls = [];
  1040. var bg = p.role === 'core' ? TIER_BG.core : (p.role === 'main' ? '#2E75B6' : (p.role === 'dist' ? TIER_BG.dist : TIER_BG.access));
  1041. var bd = bg;
  1042. if (n.alarms && n.alarms.length) { cls.push('alarm'); bd = '#E45959'; }
  1043. if (n.offline || n.oper === 'down') cls.push('offline');
  1044. if (n.latency_ms != null) {
  1045. if (+n.latency_ms > LATB) cls.push('lat-bad');
  1046. else if (+n.latency_ms > LATW) cls.push('lat-warn');
  1047. }
  1048. if (p.role === 'core') cls.push('root');
  1049. els.push({group: 'nodes', data: {
  1050. id: n.id, type: 'switch', name: n.name, ip: n.ip, tier: n.tier, depth: n.depth, hostid: n.hostid, oper: n.oper,
  1051. offline: !!(n.offline || n.oper === 'down'), last_online: n.last_online,
  1052. maxsev: n.maxsev, alarms: n.alarms || [], term_online: n.term_online, term_offline: 0, latency_ms: n.latency_ms,
  1053. label: switchLabel(n, p.role, p.net), tw: p.w - 12, bg: bg, bd: bd, w: p.w, h: p.h,
  1054. group: p.group + ' · ' + netLabel(p.net), blockGroup: p.group, net: p.net, role: p.role
  1055. }, position: {x: p.x, y: p.y}, classes: cls.join(' ')});
  1056. });
  1057. // Every protocol-discovered link = one straight segment. Cytoscape's
  1058. // percentage endpoints are unreliable with preset labels, so use two
  1059. // invisible anchor nodes placed exactly on the label left-edge midpoint.
  1060. // The labels paint on top, so a parent line starts under the parent label
  1061. // and ends exactly at the child's left edge.
  1062. pathParent = buildParents(snap, L);
  1063. (snap.edges || []).forEach(function (e, i) {
  1064. var ps = byId[e.source], pt = byId[e.target];
  1065. if (!ps || !pt) return;
  1066. var sp = 'sp_' + i, tp = 'tp_' + i;
  1067. els.push({group: 'nodes', data: {id: sp, type: 'edgepoint'}, position: {x: ps.x - ps.w / 2, y: ps.y}, selectable: false, grabbable: false});
  1068. els.push({group: 'nodes', data: {id: tp, type: 'edgepoint'}, position: {x: pt.x - pt.w / 2, y: pt.y}, selectable: false, grabbable: false});
  1069. var col = speedColor(e.speed, e.state);
  1070. els.push({group: 'edges', data: {
  1071. id: 'e_' + i, type: 'link', source: sp, target: tp, p: e.source, c: e.target,
  1072. speed: e.speed, state: e.state, a_port: e.a_port, b_port: e.b_port,
  1073. color: col, ls: (e.state && e.state !== 'up') ? 'dashed' : 'solid', w: Math.min(4, speedWidth(e.speed))
  1074. }});
  1075. });
  1076. if (showTerm) addTerminals(L, byId, els);
  1077. applyElements(els, true);
  1078. if (focusGroupLabel) {
  1079. setTimeout(function () { fitGroup(focusGroupLabel); }, 80);
  1080. focusGroupLabel = null;
  1081. }
  1082. }
  1083. function addTerminals(L, byId, els) {
  1084. var bySw = {};
  1085. ((lastSnap && lastSnap.terminals) || []).forEach(function (tm) {
  1086. if (!byId[tm.sw_ip]) return;
  1087. if (!terminalOnline(tm)) return;
  1088. (bySw[tm.sw_ip] = bySw[tm.sw_ip] || []).push(tm);
  1089. });
  1090. Object.keys(bySw).forEach(function (sw) {
  1091. var p = byId[sw]; if (!p || p.role === 'core' || p.role === 'main') return;
  1092. bySw[sw].sort(function (a, b) { return (terminalOnline(a) === terminalOnline(b)) ? 0 : (terminalOnline(a) ? -1 : 1); });
  1093. // A search hit is pinned into the visible 24 so any MAC in the snapshot
  1094. // can be located even when its switch has more than 24 terminals.
  1095. if (pinnedTerm) {
  1096. for (var pi = 0; pi < bySw[sw].length; pi++) {
  1097. if (bySw[sw][pi].mac === pinnedTerm) {
  1098. if (pi > 0) { var pin = bySw[sw].splice(pi, 1)[0]; bySw[sw].unshift(pin); }
  1099. break;
  1100. }
  1101. }
  1102. }
  1103. bySw[sw].slice(0, 24).forEach(function (tm, k) {
  1104. var col = k % 12, row = Math.floor(k / 12);
  1105. var x = p.x - p.w / 2 + 16 + col * 9;
  1106. var y = p.y + 16 + row * 9;
  1107. els.push({group: 'nodes', data: {
  1108. id: 't_' + tm.mac, type: 'term', bg: terminalOnline(tm) ? '#2ECC71' : '#95a5a6',
  1109. mac: tm.mac, ip: tm.ip, online: terminalOnline(tm), speed: tm.speed,
  1110. sw_name: tm.sw_name, port: tm.port, label: '', termText: terminalText(tm)
  1111. }, position: {x: x, y: y}, selectable: false});
  1112. els.push({group: 'edges', data: {id: 'te_' + tm.mac, type: 'term', source: sw, target: 't_' + tm.mac}});
  1113. });
  1114. });
  1115. }
  1116. function renderOverview(snap) {
  1117. var O = computeOverview(snap), els = [], idx = {};
  1118. var nb = el('l2-net-tabs'); if (nb) { nb.style.display = 'none'; nb.innerHTML = ''; }
  1119. if (O.root) {
  1120. els.push({group: 'nodes', data: {id: '__root', type: 'root', label: CFG.root_label || 'COBCO', w: O.root.w, h: O.root.h},
  1121. position: {x: O.root.cx, y: O.root.cy}, selectable: false, grabbable: false});
  1122. }
  1123. O.tiles.forEach(function (tile, i) {
  1124. var s = O.stats[tile.label] || {nodes: [], alarms: 0, offline: 0, maxsev: -1};
  1125. idx[tile.label] = 'grp_' + i;
  1126. var isCore = /core/i.test(tile.label) || /data\s*center/i.test(tile.label);
  1127. var bd = s.alarms ? '#E45959' : (dark ? '#41597c' : '#b9c8dc');
  1128. var bg = isCore ? TIER_BG.core : (dark ? '#22344e' : '#eef3fb');
  1129. var fg = isCore ? '#ffffff' : (dark ? '#dfe9fb' : '#1e3352');
  1130. var lbl = tile.label + '\n' + s.nodes.length + ' ' + t('l2.unit_sw', 'sw') +
  1131. (s.up ? ' ↑ ' + s.up : '') + (s.alarms ? ' ⚠ ' + s.alarms : '') + (s.offline ? ' ◌ ' + s.offline : '');
  1132. var cls = [];
  1133. if (s.alarms) cls.push('alarm');
  1134. if (isCore) cls.push('core');
  1135. els.push({group: 'nodes', data: {
  1136. id: idx[tile.label], type: 'tile', group: tile.label,
  1137. n: s.nodes.length, alarms: s.alarms, offline: s.offline, maxsev: s.maxsev, up: s.up,
  1138. label: lbl, bg: bg, fg: fg, bd: bd, w: TILE_W, h: TILE_H
  1139. }, position: {x: tile.x + tile.w / 2, y: tile.y + tile.h / 2}, classes: cls.join(' ')});
  1140. });
  1141. applyElements(els);
  1142. }
  1143. function renderNetTabs(D) {
  1144. var box = el('l2-net-tabs');
  1145. if (!box) return;
  1146. box.innerHTML = '';
  1147. if (!D.nets || D.nets.length < 2) { box.style.display = 'none'; return; }
  1148. box.style.display = 'flex';
  1149. D.nets.forEach(function (n) {
  1150. var b = document.createElement('button');
  1151. b.type = 'button';
  1152. b.className = 'l2-net-tab' + (n.key === D.activeNet ? ' active' : '');
  1153. b.textContent = networkLabel(n.key) + ' · ' + n.count + ' ' + t('l2.unit_sw', 'sw');
  1154. b.addEventListener('click', function () { detailNet = n.key; render(lastSnap); });
  1155. box.appendChild(b);
  1156. });
  1157. }
  1158. function renderDetail(snap) {
  1159. var D = computeDetail(snap, viewGroup), els = [];
  1160. renderNetTabs(D);
  1161. function frameEl(id, type, fr) {
  1162. els.push({group: 'nodes', data: {id: id, type: type, label: fr.label, w: fr.w, h: fr.h},
  1163. position: {x: fr.x + fr.w / 2, y: fr.y + fr.h / 2}, selectable: false, grabbable: false});
  1164. }
  1165. 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});
  1166. if (D.frames.dist) frameEl('__dist', 'floorframe', D.frames.dist);
  1167. D.floors.forEach(function (f, i) { frameEl('__floor_' + i, 'floorframe', f.frame); });
  1168. (D.ghosts || []).forEach(function (g) {
  1169. var nm = (g.node && g.node.name) || g.id;
  1170. var ip = (g.node && g.node.ip) || '';
  1171. var tail = String(nm).split(/[-\s]+/).filter(Boolean).slice(-1)[0] || '';
  1172. var short = (tail ? tail + ' · ' : '') + ip;
  1173. els.push({group: 'nodes', data: {id: g.id, type: 'ghost', label: short}, position: {x: g.x, y: g.y}, selectable: false, grabbable: false});
  1174. els.push({group: 'edges', data: {
  1175. id: 'ghe_' + g.id, type: 'uplink', source: g.id, target: g.to,
  1176. speed: g.speed, state: g.state, lbl: speedText(g.speed),
  1177. color: speedColor(g.speed, g.state), ls: (g.state && g.state !== 'up') ? 'dashed' : 'solid',
  1178. w: Math.max(2.6, speedWidth(g.speed))
  1179. }});
  1180. });
  1181. var switchIds = {};
  1182. D.nodes.forEach(function (n, ni) {
  1183. switchIds[n.id] = true;
  1184. var p = D.pos[n.id] || {x: 0, y: 0};
  1185. var tier = n.tier || 'access';
  1186. var tkey = (tier.indexOf('dist') === 0) ? 'dist' : (tier.indexOf('core') === 0 ? 'core' : 'access');
  1187. var isRoot = D.dists && D.dists.length && n.id === D.dists[0].id;
  1188. var cls = [];
  1189. var bd = (dark ? TIER_BG_DARK[tkey] : TIER_BG[tkey]) || '#555';
  1190. var bg = (dark ? TIER_BG_DARK[tkey] : TIER_BG[tkey]) || '#777';
  1191. if (!isRoot && ni % 2 === 1) bg = dark ? '#4f8a3a' : '#6ca75a';
  1192. if (n.alarms && n.alarms.length) {
  1193. cls.push('alarm');
  1194. bd = '#E45959';
  1195. }
  1196. if (n.latency_ms != null) {
  1197. if (+n.latency_ms > LATB) cls.push('lat-bad');
  1198. else if (+n.latency_ms > LATW) cls.push('lat-warn');
  1199. }
  1200. if (isRoot) cls.push('root');
  1201. els.push({group: 'nodes', data: {
  1202. id: n.id, type: 'switch', name: n.name, ip: n.ip, tier: tier, depth: n.depth,
  1203. hostid: n.hostid, oper: n.oper, maxsev: n.maxsev, alarms: n.alarms || [],
  1204. term_online: n.term_online, term_offline: n.term_offline, latency_ms: n.latency_ms,
  1205. label: isRoot ? rootLabel(n) : rowLabel(n), tw: isRoot ? 150 : 360,
  1206. bg: bg, bd: isRoot ? bd : '#25501e',
  1207. w: isRoot ? 170 : 380, h: isRoot ? 50 : 24, group: viewGroup
  1208. }, position: p, classes: cls.join(' ')});
  1209. });
  1210. D.internal.forEach(function (e, i) {
  1211. var root = D.dists && D.dists[0];
  1212. if (root && (e.source === root.id || e.target === root.id)) {
  1213. var childId = e.source === root.id ? e.target : e.source;
  1214. var pp = D.pos[root.id], cp = D.pos[childId];
  1215. if (pp && cp) {
  1216. var sp = 'sp_' + i, tp = 'tp_' + i;
  1217. // Exact left-edge midpoints, straight line. The parent label is
  1218. // allowed to cover the line where it exits the parent.
  1219. els.push({group: 'nodes', data: {id: sp, type: 'edgepoint'}, position: {x: pp.x - 86, y: pp.y}, selectable: false, grabbable: false});
  1220. els.push({group: 'nodes', data: {id: tp, type: 'edgepoint'}, position: {x: cp.x - 191, y: cp.y}, selectable: false, grabbable: false});
  1221. els.push({group: 'edges', data: {
  1222. id: 'e_' + i, type: 'detail', source: sp, target: tp, p: root.id, c: childId,
  1223. speed: e.speed, state: e.state, a_port: e.a_port, b_port: e.b_port,
  1224. color: speedColor(e.speed, e.state), ls: (e.state && e.state !== 'up') ? 'dashed' : 'solid',
  1225. w: Math.min(2.4, speedWidth(e.speed))
  1226. }, classes: 'detail'});
  1227. return;
  1228. }
  1229. }
  1230. els.push({group: 'edges', data: {
  1231. id: 'e_' + i, source: e.source, target: e.target, speed: e.speed, state: e.state,
  1232. a_port: e.a_port, b_port: e.b_port,
  1233. color: speedColor(e.speed, e.state), ls: (e.state && e.state !== 'up') ? 'dashed' : 'solid',
  1234. w: Math.min(2.4, speedWidth(e.speed))
  1235. }, classes: 'detail'});
  1236. });
  1237. if (showTerm) {
  1238. var bySw = {};
  1239. (snap.terminals || []).forEach(function (tm) {
  1240. if (!switchIds[tm.sw_ip]) return;
  1241. if (!terminalOnline(tm)) return;
  1242. (bySw[tm.sw_ip] = bySw[tm.sw_ip] || []).push(tm);
  1243. });
  1244. Object.keys(bySw).forEach(function (sw) {
  1245. var base = D.pos[sw]; if (!base) return;
  1246. bySw[sw].sort(function (a, b) { return (a.online === b.online) ? 0 : (a.online ? -1 : 1); });
  1247. bySw[sw].forEach(function (tm, k) {
  1248. var col = k % 9, row = Math.floor(k / 9);
  1249. var x = base.x - 54 + col * 13;
  1250. var y = base.y + NODE_H / 2 + 16 + row * 13;
  1251. els.push({group: 'nodes', data: {id: 't_' + tm.mac, type: 'term', bg: '#2ECC71', mac: tm.mac, ip: tm.ip, online: true},
  1252. position: {x: x, y: y}, selectable: false});
  1253. els.push({group: 'edges', data: {id: 'te_' + tm.mac, type: 'term', source: sw, target: 't_' + tm.mac}});
  1254. });
  1255. });
  1256. }
  1257. pathParent = D.parentOf || {};
  1258. lockedPath = null;
  1259. applyElements(els);
  1260. }
  1261. /* ---------------- side panels & stats ---------------- */
  1262. function updateStats(snap) {
  1263. var s = snap.stats || {};
  1264. var txt = '● ' + (s.switches || 0) + ' | ⇄ ' + (s.edges || 0) + ' | ' +
  1265. (s.term_total || 0) + ' ' + t('l2.terminals', 'terminals') + ' | ' +
  1266. (s.switches_offline || 0) + ' ' + t('l2.offline_short', 'offline SW') + ' | ⚠ ' + (s.alarms || 0);
  1267. var up = snap.generated_iso ? (' · ' + t('l2.updated', 'Updated') + ' ' + snap.generated_iso) : '';
  1268. el('l2-stats').textContent = txt + up;
  1269. }
  1270. function buildLegend() {
  1271. var h = '';
  1272. 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>'; }
  1273. h += '<div class="lg-title">' + t('l2.legend_speed', 'Link speed') + '</div>';
  1274. h += sw(colors['100'], '100M') + sw(colors['1000'], '1G') + sw(colors['10000'], '10G') + sw(colors['20000'], '20G+');
  1275. h += sw(colors.unknown, t('lbl.color_unknown', 'Unknown'));
  1276. h += sw(colors.down, t('lbl.color_down', 'Down'), true);
  1277. h += '<div class="lg-title">' + t('l2.legend_tier', 'Tier') + '</div>';
  1278. h += '<div class="lg-row"><span class="lg-box" style="background:' + TIER_BG.core + '"></span>' + t('l2.core', 'Core') +
  1279. '<span class="lg-box" style="background:#2E75B6;margin-left:8px"></span>' + t('l2.main', 'Main switch') +
  1280. '<span class="lg-box" style="background:' + TIER_BG.dist + ';margin-left:8px"></span>' + t('l2.dist', 'Distribution') +
  1281. '<span class="lg-box" style="background:' + TIER_BG.access + ';margin-left:8px"></span>' + t('l2.access', 'Access') + '</div>';
  1282. h += '<div class="lg-title">' + t('l2.legend_lat', 'Latency') + '</div>';
  1283. h += '<div class="lg-row"><span class="lg-box" style="background:#F39C12;border-radius:2px"></span>&gt;' + LATW + 'ms' +
  1284. '<span class="lg-box" style="background:#E74C3C;border-radius:2px;margin-left:8px"></span>&gt;' + LATB + 'ms</div>';
  1285. h += '<div class="lg-row"><span class="lg-box" style="background:#E45959;border-radius:2px"></span>' + t('l2.alarms', 'Alarms') + '</div>';
  1286. h += '<div class="lg-row"><span class="lg-box" style="background:#2ECC71;border-radius:50%"></span>' + t('l2.term_on', 'Online') +
  1287. '<span class="lg-box" style="background:#C0392B;border-radius:2px;margin-left:8px"></span>' + t('l2.offline', 'Offline switch') + '</div>';
  1288. 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>';
  1289. el('l2-legend').innerHTML = h;
  1290. }
  1291. function buildAlarms(snap) {
  1292. var items = [];
  1293. (snap.nodes || []).forEach(function (n) {
  1294. if (n.maxsev != null && n.maxsev >= 0 && n.alarms && n.alarms.length) {
  1295. n.alarms.forEach(function (a) {
  1296. items.push({sev: n.maxsev, title: (a.name || a.description || ''), host: n.name, ip: n.ip, id: n.id, hostid: n.hostid});
  1297. });
  1298. }
  1299. });
  1300. el('l2-alarm-count').textContent = items.length;
  1301. if (!items.length) { el('l2-alarm-list').innerHTML = '<div class="panel-empty">' + t('l2.alarm_empty', 'No active alarms.') + '</div>'; return; }
  1302. items.sort(function (a, b) { return b.sev - a.sev; });
  1303. var h = '';
  1304. items.forEach(function (it) {
  1305. var col = SEV_COLORS[Math.max(0, Math.min(5, it.sev))] || '#999';
  1306. h += '<div class="alarm-row" data-id="' + (it.id || '') + '" data-hostid="' + (it.hostid || '') + '">' +
  1307. '<span class="alarm-dot" style="background:' + col + '"></span>' +
  1308. '<div class="alarm-main"><div class="alarm-title">' + esc(it.title) + '</div>' +
  1309. '<div class="alarm-sub">' + esc(it.host || '') + (it.ip ? ' · ' + esc(it.ip) : '') + '</div></div></div>';
  1310. });
  1311. var box = el('l2-alarm-list');
  1312. box.innerHTML = h;
  1313. box.querySelectorAll('.alarm-row').forEach(function (r) {
  1314. r.addEventListener('click', function () {
  1315. var id = r.getAttribute('data-id');
  1316. if (id) revealById(id);
  1317. else { var hid = r.getAttribute('data-hostid'); if (hid) window.open(META.zabbixHostUrl.replace('__HOSTID__', hid), '_blank'); }
  1318. });
  1319. });
  1320. }
  1321. function fmtTs(ts) {
  1322. if (!ts) return '—';
  1323. try {
  1324. var d = new Date(Number(ts) * 1000);
  1325. var p = function (x) { return (x < 10 ? '0' : '') + x; };
  1326. return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
  1327. } catch (e) { return '—'; }
  1328. }
  1329. function buildOffline(snap) {
  1330. var off = (snap.nodes || []).filter(function (n) { return n.offline || n.oper === 'down'; });
  1331. el('l2-offline-count').textContent = off.length;
  1332. if (!off.length) { el('l2-offline-list').innerHTML = '<div class="panel-empty">' + t('l2.offline_empty', 'No offline switches.') + '</div>'; return; }
  1333. off.sort(function (a, b) {
  1334. var ra = roleOf(a), rb = roleOf(b);
  1335. if (ra !== rb) return ra === 'dist' ? -1 : 1;
  1336. return String(a.name || '').localeCompare(String(b.name || ''));
  1337. });
  1338. var h = '';
  1339. off.forEach(function (n) {
  1340. var role = roleOf(n);
  1341. var roleTxt = role === 'dist' ? t('l2.dist', 'Distribution') : t('l2.access', 'Access');
  1342. h += '<div class="off-row sw-row" data-id="' + esc(n.id) + '">' +
  1343. '<span class="term-dot" style="background:#C0392B"></span>' +
  1344. '<div class="off-main"><div class="off-mac">' + esc(n.name || n.ip) + '</div>' +
  1345. '<div class="off-sub">' + esc(roleTxt) + ' · ' + esc(n.ip || '') +
  1346. ' · ' + t('l2.last_online', 'last online') + ': ' + esc(fmtTs(n.last_online)) + '</div></div></div>';
  1347. });
  1348. var box = el('l2-offline-list');
  1349. box.innerHTML = h;
  1350. box.querySelectorAll('.off-row').forEach(function (r) {
  1351. r.addEventListener('click', function () {
  1352. var id = r.getAttribute('data-id');
  1353. if (id) revealById(id);
  1354. });
  1355. });
  1356. }
  1357. function buildTerminals(snap) {
  1358. var list = (snap.terminals || []).filter(function (tm) { return terminalOnline(tm); });
  1359. if (el('l2-term-count')) el('l2-term-count').textContent = list.length;
  1360. var box = el('l2-term-list');
  1361. if (!box) return;
  1362. if (!showTerm) {
  1363. box.innerHTML = '<div class="panel-empty">' + t('l2.show_term', 'Show terminals') + '</div>';
  1364. return;
  1365. }
  1366. if (!list.length) {
  1367. box.innerHTML = '<div class="panel-empty">' + t('l2.no_data', 'No data') + '</div>';
  1368. return;
  1369. }
  1370. list.sort(function (a, b) {
  1371. var x = String(a.sw_name || a.sw_ip || '').localeCompare(String(b.sw_name || b.sw_ip || ''));
  1372. return x || String(a.mac).localeCompare(String(b.mac));
  1373. });
  1374. var h = '';
  1375. list.forEach(function (tm) {
  1376. h += '<div class="off-row term-row" data-mac="' + esc(tm.mac) + '">' +
  1377. '<span class="term-dot" style="background:#2ECC71"></span>' +
  1378. '<div class="off-main"><div class="off-mac">' + esc(tm.mac) + '</div>' +
  1379. '<div class="off-sub"><span class="term-rate">' + esc(speedText(tm.speed)) + '</span> · ' +
  1380. esc(tm.sw_name || tm.sw_ip || '') + ' · ' + esc(tm.port || '') + '</div></div></div>';
  1381. });
  1382. box.innerHTML = h;
  1383. box.querySelectorAll('.term-row').forEach(function (r) {
  1384. r.addEventListener('click', function () { revealById('t_' + r.getAttribute('data-mac')); });
  1385. });
  1386. }
  1387. function buildHistory() {
  1388. var box = el('l2-history-list');
  1389. if (!box) return;
  1390. var list = window.L2_HISTORY || [];
  1391. var used = window.L2_META && window.L2_META.usedTs ? String(window.L2_META.usedTs) : '';
  1392. var h = '<div class="off-row hist-row' + (used ? '' : ' active') + '" data-ts=""><div class="off-main">' +
  1393. '<div class="off-mac">' + t('l2.history_live', 'Live / latest') + '</div>' +
  1394. '<div class="off-sub">' + t('l2.history_live_hint', 'Newest snapshot, auto refresh') + '</div></div></div>';
  1395. if (!list.length) {
  1396. h += '<div class="panel-empty">' + t('l2.history_empty', 'No history snapshots yet.') + '</div>';
  1397. } else {
  1398. list.forEach(function (it) {
  1399. var st = it.stats || {};
  1400. var ts = String(it.ts || '');
  1401. var sub = (st.switches != null ? st.switches + ' ' + t('l2.unit_sw', 'sw') : '') +
  1402. (st.term_total != null ? ' · ' + st.term_total + ' ' + t('l2.terminals', 'terminals') : '') +
  1403. (st.switches_offline ? ' · ' + st.switches_offline + ' ' + t('l2.offline_short', 'offline SW') : '');
  1404. h += '<div class="off-row hist-row' + (ts === used ? ' active' : '') + '" data-ts="' + esc(ts) + '">' +
  1405. '<div class="off-main"><div class="off-mac">' + esc(it.captured_at || fmtTs(it.ts)) + '</div>' +
  1406. '<div class="off-sub">' + esc(sub) + '</div></div></div>';
  1407. });
  1408. }
  1409. box.innerHTML = h;
  1410. box.querySelectorAll('.hist-row').forEach(function (r) {
  1411. r.addEventListener('click', function () {
  1412. var ts = r.getAttribute('data-ts');
  1413. var u = new URL(window.location.href);
  1414. u.searchParams.set('action', 'l2.topology.view');
  1415. if (ts) u.searchParams.set('ts', ts);
  1416. else u.searchParams.delete('ts');
  1417. window.location.href = u.toString();
  1418. });
  1419. });
  1420. if (el('l2-history-count')) el('l2-history-count').textContent = list.length;
  1421. // Keep the toolbar selector in sync with the same history list. The
  1422. // side panel is the primary entry point; the selector is a shortcut.
  1423. var sel = el('l2-ts');
  1424. if (sel) {
  1425. var old = sel.value;
  1426. sel.innerHTML = '';
  1427. var live = document.createElement('option');
  1428. live.value = '';
  1429. live.textContent = t('l2.time_live', 'Live');
  1430. sel.appendChild(live);
  1431. list.forEach(function (it) {
  1432. var opt = document.createElement('option');
  1433. opt.value = String(it.ts || '');
  1434. opt.textContent = it.captured_at || fmtTs(it.ts);
  1435. sel.appendChild(opt);
  1436. });
  1437. sel.value = used || old || '';
  1438. }
  1439. }
  1440. function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]; }); }
  1441. /* ---------------- interactions ---------------- */
  1442. function focusNode(id) {
  1443. if (!cy) return;
  1444. var n = cy.getElementById(id);
  1445. if (!n || n.empty() || n.length === 0) return;
  1446. cy.elements().removeClass('hl');
  1447. n.addClass('hl');
  1448. cy.animate({center: {eles: n}, zoom: Math.max(cy.zoom(), 1.1), duration: 380});
  1449. setTimeout(function () { n.removeClass('hl'); }, 2600);
  1450. }
  1451. function groupOfNodeId(id) {
  1452. var hit = (lastSnap && lastSnap.nodes || []).filter(function (n) { return n.id === id; })[0];
  1453. return hit ? groupOf(hit).label : null;
  1454. }
  1455. // Make sure a switch/terminal is visible (auto-opens its workshop from overview).
  1456. function revealById(id) {
  1457. if (!lastSnap) return;
  1458. id = String(id);
  1459. if (id.indexOf('t_') === 0) {
  1460. var tm = (lastSnap.terminals || []).filter(function (x) { return 't_' + x.mac === id; })[0];
  1461. if (!tm) return;
  1462. pinnedTerm = tm.mac;
  1463. showTerm = true;
  1464. if (el('l2-showterm')) el('l2-showterm').checked = true;
  1465. if (!cy || cy.getElementById(id).empty()) render(lastSnap);
  1466. setTimeout(function () {
  1467. // Center the switch that the terminal hangs under, then flash the
  1468. // terminal dot so the exact port location is obvious.
  1469. focusNode(tm.sw_ip || id);
  1470. var tn = cy && cy.getElementById(id);
  1471. if (tn && tn.length) {
  1472. tn.addClass('hl');
  1473. setTimeout(function () { tn.removeClass('hl'); }, 2600);
  1474. }
  1475. }, 90);
  1476. } else {
  1477. focusNode(id);
  1478. }
  1479. }
  1480. function clearPath() {
  1481. if (!cy) return;
  1482. cy.elements().removeClass('dim pathhl');
  1483. lockedPath = null;
  1484. }
  1485. function highlightPath(nodeId) {
  1486. if (!cy) return;
  1487. var chain = [], id = nodeId, guard = 0;
  1488. while (id && guard++ < 40) {
  1489. chain.unshift(id);
  1490. id = pathParent[id] || null;
  1491. }
  1492. cy.elements('node[type="switch"], node[type="ghost"], edge').addClass('dim').removeClass('pathhl');
  1493. var nodes = cy.collection();
  1494. chain.forEach(function (cid) { var n = cy.getElementById(cid); if (n && n.length) nodes = nodes.union(n); });
  1495. nodes.removeClass('dim').addClass('pathhl');
  1496. var edges = cy.edges().filter(function (e) {
  1497. var p = e.data('p'), c = e.data('c');
  1498. if (p && c) return chain.indexOf(p) >= 0 && chain.indexOf(c) >= 0;
  1499. var s = e.data('source'), t = e.data('target');
  1500. return chain.indexOf(s) >= 0 && chain.indexOf(t) >= 0;
  1501. });
  1502. edges.removeClass('dim').addClass('pathhl');
  1503. }
  1504. function bindCy() {
  1505. cy.on('mouseover', 'node[type="switch"]', function (e) {
  1506. highlightPath(e.target.id());
  1507. var d = e.target.data();
  1508. var arr = d.alarms || [];
  1509. var sev = (d.maxsev != null && d.maxsev >= 0) ? ('<span style="color:' + SEV_COLORS[d.maxsev] + '">● ' + SEV_NAMES[d.maxsev] + '</span>') : '—';
  1510. var lat = (d.latency_ms != null) ? (d.latency_ms + ' ms') : '—';
  1511. var stat = (d.offline || d.oper === 'down') ? ('<span style="color:#E45959">' + esc(t('l2.offline', 'Offline')) + '</span>') : esc(d.oper || 'up');
  1512. var html = '<b>' + esc(d.name) + '</b><br>' + esc(d.ip) + ' · ' + esc(d.group || '') + '<br>' +
  1513. 'Tier: ' + esc(d.tier) + ' · ' + stat + '<br>' +
  1514. 'Latency: ' + lat + '<br>' +
  1515. 'Terminals: ' + (d.term_online || 0) + '<br>' +
  1516. 'Alarm: ' + sev;
  1517. if (d.offline || d.oper === 'down') html += '<br>' + esc(t('l2.last_online', 'last online')) + ': ' + esc(fmtTs(d.last_online));
  1518. if (arr.length) html += '<hr>' + arr.map(function (a) { return esc(a.name || a.description || ''); }).join('<br>');
  1519. showTip(e, html);
  1520. });
  1521. cy.on('mouseout', 'node[type="switch"]', function () {
  1522. if (!lockedPath) clearPath();
  1523. hideTip();
  1524. });
  1525. cy.on('mouseover', 'edge:not([type="term"])', function (e) {
  1526. var d = e.target.data();
  1527. var sp = (parseFloat(d.speed) || 0) >= 1000 ? ((parseFloat(d.speed) / 1000) + 'G') : (d.speed + 'M');
  1528. showTip(e, 'Link ' + esc(d.a_port || '') + ' ↔ ' + esc(d.b_port || '') + '<br>' + sp + ' · ' + esc(d.state || ''));
  1529. });
  1530. cy.on('mouseout', 'edge', hideTip);
  1531. cy.on('mouseover', 'node[type="term"]', function (e) {
  1532. var d = e.target.data();
  1533. e.target.data('label', d.termText || '');
  1534. showTip(e, '<b>' + esc(t('l2.term_title', 'Terminal')) + ' ' + esc(d.mac || '') + '</b><br>' +
  1535. esc(t('l2.term_mac', 'MAC address')) + ': ' + esc(d.mac || '') + '<br>' +
  1536. esc(t('l2.term_ip', 'IP address')) + ': ' + esc(d.ip || '—') + '<br>' +
  1537. esc(t('l2.term_rate', 'Link rate')) + ': ' + esc(speedText(d.speed)) + '<br>' +
  1538. esc(t('l2.term_switch', 'Switch')) + ': ' + esc(d.sw_name || '—') + '<br>' +
  1539. esc(t('l2.term_port', 'Port')) + ': ' + esc(d.port || '—'));
  1540. });
  1541. cy.on('mouseout', 'node[type="term"]', function (e) {
  1542. e.target.data('label', '');
  1543. hideTip();
  1544. });
  1545. cy.on('tap', 'node[type="switch"]', function (e) {
  1546. lockedPath = e.target.id();
  1547. highlightPath(lockedPath);
  1548. });
  1549. cy.on('tap', 'node[type="wsframe"]', function (e) {
  1550. cy.fit(e.target, 60);
  1551. });
  1552. cy.on('tap', function (e) {
  1553. if (e.target === cy) clearPath();
  1554. });
  1555. cy.on('dbltap', 'node[type="switch"]', function (e) {
  1556. var hid = e.target.data('hostid');
  1557. if (hid) window.open(META.zabbixHostUrl.replace('__HOSTID__', hid), '_blank');
  1558. });
  1559. }
  1560. var SEV_NAMES = ['未分类','信息','警告','一般严重','严重','灾难'];
  1561. function showTip(e, html) {
  1562. var tip = el('l2-tip');
  1563. tip.innerHTML = html;
  1564. tip.style.display = 'block';
  1565. var p = e.renderedPosition || {x: 0, y: 0};
  1566. var box = el('l2-cy').getBoundingClientRect();
  1567. var x = box.left + p.x + 16, y = box.top + p.y + 16;
  1568. tip.style.left = x + 'px'; tip.style.top = y + 'px';
  1569. }
  1570. function hideTip() { el('l2-tip').style.display = 'none'; }
  1571. /* ---------------- search ---------------- */
  1572. function buildIndex(snap) {
  1573. var idx = [];
  1574. (snap.nodes || []).forEach(function (n) { idx.push({id: n.id, label: n.name, sub: n.ip, kind: 'sw'}); });
  1575. (snap.terminals || []).forEach(function (tm) {
  1576. if (!terminalOnline(tm)) return;
  1577. idx.push({id: 't_' + tm.mac, label: tm.mac, sub: (tm.ip || '') + ' · ' + speedText(tm.speed) + ' · ' + (tm.sw_name || ''), kind: 'term', mac: tm.mac});
  1578. });
  1579. return idx;
  1580. }
  1581. var INDEX = [];
  1582. function doSearch(q) {
  1583. var box = el('l2-sug');
  1584. q = (q || '').trim().toLowerCase();
  1585. if (!q) { box.style.display = 'none'; box.innerHTML = ''; return; }
  1586. var hits = [];
  1587. var qn = q.replace(/[^a-z0-9]/g, '');
  1588. for (var i = 0; i < INDEX.length && hits.length < 14; i++) {
  1589. var it = INDEX[i];
  1590. var hay = ((it.label || '') + ' ' + (it.sub || '')).toLowerCase();
  1591. var hayN = hay.replace(/[^a-z0-9]/g, '');
  1592. if (hay.indexOf(q) >= 0 || (qn && hayN.indexOf(qn) >= 0)) hits.push(it);
  1593. }
  1594. if (!hits.length) { box.innerHTML = '<div class="sug-empty">—</div>'; box.style.display = 'block'; return; }
  1595. box.innerHTML = hits.map(function (h) {
  1596. return '<div class="sug-row" data-id="' + esc(h.id) + '"><span class="sug-kind ' + h.kind + '">' + (h.kind === 'sw' ? 'SW' : 'PC') + '</span>' +
  1597. '<span class="sug-lbl">' + esc(h.label) + '</span><span class="sug-sub">' + esc(h.sub || '') + '</span></div>';
  1598. }).join('');
  1599. box.style.display = 'block';
  1600. box.querySelectorAll('.sug-row').forEach(function (r) {
  1601. r.addEventListener('click', function () {
  1602. var id = r.getAttribute('data-id');
  1603. revealById(id);
  1604. box.style.display = 'none';
  1605. });
  1606. });
  1607. }
  1608. /* ---------------- toolbar wiring ---------------- */
  1609. function isZabbixDark() {
  1610. var html = document.documentElement;
  1611. var cs = String(html.getAttribute('color-scheme') || '').toLowerCase();
  1612. if (cs === 'dark') return true;
  1613. if (cs === 'light') return false;
  1614. var body = document.body;
  1615. var theme = [
  1616. html.getAttribute('theme'), html.getAttribute('data-theme'), html.className,
  1617. body ? body.getAttribute('theme') : '', body ? body.getAttribute('data-theme') : '',
  1618. body ? body.className : ''
  1619. ].join(' ');
  1620. return /dark/i.test(theme);
  1621. }
  1622. function applyTheme() {
  1623. var nd = isZabbixDark();
  1624. var changed = nd !== dark;
  1625. dark = nd;
  1626. var wrap = el('l2-wrap');
  1627. if (wrap) wrap.classList.toggle('l2-dark', dark);
  1628. if (changed && cy) cy.style(styleFor(dark));
  1629. }
  1630. function fitViewport() {
  1631. var wrap = el('l2-wrap');
  1632. if (!wrap) return;
  1633. var top = Math.max(0, wrap.getBoundingClientRect().top);
  1634. var h = Math.max(520, window.innerHeight - top - 8);
  1635. wrap.style.height = h + 'px';
  1636. if (cy) cy.resize();
  1637. }
  1638. function setupControls() {
  1639. applyTheme();
  1640. if (window.MutationObserver) {
  1641. var mo = new MutationObserver(applyTheme);
  1642. mo.observe(document.documentElement, {attributes: true, attributeFilter: ['theme', 'data-theme', 'color-scheme', 'class']});
  1643. if (document.body) {
  1644. mo.observe(document.body, {attributes: true, attributeFilter: ['theme', 'data-theme', 'color-scheme', 'class']});
  1645. }
  1646. }
  1647. var back = el('l2-back');
  1648. if (back) back.style.display = 'none';
  1649. el('l2-showterm').addEventListener('change', function () { showTerm = el('l2-showterm').checked; render(lastSnap); });
  1650. el('l2-zin').addEventListener('click', function () { if (cy) cy.zoom({level: cy.zoom() * 1.25, renderedPosition: {x: cy.width() / 2, y: cy.height() / 2}}); });
  1651. el('l2-zout').addEventListener('click', function () { if (cy) cy.zoom({level: cy.zoom() / 1.25, renderedPosition: {x: cy.width() / 2, y: cy.height() / 2}}); });
  1652. el('l2-fit').addEventListener('click', function () { if (cy) cy.fit(cy.elements(), 50); });
  1653. el('l2-refresh-btn').addEventListener('click', function () { reload(true); });
  1654. window.addEventListener('resize', fitViewport);
  1655. var s = el('l2-search');
  1656. s.addEventListener('input', function () { doSearch(s.value); });
  1657. document.addEventListener('click', function (ev) {
  1658. if (!ev.target.closest('.l2-search-wrap')) el('l2-sug').style.display = 'none';
  1659. });
  1660. el('l2-ts').addEventListener('change', function () {
  1661. var v = el('l2-ts').value;
  1662. var u = '/zabbix.php?action=l2.topology.view';
  1663. if (v) u += '&ts=' + encodeURIComponent(v);
  1664. window.location = u;
  1665. });
  1666. el('l2-pause').addEventListener('click', function () {
  1667. paused = !paused;
  1668. el('l2-pause').classList.toggle('on', paused);
  1669. el('l2-pause').textContent = paused ? '▶' : '❚❚';
  1670. el('l2-pause').title = paused ? t('l2.resume', 'Resume') : t('l2.pause', 'Pause');
  1671. });
  1672. }
  1673. function reload(manual) {
  1674. if (!META.isLive) return;
  1675. fetch(META.snapshotUrl + '?t=' + Date.now(), {cache: 'no-store'})
  1676. .then(function (r) { return r.json(); })
  1677. .then(function (j) { if (j && j.nodes) { SNAP = j; INDEX = buildIndex(j); render(j); } })
  1678. .catch(function () {})
  1679. .then(function () { if (manual) resetCountdown(); });
  1680. }
  1681. function resetCountdown() { countdown = META.refreshSeconds || 300; }
  1682. function startTimer() {
  1683. if (!META.isLive) return;
  1684. resetCountdown();
  1685. timer = setInterval(function () {
  1686. if (paused) { el('l2-countdown').textContent = '⏸'; return; }
  1687. countdown--;
  1688. if (countdown <= 0) { countdown = META.refreshSeconds || 300; reload(false); }
  1689. var m = Math.floor(countdown / 60), s = countdown % 60;
  1690. el('l2-countdown').textContent = '↻ ' + m + ':' + (s < 10 ? '0' + s : s);
  1691. }, 1000);
  1692. }
  1693. /* ---------------- boot ---------------- */
  1694. function boot() {
  1695. setupControls();
  1696. fitViewport();
  1697. try {
  1698. var qs = new URLSearchParams(window.location.search);
  1699. var qg = qs.get('g');
  1700. if (qg) focusGroupLabel = qg;
  1701. } catch (e) {}
  1702. if (!SNAP || !SNAP.nodes) {
  1703. el('l2-cy').innerHTML = '<div class="l2-empty">' + t('l2.no_data', 'No data') + '</div>';
  1704. } else {
  1705. INDEX = buildIndex(SNAP);
  1706. render(SNAP);
  1707. }
  1708. setTimeout(fitViewport, 60);
  1709. if (META.isLive) startTimer();
  1710. else { el('l2-pause').disabled = true; el('l2-countdown').textContent = t('l2.snapshot', 'Snapshot'); }
  1711. }
  1712. if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
  1713. else boot();
  1714. })();