app.js 113 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519
  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. if (!colors['2500']) colors['2500'] = '#17C3B2';
  15. var LATW = (CFG.latency_warn != null) ? +CFG.latency_warn : 3;
  16. var LATB = (CFG.latency_bad != null) ? +CFG.latency_bad : 5;
  17. var TIER_BG = {core:'#1F4E78', dist:'#2E75B6', access:'#548235', upstream:'#34495E'};
  18. var TIER_BG_DARK = {core:'#1B4A72', dist:'#2A5E93', access:'#3E6B2E', upstream:'#2C3E50'};
  19. var SEV_COLORS = ['#97AAB3','#7499FF','#FFC859','#FFA059','#E97659','#E45959'];
  20. var NODE_W = 156, NODE_H = 50, GX = 26, GY = 18, PADX = 20, PADY = 42;
  21. var DGAP_X = 18, DGAP_Y = 26;
  22. var GROUP_GAP_X = 46, GROUP_GAP_Y = 48, MAXW = 2800;
  23. // overview ("全景") tiles
  24. var TILE_W = 232, TILE_H = 116, TILE_GAP_X = 24, TILE_GAP_Y = 22;
  25. var cy = null;
  26. var dark = false;
  27. var showTerm = false;
  28. var onlyOff = false;
  29. var pinnedTerm = null;
  30. var termPortByMac = {}; // terminal MAC -> rendered port node id
  31. var paused = false;
  32. var countdown = 0;
  33. var timer = null;
  34. var lastSnap = null;
  35. var viewGroup = null; // direct-link focus group, never hides the full chart
  36. var detailNet = null; // null/auto, 'SURVE' or 'OFFICE' inside a workshop
  37. var focusGroupLabel = null; // ?g= target, zoomed after the full chart is built
  38. var pathParent = {}; // child -> parent chain for hover highlighting
  39. var lastRoleMap = {}; // switch id -> computed display role
  40. var lockedPath = null; // switch id whose path is locked by click
  41. function el(id) { return document.getElementById(id); }
  42. function t(k, d) { return (T[k] != null) ? T[k] : (d != null ? d : k); }
  43. // PoE is a capability flag written by the collector's SNMP probe. The
  44. // lightning mark is display-only, like the topology itself.
  45. function poePrefix(n) { return (n && n.poe) ? '\u26A1\uFE0F ' : ''; }
  46. /* ---------------- name parsing / grouping ---------------- */
  47. function parseName(name) {
  48. var r = {workshop:'', floor:'', cab:'', u:'', area:'', role:'', ip:''};
  49. if (!name) return r;
  50. var toks = String(name).split('-');
  51. for (var i = 0; i < toks.length; i++) {
  52. var tk = toks[i];
  53. if (/^\d{1,3}(\.\d{1,3}){3}$/.test(tk)) { r.ip = tk; continue; }
  54. if (!r.workshop && /^\d{4}[A-Za-z]?$/.test(tk)) { r.workshop = tk; continue; }
  55. if (!r.floor) {
  56. var fm = /^(GRD|\d{1,2}F)([A-Z])?(\d+)?$/i.exec(tk);
  57. if (fm) { r.floor = fm[1].toUpperCase(); r.cab = fm[2] || ''; r.u = fm[3] || ''; continue; }
  58. }
  59. if (r.floor && !r.u && /^\d{1,3}$/.test(tk)) { r.u = tk; continue; }
  60. if (!r.area && /^(OFFICE|SURVE|GRDA|DC|IDC|LAB|PROD)$/i.test(tk)) { r.area = tk.toUpperCase(); continue; }
  61. if (!r.role && /^(Dist|Access|Core)$/i.test(tk)) { r.role = tk; }
  62. }
  63. return r;
  64. }
  65. function labelFor(n) {
  66. var p = parseName(n.name);
  67. var l1 = [];
  68. if (p.workshop) l1.push(p.workshop);
  69. if (p.area) l1.push(p.area);
  70. if (!l1.length) {
  71. l1.push((n.name || '').split('-')[0] || n.ip);
  72. }
  73. var l2 = [];
  74. if (p.floor) { var loc = p.floor + 'F' + (p.cab || '') + (p.u ? '-' + p.u + 'U' : ''); l1.push(loc); }
  75. if (p.role) l2.push(p.role);
  76. l2.push(n.ip);
  77. return poePrefix(n) + l1.join(' · ') + '\n' + l2.join(' · ');
  78. }
  79. function detailLabel(n) {
  80. var pre = poePrefix(n);
  81. var s = labelFor(n);
  82. if (pre && s.indexOf(pre) === 0) s = s.slice(pre.length);
  83. if (viewGroup) {
  84. var p = viewGroup + ' · ';
  85. if (s.indexOf(p) === 0) s = s.substring(p.length);
  86. }
  87. return pre + s;
  88. }
  89. function rowLabel(n) {
  90. var s = String((n && n.name) || (n && n.ip) || '');
  91. s = s.replace(/^COBCO-/i, '');
  92. if (viewGroup) {
  93. try { s = s.replace(new RegExp('^' + viewGroup + '-', 'i'), ''); } catch (e) {}
  94. }
  95. return poePrefix(n) + s;
  96. }
  97. function rootLabel(n) {
  98. var pre = poePrefix(n);
  99. var s = rowLabel(n);
  100. if (pre && s.indexOf(pre) === 0) s = s.slice(pre.length);
  101. var m = /^(.+?)-(SURVE|OFFICE|OTHER)-(Dist|Access)-(.+)$/i.exec(s);
  102. if (m) return pre + m[2].toUpperCase() + ' · ' + m[1] + '\n' + m[3] + ' · ' + m[4];
  103. return pre + s;
  104. }
  105. function expandLabel(tpl, m) {
  106. return String(tpl).replace(/\$(\d)/g, function (_, d) { return m && m[+d] != null ? m[+d] : ''; });
  107. }
  108. function groupOf(n) {
  109. var name = n.name || n.ip || '';
  110. var rules = CFG.group_rules || [];
  111. for (var i = 0; i < rules.length; i++) {
  112. var r = rules[i];
  113. if (r.match === 'regex') {
  114. var re;
  115. try { re = new RegExp(r.pattern); } catch (e) { continue; }
  116. var m = re.exec(name);
  117. if (m) return {label: expandLabel(r.label, m) || (CFG.other_label || 'Other'), big: !!r.big};
  118. } else {
  119. if (name.toLowerCase().indexOf(String(r.pattern).toLowerCase()) >= 0) return {label: r.label || (CFG.other_label || 'Other'), big: !!r.big};
  120. }
  121. }
  122. return {label: CFG.other_label || 'Other', big: false};
  123. }
  124. /* ---------------- overview layout (workshop tiles) ---------------- */
  125. function groupStats(snap) {
  126. var map = {};
  127. var nodeById = {};
  128. (snap.nodes || []).forEach(function (n) {
  129. nodeById[n.id] = n;
  130. var g = groupOf(n);
  131. 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});
  132. s.nodes.push(n);
  133. s.offline += (n.offline || n.oper === 'down') ? 1 : 0;
  134. if (n.alarms && n.alarms.length) {
  135. s.alarms += n.alarms.length;
  136. var sev = (n.maxsev != null && +n.maxsev >= 0) ? +n.maxsev : -1;
  137. n.alarms.forEach(function (a) { if (a && a.sev != null && +a.sev > sev) sev = +a.sev; });
  138. if (sev > s.maxsev) s.maxsev = sev;
  139. }
  140. if (n.latency_ms != null && +n.latency_ms > s.latmax) s.latmax = +n.latency_ms;
  141. });
  142. (snap.edges || []).forEach(function (e) {
  143. var a = nodeById[e.source], b = nodeById[e.target];
  144. if (!a || !b) return;
  145. var ga = groupOf(a), gb = groupOf(b);
  146. if (ga.label === gb.label) return;
  147. // count links that leave this workshop towards a core switch
  148. if ((/core/i.test(a.tier || '') || /core/i.test(a.name || '')) && map[gb.label]) map[gb.label].up++;
  149. if ((/core/i.test(b.tier || '') || /core/i.test(b.name || '')) && map[ga.label]) map[ga.label].up++;
  150. });
  151. return map;
  152. }
  153. function groupRank(l) {
  154. if (/core/i.test(l)) return 0;
  155. if (/data\s*center/i.test(l)) return 1;
  156. var m = /^(\d{4})([A-Za-z]?)$/.exec(l);
  157. if (m) return 1000 + parseInt(m[1], 10) * 40 + (m[2] ? m[2].toUpperCase().charCodeAt(0) - 64 : 0);
  158. return 5000;
  159. }
  160. function computeOverview(snap) {
  161. var stats = groupStats(snap);
  162. var labels = Object.keys(stats).sort(function (a, b) { return groupRank(a) - groupRank(b); });
  163. var special = labels.filter(function (l) { return groupRank(l) < 1000; });
  164. var others = labels.filter(function (l) { return groupRank(l) >= 1000; });
  165. var COLS = Math.max(4, Math.min(7, Math.ceil(Math.sqrt(Math.max(1, others.length)))));
  166. var gridW = COLS * TILE_W + (COLS - 1) * TILE_GAP_X;
  167. var pos = {}, tiles = [], y = 0;
  168. if (special.length) {
  169. var sW = special.length * TILE_W + (special.length - 1) * TILE_GAP_X;
  170. var sx = Math.max(0, (gridW - sW) / 2);
  171. special.forEach(function (l) {
  172. tiles.push({label: l, x: sx, y: y, w: TILE_W, h: TILE_H});
  173. pos[l] = {x: sx + TILE_W / 2, y: y + TILE_H / 2};
  174. sx += TILE_W + TILE_GAP_X;
  175. });
  176. y += TILE_H + 46;
  177. }
  178. var maxX = gridW, maxY = y;
  179. for (var i = 0; i < others.length; i += COLS) {
  180. var row = others.slice(i, i + COLS);
  181. var rW = row.length * TILE_W + (row.length - 1) * TILE_GAP_X;
  182. var rx = Math.max(0, (gridW - rW) / 2);
  183. row.forEach(function (l, j) {
  184. tiles.push({label: l, x: rx + j * (TILE_W + TILE_GAP_X), y: y, w: TILE_W, h: TILE_H});
  185. pos[l] = {x: rx + j * (TILE_W + TILE_GAP_X) + TILE_W / 2, y: y + TILE_H / 2};
  186. });
  187. y += TILE_H + TILE_GAP_Y;
  188. maxY = y - TILE_GAP_Y;
  189. maxX = Math.max(maxX, rx + rW);
  190. }
  191. var root = {x: -34, y: -64, w: maxX + 68, h: maxY + 64 + 40};
  192. root.cx = root.x + root.w / 2; root.cy = root.y + root.h / 2;
  193. return {stats: stats, labels: labels, pos: pos, tiles: tiles, root: root};
  194. }
  195. function aggregateEdges(snap) {
  196. var gid = {};
  197. (snap.nodes || []).forEach(function (n) { gid[n.id] = groupOf(n).label; });
  198. var pairs = {};
  199. (snap.edges || []).forEach(function (e) {
  200. var a = gid[e.source], b = gid[e.target];
  201. if (!a || !b || a === b) return;
  202. var k = (a < b) ? (a + '\u0001' + b) : (b + '\u0001' + a);
  203. var p = pairs[k] || (pairs[k] = {a: a, b: b, count: 0, speed: 0, down: false});
  204. p.count++;
  205. if ((parseFloat(e.speed) || 0) > p.speed) p.speed = parseFloat(e.speed) || 0;
  206. if (e.state && e.state !== 'up') p.down = true;
  207. });
  208. return Object.keys(pairs).map(function (k) { return pairs[k]; });
  209. }
  210. /* ---------------- detail layout (one workshop, left-to-right tree) ---------------- */
  211. function floorInfo(n) {
  212. var toks = String((n && n.name) || '').split('-');
  213. for (var i = 0; i < toks.length; i++) {
  214. var tk = toks[i];
  215. var bm = /^B(\d+)$/i.exec(tk);
  216. if (bm) { var bn = parseInt(bm[1], 10); return {key: 'B' + bn, label: 'B' + bn, rank: -200 + bn}; }
  217. if (/^GRD/i.test(tk)) return {key: 'GRD', label: 'GRD', rank: -100};
  218. var fm = /^(\d+(?:\.\d+)?)F/i.exec(tk);
  219. if (fm) {
  220. var num = parseFloat(fm[1]);
  221. var txt = (num % 1 ? num.toFixed(1) : String(num)) + 'F';
  222. return {key: txt, label: txt, rank: num * 10};
  223. }
  224. var cm = /^(\d)([A-Z])(\w+)$/i.exec(tk);
  225. if (cm) {
  226. var n2 = parseInt(cm[1], 10);
  227. return {key: n2 + 'F', label: n2 + 'F', rank: n2 * 10};
  228. }
  229. }
  230. return {key: 'NA', label: t('l2.floor_na', 'Other'), rank: -500};
  231. }
  232. function baseNetworkOf(n) {
  233. var s = String((n && n.name) || '').toUpperCase();
  234. if (s.indexOf('SURVE') >= 0) return 'SURVE';
  235. if (s.indexOf('OFFICE') >= 0) return 'OFFICE';
  236. var ip = String((n && n.ip) || '');
  237. if (/^10\.169\./.test(ip)) return 'SURVE';
  238. if (/^10\.160\./.test(ip)) return 'OFFICE';
  239. return 'OTHER';
  240. }
  241. function networkOf(n) { return baseNetworkOf(n); }
  242. function networkLabel(key) {
  243. if (key === 'SURVE') return t('l2.net_surve', 'SURVE 监控网');
  244. if (key === 'OFFICE') return t('l2.net_office', 'OFFICE 办公网');
  245. return t('l2.net_other', '其他网络');
  246. }
  247. function computeDetail(snap, label) {
  248. var all = snap.nodes || [];
  249. var byId = {}, groupNodes = [];
  250. all.forEach(function (n) { byId[n.id] = n; if (groupOf(n).label === label) groupNodes.push(n); });
  251. // Split one workshop by physical network first: office and surveillance never mix.
  252. var gids = {}, rawAdj = {};
  253. groupNodes.forEach(function (n) { gids[n.id] = true; rawAdj[n.id] = []; });
  254. (snap.edges || []).forEach(function (e) {
  255. if (gids[e.source] && gids[e.target]) { rawAdj[e.source].push(e.target); rawAdj[e.target].push(e.source); }
  256. });
  257. var netMap = {};
  258. groupNodes.forEach(function (n) { netMap[n.id] = baseNetworkOf(n); });
  259. // Unlabelled access switches inherit the network of the distribution they hang from.
  260. for (var pass = 0; pass < 4; pass++) {
  261. groupNodes.forEach(function (n) {
  262. if (netMap[n.id] !== 'OTHER') return;
  263. var votes = {};
  264. rawAdj[n.id].forEach(function (id) {
  265. var k = netMap[id];
  266. if (k && k !== 'OTHER') votes[k] = (votes[k] || 0) + 1;
  267. });
  268. var best = null, bestN = 0;
  269. Object.keys(votes).forEach(function (k) { if (votes[k] > bestN) { best = k; bestN = votes[k]; } });
  270. if (best) netMap[n.id] = best;
  271. });
  272. }
  273. var counts = {}, nets = [];
  274. groupNodes.forEach(function (n) {
  275. var k = netMap[n.id];
  276. if (!counts[k]) { counts[k] = 0; nets.push(k); }
  277. counts[k]++;
  278. });
  279. nets.sort(function (a, b) {
  280. if (a === 'SURVE') return -1;
  281. if (b === 'SURVE') return 1;
  282. if (a === 'OFFICE') return -1;
  283. if (b === 'OFFICE') return 1;
  284. return counts[b] - counts[a];
  285. });
  286. var activeNet = (detailNet && counts[detailNet]) ? detailNet : (nets.length ? nets[0] : 'OTHER');
  287. var nodes = groupNodes.filter(function (n) { return netMap[n.id] === activeNet; });
  288. var ids = {};
  289. nodes.forEach(function (n) { ids[n.id] = true; });
  290. var internal = [], external = [];
  291. (snap.edges || []).forEach(function (e) {
  292. var a = ids[e.source], b = ids[e.target];
  293. if (a && b) internal.push(e);
  294. else if (a || b) external.push(e);
  295. });
  296. var adj = {};
  297. nodes.forEach(function (n) { adj[n.id] = []; });
  298. internal.forEach(function (e) { adj[e.source].push(e.target); adj[e.target].push(e.source); });
  299. // Distribution switches are the left-hand roots of the workshop tree.
  300. var dists = nodes.filter(function (n) { return /dist/i.test(n.tier || '') && /dist/i.test(n.name || ''); })
  301. .sort(function (a, b) { return (adj[b.id].length - adj[a.id].length) || String(a.name).localeCompare(String(b.name)); });
  302. if (!dists.length) {
  303. var cand = nodes.slice().sort(function (a, b) { return adj[b.id].length - adj[a.id].length; })[0];
  304. if (cand) dists = [cand];
  305. }
  306. // One physical root per network tab; peer/redundant distribution switches are listed below it.
  307. if (dists.length > 1) dists = [dists[0]];
  308. // Group all non-distribution switches into floor buckets (GRD lowest, then 1F/2F/3F...).
  309. var floorMap = {};
  310. nodes.forEach(function (n) {
  311. if (dists.indexOf(n) >= 0) return;
  312. if (/core/i.test(n.tier || '')) return;
  313. var fi = floorInfo(n);
  314. var f = floorMap[fi.key] || (floorMap[fi.key] = {key: fi.key, label: fi.label, rank: fi.rank, nodes: []});
  315. f.nodes.push(n);
  316. });
  317. var floors = Object.keys(floorMap).map(function (k) { return floorMap[k]; });
  318. floors.forEach(function (f) {
  319. f.nodes.sort(function (a, b) {
  320. var pa = parseName(a.name), pb = parseName(b.name);
  321. var ca = (pa.cab || '') + (pa.u || ''), cb = (pb.cab || '') + (pb.u || '');
  322. if (ca !== cb) return ca.localeCompare(cb);
  323. return String(a.name).localeCompare(String(b.name));
  324. });
  325. });
  326. // Highest floor at the top, GRD at the bottom.
  327. floors.sort(function (a, b) { return b.rank - a.rank; });
  328. var WORK_L = 300, FLOOR_X = 520, DIST_FRAME_W = 190, TOP_OFF = 34;
  329. var ROW_W = 380, ROW_H = 24, ROW_GAP = 8, FLOOR_HEAD = 28, FLOOR_GAP = 12;
  330. var floorW = ROW_W + PADX * 2 + 110;
  331. floors.forEach(function (f) {
  332. f.cols = 1;
  333. f.rows = f.nodes.length;
  334. f.innerW = ROW_W;
  335. f.h = FLOOR_HEAD + f.rows * ROW_H + Math.max(0, f.rows - 1) * ROW_GAP + 10;
  336. });
  337. var workRight = FLOOR_X + floorW + 14;
  338. var workW = workRight - WORK_L;
  339. var pos = {}, top = 44 + TOP_OFF;
  340. floors.forEach(function (f) {
  341. f.frame = {x: FLOOR_X, y: top, w: floorW, h: f.h};
  342. f.nodes.forEach(function (n, i) {
  343. pos[n.id] = {
  344. x: FLOOR_X + PADX + ROW_W / 2,
  345. y: top + FLOOR_HEAD + i * (ROW_H + ROW_GAP) + ROW_H / 2
  346. };
  347. });
  348. top += f.h + FLOOR_GAP;
  349. });
  350. var workH = Math.max(220, top + 6);
  351. var distFrame = dists.length ? {x: WORK_L + 8, y: 44 + TOP_OFF, w: DIST_FRAME_W, h: workH - (44 + TOP_OFF) - 14} : null;
  352. var distX = WORK_L + 8 + DIST_FRAME_W / 2;
  353. if (dists.length) {
  354. var childYs = floors.reduce(function (acc, f) {
  355. return acc.concat(f.nodes.map(function (n) { return pos[n.id] ? pos[n.id].y : 0; }));
  356. }, []);
  357. var cy = childYs.length ? (Math.min.apply(null, childYs) + Math.max.apply(null, childYs)) / 2 : distFrame.y + distFrame.h / 2;
  358. pos[dists[0].id] = {x: distX, y: cy};
  359. }
  360. var missing = nodes.filter(function (n) { return !pos[n.id]; });
  361. missing.forEach(function (n, i) {
  362. pos[n.id] = {x: distX, y: (distFrame ? distFrame.y + 30 : 80) + i * (NODE_H + 12)};
  363. });
  364. // One ghost core per external core→distribution uplink, aligned left of its distribution switch.
  365. var ghosts = [], ghostSeen = {};
  366. external.forEach(function (e) {
  367. var inside = ids[e.source] ? e.source : e.target;
  368. var outside = ids[e.source] ? e.target : e.source;
  369. if (!pos[inside] || !byId[outside]) return;
  370. // Only a real core uplink gets a ghost; other cross-group edges are not drawn as cores.
  371. if (!/core/i.test(byId[outside].tier || '') && !/core/i.test(byId[outside].name || '')) return;
  372. var key = outside + '|' + inside;
  373. if (ghostSeen[key]) return;
  374. var g = {id: 'gh_' + outside + '_' + inside, to: inside, node: byId[outside], speed: e.speed, state: e.state};
  375. ghostSeen[key] = g;
  376. ghosts.push(g);
  377. });
  378. var ghostAt = {};
  379. ghosts.forEach(function (g) {
  380. var base = pos[g.to];
  381. var k = ghostAt[g.to] || 0;
  382. ghostAt[g.to] = k + 1;
  383. g.x = 92;
  384. g.y = base.y + (k ? (k % 2 ? 1 : -1) * 46 * Math.ceil(k / 2) : 0);
  385. });
  386. // Root each tree at the distribution switch; remember every child's parent for path highlighting.
  387. var parentOf = {}, ghostOfDist = {};
  388. ghosts.forEach(function (g) { if (!ghostOfDist[g.to]) ghostOfDist[g.to] = g.id; });
  389. var seen = {};
  390. dists.forEach(function (d) {
  391. if (seen[d.id]) return;
  392. seen[d.id] = true;
  393. parentOf[d.id] = ghostOfDist[d.id] || null;
  394. var queue = [d.id];
  395. while (queue.length) {
  396. var cur = queue.shift();
  397. adj[cur].forEach(function (nb) {
  398. if (seen[nb]) return;
  399. seen[nb] = true;
  400. parentOf[nb] = cur;
  401. queue.push(nb);
  402. });
  403. }
  404. });
  405. nodes.forEach(function (n) { if (!seen[n.id]) seen[n.id] = true; });
  406. var workshop = {x: WORK_L, y: 0, w: workW, h: workH, label: label + ' · ' + networkLabel(activeNet) + ' · ' + nodes.length + ' ' + t('l2.unit_sw', 'sw')};
  407. var distFrameFull = distFrame ? {
  408. x: distFrame.x, y: distFrame.y, w: distFrame.w, h: distFrame.h,
  409. label: t('l2.dist', 'Distribution') + ' · ' + dists.length
  410. } : null;
  411. floors.forEach(function (f) {
  412. f.frame.label = f.label + ' · ' + f.nodes.length + ' ' + t('l2.unit_sw', 'sw');
  413. });
  414. var root = {x: -20, y: -48, w: workRight + 40, h: workH + 80};
  415. root.cx = root.x + root.w / 2; root.cy = root.y + root.h / 2;
  416. return {
  417. nodes: nodes, byId: byId, pos: pos, ghosts: ghosts, parentOf: parentOf,
  418. dists: dists, floors: floors, frames: {workshop: workshop, dist: distFrameFull},
  419. root: root, totalW: workRight, ids: ids, internal: internal,
  420. nets: nets.map(function (k) { return {key: k, count: counts[k]}; }), activeNet: activeNet, totalNodes: groupNodes.length
  421. };
  422. }
  423. /* ---------------- one-chart layout: fixed columns by tier ----------------
  424. Left to right: physical core → each network's main switch → every
  425. workshop distribution switch → access switches. A workshop frame spans
  426. the distribution + access columns; each floor is a small frame inside it.
  427. Same workshop on two networks is always two separate frames. */
  428. var CORE_X = 130, MAIN_X = 430, DIST_X = 790, ACCESS_X = 1300;
  429. var CORE_W = 200, MAIN_W = 300, NODE_W3 = 300;
  430. var WORK_LEFT = 610, WORK_RIGHT = 1530;
  431. var FLOOR_LEFT = 1130;
  432. var CHART_W = 1560;
  433. var ACCESS_STEP = 380, DIST_STEP = 86;
  434. var SEC_HEAD = 46, CORE_ROW = 92, WORK_TOP = 148;
  435. var ROW_H = 34, ROW_GAP = 14, WS_GAP = 36, SEC_GAP = 52;
  436. var FLOOR_GAP = 26;
  437. // Front-panel port colours are deliberately separate from link colours:
  438. // the ring shows the port's maximum capability, the inner fill shows the
  439. // negotiated rate. This is why a 10GE port with a 1G link is purple/green.
  440. var PORT_COLORS = {
  441. 100: '#F39C12', 1000: '#2ECC71', 2500: '#17C3B2', 5000: '#17C3B2',
  442. 10000: '#9B59B6', 20000: '#8E44AD', 40000: '#7D3C98', 100000: '#6C3483',
  443. unknown: '#95A5A6'
  444. };
  445. function portColor(v) {
  446. var n = parseFloat(v);
  447. if (!isFinite(n) || n <= 0) return PORT_COLORS.unknown;
  448. if (n <= 100) return PORT_COLORS[100];
  449. if (n <= 1000) return PORT_COLORS[1000];
  450. if (n <= 2500) return PORT_COLORS[2500];
  451. if (n <= 5000) return PORT_COLORS[5000];
  452. if (n <= 10000) return PORT_COLORS[10000];
  453. if (n <= 20000) return PORT_COLORS[20000];
  454. if (n <= 40000) return PORT_COLORS[40000];
  455. return PORT_COLORS[100000];
  456. }
  457. function portMaxFromName(name, fallback) {
  458. var s = String(name || '').toUpperCase().replace(/\s+/g, '');
  459. var m = s.match(/^(100GE|50GE|40GE|25GE|10GE|XGE|2\.5GE|GE|FE)/);
  460. if (m) {
  461. return { '100GE': 100000, '50GE': 50000, '40GE': 40000, '25GE': 25000,
  462. '10GE': 10000, 'XGE': 10000, '2.5GE': 2500, 'GE': 1000, 'FE': 100 }[m[1]] || fallback || 0;
  463. }
  464. if (/^GIGABITETHERNET/.test(s)) return 1000;
  465. if (/^XGIGABITETHERNET/.test(s)) return 10000;
  466. if (/^FASTETHERNET/.test(s)) return 100;
  467. return parseFloat(fallback) || 0;
  468. }
  469. function portFamily(name) {
  470. var s = String(name || '').toUpperCase().replace(/\s+/g, '');
  471. var m = s.match(/^(100GE|50GE|40GE|25GE|10GE|XGE|2\.5GE|GE|FE)/);
  472. if (m) return m[1];
  473. if (/^GIGABITETHERNET/.test(s)) return 'GE';
  474. if (/^XGIGABITETHERNET/.test(s)) return 'XGE';
  475. if (/^FASTETHERNET/.test(s)) return 'FE';
  476. return 'OTHER';
  477. }
  478. function portRows(ports) {
  479. var groups = {}, order = [];
  480. (ports || []).slice().sort(function (a, b) {
  481. return (parseInt(a.order, 10) || 0) - (parseInt(b.order, 10) || 0);
  482. }).forEach(function (p) {
  483. var f = portFamily(p.name);
  484. if (!groups[f]) { groups[f] = []; order.push(f); }
  485. groups[f].push(p);
  486. });
  487. var rows = [];
  488. order.forEach(function (f) {
  489. var arr = groups[f];
  490. // A normal access panel is two rows: 24 ports => 12+12, 48 => 24+24.
  491. // Uplink groups (usually 4-8 ports) stay on their own single row.
  492. var per = arr.length <= 12 ? arr.length : Math.ceil(arr.length / 2);
  493. for (var i = 0; i < arr.length; i += per) rows.push(arr.slice(i, i + per));
  494. });
  495. return rows;
  496. }
  497. function makeNetworks() {
  498. var raw = CFG.networks;
  499. if (!raw || !raw.length) {
  500. raw = [
  501. {key: 'SURVE', label: t('l2.net_surve', 'SURVE 监控网'), keywords: ['SURVE', '10.169.'], color: '#2E75B6'},
  502. {key: 'OFFICE', label: t('l2.net_office', 'OFFICE 办公网'), keywords: ['OFFICE', '10.160.'], color: '#548235'}
  503. ];
  504. }
  505. return raw.map(function (n, i) {
  506. var kws = n.keywords;
  507. if (!(kws instanceof Array)) kws = String(kws || '').split(/[,,;;\s]+/);
  508. return {
  509. key: String(n.key || ('NET' + i)).trim().toUpperCase(),
  510. label: String(n.label || n.key || ('NET' + i)),
  511. keywords: kws.map(function (s) { return String(s).trim(); }).filter(Boolean),
  512. color: /^#[0-9a-fA-F]{6}$/.test(String(n.color || '')) ? n.color : '#607D8B'
  513. };
  514. });
  515. }
  516. var NETWORKS = makeNetworks();
  517. function netLabel(key) {
  518. for (var i = 0; i < NETWORKS.length; i++) if (NETWORKS[i].key === key) return NETWORKS[i].label;
  519. return key === 'OTHER' ? t('l2.net_other', '其他网络') : String(key || '');
  520. }
  521. function netColor(key) {
  522. for (var i = 0; i < NETWORKS.length; i++) if (NETWORKS[i].key === key) return NETWORKS[i].color;
  523. return '#607D8B';
  524. }
  525. function rawNetOf(n) {
  526. var hay = (String((n && n.name) || '') + ' ' + String((n && n.ip) || '')).toUpperCase();
  527. for (var i = 0; i < NETWORKS.length; i++) {
  528. for (var j = 0; j < NETWORKS[i].keywords.length; j++) {
  529. if (hay.indexOf(String(NETWORKS[i].keywords[j]).toUpperCase()) >= 0) return NETWORKS[i].key;
  530. }
  531. }
  532. return 'OTHER';
  533. }
  534. // Core/root switch rules are configured from the L2 tab, one row per core.
  535. // Multiple cores in the same network are supported and all of them are
  536. // placed in the left-most core column.
  537. function coreRules() {
  538. var h = CFG.core_hierarchy;
  539. if (h && h.sub_cores instanceof Array) return h.sub_cores;
  540. var r = CFG.core_rules;
  541. return (r instanceof Array) ? r : [];
  542. }
  543. function coreRuleHit(rule, n) {
  544. var pattern = String(rule.pattern || '');
  545. if (!pattern) return false;
  546. var net = String(rule.net || '').toUpperCase();
  547. if (net) {
  548. var raw = rawNetOf(n);
  549. // A core with no recognisable network keyword may still be assigned by
  550. // the rule; a switch already known to belong to another network must
  551. // not be stolen by a broad pattern.
  552. if (raw !== 'OTHER' && raw !== net) return false;
  553. }
  554. var hay = String((n && n.name) || '') + ' ' + String((n && n.ip) || '');
  555. if (String(rule.match || 'contains') === 'regex') {
  556. try { return new RegExp(pattern, 'i').test(hay); } catch (e) { return false; }
  557. }
  558. return hay.toLowerCase().indexOf(pattern.toLowerCase()) >= 0;
  559. }
  560. function configuredCore(n) {
  561. var rules = coreRules();
  562. for (var i = 0; i < rules.length; i++) if (coreRuleHit(rules[i], n)) return true;
  563. return false;
  564. }
  565. function coreLabelOf(n) {
  566. var rules = coreRules();
  567. for (var i = 0; i < rules.length; i++) {
  568. if (coreRuleHit(rules[i], n)) return String(rules[i].label || '');
  569. }
  570. return '';
  571. }
  572. function configuredCoreNet(n) {
  573. var rules = coreRules();
  574. for (var i = 0; i < rules.length; i++) {
  575. if (coreRuleHit(rules[i], n) && String(rules[i].net || '').trim() !== '') return String(rules[i].net).toUpperCase();
  576. }
  577. return '';
  578. }
  579. // Network is decided first by name/IP keyword, then by the rest of the
  580. // workshop, then by graph adjacency. No switch is ever shown under the
  581. // wrong upstream network.
  582. function assignNetworks(snap) {
  583. var nodes = snap.nodes || [], byId = {}, net = {}, adj = {};
  584. nodes.forEach(function (n) {
  585. byId[n.id] = n; net[n.id] = rawNetOf(n); adj[n.id] = [];
  586. var cn = configuredCoreNet(n);
  587. if (cn) net[n.id] = cn;
  588. });
  589. (snap.edges || []).forEach(function (e) {
  590. if (adj[e.source] && adj[e.target]) { adj[e.source].push(e.target); adj[e.target].push(e.source); }
  591. });
  592. var groupVote = {};
  593. nodes.forEach(function (n) {
  594. var k = groupOf(n).label, v = net[n.id];
  595. if (v && v !== 'OTHER') (groupVote[k] = groupVote[k] || {})[v] = true;
  596. });
  597. nodes.forEach(function (n) {
  598. if (net[n.id] !== 'OTHER') return;
  599. var v = groupVote[groupOf(n).label], keys = v ? Object.keys(v) : [];
  600. if (keys.length === 1) net[n.id] = keys[0];
  601. });
  602. for (var pass = 0; pass < 8; pass++) {
  603. var changed = false;
  604. nodes.forEach(function (n) {
  605. if (net[n.id] !== 'OTHER') return;
  606. var votes = {};
  607. adj[n.id].forEach(function (id) { if (net[id] && net[id] !== 'OTHER') votes[net[id]] = (votes[net[id]] || 0) + 1; });
  608. var best = null, bn = 0;
  609. Object.keys(votes).forEach(function (k) { if (votes[k] > bn) { best = k; bn = votes[k]; } });
  610. if (best) { net[n.id] = best; changed = true; }
  611. });
  612. if (!changed) break;
  613. }
  614. return net;
  615. }
  616. function isUpstreamRole(n) {
  617. return ['firewall', 'behavior', 'total_core'].indexOf(String((n && n.hierarchy_role) || '')) >= 0;
  618. }
  619. function roleOf(n) {
  620. var p = parseName(n.name || '');
  621. var r = String(p.role || '').toLowerCase();
  622. var tier = String(n.tier || '').toLowerCase();
  623. var childCount = parseInt(n.children, 10);
  624. if (isNaN(childCount)) childCount = -1;
  625. if (configuredCore(n)) return 'core';
  626. // Backward-compatible fallback only when no core configuration exists
  627. // (old config). An explicitly empty list means the administrator wants
  628. // no configured core column.
  629. var hasCoreCfg = (CFG.core_hierarchy && CFG.core_hierarchy.sub_cores instanceof Array)
  630. || (CFG.core_rules instanceof Array);
  631. if (!hasCoreCfg && (/^core(\s|-)/i.test(String(n.name || '')) || groupOf(n).label === 'Core')) return 'core';
  632. // Hostname role is the display truth for Access switches. An access
  633. // switch may itself have access children (cascade/stack); it still stays
  634. // an access node and the children simply take a further-right column.
  635. // A Dist switch stays a distribution point even when it has only one child.
  636. if (r === 'access') return 'access';
  637. if (r === 'dist') {
  638. // Old snapshots sometimes call a leaf "Dist"; if it has no children and
  639. // the collector already marked it as access, keep it an access leaf.
  640. if (tier === 'access' && childCount <= 0) return 'access';
  641. return 'dist';
  642. }
  643. if (r === 'core') return childCount > 0 ? 'dist' : 'main';
  644. if (tier === 'core') {
  645. if (childCount > 0) return 'dist';
  646. return 'main';
  647. }
  648. if (childCount > 0) return 'dist';
  649. if (tier === 'distribution') return 'dist';
  650. return 'access';
  651. }
  652. function roleName(r) {
  653. if (r === 'core') return t('l2.core', '核心');
  654. if (r === 'main') return t('l2.main', '主交换');
  655. if (r === 'dist') return t('l2.dist', '汇聚');
  656. return t('l2.access', '接入');
  657. }
  658. function shortLoc(n) {
  659. var p = parseName(n.name || '');
  660. if (!p.floor) return '';
  661. return p.floor + (p.cab || '') + (p.u ? '-' + p.u : '');
  662. }
  663. function switchLabel(n, role, netKey) {
  664. var g = groupOf(n).label;
  665. var s;
  666. if (role === 'upstream') {
  667. var rl = n.hierarchy_role === 'firewall' ? t('l2.firewall', 'Firewall')
  668. : (n.hierarchy_role === 'behavior' ? t('l2.behavior', 'Behavior management')
  669. : t('l2.total_core', 'Total core'));
  670. s = rl + ' · ' + (n.ip || '');
  671. }
  672. else if (role === 'core') {
  673. var cl = coreLabelOf(n) || roleName('core');
  674. s = cl + ' · ' + (netKey || rawNetOf(n)) + ' · ' + (n.ip || '');
  675. }
  676. else if (role === 'main') s = roleName('main') + ' · ' + g + ' · ' + (n.ip || '');
  677. else {
  678. var loc = shortLoc(n);
  679. s = [g, loc, roleName(role)].filter(Boolean).join(' · ') + ' · ' + (n.ip || '');
  680. }
  681. return poePrefix(n) + s;
  682. }
  683. // A workshop is the physical area served by one distribution switch, not
  684. // the four-digit prefix printed on each access switch. The same dist can
  685. // serve access switches in several rooms/buildings; they must share one
  686. // frame, otherwise their uplink line would cross unrelated frames.
  687. function workshopKeys(snap) {
  688. var byId = {}, adj = {}, keys = {};
  689. (snap.nodes || []).forEach(function (n) { byId[n.id] = n; adj[n.id] = []; });
  690. (snap.edges || []).forEach(function (e) {
  691. if (adj[e.source] && adj[e.target]) { adj[e.source].push(e.target); adj[e.target].push(e.source); }
  692. });
  693. function depthOf(n) {
  694. var d = parseInt(n && n.depth, 10);
  695. return (isFinite(d) && d >= 0 && d < 99) ? d : 999;
  696. }
  697. // Walk upstream to the highest non-core node. This is the real workshop
  698. // anchor. It keeps cascaded access (2.59 -> 2.75) in the 4401 frame while
  699. // still allowing an access switch that is itself an anchor (4504 -> .118/
  700. // 6109 access) to own one frame. No protocol edge is changed here.
  701. function rootOf(n) {
  702. var cur = n, guard = 0;
  703. while (cur && guard++ < 80) {
  704. var cd = depthOf(cur), best = null;
  705. (adj[cur.id] || []).forEach(function (id) {
  706. var up = byId[id];
  707. if (!up || up.id === cur.id) return;
  708. var ud = depthOf(up);
  709. if (ud < cd) {
  710. if (!best || ud < depthOf(best)) best = up;
  711. }
  712. });
  713. if (!best) return cur;
  714. if (roleOf(best) === 'core') return cur;
  715. cur = best;
  716. }
  717. return cur || n;
  718. }
  719. (snap.nodes || []).forEach(function (n) {
  720. if (isUpstreamRole(n)) return;
  721. var r = roleOf(n);
  722. if (r === 'core' || r === 'main') return;
  723. var root = rootOf(n);
  724. keys[n.id] = groupOf(root || n).label;
  725. });
  726. return keys;
  727. }
  728. function workshopList(snap, netMap, netKey, wsKeys) {
  729. var map = {}, order = [];
  730. (snap.nodes || []).forEach(function (n) {
  731. if (isUpstreamRole(n)) return;
  732. if (netMap[n.id] !== netKey) return;
  733. var r = roleOf(n);
  734. if (r === 'core' || r === 'main') return;
  735. var g = (wsKeys && wsKeys[n.id]) || groupOf(n).label;
  736. if (!map[g]) { map[g] = {key: g, label: g, extra: [], net: netKey, dists: [], access: [], all: []}; order.push(g); }
  737. map[g].all.push(n);
  738. if (r === 'dist') map[g].dists.push(n); else map[g].access.push(n);
  739. });
  740. return order.map(function (g) { return map[g]; }).filter(function (w) { return w.all.length > 0; })
  741. .map(function (w) {
  742. w.access.forEach(function (a) {
  743. var raw = groupOf(a).label;
  744. if (raw !== w.key && w.extra.indexOf(raw) < 0) w.extra.push(raw);
  745. });
  746. w.label = w.key + (w.extra.length ? ' + ' + w.extra.slice(0, 3).join('/') + (w.extra.length > 3 ? '…' : '') : '');
  747. return w;
  748. });
  749. }
  750. function layoutWorkshop(w, top, snap) {
  751. var portRowMax = 0;
  752. if (showTerm) {
  753. w.all.forEach(function (n) {
  754. var rows = portRows(n.ports).length;
  755. if (!rows) rows = 2; // generic 24-port fallback
  756. if (rows > portRowMax) portRowMax = rows;
  757. });
  758. }
  759. // Every port row is 14px high (9px circle + breathing room). Keep a
  760. // little space below the last row; when ports are unavailable the old
  761. // 24px terminal area remains.
  762. var portExtra = showTerm ? Math.max(24, portRowMax * 14 + 10) : 0;
  763. var rowH = ROW_H + ROW_GAP + portExtra;
  764. var floorMap = {}, floors = [], accessFloor = {};
  765. w.access.forEach(function (n) {
  766. var fi = floorInfo(n);
  767. var k = fi.key;
  768. if (!floorMap[k]) { floorMap[k] = {key: k, label: fi.label, rank: (fi.key === 'NA' ? 999 : fi.rank), nodes: [], rows: []}; floors.push(floorMap[k]); }
  769. floorMap[k].nodes.push(n);
  770. accessFloor[n.id] = k;
  771. });
  772. floors.sort(function (a, b) { return a.rank - b.rank || String(a.label).localeCompare(String(b.label)); });
  773. // Build the access sub-columns from the real graph: direct children of a
  774. // dist are column 0, their access children column 1, and so on. No
  775. // logical parent/child is replaced by this; it only decides x positions.
  776. var byId = {}, adj = {}, member = {};
  777. w.all.forEach(function (n) { byId[n.id] = n; adj[n.id] = []; member[n.id] = true; });
  778. (snap.edges || []).forEach(function (e) {
  779. if (member[e.source] && member[e.target]) { adj[e.source].push(e.target); adj[e.target].push(e.source); }
  780. });
  781. function numDepth(n) {
  782. var d = parseInt(n && n.depth, 10);
  783. return (isFinite(d) && d >= 0 && d < 99) ? d : 99;
  784. }
  785. var starts = w.dists.map(function (n) { return n.id; });
  786. if (!starts.length) {
  787. // A frame with no Dist (e.g. 4504 when its hostname says Access) still
  788. // has a top access node. Find nodes with no upstream member.
  789. w.access.forEach(function (n) {
  790. var up = false;
  791. (adj[n.id] || []).forEach(function (id) {
  792. var other = byId[id];
  793. if (other && numDepth(other) < numDepth(n)) up = true;
  794. });
  795. if (!up) starts.push(n.id);
  796. });
  797. if (!starts.length && w.access.length) starts.push(w.access[0].id);
  798. }
  799. var level = {}, queue = [];
  800. starts.forEach(function (id) { if (byId[id]) { level[id] = 0; queue.push(id); } });
  801. while (queue.length) {
  802. var cur = queue.shift();
  803. (adj[cur] || []).forEach(function (id) {
  804. if (!byId[id] || level[id] != null) return;
  805. level[id] = level[cur] + 1;
  806. queue.push(id);
  807. });
  808. }
  809. var base = w.dists.length ? 1 : 0;
  810. w.access.forEach(function (n) {
  811. var lv = level[n.id];
  812. n._col = (lv == null) ? 0 : Math.max(0, lv - base);
  813. if (n._col > 12) n._col = 12;
  814. });
  815. var maxCol = 0;
  816. floors.forEach(function (f) {
  817. // A child fed from another floor (e.g. 1FA-27 2.59 -> GRD 2.75) is put
  818. // at the top of its own floor frame. Its straight line then stays out
  819. // of the labels of the floor's normal first column.
  820. function parentFloorKey(n) {
  821. var best = null, bestD = 999;
  822. (adj[n.id] || []).forEach(function (id) {
  823. var up = byId[id];
  824. if (!up || !accessFloor[id]) return;
  825. var ud = numDepth(up);
  826. if (ud < numDepth(n) && ud < bestD) { best = up; bestD = ud; }
  827. });
  828. return best ? accessFloor[best.id] : f.key;
  829. }
  830. f.nodes.sort(function (a, b) {
  831. var ca = (parentFloorKey(a) === f.key ? 1 : 0) - (parentFloorKey(b) === f.key ? 1 : 0);
  832. if (ca) return ca;
  833. var cc = (a._col || 0) - (b._col || 0);
  834. if (cc) return cc;
  835. return String(a.name).localeCompare(String(b.name));
  836. });
  837. f.nodes.forEach(function (n) { maxCol = Math.max(maxCol, n._col || 0); f.maxCol = Math.max(f.maxCol || 0, n._col || 0); });
  838. f.crossFloor = f.nodes.some(function (n) { return parentFloorKey(n) !== f.key; });
  839. f.h = 28 + f.nodes.length * rowH + 10;
  840. });
  841. var accessH = 0;
  842. floors.forEach(function (f) { accessH += f.h; });
  843. if (floors.length > 1) accessH += (floors.length - 1) * FLOOR_GAP;
  844. var distH = w.dists.length ? (w.dists.length * DIST_STEP + (w.dists.length - 1) * 10) : 0;
  845. var inner = Math.max(accessH, distH, 54);
  846. var h = 40 + inner + 12;
  847. w.top = top; w.h = h; w.rowH = rowH;
  848. w.right = Math.max(WORK_RIGHT, ACCESS_X + maxCol * ACCESS_STEP + NODE_W3 / 2 + 26);
  849. // Floors are stacked bottom-up: GRD lowest, then 1F → 3F → 4F → 7F.
  850. var cursor = top + h - 10;
  851. floors.forEach(function (f) {
  852. cursor -= f.h;
  853. var floorW = Math.max(340, ACCESS_X + (f.maxCol || 0) * ACCESS_STEP + NODE_W3 / 2 + 26 - FLOOR_LEFT);
  854. f.frame = {x: FLOOR_LEFT, y: cursor, w: floorW, h: f.h};
  855. if (f.crossFloor) f.labelX = FLOOR_LEFT + 72;
  856. f.nodes.forEach(function (n, i) {
  857. f.rows.push({id: n.id, x: ACCESS_X + (n._col || 0) * ACCESS_STEP, y: cursor + 28 + rowH / 2 + i * rowH});
  858. });
  859. cursor -= FLOOR_GAP;
  860. });
  861. var dstart = top + 40 + (inner - distH) / 2;
  862. w.distPos = w.dists.map(function (n, i) {
  863. return {id: n.id, x: DIST_X, y: dstart + DIST_STEP / 2 + i * (DIST_STEP + 10)};
  864. });
  865. w.floors = floors;
  866. w.title = w.label + ' · ' + netLabel(w.net) + ' · ' + w.all.length + ' ' + t('l2.unit_sw', '台');
  867. // A workshop without a distribution switch only needs a box around its
  868. // access columns; keeping the full dist-column width would leave a large
  869. // empty rectangle and make the chart look sparse.
  870. var frameLeft = w.dists.length ? WORK_LEFT : FLOOR_LEFT - 40;
  871. w.frame = {x: frameLeft, y: top, w: w.right - frameLeft, h: h};
  872. return h;
  873. }
  874. function computeFull(snap) {
  875. var nodes = (snap.nodes || []).slice();
  876. var netMap = assignNetworks(snap);
  877. var sections = [], placements = [], frames = [], roleMap = {};
  878. var wsKeys = workshopKeys(snap);
  879. var y = 100, maxRight = CHART_W;
  880. // Optional upstream band: firewall -> behavior management -> total core.
  881. // If a role is empty or has no matching host in this snapshot, it is not
  882. // placed at all, so the topology reserves no blank space for it.
  883. var upstream = (snap.upstream || []).filter(function (n) { return n && n.id; });
  884. var upstreamIds = {};
  885. upstream.forEach(function (n) { upstreamIds[n.id] = true; });
  886. // A configured upstream host can also be part of the STP graph. The
  887. // collector then keeps one node and marks it with hierarchy_role; move
  888. // that node into the upstream band instead of drawing it twice.
  889. nodes = nodes.filter(function (n) {
  890. if (!isUpstreamRole(n)) return true;
  891. if (!upstreamIds[n.id]) {
  892. upstream.push(n);
  893. upstreamIds[n.id] = true;
  894. }
  895. return false;
  896. });
  897. var UP_ROLE_ORDER = ['firewall', 'behavior', 'total_core'];
  898. var UP_ROLE_LABEL = {
  899. firewall: t('l2.firewall', '防火墙'),
  900. behavior: t('l2.behavior', '行为管理'),
  901. total_core: t('l2.total_core', '总核心')
  902. };
  903. if (upstream.length) {
  904. upstream.sort(function (a, b) {
  905. var ai = UP_ROLE_ORDER.indexOf(a.hierarchy_role), bi = UP_ROLE_ORDER.indexOf(b.hierarchy_role);
  906. return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);
  907. });
  908. // x is the node centre in Cytoscape, so start at half the node width
  909. // plus the frame's left padding; otherwise the first node is clipped by
  910. // the sidebar/canvas edge. Keep enough width for “role · IP” labels.
  911. var ux = 170;
  912. upstream.forEach(function (n) {
  913. var rl = UP_ROLE_LABEL[n.hierarchy_role] || t('l2.upstream', '上游');
  914. placements.push({node: n, role: 'upstream', x: ux, y: 68, w: 280, h: 46, net: '', group: rl});
  915. roleMap[n.id] = 'upstream';
  916. ux += 296;
  917. });
  918. frames.push({id: 'upframe', type: 'wsframe', x: 10, y: 34, w: CHART_W - 20, h: 84,
  919. label: t('l2.upstream', '上游层级'), net: '', color: '#8E44AD'});
  920. y = 146;
  921. maxRight = Math.max(maxRight, ux + 20);
  922. }
  923. NETWORKS.forEach(function (net) {
  924. var secNodes = nodes.filter(function (n) { return netMap[n.id] === net.key; });
  925. if (!secNodes.length) return;
  926. var cores = [], main = null;
  927. secNodes.forEach(function (n) {
  928. roleMap[n.id] = roleOf(n);
  929. if (roleMap[n.id] === 'core') cores.push(n);
  930. if (roleMap[n.id] === 'main' && !main) main = n;
  931. });
  932. var ws = workshopList(snap, netMap, net.key, wsKeys);
  933. var sec = {
  934. key: net.key, label: netLabel(net.key) + ' · ' + secNodes.length + ' ' + t('l2.unit_sw', '台'),
  935. x: 10, y: y, w: CHART_W - 20, h: 0, color: netColor(net.key),
  936. core: cores[0] || null, cores: cores, main: main, workshops: ws
  937. };
  938. var cur = y + WORK_TOP;
  939. ws.forEach(function (w) {
  940. layoutWorkshop(w, cur, snap);
  941. maxRight = Math.max(maxRight, w.right || WORK_RIGHT);
  942. cur += w.h + WS_GAP;
  943. 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,
  944. label: w.title, group: w.key, net: net.key, color: net.color});
  945. w.floors.forEach(function (f, fi) {
  946. frames.push({id: 'ff_' + net.key + '_' + w.key + '_' + fi, type: 'floorframe', x: f.frame.x, y: f.frame.y,
  947. w: f.frame.w, h: f.frame.h, label: f.label + ' · ' + f.nodes.length + ' ' + t('l2.unit_sw', '台'), labelX: f.labelX, net: net.key, color: net.color});
  948. });
  949. w.distPos.forEach(function (p) {
  950. var n = nodes.filter(function (x) { return x.id === p.id; })[0];
  951. 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});
  952. });
  953. w.floors.forEach(function (f) {
  954. f.rows.forEach(function (p) {
  955. var n = nodes.filter(function (x) { return x.id === p.id; })[0];
  956. 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});
  957. });
  958. });
  959. });
  960. sec.h = (cur - y) + 16;
  961. // The network main switch stays at the top of its tier column (its access
  962. // children sit directly under it, as in the reference chart); the core
  963. // sits lower in the column so its uplinks fan symmetrically.
  964. sec.mainY = y + SEC_HEAD + 46;
  965. sec.coreY = y + Math.max(CORE_ROW + SEC_HEAD, Math.round(sec.h * 0.55));
  966. if (cores.length) {
  967. var coreStep = 64;
  968. var coreTop = sec.coreY - ((cores.length - 1) * coreStep) / 2;
  969. cores.forEach(function (cn, ci) {
  970. placements.push({node: cn, role: 'core', x: CORE_X, y: coreTop + ci * coreStep, w: CORE_W, h: 54, net: net.key, group: 'Core'});
  971. });
  972. }
  973. 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});
  974. sections.push(sec);
  975. y += sec.h + SEC_GAP;
  976. });
  977. var chartW = Math.max(CHART_W, maxRight + 40);
  978. sections.forEach(function (sec) { sec.w = chartW - 20; });
  979. frames.forEach(function (f) { if (f.id === 'upframe') f.w = chartW - 20; });
  980. var root = {x: 0, y: 0, w: chartW, h: y + 6};
  981. return {sections: sections, placements: placements, frames: frames, netMap: netMap, roleMap: roleMap, root: root};
  982. }
  983. // Immediate upstream parent per switch: access -> its workshop dist;
  984. // dist -> core when directly attached, otherwise the network main switch.
  985. function buildParents(snap, L) {
  986. var byId = {}, adj = {}, edgeSpeed = {};
  987. L.placements.forEach(function (p) { byId[p.node.id] = p; });
  988. (snap.nodes || []).forEach(function (n) { adj[n.id] = []; });
  989. (snap.edges || []).forEach(function (e) {
  990. if (!adj[e.source] || !adj[e.target]) return;
  991. adj[e.source].push(e.target); adj[e.target].push(e.source);
  992. edgeSpeed[e.source + '|' + e.target] = edgeSpeed[e.target + '|' + e.source] = parseFloat(e.speed) || 0;
  993. });
  994. var parent = {};
  995. Object.keys(byId).forEach(function (id) {
  996. var role = L.roleMap[id] || 'access';
  997. if (role === 'core') return;
  998. var p = byId[id], cands = (adj[id] || []).map(function (x) { return {id: x, role: L.roleMap[x] || 'access', p: byId[x]}; })
  999. .filter(function (c) { return c.p; });
  1000. var want = role === 'main' ? ['core'] : (role === 'dist' ? ['core', 'main'] : ['dist', 'main', 'core']);
  1001. var best = null;
  1002. for (var wi = 0; wi < want.length && !best; wi++) {
  1003. var list = cands.filter(function (c) {
  1004. if (c.role !== want[wi]) return false;
  1005. if (role === 'access' && want[wi] === 'dist' && c.p.ws && p.ws && c.p.ws !== p.ws) return false;
  1006. return true;
  1007. });
  1008. if (list.length) {
  1009. list.sort(function (a, b) { return (edgeSpeed[id + '|' + b.id] || 0) - (edgeSpeed[id + '|' + a.id] || 0); });
  1010. best = list[0];
  1011. }
  1012. }
  1013. if (best) parent[id] = best.id;
  1014. });
  1015. return parent;
  1016. }
  1017. /* ---------------- speed colour ---------------- */
  1018. function speedColor(speed, state) {
  1019. if (state && state !== 'up') return colors.down;
  1020. var s = parseFloat(speed);
  1021. if (!isFinite(s) || s <= 0) return colors.unknown;
  1022. if (s <= 100) return colors['100'];
  1023. if (s <= 1000) return colors['1000'];
  1024. if (s <= 2500) return colors['2500'];
  1025. if (s <= 10000) return colors['10000'];
  1026. return colors['20000'];
  1027. }
  1028. function speedWidth(speed) {
  1029. var s = parseFloat(speed) || 0;
  1030. if (s >= 20000) return 4.5;
  1031. if (s >= 10000) return 3.6;
  1032. if (s >= 1000) return 2.6;
  1033. return 1.8;
  1034. }
  1035. // Animation speed (pixels per 60Hz frame) by link rate. The visible
  1036. // flow is only an overlay; the protocol/display link itself never changes.
  1037. function flowRate(speed) {
  1038. var s = parseFloat(speed) || 0;
  1039. if (s >= 20000) return 4.6;
  1040. if (s >= 10000) return 3.4;
  1041. if (s >= 1000) return 2.0;
  1042. if (s > 0) return 0.9;
  1043. return 0.6;
  1044. }
  1045. function speedText(speed) {
  1046. var s = parseFloat(speed) || 0;
  1047. if (!s) return '?';
  1048. if (s >= 1000) return (s / 1000) + 'G';
  1049. return s + 'M';
  1050. }
  1051. function terminalOnline(tm) {
  1052. return tm && (tm.online === true || tm.online === 1 || tm.online === '1' || tm.online === 'true');
  1053. }
  1054. function terminalText(tm) {
  1055. return tm.mac + '\n' + speedText(tm.speed);
  1056. }
  1057. /* ---------------- style ---------------- */
  1058. function styleFor(d) {
  1059. var bg = d ? TIER_BG_DARK : TIER_BG;
  1060. return [
  1061. {selector: 'node', style: {
  1062. 'width': 'data(w)', 'height': 'data(h)', 'shape': 'round-rectangle',
  1063. 'background-color': 'data(bg)', 'border-width': 2, 'border-color': 'data(bd)',
  1064. 'label': 'data(label)', 'color': d ? '#eaf1fb' : '#ffffff',
  1065. 'font-size': 11.5, 'font-weight': 600, 'text-valign': 'center', 'text-halign': 'center',
  1066. 'text-wrap': 'wrap', 'text-max-width': NODE_W - 16, 'padding': '2px',
  1067. 'overlay-opacity': 0, 'z-index-compare': 'manual', 'z-index': 20
  1068. }},
  1069. {selector: 'node[type="frame"]', style: {
  1070. 'background-opacity': d ? 0.05 : 0.035, 'background-color': d ? '#5b7bb0' : '#8fa6c4',
  1071. 'border-width': 1.6, 'border-style': 'dashed', 'border-color': d ? '#3f5377' : '#c2ccdb',
  1072. 'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center',
  1073. 'font-size': 'data(fs)', 'font-weight': 800, 'color': d ? '#aebfd8' : '#526178',
  1074. 'text-margin-y': 8, 'padding': '0px', 'events': 'no', 'z-index-compare': 'manual', 'z-index': 2
  1075. }},
  1076. {selector: 'node[type="netframe"]', style: {
  1077. 'background-opacity': d ? 0.03 : 0.02, 'background-color': d ? '#7b91b8' : '#adc0d8',
  1078. 'border-width': 3, 'border-style': 'solid', 'border-color': 'data(bd)',
  1079. 'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center',
  1080. 'font-size': 22, 'font-weight': 900, 'color': d ? '#dbe9ff' : '#29425f',
  1081. 'text-margin-y': 8, 'padding': '0px', 'events': 'no',
  1082. 'z-index-compare': 'manual', 'z-index': 1
  1083. }},
  1084. {selector: 'node[type="wsframe"]', style: {
  1085. 'background-opacity': d ? 0.02 : 0.014, 'background-color': d ? '#6f8fc4' : '#8fa6c4',
  1086. 'border-width': 2.2, 'border-style': 'solid', 'border-color': 'data(bd)',
  1087. 'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center', 'text-wrap': 'none',
  1088. 'font-size': 15.5, 'font-weight': 900, 'color': d ? '#d7e6ff' : '#2f4560',
  1089. 'text-margin-x': 0, 'text-margin-y': 7, 'padding': '0px', 'events': 'no',
  1090. 'z-index-compare': 'manual', 'z-index': 2
  1091. }},
  1092. {selector: 'node[type="floorframe"]', style: {
  1093. 'background-opacity': d ? 0.04 : 0.025, 'background-color': d ? '#5b7bb0' : '#8fa6c4',
  1094. 'border-width': 1.1, 'border-style': 'dashed', 'border-color': 'data(bd)',
  1095. 'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center', 'text-wrap': 'none',
  1096. 'font-size': 11.5, 'font-weight': 800, 'color': d ? '#a9c1e3' : '#425a78',
  1097. 'text-margin-x': 0, 'text-margin-y': 6, 'padding': '0px', 'events': 'no',
  1098. 'z-index-compare': 'manual', 'z-index': 3
  1099. }},
  1100. {selector: 'node[type="root"]', style: {
  1101. 'background-opacity': d ? 0.02 : 0.012, 'background-color': d ? '#7aa0d8' : '#8ea6c2',
  1102. 'border-width': 2.2, 'border-style': 'solid', 'border-color': d ? '#476492' : '#9fb0c6',
  1103. 'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center',
  1104. 'font-size': 28, 'font-weight': 900, 'color': d ? '#cfe0ff' : '#33465f',
  1105. 'text-margin-y': 10, 'events': 'no', 'z-index-compare': 'manual', 'z-index': 0
  1106. }},
  1107. {selector: 'node[type="roottitle"]', style: {
  1108. 'width': 'data(w)', 'height': 20, 'shape': 'rectangle', 'background-opacity': 0, 'border-width': 0,
  1109. 'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'none',
  1110. 'font-size': 24, 'font-weight': 900, 'color': d ? '#cfe0ff' : '#33465f', 'events': 'no',
  1111. 'z-index-compare': 'manual', 'z-index': 5
  1112. }},
  1113. {selector: 'node[type="nettitle"]', style: {
  1114. 'width': 'data(w)', 'height': 20, 'shape': 'rectangle', 'background-opacity': 0, 'border-width': 0,
  1115. 'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'none',
  1116. 'font-size': 20, 'font-weight': 900, 'color': d ? '#dbe9ff' : '#29425f', 'events': 'no',
  1117. 'z-index-compare': 'manual', 'z-index': 5
  1118. }},
  1119. {selector: 'node[type="wslabel"]', style: {
  1120. 'width': 'data(w)', 'height': 16, 'shape': 'rectangle', 'background-opacity': 0, 'border-width': 0,
  1121. 'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'none',
  1122. 'font-size': 15, 'font-weight': 900, 'color': d ? '#d7e6ff' : '#2f4560', 'events': 'no',
  1123. 'z-index-compare': 'manual', 'z-index': 5
  1124. }},
  1125. {selector: 'node[type="floorlabel"]', style: {
  1126. 'width': 'data(w)', 'height': 16, 'shape': 'rectangle', 'background-opacity': 0, 'border-width': 0,
  1127. 'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'none',
  1128. 'font-size': 11, 'font-weight': 800, 'color': d ? '#a9c1e3' : '#425a78', 'events': 'no',
  1129. 'z-index-compare': 'manual', 'z-index': 5
  1130. }},
  1131. {selector: 'node[type="tile"]', style: {
  1132. 'width': 'data(w)', 'height': 'data(h)', 'shape': 'round-rectangle',
  1133. 'background-color': 'data(bg)', 'background-opacity': 0.97,
  1134. 'border-width': 2.5, 'border-color': 'data(bd)',
  1135. 'label': 'data(label)', 'color': 'data(fg)',
  1136. 'font-size': 16, 'font-weight': 700, 'text-valign': 'center', 'text-halign': 'center',
  1137. 'text-wrap': 'wrap', 'text-max-width': TILE_W - 26, 'line-height': 1.45,
  1138. 'padding': '6px', 'overlay-opacity': 0, 'z-index-compare': 'manual', 'z-index': 20
  1139. }},
  1140. {selector: 'node[type="tile"].alarm', style: {'border-width': 5}},
  1141. {selector: 'node[type="tile"].core', style: {'border-width': 3.5}},
  1142. {selector: 'node[type="ghost"]', style: {
  1143. 'width': 190, 'height': 40, 'shape': 'round-rectangle',
  1144. 'background-color': d ? '#52667f' : '#c8d3e2', 'background-opacity': 0.55,
  1145. 'border-width': 1.2, 'border-style': 'dashed', 'border-color': d ? '#7d93ad' : '#8fa3bc',
  1146. 'label': 'data(label)', 'color': d ? '#dce8fa' : '#3b4f68',
  1147. 'font-size': 11, 'font-weight': 600, 'text-wrap': 'wrap', 'text-max-width': 176,
  1148. 'z-index-compare': 'manual', 'z-index': 15
  1149. }},
  1150. {selector: 'node[type="edgepoint"]', style: {
  1151. 'width': 1, 'height': 1, 'opacity': 0, 'events': 'no'
  1152. }},
  1153. {selector: 'node[type="port"]', style: {
  1154. 'width': 'data(size)', 'height': 'data(size)', 'shape': 'ellipse',
  1155. 'background-color': 'data(fill)', 'background-opacity': 'data(fillOpacity)',
  1156. 'border-width': 1.5, 'border-color': 'data(ring)', 'padding': '0px',
  1157. 'label': '', 'overlay-opacity': 0, 'events': 'yes',
  1158. 'z-index-compare': 'manual', 'z-index': 28
  1159. }},
  1160. {selector: 'node[type="term"]', style: {
  1161. 'width': 7, 'height': 7, 'shape': 'ellipse', 'background-color': 'data(bg)',
  1162. 'border-width': 1, 'border-color': d ? '#2a3648' : '#cfd8e3',
  1163. 'label': 'data(label)', 'font-size': 9, 'font-weight': 700,
  1164. 'color': d ? '#eaf1fb' : '#182c44',
  1165. 'text-valign': 'bottom', 'text-halign': 'center', 'text-margin-y': 4,
  1166. 'text-wrap': 'wrap', 'text-max-width': 160,
  1167. 'text-background-color': d ? '#0d1b2a' : '#ffffff', 'text-background-opacity': 0.9,
  1168. 'text-background-padding': '2px',
  1169. 'z-index-compare': 'manual', 'z-index': 30
  1170. }},
  1171. {selector: 'edge[type="tilelink"]', style: {
  1172. 'width': 'data(w)', 'line-color': 'data(color)', 'opacity': 0.55,
  1173. 'curve-style': 'bezier', 'target-arrow-shape': 'none',
  1174. 'z-index-compare': 'manual', 'z-index': 5
  1175. }},
  1176. {selector: 'edge[type="ghost"]', style: {
  1177. 'width': 1.2, 'line-color': d ? '#6b809b' : '#a9b6c8', 'line-style': 'dashed', 'opacity': 0.7,
  1178. 'curve-style': 'bezier', 'target-arrow-shape': 'none',
  1179. 'z-index-compare': 'manual', 'z-index': 8
  1180. }},
  1181. {selector: 'edge[type="uplink"]', style: {
  1182. 'width': 'data(w)', 'line-color': 'data(color)', 'line-style': 'data(ls)', 'opacity': 0.95,
  1183. 'curve-style': 'bezier', 'target-arrow-shape': 'none',
  1184. 'label': 'data(lbl)', 'font-size': 10, 'font-weight': 700,
  1185. 'color': d ? '#dce8fa' : '#25405f',
  1186. 'text-background-color': d ? '#0d1b2a' : '#ffffff', 'text-background-opacity': 0.88,
  1187. 'text-background-padding': '2px', 'text-rotation': 'autorotate',
  1188. 'z-index-compare': 'manual', 'z-index': 40
  1189. }},
  1190. {selector: 'edge', style: {
  1191. 'width': 'data(w)', 'line-color': 'data(color)', 'line-style': 'data(ls)',
  1192. 'curve-style': 'bezier', 'opacity': 0.92, 'target-arrow-shape': 'none',
  1193. 'z-index-compare': 'manual', 'z-index': 12
  1194. }},
  1195. {selector: 'edge[type="link"]', style: {
  1196. 'width': 'data(w)', 'line-color': 'data(color)', 'line-style': 'data(ls)',
  1197. 'opacity': 0.62, 'curve-style': 'straight',
  1198. 'source-endpoint': '0% 50%', 'target-endpoint': '0% 50%',
  1199. 'target-arrow-shape': 'none', 'z-index-compare': 'manual', 'z-index': 1
  1200. }},
  1201. // Moving data-flow overlay. The base line keeps its speed colour; this
  1202. // thin dashed line runs on top and is animated downstream (core -> leaf).
  1203. {selector: 'edge[type="flow"]', style: {
  1204. 'width': 'data(fw)', 'line-color': d ? '#EDFFFC' : '#0E4B46',
  1205. 'line-style': 'dashed', 'line-dash-pattern': [4, 10], 'line-dash-offset': 0,
  1206. 'opacity': d ? 0.68 : 0.52, 'curve-style': 'straight',
  1207. 'source-endpoint': '0% 50%', 'target-endpoint': '0% 50%',
  1208. 'target-arrow-shape': 'none', 'events': 'no', 'z-index-compare': 'manual', 'z-index': 3
  1209. }},
  1210. {selector: 'edge[type="term"]', style: {
  1211. 'width': 0.7, 'line-color': d ? '#3b4a5e' : '#c5cfdb', 'opacity': 0.55,
  1212. 'curve-style': 'bezier', 'z-index-compare': 'manual', 'z-index': 11
  1213. }},
  1214. // Latency is a secondary signal and is the only state that uses a
  1215. // coloured frame. Alarm/offline are filled red at the node level.
  1216. {selector: 'node.lat-warn', style: {'border-color': '#F39C12', 'border-width': 4, 'border-style': 'solid'}},
  1217. {selector: 'node.lat-bad', style: {'border-color': '#E74C3C', 'border-width': 6}},
  1218. {selector: 'node.alarm', style: {'color': '#ffffff'}},
  1219. {selector: 'node.offline', style: {'color': '#ffffff', 'opacity': 0.92}},
  1220. {selector: 'node.dim', style: {'opacity': 0.12}},
  1221. {selector: 'edge.dim', style: {'opacity': 0.08}},
  1222. {selector: 'edge.hl', style: {'width': 5, 'line-color': '#FFD54A', 'opacity': 1, 'z-index-compare': 'manual', 'z-index': 45}},
  1223. {selector: 'node.hl', style: {'border-color': '#ff0', 'border-width': 7, 'overlay-color': '#ff0', 'overlay-opacity': 0.25, 'overlay-padding': 6}},
  1224. {selector: 'node.pathhl', style: {'border-width': 5, 'border-color': '#FFD54A', 'overlay-color': '#FFD54A', 'overlay-opacity': 0.2, 'overlay-padding': 4}},
  1225. {selector: 'edge.pathhl', style: {'width': 5.5, 'line-color': '#FFD54A', 'opacity': 1}},
  1226. {selector: 'edge.detail', style: {'opacity': 0.72, 'curve-style': 'straight', 'source-endpoint': '0% 50%', 'target-endpoint': '0% 50%'}},
  1227. {selector: 'node[type="switch"]', style: {
  1228. 'background-color': function (e) { return e.data('bg'); },
  1229. 'text-max-width': 'data(tw)', 'text-wrap': 'none',
  1230. 'text-halign': 'center', 'font-size': 10.5
  1231. }},
  1232. {selector: 'node[type="switch"].root', style: {'text-wrap': 'wrap'}}
  1233. ];
  1234. }
  1235. /* ---------------- render ---------------- */
  1236. // One tall column chart cannot be squeezed into a wide window without
  1237. // wasting half the canvas. The initial view fits the chart WIDTH and
  1238. // anchors at the top (core switches first); the user can pan down or press
  1239. // "适应窗口" to see the entire graph. This keeps labels readable.
  1240. function initialView() {
  1241. if (!cy) return;
  1242. var bb = cy.elements().boundingBox();
  1243. if (!bb || !bb.w || !bb.h) return;
  1244. var zw = (cy.width() - 30) / bb.w;
  1245. var zh = (cy.height() - 30) / bb.h;
  1246. var tall = (bb.h * zw > cy.height() * 1.5);
  1247. var z = tall ? zw * 0.95 : Math.min(zw, zh);
  1248. cy.zoom({level: z});
  1249. if (tall) {
  1250. cy.pan({x: 15 - bb.x * z, y: 28 - bb.y * z});
  1251. } else {
  1252. cy.pan({x: (cy.width() - bb.w * z) / 2 - bb.x * z, y: (cy.height() - bb.h * z) / 2 - bb.y * z});
  1253. }
  1254. }
  1255. function applyElements(els, keepView) {
  1256. if (!cy) {
  1257. cy = cytoscape({
  1258. container: el('l2-cy'),
  1259. elements: els,
  1260. style: styleFor(dark),
  1261. layout: {name: 'preset', fit: false},
  1262. wheelSensitivity: 0.2,
  1263. minZoom: 0.02, maxZoom: 4
  1264. });
  1265. bindCy();
  1266. setTimeout(initialView, 0);
  1267. } else {
  1268. var z = cy.zoom(), p = cy.pan();
  1269. cy.elements().remove();
  1270. cy.add(els);
  1271. cy.style(styleFor(dark));
  1272. cy.layout({name: 'preset', fit: false}).run();
  1273. if (keepView) { cy.zoom(z); cy.pan(p); }
  1274. else { initialView(); }
  1275. }
  1276. startFlow();
  1277. }
  1278. // Data-flow animation. Each flow overlay has its own rate (link speed), so
  1279. // a 10G/20G link visibly moves faster than a 100M/1G link. This only
  1280. // changes the visual dash phase; no topology/state is touched.
  1281. var flowRaf = null, flowLast = 0;
  1282. function startFlow() {
  1283. if (flowRaf != null) return;
  1284. flowRaf = requestAnimationFrame(flowTick);
  1285. }
  1286. function flowTick(ts) {
  1287. if (!cy) { flowRaf = null; return; }
  1288. var dt = flowLast ? Math.min(80, ts - flowLast) : 16;
  1289. flowLast = ts;
  1290. if (!document.hidden) {
  1291. cy.batch(function () {
  1292. cy.edges('[type="flow"]').forEach(function (e) {
  1293. var rate = parseFloat(e.data('flowRate')) || 0.6;
  1294. var off = (e.scratch('flowOffset') || 0) - rate * dt / 16;
  1295. off = off % 14;
  1296. if (off < 0) off += 14;
  1297. e.scratch('flowOffset', off);
  1298. e.style('line-dash-offset', off);
  1299. });
  1300. });
  1301. }
  1302. flowRaf = requestAnimationFrame(flowTick);
  1303. }
  1304. function fitGroup(label) {
  1305. if (!cy || !label) return;
  1306. var eles = cy.nodes().filter(function (n) {
  1307. return n.data('blockGroup') === label || n.data('group') === label;
  1308. });
  1309. if (eles.length) cy.fit(eles, 70);
  1310. }
  1311. function render(snap) {
  1312. if (!snap || !snap.nodes) {
  1313. el('l2-cy').innerHTML = '<div class="l2-empty">' + t('l2.no_data', 'No data') + '</div>';
  1314. return;
  1315. }
  1316. lastSnap = snap;
  1317. renderFull(snap);
  1318. updateStats(snap);
  1319. buildLegend();
  1320. buildAlarms(snap);
  1321. buildOffline(snap);
  1322. buildTerminals(snap);
  1323. }
  1324. function renderFull(snap) {
  1325. var L = computeFull(snap), els = [];
  1326. if (!L.placements.length) { applyElements([], false); return; }
  1327. lastRoleMap = L.roleMap || {};
  1328. var byId = {};
  1329. L.placements.forEach(function (p) { byId[p.node.id] = p; });
  1330. els.push({group: 'nodes', data: {id: '__root', type: 'root', label: '', w: L.root.w, h: L.root.h},
  1331. position: {x: L.root.w / 2, y: L.root.h / 2}, selectable: false, grabbable: false});
  1332. els.push({group: 'nodes', data: {id: '__roottitle', type: 'roottitle', label: CFG.root_label || 'COBCO', w: L.root.w, h: 20},
  1333. position: {x: L.root.w / 2, y: 22}, selectable: false, grabbable: false});
  1334. L.sections.forEach(function (sec, si) {
  1335. els.push({group: 'nodes', data: {
  1336. id: '__net_' + si, type: 'netframe', label: '', w: sec.w, h: sec.h, net: sec.key, bd: sec.color
  1337. }, position: {x: sec.x + sec.w / 2, y: sec.y + sec.h / 2}, selectable: false, grabbable: false});
  1338. els.push({group: 'nodes', data: {id: '__nettitle_' + si, type: 'nettitle', label: sec.label, w: sec.w, h: 20},
  1339. position: {x: sec.x + sec.w / 2, y: sec.y + 21}, selectable: false, grabbable: false});
  1340. });
  1341. L.frames.forEach(function (f) {
  1342. els.push({group: 'nodes', data: {
  1343. 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)
  1344. }, position: {x: f.x + f.w / 2, y: f.y + f.h / 2}, selectable: false, grabbable: false});
  1345. var tt = f.type === 'floorframe' ? 'floorlabel' : 'wslabel';
  1346. var ty = f.type === 'floorframe' ? 11 : 18;
  1347. els.push({group: 'nodes', data: {id: 'label_' + f.id, type: tt, label: f.label, w: f.w, h: 16},
  1348. position: {x: (f.labelX != null ? f.labelX : f.x + f.w / 2), y: f.y + ty}, selectable: false, grabbable: false});
  1349. });
  1350. L.placements.forEach(function (p) {
  1351. var n = p.node, cls = [];
  1352. var bg = p.role === 'upstream' ? TIER_BG.upstream
  1353. : (p.role === 'core' ? TIER_BG.core : (p.role === 'main' ? '#2E75B6' : (p.role === 'dist' ? TIER_BG.dist : TIER_BG.access)));
  1354. var bd = bg;
  1355. // Alarm and offline are node-level states: fill the whole node red.
  1356. // Only latency > LATB is allowed to draw a red frame; LATW..LATB uses
  1357. // an orange frame. Alarm must never be represented by a red border alone.
  1358. if (n.alarms && n.alarms.length) { cls.push('alarm'); bg = '#E45959'; bd = '#E45959'; }
  1359. if (n.offline || n.oper === 'down') { cls.push('offline'); bg = '#C0392B'; bd = '#C0392B'; }
  1360. // Alarm/offline already owns the whole fill; do not put a latency frame
  1361. // around a red alarm node (that would read as a contradictory state).
  1362. if (!cls.length && n.latency_ms != null) {
  1363. if (+n.latency_ms > LATB) cls.push('lat-bad');
  1364. else if (+n.latency_ms > LATW) cls.push('lat-warn');
  1365. }
  1366. if (p.role === 'core') cls.push('root');
  1367. els.push({group: 'nodes', data: {
  1368. id: n.id, type: 'switch', name: n.name, ip: n.ip, tier: n.tier, depth: n.depth, hostid: n.hostid, oper: n.oper,
  1369. offline: !!(n.offline || n.oper === 'down'), last_online: n.last_online,
  1370. maxsev: n.maxsev, alarms: n.alarms || [], term_online: n.term_online, term_offline: 0, latency_ms: n.latency_ms,
  1371. poe: !!n.poe,
  1372. label: switchLabel(n, p.role, p.net), tw: p.w - 12, bg: bg, bd: bd, w: p.w, h: p.h,
  1373. group: p.role === 'upstream' ? p.group : (p.group + ' · ' + netLabel(p.net)),
  1374. blockGroup: p.group, net: p.net, role: p.role
  1375. }, position: {x: p.x, y: p.y}, classes: cls.join(' ')});
  1376. });
  1377. // Every protocol-discovered link = one straight segment. Cytoscape's
  1378. // percentage endpoints are unreliable with preset labels, so use two
  1379. // invisible anchor nodes placed exactly on the label left-edge midpoint.
  1380. // The labels paint on top, so a parent line starts under the parent label
  1381. // and ends exactly at the child's left edge.
  1382. pathParent = buildParents(snap, L);
  1383. (snap.edges || []).forEach(function (e, i) {
  1384. var ps = byId[e.source], pt = byId[e.target];
  1385. if (!ps || !pt) return;
  1386. var sp = 'sp_' + i, tp = 'tp_' + i;
  1387. els.push({group: 'nodes', data: {id: sp, type: 'edgepoint'}, position: {x: ps.x - ps.w / 2, y: ps.y}, selectable: false, grabbable: false});
  1388. els.push({group: 'nodes', data: {id: tp, type: 'edgepoint'}, position: {x: pt.x - pt.w / 2, y: pt.y}, selectable: false, grabbable: false});
  1389. var col = speedColor(e.speed, e.state);
  1390. els.push({group: 'edges', data: {
  1391. id: 'e_' + i, type: 'link', source: sp, target: tp, p: e.source, c: e.target,
  1392. speed: e.speed, state: e.state, a_port: e.a_port, b_port: e.b_port,
  1393. color: col, ls: (e.state && e.state !== 'up') ? 'dashed' : 'solid', w: Math.min(4, speedWidth(e.speed))
  1394. }});
  1395. if (e.state === 'up') {
  1396. var from = e.source, to = e.target;
  1397. var sd = parseInt(ps.node && ps.node.depth, 10), td = parseInt(pt.node && pt.node.depth, 10);
  1398. if (td < sd || (td === sd && pt.y < ps.y)) { from = e.target; to = e.source; }
  1399. var fsp = (from === e.source) ? sp : tp, ftp = (to === e.source) ? sp : tp;
  1400. els.push({group: 'edges', data: {
  1401. id: 'f_' + i, type: 'flow', source: fsp, target: ftp, p: e.source, c: e.target,
  1402. speed: e.speed, state: e.state, a_port: e.a_port, b_port: e.b_port,
  1403. flowRate: flowRate(e.speed), fw: Math.max(0.9, Math.min(1.5, speedWidth(e.speed) * 0.35))
  1404. }});
  1405. }
  1406. });
  1407. if (showTerm) addTerminals(L, byId, els);
  1408. applyElements(els, true);
  1409. if (focusGroupLabel) {
  1410. setTimeout(function () { fitGroup(focusGroupLabel); }, 80);
  1411. focusGroupLabel = null;
  1412. }
  1413. }
  1414. function portIndexFromName(name) {
  1415. var s = String(name || '').toUpperCase();
  1416. if (/^(ETH-TRUNK|VLANIF|NULL|INLOOPBACK|STACK-PORT|LOOPBACK|TUNNEL|METH|VBDIF)/.test(s)) return 0;
  1417. var m = s.match(/(\d+)(?!.*\d)/);
  1418. var n = m ? parseInt(m[1], 10) : 0;
  1419. return n > 0 && n <= 1024 ? n : 0;
  1420. }
  1421. function addTerminals(L, byId, els) {
  1422. termPortByMac = {};
  1423. var bySw = {}, termBySwPort = {};
  1424. function pkey(s) { return String(s || '').toUpperCase(); }
  1425. ((lastSnap && lastSnap.terminals) || []).forEach(function (tm) {
  1426. if (!byId[tm.sw_ip]) return;
  1427. if (!terminalOnline(tm)) return;
  1428. (bySw[tm.sw_ip] = bySw[tm.sw_ip] || []).push(tm);
  1429. if (tm.port) termBySwPort[tm.sw_ip + '|' + pkey(tm.port)] = tm;
  1430. });
  1431. Object.keys(byId).forEach(function (sw) {
  1432. var p = byId[sw]; if (!p || p.role === 'core' || p.role === 'main') return;
  1433. var terms = bySw[sw] || [];
  1434. var rawPorts = (p.node && p.node.ports) || [];
  1435. var ports = rawPorts.slice();
  1436. if (!rawPorts.length) {
  1437. // The switch did not report a front-panel inventory (for example an
  1438. // old history snapshot). Fall back to a generic 24-port access panel
  1439. // laid out as two rows of 12. Terminals are placed into their real
  1440. // suffix position when the port name allows it; the remaining ports
  1441. // stay visible as unused hollow circles.
  1442. var used = {}, extra = [];
  1443. terms.forEach(function (tm) {
  1444. var idx = portIndexFromName(tm.port);
  1445. if (idx) used[idx] = tm; else extra.push(tm);
  1446. });
  1447. var maxIdx = 24;
  1448. Object.keys(used).forEach(function (k) { maxIdx = Math.max(maxIdx, parseInt(k, 10) || 0); });
  1449. ports = [];
  1450. for (var pi = 1; pi <= maxIdx; pi++) {
  1451. var ptm = used[pi];
  1452. var pspeed = ptm ? (parseFloat(ptm.speed) || 0) : 0;
  1453. ports.push({
  1454. name: ptm ? (ptm.port || ('GE1/0/' + pi)) : ('GE1/0/' + pi),
  1455. order: pi, speed: pspeed,
  1456. max: ptm ? portMaxFromName(ptm.port, pspeed) : 1000,
  1457. up: !!ptm, synthetic: true
  1458. });
  1459. }
  1460. extra.forEach(function (tm, k) {
  1461. var espeed = parseFloat(tm.speed) || 0;
  1462. ports.push({
  1463. name: tm.port || ('GE1/0/' + (maxIdx + k + 1)), order: 1000 + k,
  1464. speed: espeed, max: portMaxFromName(tm.port, espeed),
  1465. up: true, synthetic: true
  1466. });
  1467. });
  1468. }
  1469. var known = {};
  1470. ports.forEach(function (pt) { known[pkey(pt.name)] = true; });
  1471. // A terminal may sit on a logical interface (for example an Eth-Trunk)
  1472. // whose physical ports are not individually listed. Keep it visible as
  1473. // an extra port-like circle rather than silently dropping it.
  1474. terms.forEach(function (tm) {
  1475. if (tm.port && !known[pkey(tm.port)]) {
  1476. ports.push({name: tm.port, order: 9999, speed: parseFloat(tm.speed) || 0,
  1477. max: portMaxFromName(tm.port, parseFloat(tm.speed) || 0), up: true, synthetic: true});
  1478. known[pkey(tm.port)] = true;
  1479. }
  1480. });
  1481. var rows = portRows(ports), maxCols = 0;
  1482. rows.forEach(function (r) { if (r.length > maxCols) maxCols = r.length; });
  1483. var step = 12.5;
  1484. var startX = p.x - ((maxCols - 1) * step) / 2;
  1485. var pidx = 0;
  1486. rows.forEach(function (row, ri) {
  1487. row.forEach(function (port, ci) {
  1488. var tm = termBySwPort[sw + '|' + pkey(port.name)] || null;
  1489. // The IF-MIB port speed is the negotiated physical link rate, so it
  1490. // is the primary source for the filled centre. Terminal records are
  1491. // only a fallback when the switch did not report a speed.
  1492. var actual = parseFloat(port.speed) || 0;
  1493. if (!actual) actual = parseFloat(tm && tm.speed) || 0;
  1494. var max = parseFloat(port.max) || portMaxFromName(port.name, actual);
  1495. var filled = !!port.up && actual > 0;
  1496. var pid = 'p_' + sw + '_' + (port.order || pidx) + '_' + pidx;
  1497. pidx++;
  1498. els.push({group: 'nodes', data: {
  1499. id: pid, type: 'port', name: port.name, port: port.name,
  1500. sw_name: (p.node && p.node.name) || '', sw_ip: sw,
  1501. max: max, speed: actual, up: !!port.up, filled: filled ? 1 : 0,
  1502. fill: filled ? portColor(actual) : '#000000', fillOpacity: filled ? 1 : 0,
  1503. ring: portColor(max), size: 9,
  1504. term: tm || null, termText: tm ? terminalText(tm) : ''
  1505. }, position: {x: startX + ci * step, y: p.y + 18 + ri * 14}, selectable: false});
  1506. if (tm) termPortByMac[tm.mac] = pid;
  1507. });
  1508. });
  1509. });
  1510. }
  1511. function renderOverview(snap) {
  1512. var O = computeOverview(snap), els = [], idx = {};
  1513. var nb = el('l2-net-tabs'); if (nb) { nb.style.display = 'none'; nb.innerHTML = ''; }
  1514. if (O.root) {
  1515. els.push({group: 'nodes', data: {id: '__root', type: 'root', label: CFG.root_label || 'COBCO', w: O.root.w, h: O.root.h},
  1516. position: {x: O.root.cx, y: O.root.cy}, selectable: false, grabbable: false});
  1517. }
  1518. O.tiles.forEach(function (tile, i) {
  1519. var s = O.stats[tile.label] || {nodes: [], alarms: 0, offline: 0, maxsev: -1};
  1520. idx[tile.label] = 'grp_' + i;
  1521. var isCore = /core/i.test(tile.label) || /data\s*center/i.test(tile.label);
  1522. var bd = s.alarms ? '#E45959' : (dark ? '#41597c' : '#b9c8dc');
  1523. var bg = isCore ? TIER_BG.core : (dark ? '#22344e' : '#eef3fb');
  1524. var fg = isCore ? '#ffffff' : (dark ? '#dfe9fb' : '#1e3352');
  1525. var lbl = tile.label + '\n' + s.nodes.length + ' ' + t('l2.unit_sw', 'sw') +
  1526. (s.up ? ' ↑ ' + s.up : '') + (s.alarms ? ' ⚠ ' + s.alarms : '') + (s.offline ? ' ◌ ' + s.offline : '');
  1527. var cls = [];
  1528. if (s.alarms) cls.push('alarm');
  1529. if (isCore) cls.push('core');
  1530. els.push({group: 'nodes', data: {
  1531. id: idx[tile.label], type: 'tile', group: tile.label,
  1532. n: s.nodes.length, alarms: s.alarms, offline: s.offline, maxsev: s.maxsev, up: s.up,
  1533. label: lbl, bg: bg, fg: fg, bd: bd, w: TILE_W, h: TILE_H
  1534. }, position: {x: tile.x + tile.w / 2, y: tile.y + tile.h / 2}, classes: cls.join(' ')});
  1535. });
  1536. applyElements(els);
  1537. }
  1538. function renderNetTabs(D) {
  1539. var box = el('l2-net-tabs');
  1540. if (!box) return;
  1541. box.innerHTML = '';
  1542. if (!D.nets || D.nets.length < 2) { box.style.display = 'none'; return; }
  1543. box.style.display = 'flex';
  1544. D.nets.forEach(function (n) {
  1545. var b = document.createElement('button');
  1546. b.type = 'button';
  1547. b.className = 'l2-net-tab' + (n.key === D.activeNet ? ' active' : '');
  1548. b.textContent = networkLabel(n.key) + ' · ' + n.count + ' ' + t('l2.unit_sw', 'sw');
  1549. b.addEventListener('click', function () { detailNet = n.key; render(lastSnap); });
  1550. box.appendChild(b);
  1551. });
  1552. }
  1553. function renderDetail(snap) {
  1554. var D = computeDetail(snap, viewGroup), els = [];
  1555. renderNetTabs(D);
  1556. function frameEl(id, type, fr) {
  1557. els.push({group: 'nodes', data: {id: id, type: type, label: fr.label, w: fr.w, h: fr.h},
  1558. position: {x: fr.x + fr.w / 2, y: fr.y + fr.h / 2}, selectable: false, grabbable: false});
  1559. }
  1560. 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});
  1561. if (D.frames.dist) frameEl('__dist', 'floorframe', D.frames.dist);
  1562. D.floors.forEach(function (f, i) { frameEl('__floor_' + i, 'floorframe', f.frame); });
  1563. (D.ghosts || []).forEach(function (g) {
  1564. var nm = (g.node && g.node.name) || g.id;
  1565. var ip = (g.node && g.node.ip) || '';
  1566. var tail = String(nm).split(/[-\s]+/).filter(Boolean).slice(-1)[0] || '';
  1567. var short = (tail ? tail + ' · ' : '') + ip;
  1568. els.push({group: 'nodes', data: {id: g.id, type: 'ghost', label: short}, position: {x: g.x, y: g.y}, selectable: false, grabbable: false});
  1569. els.push({group: 'edges', data: {
  1570. id: 'ghe_' + g.id, type: 'uplink', source: g.id, target: g.to,
  1571. speed: g.speed, state: g.state, lbl: speedText(g.speed),
  1572. color: speedColor(g.speed, g.state), ls: (g.state && g.state !== 'up') ? 'dashed' : 'solid',
  1573. w: Math.max(2.6, speedWidth(g.speed))
  1574. }});
  1575. });
  1576. var switchIds = {};
  1577. D.nodes.forEach(function (n, ni) {
  1578. switchIds[n.id] = true;
  1579. var p = D.pos[n.id] || {x: 0, y: 0};
  1580. var tier = n.tier || 'access';
  1581. var tkey = (tier.indexOf('dist') === 0) ? 'dist' : (tier.indexOf('core') === 0 ? 'core' : 'access');
  1582. var isRoot = D.dists && D.dists.length && n.id === D.dists[0].id;
  1583. var cls = [];
  1584. var bd = (dark ? TIER_BG_DARK[tkey] : TIER_BG[tkey]) || '#555';
  1585. var bg = (dark ? TIER_BG_DARK[tkey] : TIER_BG[tkey]) || '#777';
  1586. if (!isRoot && ni % 2 === 1) bg = dark ? '#4f8a3a' : '#6ca75a';
  1587. if (n.alarms && n.alarms.length) {
  1588. cls.push('alarm');
  1589. bg = '#E45959';
  1590. bd = '#E45959';
  1591. }
  1592. if (n.offline || n.oper === 'down') {
  1593. cls.push('offline');
  1594. bg = '#C0392B';
  1595. bd = '#C0392B';
  1596. }
  1597. if (!cls.length && n.latency_ms != null) {
  1598. if (+n.latency_ms > LATB) cls.push('lat-bad');
  1599. else if (+n.latency_ms > LATW) cls.push('lat-warn');
  1600. }
  1601. if (isRoot) cls.push('root');
  1602. els.push({group: 'nodes', data: {
  1603. id: n.id, type: 'switch', name: n.name, ip: n.ip, tier: tier, depth: n.depth,
  1604. hostid: n.hostid, oper: n.oper, maxsev: n.maxsev, alarms: n.alarms || [],
  1605. term_online: n.term_online, term_offline: n.term_offline, latency_ms: n.latency_ms,
  1606. poe: !!n.poe,
  1607. label: isRoot ? rootLabel(n) : rowLabel(n), tw: isRoot ? 150 : 360,
  1608. bg: bg, bd: bd,
  1609. w: isRoot ? 170 : 380, h: isRoot ? 50 : 24, group: viewGroup
  1610. }, position: p, classes: cls.join(' ')});
  1611. });
  1612. D.internal.forEach(function (e, i) {
  1613. var root = D.dists && D.dists[0];
  1614. if (root && (e.source === root.id || e.target === root.id)) {
  1615. var childId = e.source === root.id ? e.target : e.source;
  1616. var pp = D.pos[root.id], cp = D.pos[childId];
  1617. if (pp && cp) {
  1618. var sp = 'sp_' + i, tp = 'tp_' + i;
  1619. // Exact left-edge midpoints, straight line. The parent label is
  1620. // allowed to cover the line where it exits the parent.
  1621. els.push({group: 'nodes', data: {id: sp, type: 'edgepoint'}, position: {x: pp.x - 86, y: pp.y}, selectable: false, grabbable: false});
  1622. els.push({group: 'nodes', data: {id: tp, type: 'edgepoint'}, position: {x: cp.x - 191, y: cp.y}, selectable: false, grabbable: false});
  1623. els.push({group: 'edges', data: {
  1624. id: 'e_' + i, type: 'detail', source: sp, target: tp, p: root.id, c: childId,
  1625. speed: e.speed, state: e.state, a_port: e.a_port, b_port: e.b_port,
  1626. color: speedColor(e.speed, e.state), ls: (e.state && e.state !== 'up') ? 'dashed' : 'solid',
  1627. w: Math.min(2.4, speedWidth(e.speed))
  1628. }, classes: 'detail'});
  1629. if (e.state === 'up') {
  1630. els.push({group: 'edges', data: {
  1631. id: 'f_' + i, type: 'flow', source: sp, target: tp, p: root.id, c: childId,
  1632. speed: e.speed, state: e.state, a_port: e.a_port, b_port: e.b_port,
  1633. flowRate: flowRate(e.speed), fw: Math.max(0.9, Math.min(1.4, speedWidth(e.speed) * 0.35))
  1634. }});
  1635. }
  1636. return;
  1637. }
  1638. }
  1639. els.push({group: 'edges', data: {
  1640. id: 'e_' + i, source: e.source, target: e.target, speed: e.speed, state: e.state,
  1641. a_port: e.a_port, b_port: e.b_port,
  1642. color: speedColor(e.speed, e.state), ls: (e.state && e.state !== 'up') ? 'dashed' : 'solid',
  1643. w: Math.min(2.4, speedWidth(e.speed))
  1644. }, classes: 'detail'});
  1645. if (e.state === 'up') {
  1646. var from = e.source, to = e.target;
  1647. var ap = D.pos[e.source], bp = D.pos[e.target], po = D.parentOf || {};
  1648. if (po[e.source] === e.target) { from = e.target; to = e.source; }
  1649. else if (po[e.target] === e.source) { from = e.source; to = e.target; }
  1650. else if (ap && bp && bp.y < ap.y) { from = e.target; to = e.source; }
  1651. els.push({group: 'edges', data: {
  1652. id: 'f_' + i, type: 'flow', source: from, target: to, p: e.source, c: e.target,
  1653. speed: e.speed, state: e.state, a_port: e.a_port, b_port: e.b_port,
  1654. flowRate: flowRate(e.speed), fw: Math.max(0.9, Math.min(1.4, speedWidth(e.speed) * 0.35))
  1655. }});
  1656. }
  1657. });
  1658. if (showTerm) {
  1659. var bySw = {};
  1660. (snap.terminals || []).forEach(function (tm) {
  1661. if (!switchIds[tm.sw_ip]) return;
  1662. if (!terminalOnline(tm)) return;
  1663. (bySw[tm.sw_ip] = bySw[tm.sw_ip] || []).push(tm);
  1664. });
  1665. Object.keys(bySw).forEach(function (sw) {
  1666. var base = D.pos[sw]; if (!base) return;
  1667. bySw[sw].sort(function (a, b) { return (a.online === b.online) ? 0 : (a.online ? -1 : 1); });
  1668. bySw[sw].forEach(function (tm, k) {
  1669. var col = k % 9, row = Math.floor(k / 9);
  1670. var x = base.x - 54 + col * 13;
  1671. var y = base.y + NODE_H / 2 + 16 + row * 13;
  1672. els.push({group: 'nodes', data: {id: 't_' + tm.mac, type: 'term', bg: '#2ECC71', mac: tm.mac, ip: tm.ip, online: true},
  1673. position: {x: x, y: y}, selectable: false});
  1674. els.push({group: 'edges', data: {id: 'te_' + tm.mac, type: 'term', source: sw, target: 't_' + tm.mac}});
  1675. });
  1676. });
  1677. }
  1678. pathParent = D.parentOf || {};
  1679. lockedPath = null;
  1680. applyElements(els);
  1681. }
  1682. /* ---------------- side panels & stats ---------------- */
  1683. function updateStats(snap) {
  1684. var s = snap.stats || {};
  1685. var txt = '● ' + (s.switches || 0) + ' | ⇄ ' + (s.edges || 0) + ' | ⚡ ' + (s.poe_switches || 0) + ' ' + t('l2.poe', 'PoE') + ' | ' +
  1686. (s.term_total || 0) + ' ' + t('l2.terminals', 'terminals') + ' | ' +
  1687. (s.switches_offline || 0) + ' ' + t('l2.offline_short', 'offline SW') + ' | ⚠ ' + (s.alarms || 0);
  1688. var up = snap.generated_iso ? (' · ' + t('l2.updated', 'Updated') + ' ' + snap.generated_iso) : '';
  1689. el('l2-stats').textContent = txt + up;
  1690. }
  1691. function buildLegend() {
  1692. var h = '';
  1693. function sw(c, label, dashed) { return '<span class="lg-item"><span class="lg-line' + (dashed ? ' lg-dash' : '') + '" style="border-top-color:' + c + '"></span>' + label + '</span>'; }
  1694. h += '<div class="lg-title">' + t('l2.legend_speed', 'Link speed') + '</div>';
  1695. h += '<div class="lg-grid">' + sw(colors['100'], '100M') + sw(colors['1000'], '1G') +
  1696. sw(colors['2500'], '2.5G') + sw(colors['10000'], '10G') + sw(colors['20000'], '20G+') +
  1697. sw(colors.unknown, t('lbl.color_unknown', 'Unknown')) + sw(colors.down, t('lbl.color_down', 'Down'), true) + '</div>';
  1698. h += '<div class="lg-title">' + t('l2.legend_tier', 'Tier') + '</div>';
  1699. h += '<div class="lg-row"><span class="lg-box" style="background:' + TIER_BG.core + '"></span>' + t('l2.core', 'Core') +
  1700. '<span class="lg-box" style="background:#2E75B6;margin-left:8px"></span>' + t('l2.main', 'Main switch') +
  1701. '<span class="lg-box" style="background:' + TIER_BG.dist + ';margin-left:8px"></span>' + t('l2.dist', 'Distribution') +
  1702. '<span class="lg-box" style="background:' + TIER_BG.access + ';margin-left:8px"></span>' + t('l2.access', 'Access') + '</div>';
  1703. h += '<div class="lg-title">' + t('l2.legend_lat', 'Latency') + '</div>';
  1704. // Latency + alarm state share one compact row. This keeps the legend
  1705. // short enough to leave more room for the real topology on small screens.
  1706. h += '<div class="lg-row lg-compact">' +
  1707. '<span class="lg-box" style="background:transparent;border:2px solid #F39C12;border-radius:2px"></span>&gt;' + LATW + 'ms' +
  1708. '<span class="lg-box" style="background:transparent;border:2px solid #E74C3C;border-radius:2px"></span>&gt;' + LATB + 'ms' +
  1709. '<span class="lg-sep"></span>' +
  1710. '<span class="lg-box" style="background:#E45959;border-radius:2px"></span>' + t('l2.alarms', 'Alarms') +
  1711. '<span class="lg-box" style="background:#2ECC71;border-radius:50%"></span>' + t('l2.term_on', 'Online') +
  1712. '<span class="lg-box" style="background:#C0392B;border-radius:2px"></span>' + t('l2.offline', 'Offline switch') +
  1713. '</div>';
  1714. el('l2-legend').innerHTML = h;
  1715. }
  1716. function buildAlarms(snap) {
  1717. var items = [];
  1718. (snap.nodes || []).concat(snap.upstream || []).forEach(function (n) {
  1719. if (n.maxsev != null && n.maxsev >= 0 && n.alarms && n.alarms.length) {
  1720. n.alarms.forEach(function (a) {
  1721. items.push({sev: n.maxsev, title: (a.name || a.description || ''), host: n.name, ip: n.ip, id: n.id, hostid: n.hostid});
  1722. });
  1723. }
  1724. });
  1725. el('l2-alarm-count').textContent = items.length;
  1726. if (!items.length) { el('l2-alarm-list').innerHTML = '<div class="panel-empty">' + t('l2.alarm_empty', 'No active alarms.') + '</div>'; return; }
  1727. items.sort(function (a, b) { return b.sev - a.sev; });
  1728. var h = '';
  1729. items.forEach(function (it) {
  1730. var col = SEV_COLORS[Math.max(0, Math.min(5, it.sev))] || '#999';
  1731. h += '<div class="alarm-row" data-id="' + (it.id || '') + '" data-hostid="' + (it.hostid || '') + '">' +
  1732. '<span class="alarm-dot" style="background:' + col + '"></span>' +
  1733. '<div class="alarm-main"><div class="alarm-title">' + esc(it.title) + '</div>' +
  1734. '<div class="alarm-sub">' + esc(it.host || '') + (it.ip ? ' · ' + esc(it.ip) : '') + '</div></div></div>';
  1735. });
  1736. var box = el('l2-alarm-list');
  1737. box.innerHTML = h;
  1738. box.querySelectorAll('.alarm-row').forEach(function (r) {
  1739. r.addEventListener('click', function () {
  1740. var id = r.getAttribute('data-id');
  1741. if (id) revealById(id);
  1742. else { var hid = r.getAttribute('data-hostid'); if (hid) window.open(META.zabbixHostUrl.replace('__HOSTID__', hid), '_blank'); }
  1743. });
  1744. });
  1745. }
  1746. function fmtTs(ts) {
  1747. if (!ts) return '—';
  1748. try {
  1749. var d = new Date(Number(ts) * 1000);
  1750. var p = function (x) { return (x < 10 ? '0' : '') + x; };
  1751. return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
  1752. } catch (e) { return '—'; }
  1753. }
  1754. function buildOffline(snap) {
  1755. var off = (snap.nodes || []).filter(function (n) { return n.offline || n.oper === 'down'; });
  1756. el('l2-offline-count').textContent = off.length;
  1757. if (!off.length) { el('l2-offline-list').innerHTML = '<div class="panel-empty">' + t('l2.offline_empty', 'No offline switches.') + '</div>'; return; }
  1758. off.sort(function (a, b) {
  1759. var ra = roleOf(a), rb = roleOf(b);
  1760. if (ra !== rb) return ra === 'dist' ? -1 : 1;
  1761. return String(a.name || '').localeCompare(String(b.name || ''));
  1762. });
  1763. var h = '';
  1764. off.forEach(function (n) {
  1765. var role = roleOf(n);
  1766. var roleTxt = role === 'dist' ? t('l2.dist', 'Distribution') : t('l2.access', 'Access');
  1767. h += '<div class="off-row sw-row" data-id="' + esc(n.id) + '">' +
  1768. '<span class="term-dot" style="background:#C0392B"></span>' +
  1769. '<div class="off-main"><div class="off-mac">' + esc(poePrefix(n) + (n.name || n.ip)) + '</div>' +
  1770. '<div class="off-sub">' + esc(roleTxt) + ' · ' + esc(n.ip || '') +
  1771. ' · ' + t('l2.last_online', 'last online') + ': ' + esc(fmtTs(n.last_online)) + '</div></div></div>';
  1772. });
  1773. var box = el('l2-offline-list');
  1774. box.innerHTML = h;
  1775. box.querySelectorAll('.off-row').forEach(function (r) {
  1776. r.addEventListener('click', function () {
  1777. var id = r.getAttribute('data-id');
  1778. if (id) revealById(id);
  1779. });
  1780. });
  1781. }
  1782. function buildTerminals(snap) {
  1783. var list = (snap.terminals || []).filter(function (tm) { return terminalOnline(tm); });
  1784. if (el('l2-term-count')) el('l2-term-count').textContent = list.length;
  1785. var box = el('l2-term-list');
  1786. if (!box) return;
  1787. if (!showTerm) {
  1788. box.innerHTML = '<div class="panel-empty">' + t('l2.show_term', 'Show terminals') + '</div>';
  1789. return;
  1790. }
  1791. if (!list.length) {
  1792. box.innerHTML = '<div class="panel-empty">' + t('l2.no_data', 'No data') + '</div>';
  1793. return;
  1794. }
  1795. list.sort(function (a, b) {
  1796. var x = String(a.sw_name || a.sw_ip || '').localeCompare(String(b.sw_name || b.sw_ip || ''));
  1797. return x || String(a.mac).localeCompare(String(b.mac));
  1798. });
  1799. var h = '';
  1800. list.forEach(function (tm) {
  1801. h += '<div class="off-row term-row" data-mac="' + esc(tm.mac) + '">' +
  1802. '<span class="term-dot" style="background:#2ECC71"></span>' +
  1803. '<div class="off-main"><div class="off-mac">' + esc(tm.mac) + '</div>' +
  1804. '<div class="off-sub"><span class="term-rate">' + esc(speedText(tm.speed)) + '</span> · ' +
  1805. esc(tm.sw_name || tm.sw_ip || '') + ' · ' + esc(tm.port || '') + '</div></div></div>';
  1806. });
  1807. box.innerHTML = h;
  1808. box.querySelectorAll('.term-row').forEach(function (r) {
  1809. r.addEventListener('click', function () { revealById('t_' + r.getAttribute('data-mac')); });
  1810. });
  1811. }
  1812. function buildHistory() {
  1813. var picker = el('l2-ts-picker');
  1814. if (!picker || picker.getAttribute('data-built') === '1') return;
  1815. picker.setAttribute('data-built', '1');
  1816. var trigger = el('l2-ts-trigger');
  1817. var pop = el('l2-ts-pop');
  1818. var daysEl = el('l2-ts-days');
  1819. var timesEl = el('l2-ts-times');
  1820. var timesTitle = el('l2-ts-times-title');
  1821. var label = el('l2-ts-current');
  1822. var list = window.L2_HISTORY || [];
  1823. var meta = window.L2_META || {};
  1824. var used = (!meta.isLive && meta.usedTs) ? String(meta.usedTs) : '';
  1825. var groups = {}, days = [];
  1826. // Group only timestamps that actually exist in l2_topology_history.
  1827. // The popover therefore never offers an arbitrary date/time and cannot
  1828. // lead to a blank page.
  1829. list.forEach(function (it) {
  1830. var day = String(it.captured_at || fmtTs(it.ts)).slice(0, 10);
  1831. if (!groups[day]) { groups[day] = []; days.push(day); }
  1832. groups[day].push(it);
  1833. });
  1834. var currentDay = '';
  1835. var currentTs = '';
  1836. if (used) {
  1837. list.forEach(function (it) {
  1838. if (String(it.ts) === used) {
  1839. currentTs = used;
  1840. currentDay = String(it.captured_at || fmtTs(it.ts)).slice(0, 10);
  1841. }
  1842. });
  1843. }
  1844. function timeText(it) {
  1845. var ca = String(it.captured_at || '');
  1846. return ca.length >= 19 ? ca.slice(11, 19) : fmtTs(it.ts);
  1847. }
  1848. function go(ts) {
  1849. var u = new URL(window.location.href);
  1850. u.searchParams.set('action', 'l2.topology.view');
  1851. if (ts) u.searchParams.set('ts', ts);
  1852. else u.searchParams.delete('ts');
  1853. window.location.href = u.toString();
  1854. }
  1855. function positionPop() {
  1856. if (!pop) return;
  1857. if (window.matchMedia('(max-width: 720px)').matches) {
  1858. var rect = trigger.getBoundingClientRect();
  1859. var width = Math.min(430, window.innerWidth - 20);
  1860. var left = Math.max(10, Math.min(rect.left, window.innerWidth - 10 - width));
  1861. pop.style.position = 'fixed';
  1862. pop.style.left = left + 'px';
  1863. pop.style.right = 'auto';
  1864. pop.style.top = Math.max(10, rect.bottom + 6) + 'px';
  1865. pop.style.width = width + 'px';
  1866. }
  1867. else {
  1868. pop.style.position = '';
  1869. pop.style.left = '';
  1870. pop.style.right = '';
  1871. pop.style.top = '';
  1872. pop.style.width = '';
  1873. }
  1874. }
  1875. function setExpanded(open) {
  1876. picker.classList.toggle('open', open);
  1877. trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
  1878. if (open) positionPop();
  1879. }
  1880. function updateLabel() {
  1881. if (!currentDay) { label.textContent = t('l2.time_live_full', '实时(最新)'); return; }
  1882. if (!currentTs) { label.textContent = currentDay + ' · ' + t('l2.time_choose', '选择时间'); return; }
  1883. var found = null;
  1884. (groups[currentDay] || []).forEach(function (it) { if (String(it.ts) === currentTs) found = it; });
  1885. label.textContent = found ? currentDay + ' ' + timeText(found) : currentDay;
  1886. }
  1887. function renderDays() {
  1888. daysEl.innerHTML = '';
  1889. var live = document.createElement('button');
  1890. live.type = 'button';
  1891. live.className = 'l2-ts-day' + (currentDay ? '' : ' active');
  1892. live.textContent = t('l2.time_live_full', '实时(最新)');
  1893. live.addEventListener('click', function () { go(''); });
  1894. daysEl.appendChild(live);
  1895. days.forEach(function (day) {
  1896. var b = document.createElement('button');
  1897. b.type = 'button';
  1898. b.className = 'l2-ts-day' + (day === currentDay ? ' active' : '');
  1899. b.textContent = day + ' (' + groups[day].length + ')';
  1900. b.addEventListener('click', function (ev) {
  1901. ev.stopPropagation();
  1902. currentDay = day;
  1903. currentTs = '';
  1904. renderDays();
  1905. renderTimes();
  1906. updateLabel();
  1907. });
  1908. daysEl.appendChild(b);
  1909. });
  1910. }
  1911. function renderTimes() {
  1912. timesEl.innerHTML = '';
  1913. if (!currentDay) {
  1914. timesTitle.textContent = t('l2.time_live', '实时');
  1915. var empty = document.createElement('div');
  1916. empty.className = 'l2-ts-empty';
  1917. empty.textContent = t('l2.time_live_hint', '当前显示最新采集数据');
  1918. timesEl.appendChild(empty);
  1919. return;
  1920. }
  1921. timesTitle.textContent = currentDay;
  1922. (groups[currentDay] || []).forEach(function (it) {
  1923. var b = document.createElement('button');
  1924. b.type = 'button';
  1925. b.className = 'l2-ts-time' + (String(it.ts) === currentTs ? ' active' : '');
  1926. b.textContent = timeText(it);
  1927. b.addEventListener('click', function () { go(String(it.ts)); });
  1928. timesEl.appendChild(b);
  1929. });
  1930. }
  1931. trigger.addEventListener('click', function (ev) {
  1932. ev.stopPropagation();
  1933. setExpanded(!picker.classList.contains('open'));
  1934. });
  1935. window.addEventListener('resize', function () {
  1936. if (picker.classList.contains('open')) positionPop();
  1937. });
  1938. window.addEventListener('scroll', function () {
  1939. if (picker.classList.contains('open')) positionPop();
  1940. }, {passive: true});
  1941. document.addEventListener('click', function (ev) {
  1942. if (!picker.contains(ev.target)) setExpanded(false);
  1943. });
  1944. document.addEventListener('keydown', function (ev) {
  1945. if (ev.key === 'Escape') setExpanded(false);
  1946. });
  1947. renderDays();
  1948. renderTimes();
  1949. updateLabel();
  1950. }
  1951. function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]; }); }
  1952. /* ---------------- interactions ---------------- */
  1953. function focusNode(id) {
  1954. if (!cy) return;
  1955. var n = cy.getElementById(id);
  1956. if (!n || n.empty() || n.length === 0) return;
  1957. cy.elements().removeClass('hl');
  1958. n.addClass('hl');
  1959. cy.animate({center: {eles: n}, zoom: Math.max(cy.zoom(), 1.1), duration: 380});
  1960. setTimeout(function () { n.removeClass('hl'); }, 2600);
  1961. }
  1962. // cy.png() only contains the Cytoscape canvas, not the HTML legend. Append
  1963. // a dedicated legend strip to the exported PNG (same symbols as the page,
  1964. // alarm block on the latency row, and no hover hint).
  1965. function withExportLegend(dataUrl) {
  1966. return new Promise(function (resolve, reject) {
  1967. var img = new Image();
  1968. img.onload = function () {
  1969. try {
  1970. var s = Math.max(1.0, Math.min(1.6, img.width / 1300));
  1971. var pad = Math.round(16 * s);
  1972. var legendH = Math.round(154 * s);
  1973. var cv = document.createElement('canvas');
  1974. cv.width = img.width;
  1975. cv.height = img.height + legendH;
  1976. var ctx = cv.getContext('2d');
  1977. ctx.fillStyle = '#ffffff';
  1978. ctx.fillRect(0, 0, cv.width, cv.height);
  1979. ctx.drawImage(img, 0, 0);
  1980. var y0 = img.height;
  1981. ctx.strokeStyle = '#cbd5e1';
  1982. ctx.lineWidth = Math.max(1, Math.round(s));
  1983. ctx.beginPath(); ctx.moveTo(0, y0); ctx.lineTo(cv.width, y0); ctx.stroke();
  1984. ctx.textBaseline = 'middle';
  1985. var family = '"Microsoft YaHei","PingFang SC",Arial,sans-serif';
  1986. function title(text, y) {
  1987. ctx.fillStyle = '#1f2937';
  1988. ctx.font = '600 ' + Math.round(12.5 * s) + 'px ' + family;
  1989. ctx.fillText(text, pad, y);
  1990. }
  1991. function lineItem(x, y, color, text, dashed) {
  1992. ctx.strokeStyle = color;
  1993. ctx.lineWidth = Math.max(2, Math.round(3 * s));
  1994. ctx.setLineDash(dashed ? [Math.round(5 * s), Math.round(3 * s)] : []);
  1995. ctx.beginPath();
  1996. ctx.moveTo(x, y); ctx.lineTo(x + Math.round(24 * s), y); ctx.stroke();
  1997. ctx.setLineDash([]);
  1998. ctx.fillStyle = '#263445';
  1999. ctx.font = Math.round(11.5 * s) + 'px ' + family;
  2000. ctx.fillText(text, x + Math.round(30 * s), y + 1);
  2001. return x + Math.round(30 * s) + ctx.measureText(text).width + Math.round(18 * s);
  2002. }
  2003. function boxItem(x, y, color, text, circle, outline) {
  2004. var bw = Math.round(12 * s), by = y - bw / 2;
  2005. ctx.fillStyle = outline ? 'transparent' : color;
  2006. ctx.strokeStyle = color;
  2007. ctx.lineWidth = Math.max(2, Math.round(2 * s));
  2008. ctx.beginPath();
  2009. if (circle) ctx.arc(x + bw / 2, y, bw / 2, 0, Math.PI * 2);
  2010. else ctx.rect(x, by, bw, bw);
  2011. if (outline) ctx.stroke(); else ctx.fill();
  2012. ctx.fillStyle = '#263445';
  2013. ctx.font = Math.round(11.5 * s) + 'px ' + family;
  2014. ctx.fillText(text, x + bw + Math.round(5 * s), y + 1);
  2015. return x + bw + Math.round(5 * s) + ctx.measureText(text).width + Math.round(16 * s);
  2016. }
  2017. var y = y0 + Math.round(18 * s);
  2018. title(t('l2.legend_speed', '链路速率'), y);
  2019. y += Math.round(22 * s);
  2020. var x = pad;
  2021. x = lineItem(x, y, colors['100'], '100M');
  2022. x = lineItem(x, y, colors['1000'], '1G');
  2023. x = lineItem(x, y, colors['2500'], '2.5G');
  2024. x = lineItem(x, y, colors['10000'], '10G');
  2025. x = lineItem(x, y, colors['20000'], '20G+');
  2026. x = lineItem(x, y, colors.unknown, t('lbl.color_unknown', 'Unknown'));
  2027. lineItem(x, y, colors.down, t('lbl.color_down', 'Down'), true);
  2028. y += Math.round(24 * s);
  2029. title(t('l2.legend_tier', '层级'), y);
  2030. y += Math.round(22 * s);
  2031. x = pad;
  2032. x = boxItem(x, y, TIER_BG.core, t('l2.core', '核心'));
  2033. x = boxItem(x, y, '#2E75B6', t('l2.main', '主交换'));
  2034. x = boxItem(x, y, TIER_BG.dist, t('l2.dist', '汇聚'));
  2035. boxItem(x, y, TIER_BG.access, t('l2.access', '接入'));
  2036. y += Math.round(24 * s);
  2037. title(t('l2.legend_lat', '延迟'), y);
  2038. y += Math.round(22 * s);
  2039. x = pad;
  2040. x = boxItem(x, y, '#F39C12', '>' + LATW + 'ms', false, true);
  2041. x = boxItem(x, y, '#E74C3C', '>' + LATB + 'ms', false, true);
  2042. x += Math.round(16 * s);
  2043. x = boxItem(x, y, '#E45959', t('l2.alarms', '告警'));
  2044. x = boxItem(x, y, '#2ECC71', t('l2.term_on', '在线'), true);
  2045. boxItem(x, y, '#C0392B', t('l2.offline', '离线交换机'));
  2046. resolve(cv.toDataURL('image/png'));
  2047. } catch (e) { reject(e); }
  2048. };
  2049. img.onerror = function () { reject(new Error('legend image load failed')); };
  2050. img.src = dataUrl;
  2051. });
  2052. }
  2053. async function exportPng() {
  2054. if (!cy) return;
  2055. var btn = el('l2-export'), oldText = btn ? btn.textContent : '';
  2056. if (btn) { btn.disabled = true; btn.textContent = t('l2.exporting', '正在导出…'); }
  2057. var wasDark = dark;
  2058. var flowEdges = cy.edges('[type="flow"]');
  2059. try {
  2060. // The exported file must always be the day palette, even when the
  2061. // operator is currently using Zabbix dark mode. The moving flow
  2062. // overlay is transient screen decoration, so keep the exported static
  2063. // diagram on solid speed-coloured links.
  2064. cy.elements().removeClass('hl dim pathhl');
  2065. flowEdges.style('display', 'none');
  2066. cy.style(styleFor(false));
  2067. if (typeof cy.forceRender === 'function') cy.forceRender();
  2068. var raw = cy.png({full: true, scale: 1, maxWidth: 2600, maxHeight: 18000, bg: '#ffffff'});
  2069. var data = await withExportLegend(raw);
  2070. var now = new Date();
  2071. function p2(v) { return (v < 10 ? '0' : '') + v; }
  2072. var suffix = (!META.isLive && META.usedTs) ? ('_' + META.usedTs) : '_live';
  2073. var name = 'L2_Topology_' + now.getFullYear() + p2(now.getMonth() + 1) + p2(now.getDate()) +
  2074. '_' + p2(now.getHours()) + p2(now.getMinutes()) + suffix + '.png';
  2075. var a = document.createElement('a');
  2076. a.href = data;
  2077. a.download = name;
  2078. document.body.appendChild(a);
  2079. a.click();
  2080. document.body.removeChild(a);
  2081. } catch (e) {
  2082. window.alert(t('l2.export_fail', '导出失败,请重试。'));
  2083. } finally {
  2084. flowEdges.style('display', 'element');
  2085. cy.style(styleFor(wasDark));
  2086. if (typeof cy.forceRender === 'function') cy.forceRender();
  2087. if (btn) { btn.disabled = false; btn.textContent = oldText; }
  2088. }
  2089. }
  2090. function groupOfNodeId(id) {
  2091. var hit = (lastSnap && lastSnap.nodes || []).filter(function (n) { return n.id === id; })[0];
  2092. return hit ? groupOf(hit).label : null;
  2093. }
  2094. // Make sure a switch/terminal is visible (auto-opens its workshop from overview).
  2095. function revealById(id) {
  2096. if (!lastSnap) return;
  2097. id = String(id);
  2098. if (id.indexOf('t_') === 0) {
  2099. var tm = (lastSnap.terminals || []).filter(function (x) { return 't_' + x.mac === id; })[0];
  2100. if (!tm) return;
  2101. pinnedTerm = tm.mac;
  2102. showTerm = true;
  2103. if (el('l2-showterm')) el('l2-showterm').checked = true;
  2104. var targetId = termPortByMac[tm.mac] || id;
  2105. if (!cy || cy.getElementById(targetId).empty()) render(lastSnap);
  2106. targetId = termPortByMac[tm.mac] || id;
  2107. setTimeout(function () {
  2108. // Center the switch that the terminal hangs under, then flash the
  2109. // exact physical port circle so the port location is obvious.
  2110. focusNode(tm.sw_ip || id);
  2111. var tn = cy && cy.getElementById(targetId);
  2112. if (tn && tn.length) {
  2113. tn.addClass('hl');
  2114. setTimeout(function () { tn.removeClass('hl'); }, 2600);
  2115. }
  2116. }, 90);
  2117. } else {
  2118. focusNode(id);
  2119. }
  2120. }
  2121. function clearPath() {
  2122. if (!cy) return;
  2123. cy.elements().removeClass('dim pathhl');
  2124. lockedPath = null;
  2125. }
  2126. function upstreamGraph(nodeId) {
  2127. var snap = lastSnap || {};
  2128. var nodes = snap.nodes || [], edges = snap.edges || [];
  2129. var byId = {}, adj = {};
  2130. nodes.forEach(function (n) { byId[n.id] = n; adj[n.id] = []; });
  2131. edges.forEach(function (e) {
  2132. if (adj[e.source] && adj[e.target]) { adj[e.source].push(e.target); adj[e.target].push(e.source); }
  2133. });
  2134. var coreIds = nodes.filter(function (n) {
  2135. return (lastRoleMap[n.id] || roleOf(n)) === 'core';
  2136. }).map(function (n) { return n.id; });
  2137. // If the configured core list is empty, fall back to the collector's root
  2138. // tier so the upstream chain still starts at the physical core.
  2139. if (!coreIds.length) {
  2140. nodes.forEach(function (n) {
  2141. if (String(n.tier || '').toLowerCase() === 'core' || /^core(\s|-)/i.test(String(n.name || ''))) coreIds.push(n.id);
  2142. });
  2143. }
  2144. var level = {}, queue = [];
  2145. coreIds.forEach(function (id) { level[id] = 0; queue.push(id); });
  2146. while (queue.length) {
  2147. var cur = queue.shift();
  2148. (adj[cur] || []).forEach(function (nb) {
  2149. if (level[nb] == null) { level[nb] = level[cur] + 1; queue.push(nb); }
  2150. });
  2151. }
  2152. var nodeSet = {}, edgeSet = {}, stack = [nodeId];
  2153. nodeSet[nodeId] = true;
  2154. while (stack.length) {
  2155. var cur = stack.pop();
  2156. var curLv = level[cur];
  2157. if (curLv == null || curLv <= 0) continue;
  2158. (adj[cur] || []).forEach(function (nb) {
  2159. if (level[nb] !== curLv - 1) return;
  2160. // Cytoscape edges are anchored to hidden edgepoint nodes; the real
  2161. // switch pair is stored in p/c, so identify both orientations.
  2162. edgeSet[cur + '|' + nb] = true;
  2163. edgeSet[nb + '|' + cur] = true;
  2164. if (!nodeSet[nb]) { nodeSet[nb] = true; stack.push(nb); }
  2165. });
  2166. }
  2167. return {nodes: nodeSet, edges: edgeSet};
  2168. }
  2169. function highlightPath(nodeId) {
  2170. if (!cy) return;
  2171. var up = upstreamGraph(nodeId);
  2172. cy.elements('node[type="switch"], node[type="ghost"], edge').addClass('dim').removeClass('pathhl');
  2173. var nodes = cy.collection();
  2174. Object.keys(up.nodes).forEach(function (id) {
  2175. var n = cy.getElementById(id);
  2176. if (n && n.length) nodes = nodes.union(n);
  2177. });
  2178. nodes.removeClass('dim').addClass('pathhl');
  2179. var edges = cy.edges().filter(function (e) {
  2180. if (e.data('type') === 'flow') return false;
  2181. var p = e.data('p'), c = e.data('c');
  2182. return p && c && (up.edges[p + '|' + c] || up.edges[c + '|' + p]);
  2183. });
  2184. edges.removeClass('dim').addClass('pathhl');
  2185. }
  2186. function bindCy() {
  2187. cy.on('mouseover', 'node[type="switch"]', function (e) {
  2188. highlightPath(e.target.id());
  2189. var d = e.target.data();
  2190. var arr = d.alarms || [];
  2191. var sev = (d.maxsev != null && d.maxsev >= 0) ? ('<span style="color:' + SEV_COLORS[d.maxsev] + '">● ' + SEV_NAMES[d.maxsev] + '</span>') : '—';
  2192. var lat = (d.latency_ms != null) ? (d.latency_ms + ' ms') : '—';
  2193. var stat = (d.offline || d.oper === 'down') ? ('<span style="color:#E45959">' + esc(t('l2.offline', 'Offline')) + '</span>') : esc(d.oper || 'up');
  2194. var html = '<b>' + esc(poePrefix({poe: d.poe}) + d.name) + '</b><br>' + esc(d.ip) + ' · ' + esc(d.group || '') + '<br>' +
  2195. 'Tier: ' + esc(d.tier) + ' · ' + stat + '<br>' +
  2196. 'Latency: ' + lat + '<br>' +
  2197. 'Terminals: ' + (d.term_online || 0) + '<br>' +
  2198. 'Alarm: ' + sev;
  2199. if (d.offline || d.oper === 'down') html += '<br>' + esc(t('l2.last_online', 'last online')) + ': ' + esc(fmtTs(d.last_online));
  2200. if (arr.length) html += '<hr>' + arr.map(function (a) { return esc(a.name || a.description || ''); }).join('<br>');
  2201. showTip(e, html);
  2202. });
  2203. cy.on('mouseout', 'node[type="switch"]', function () {
  2204. if (!lockedPath) clearPath();
  2205. hideTip();
  2206. });
  2207. cy.on('mouseover', 'edge:not([type="term"])', function (e) {
  2208. var d = e.target.data();
  2209. var sp = (parseFloat(d.speed) || 0) >= 1000 ? ((parseFloat(d.speed) / 1000) + 'G') : (d.speed + 'M');
  2210. showTip(e, 'Link ' + esc(d.a_port || '') + ' ↔ ' + esc(d.b_port || '') + '<br>' + sp + ' · ' + esc(d.state || ''));
  2211. });
  2212. cy.on('mouseout', 'edge', hideTip);
  2213. cy.on('mouseover', 'node[type="term"]', function (e) {
  2214. var d = e.target.data();
  2215. e.target.data('label', d.termText || '');
  2216. showTip(e, '<b>' + esc(t('l2.term_title', 'Terminal')) + ' ' + esc(d.mac || '') + '</b><br>' +
  2217. esc(t('l2.term_mac', 'MAC address')) + ': ' + esc(d.mac || '') + '<br>' +
  2218. esc(t('l2.term_ip', 'IP address')) + ': ' + esc(d.ip || '—') + '<br>' +
  2219. esc(t('l2.term_rate', 'Link rate')) + ': ' + esc(speedText(d.speed)) + '<br>' +
  2220. esc(t('l2.term_switch', 'Switch')) + ': ' + esc(d.sw_name || '—') + '<br>' +
  2221. esc(t('l2.term_port', 'Port')) + ': ' + esc(d.port || '—'));
  2222. });
  2223. cy.on('mouseout', 'node[type="term"]', function (e) {
  2224. e.target.data('label', '');
  2225. hideTip();
  2226. });
  2227. cy.on('mouseover', 'node[type="port"]', function (e) {
  2228. var d = e.target.data();
  2229. var html = '<b>' + esc(d.name || '') + '</b><br>' +
  2230. esc(t('l2.term_switch', 'Switch')) + ': ' + esc(d.sw_name || d.sw_ip || '') + '<br>' +
  2231. esc(t('l2.term_rate', 'Link rate')) + ': ' + (d.filled ? esc(speedText(d.speed)) : '—') + '<br>' +
  2232. 'Port max: ' + esc(speedText(d.max));
  2233. if (d.term) {
  2234. html += '<hr><b>' + esc(t('l2.term_title', 'Terminal')) + '</b><br>' +
  2235. esc(t('l2.term_mac', 'MAC address')) + ': ' + esc(d.term.mac || '') + '<br>' +
  2236. esc(t('l2.term_ip', 'IP address')) + ': ' + esc(d.term.ip || '—');
  2237. }
  2238. showTip(e, html);
  2239. });
  2240. cy.on('mouseout', 'node[type="port"]', hideTip);
  2241. cy.on('tap', 'node[type="switch"]', function (e) {
  2242. lockedPath = e.target.id();
  2243. highlightPath(lockedPath);
  2244. });
  2245. cy.on('tap', 'node[type="wsframe"]', function (e) {
  2246. cy.fit(e.target, 60);
  2247. });
  2248. cy.on('tap', function (e) {
  2249. if (e.target === cy) clearPath();
  2250. });
  2251. cy.on('dbltap', 'node[type="switch"]', function (e) {
  2252. var hid = e.target.data('hostid');
  2253. if (hid) window.open(META.zabbixHostUrl.replace('__HOSTID__', hid), '_blank');
  2254. });
  2255. }
  2256. var SEV_NAMES = ['未分类','信息','警告','一般严重','严重','灾难'];
  2257. function showTip(e, html) {
  2258. var tip = el('l2-tip');
  2259. tip.innerHTML = html;
  2260. tip.style.display = 'block';
  2261. var p = e.renderedPosition || {x: 0, y: 0};
  2262. var box = el('l2-cy').getBoundingClientRect();
  2263. var x = box.left + p.x + 16, y = box.top + p.y + 16;
  2264. tip.style.left = x + 'px'; tip.style.top = y + 'px';
  2265. }
  2266. function hideTip() { el('l2-tip').style.display = 'none'; }
  2267. /* ---------------- search ---------------- */
  2268. function buildIndex(snap) {
  2269. var idx = [];
  2270. (snap.nodes || []).forEach(function (n) { idx.push({id: n.id, label: poePrefix(n) + n.name, sub: n.ip, kind: 'sw'}); });
  2271. (snap.terminals || []).forEach(function (tm) {
  2272. if (!terminalOnline(tm)) return;
  2273. idx.push({id: 't_' + tm.mac, label: tm.mac, sub: (tm.ip || '') + ' · ' + speedText(tm.speed) + ' · ' + (tm.sw_name || ''), kind: 'term', mac: tm.mac});
  2274. });
  2275. return idx;
  2276. }
  2277. var INDEX = [];
  2278. function doSearch(q) {
  2279. var box = el('l2-sug');
  2280. q = (q || '').trim().toLowerCase();
  2281. if (!q) { box.style.display = 'none'; box.innerHTML = ''; return; }
  2282. var hits = [];
  2283. var qn = q.replace(/[^a-z0-9]/g, '');
  2284. for (var i = 0; i < INDEX.length && hits.length < 14; i++) {
  2285. var it = INDEX[i];
  2286. var hay = ((it.label || '') + ' ' + (it.sub || '')).toLowerCase();
  2287. var hayN = hay.replace(/[^a-z0-9]/g, '');
  2288. if (hay.indexOf(q) >= 0 || (qn && hayN.indexOf(qn) >= 0)) hits.push(it);
  2289. }
  2290. if (!hits.length) { box.innerHTML = '<div class="sug-empty">—</div>'; box.style.display = 'block'; return; }
  2291. box.innerHTML = hits.map(function (h) {
  2292. return '<div class="sug-row" data-id="' + esc(h.id) + '"><span class="sug-kind ' + h.kind + '">' + (h.kind === 'sw' ? 'SW' : 'PC') + '</span>' +
  2293. '<span class="sug-lbl">' + esc(h.label) + '</span><span class="sug-sub">' + esc(h.sub || '') + '</span></div>';
  2294. }).join('');
  2295. box.style.display = 'block';
  2296. box.querySelectorAll('.sug-row').forEach(function (r) {
  2297. r.addEventListener('click', function () {
  2298. var id = r.getAttribute('data-id');
  2299. revealById(id);
  2300. box.style.display = 'none';
  2301. });
  2302. });
  2303. }
  2304. /* ---------------- toolbar wiring ---------------- */
  2305. function isZabbixDark() {
  2306. var html = document.documentElement;
  2307. var cs = String(html.getAttribute('color-scheme') || '').toLowerCase();
  2308. if (cs === 'dark') return true;
  2309. if (cs === 'light') return false;
  2310. var body = document.body;
  2311. var theme = [
  2312. html.getAttribute('theme'), html.getAttribute('data-theme'), html.className,
  2313. body ? body.getAttribute('theme') : '', body ? body.getAttribute('data-theme') : '',
  2314. body ? body.className : ''
  2315. ].join(' ');
  2316. return /dark/i.test(theme);
  2317. }
  2318. function applyTheme() {
  2319. var nd = isZabbixDark();
  2320. var changed = nd !== dark;
  2321. dark = nd;
  2322. var wrap = el('l2-wrap');
  2323. if (wrap) wrap.classList.toggle('l2-dark', dark);
  2324. if (changed && cy) cy.style(styleFor(dark));
  2325. }
  2326. function fitViewport() {
  2327. var wrap = el('l2-wrap');
  2328. if (!wrap) return;
  2329. var top = Math.max(0, wrap.getBoundingClientRect().top);
  2330. var h = Math.max(520, window.innerHeight - top - 8);
  2331. wrap.style.height = h + 'px';
  2332. if (cy) cy.resize();
  2333. }
  2334. function setupControls() {
  2335. applyTheme();
  2336. if (window.MutationObserver) {
  2337. var mo = new MutationObserver(applyTheme);
  2338. mo.observe(document.documentElement, {attributes: true, attributeFilter: ['theme', 'data-theme', 'color-scheme', 'class']});
  2339. if (document.body) {
  2340. mo.observe(document.body, {attributes: true, attributeFilter: ['theme', 'data-theme', 'color-scheme', 'class']});
  2341. }
  2342. }
  2343. var back = el('l2-back');
  2344. if (back) back.style.display = 'none';
  2345. el('l2-showterm').addEventListener('change', function () { showTerm = el('l2-showterm').checked; render(lastSnap); });
  2346. el('l2-zin').addEventListener('click', function () { if (cy) cy.zoom({level: cy.zoom() * 1.25, renderedPosition: {x: cy.width() / 2, y: cy.height() / 2}}); });
  2347. el('l2-zout').addEventListener('click', function () { if (cy) cy.zoom({level: cy.zoom() / 1.25, renderedPosition: {x: cy.width() / 2, y: cy.height() / 2}}); });
  2348. el('l2-fit').addEventListener('click', function () { if (cy) cy.fit(cy.elements(), 50); });
  2349. if (el('l2-export')) el('l2-export').addEventListener('click', exportPng);
  2350. el('l2-refresh-btn').addEventListener('click', function () { reload(true); });
  2351. window.addEventListener('resize', fitViewport);
  2352. var s = el('l2-search');
  2353. s.addEventListener('input', function () { doSearch(s.value); });
  2354. document.addEventListener('click', function (ev) {
  2355. if (!ev.target.closest('.l2-search-wrap')) el('l2-sug').style.display = 'none';
  2356. });
  2357. el('l2-pause').addEventListener('click', function () {
  2358. paused = !paused;
  2359. el('l2-pause').classList.toggle('on', paused);
  2360. el('l2-pause').textContent = paused ? '▶' : '❚❚';
  2361. el('l2-pause').title = paused ? t('l2.resume', 'Resume') : t('l2.pause', 'Pause');
  2362. });
  2363. }
  2364. function reload(manual) {
  2365. if (!META.isLive) return;
  2366. fetch(META.snapshotUrl + '?t=' + Date.now(), {cache: 'no-store'})
  2367. .then(function (r) { return r.json(); })
  2368. .then(function (j) { if (j && j.nodes) { SNAP = j; INDEX = buildIndex(j); render(j); } })
  2369. .catch(function () {})
  2370. .then(function () { if (manual) resetCountdown(); });
  2371. }
  2372. function resetCountdown() { countdown = META.refreshSeconds || 300; }
  2373. function startTimer() {
  2374. if (!META.isLive) return;
  2375. resetCountdown();
  2376. timer = setInterval(function () {
  2377. if (paused) { el('l2-countdown').textContent = '⏸'; return; }
  2378. countdown--;
  2379. if (countdown <= 0) { countdown = META.refreshSeconds || 300; reload(false); }
  2380. var m = Math.floor(countdown / 60), s = countdown % 60;
  2381. el('l2-countdown').textContent = '↻ ' + m + ':' + (s < 10 ? '0' + s : s);
  2382. }, 1000);
  2383. }
  2384. /* ---------------- boot ---------------- */
  2385. function boot() {
  2386. setupControls();
  2387. buildHistory();
  2388. fitViewport();
  2389. try {
  2390. var qs = new URLSearchParams(window.location.search);
  2391. var qg = qs.get('g');
  2392. if (qg) focusGroupLabel = qg;
  2393. } catch (e) {}
  2394. if (!SNAP || !SNAP.nodes) {
  2395. el('l2-cy').innerHTML = '<div class="l2-empty">' + t('l2.no_data', 'No data') + '</div>';
  2396. } else {
  2397. INDEX = buildIndex(SNAP);
  2398. render(SNAP);
  2399. }
  2400. setTimeout(fitViewport, 60);
  2401. if (META.isLive) startTimer();
  2402. else { el('l2-pause').disabled = true; el('l2-countdown').textContent = t('l2.snapshot', 'Snapshot'); }
  2403. }
  2404. if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
  2405. else boot();
  2406. })();