app.js 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856
  1. /* =====================================================================
  2. L2 Physical Topology — display-only interactive map.
  3. Groups switches into frames (COBCO outer → workshop/DataCenter/Core),
  4. colours links by speed, nodes by latency & alarm, supports dark mode,
  5. terminal show / only-offline, live + historical snapshots, MAC/IP search.
  6. ===================================================================== */
  7. (function () {
  8. 'use strict';
  9. var CFG = window.L2_CFG || {};
  10. var T = window.L2_I18N || {};
  11. var META = window.L2_META || {};
  12. var SNAP = window.L2SNAP || null;
  13. var colors = CFG.colors || {100:'#F39C12',1000:'#2ECC71',10000:'#3498DB',20000:'#9B59B6',unknown:'#E74C3C',down:'#C0392B'};
  14. var LATW = (CFG.latency_warn != null) ? +CFG.latency_warn : 3;
  15. var LATB = (CFG.latency_bad != null) ? +CFG.latency_bad : 5;
  16. var TIER_BG = {core:'#1F4E78', dist:'#2E75B6', access:'#548235'};
  17. var TIER_BG_DARK = {core:'#1B4A72', dist:'#2A5E93', access:'#3E6B2E'};
  18. var SEV_COLORS = ['#97AAB3','#7499FF','#FFC859','#FFA059','#E97659','#E45959'];
  19. var NODE_W = 156, NODE_H = 50, GX = 26, GY = 18, PADX = 20, PADY = 42;
  20. var DGAP_X = 18, DGAP_Y = 26;
  21. var GROUP_GAP_X = 46, GROUP_GAP_Y = 48, MAXW = 2800;
  22. // overview ("全景") tiles
  23. var TILE_W = 232, TILE_H = 116, TILE_GAP_X = 24, TILE_GAP_Y = 22;
  24. var cy = null;
  25. var dark = false;
  26. var showTerm = false;
  27. var onlyOff = false;
  28. var paused = false;
  29. var countdown = 0;
  30. var timer = null;
  31. var lastSnap = null;
  32. var viewGroup = null; // null = overview, otherwise workshop label
  33. function el(id) { return document.getElementById(id); }
  34. function t(k, d) { return (T[k] != null) ? T[k] : (d != null ? d : k); }
  35. /* ---------------- name parsing / grouping ---------------- */
  36. function parseName(name) {
  37. var r = {workshop:'', floor:'', cab:'', u:'', area:'', role:'', ip:''};
  38. if (!name) return r;
  39. var toks = String(name).split('-');
  40. for (var i = 0; i < toks.length; i++) {
  41. var tk = toks[i];
  42. if (/^\d{1,3}(\.\d{1,3}){3}$/.test(tk)) { r.ip = tk; continue; }
  43. if (!r.workshop && /^\d{4}$/.test(tk)) { r.workshop = tk; continue; }
  44. if (!r.floor && /^\d[A-Z]\w*$/i.test(tk)) {
  45. var m = /^(\d)([A-Z])(\w+)$/i.exec(tk);
  46. if (m) { r.floor = m[1]; r.cab = m[2]; r.u = m[3]; continue; }
  47. }
  48. if (!r.area && /^(OFFICE|SURVE|GRDA|DC|IDC|LAB|PROD)$/i.test(tk)) { r.area = tk.toUpperCase(); continue; }
  49. if (!r.role && /^(Dist|Access|Core)$/i.test(tk)) { r.role = tk; }
  50. }
  51. return r;
  52. }
  53. function labelFor(n) {
  54. var p = parseName(n.name);
  55. var l1 = [];
  56. if (p.workshop) l1.push(p.workshop);
  57. if (p.area) l1.push(p.area);
  58. if (!l1.length) {
  59. l1.push((n.name || '').split('-')[0] || n.ip);
  60. }
  61. var l2 = [];
  62. if (p.floor) { var loc = p.floor + 'F' + (p.cab || '') + (p.u ? '-' + p.u + 'U' : ''); l1.push(loc); }
  63. if (p.role) l2.push(p.role);
  64. l2.push(n.ip);
  65. return l1.join(' · ') + '\n' + l2.join(' · ');
  66. }
  67. function expandLabel(tpl, m) {
  68. return String(tpl).replace(/\$(\d)/g, function (_, d) { return m && m[+d] != null ? m[+d] : ''; });
  69. }
  70. function groupOf(n) {
  71. var name = n.name || n.ip || '';
  72. var rules = CFG.group_rules || [];
  73. for (var i = 0; i < rules.length; i++) {
  74. var r = rules[i];
  75. if (r.match === 'regex') {
  76. var re;
  77. try { re = new RegExp(r.pattern); } catch (e) { continue; }
  78. var m = re.exec(name);
  79. if (m) return {label: expandLabel(r.label, m) || (CFG.other_label || 'Other'), big: !!r.big};
  80. } else {
  81. if (name.toLowerCase().indexOf(String(r.pattern).toLowerCase()) >= 0) return {label: r.label || (CFG.other_label || 'Other'), big: !!r.big};
  82. }
  83. }
  84. return {label: CFG.other_label || 'Other', big: false};
  85. }
  86. /* ---------------- overview layout (workshop tiles) ---------------- */
  87. function groupStats(snap) {
  88. var map = {};
  89. var nodeById = {};
  90. (snap.nodes || []).forEach(function (n) {
  91. nodeById[n.id] = n;
  92. var g = groupOf(n);
  93. 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});
  94. s.nodes.push(n);
  95. s.offline += (+n.term_offline || 0);
  96. if (n.alarms && n.alarms.length) {
  97. s.alarms += n.alarms.length;
  98. var sev = (n.maxsev != null && +n.maxsev >= 0) ? +n.maxsev : -1;
  99. n.alarms.forEach(function (a) { if (a && a.sev != null && +a.sev > sev) sev = +a.sev; });
  100. if (sev > s.maxsev) s.maxsev = sev;
  101. }
  102. if (n.latency_ms != null && +n.latency_ms > s.latmax) s.latmax = +n.latency_ms;
  103. });
  104. (snap.edges || []).forEach(function (e) {
  105. var a = nodeById[e.source], b = nodeById[e.target];
  106. if (!a || !b) return;
  107. var ga = groupOf(a), gb = groupOf(b);
  108. if (ga.label === gb.label) return;
  109. // count links that leave this workshop towards a core switch
  110. if ((/core/i.test(a.tier || '') || /core/i.test(a.name || '')) && map[gb.label]) map[gb.label].up++;
  111. if ((/core/i.test(b.tier || '') || /core/i.test(b.name || '')) && map[ga.label]) map[ga.label].up++;
  112. });
  113. return map;
  114. }
  115. function groupRank(l) {
  116. if (/core/i.test(l)) return 0;
  117. if (/data\s*center/i.test(l)) return 1;
  118. var m = /^(\d{4})([A-Za-z]?)$/.exec(l);
  119. if (m) return 1000 + parseInt(m[1], 10) * 40 + (m[2] ? m[2].toUpperCase().charCodeAt(0) - 64 : 0);
  120. return 5000;
  121. }
  122. function computeOverview(snap) {
  123. var stats = groupStats(snap);
  124. var labels = Object.keys(stats).sort(function (a, b) { return groupRank(a) - groupRank(b); });
  125. var special = labels.filter(function (l) { return groupRank(l) < 1000; });
  126. var others = labels.filter(function (l) { return groupRank(l) >= 1000; });
  127. var COLS = Math.max(4, Math.min(7, Math.ceil(Math.sqrt(Math.max(1, others.length)))));
  128. var gridW = COLS * TILE_W + (COLS - 1) * TILE_GAP_X;
  129. var pos = {}, tiles = [], y = 0;
  130. if (special.length) {
  131. var sW = special.length * TILE_W + (special.length - 1) * TILE_GAP_X;
  132. var sx = Math.max(0, (gridW - sW) / 2);
  133. special.forEach(function (l) {
  134. tiles.push({label: l, x: sx, y: y, w: TILE_W, h: TILE_H});
  135. pos[l] = {x: sx + TILE_W / 2, y: y + TILE_H / 2};
  136. sx += TILE_W + TILE_GAP_X;
  137. });
  138. y += TILE_H + 46;
  139. }
  140. var maxX = gridW, maxY = y;
  141. for (var i = 0; i < others.length; i += COLS) {
  142. var row = others.slice(i, i + COLS);
  143. var rW = row.length * TILE_W + (row.length - 1) * TILE_GAP_X;
  144. var rx = Math.max(0, (gridW - rW) / 2);
  145. row.forEach(function (l, j) {
  146. tiles.push({label: l, x: rx + j * (TILE_W + TILE_GAP_X), y: y, w: TILE_W, h: TILE_H});
  147. pos[l] = {x: rx + j * (TILE_W + TILE_GAP_X) + TILE_W / 2, y: y + TILE_H / 2};
  148. });
  149. y += TILE_H + TILE_GAP_Y;
  150. maxY = y - TILE_GAP_Y;
  151. maxX = Math.max(maxX, rx + rW);
  152. }
  153. var root = {x: -34, y: -64, w: maxX + 68, h: maxY + 64 + 40};
  154. root.cx = root.x + root.w / 2; root.cy = root.y + root.h / 2;
  155. return {stats: stats, labels: labels, pos: pos, tiles: tiles, root: root};
  156. }
  157. function aggregateEdges(snap) {
  158. var gid = {};
  159. (snap.nodes || []).forEach(function (n) { gid[n.id] = groupOf(n).label; });
  160. var pairs = {};
  161. (snap.edges || []).forEach(function (e) {
  162. var a = gid[e.source], b = gid[e.target];
  163. if (!a || !b || a === b) return;
  164. var k = (a < b) ? (a + '\u0001' + b) : (b + '\u0001' + a);
  165. var p = pairs[k] || (pairs[k] = {a: a, b: b, count: 0, speed: 0, down: false});
  166. p.count++;
  167. if ((parseFloat(e.speed) || 0) > p.speed) p.speed = parseFloat(e.speed) || 0;
  168. if (e.state && e.state !== 'up') p.down = true;
  169. });
  170. return Object.keys(pairs).map(function (k) { return pairs[k]; });
  171. }
  172. /* ---------------- detail layout (one workshop, tidy tree) ---------------- */
  173. function computeDetail(snap, label) {
  174. var all = snap.nodes || [];
  175. var byId = {}, nodes = [];
  176. all.forEach(function (n) { byId[n.id] = n; if (groupOf(n).label === label) nodes.push(n); });
  177. var ids = {};
  178. nodes.forEach(function (n) { ids[n.id] = true; });
  179. var internal = [], external = [];
  180. (snap.edges || []).forEach(function (e) {
  181. var a = ids[e.source], b = ids[e.target];
  182. if (a && b) internal.push(e);
  183. else if (a || b) external.push(e);
  184. });
  185. var adj = {}, kids = {};
  186. nodes.forEach(function (n) { adj[n.id] = []; kids[n.id] = []; });
  187. internal.forEach(function (e) { adj[e.source].push(e.target); adj[e.target].push(e.source); });
  188. function rankNode(n) {
  189. var tier = n.tier || '';
  190. if (/core/i.test(tier)) return 0;
  191. if (/dist/i.test(tier)) return 1;
  192. return 2;
  193. }
  194. var roots = nodes.slice().sort(function (a, b) {
  195. var ra = rankNode(a), rb = rankNode(b);
  196. if (ra !== rb) return ra - rb;
  197. return adj[b.id].length - adj[a.id].length;
  198. });
  199. var seen = {}, orderedRoots = [];
  200. roots.forEach(function (r) {
  201. if (seen[r.id]) return;
  202. orderedRoots.push(r);
  203. var queue = [r.id]; seen[r.id] = true;
  204. while (queue.length) {
  205. var cur = queue.shift();
  206. adj[cur].forEach(function (nb) {
  207. if (seen[nb]) return;
  208. seen[nb] = true;
  209. kids[cur].push(nb);
  210. queue.push(nb);
  211. });
  212. }
  213. });
  214. nodes.forEach(function (n) { if (!seen[n.id]) { seen[n.id] = true; orderedRoots.push(n); } });
  215. // Compact blocks: each dist root gets a grid of access switches underneath it.
  216. var pos = {}, blockX = 0, maxBlockH = 0;
  217. orderedRoots.forEach(function (rootN) {
  218. var d1 = kids[rootN.id] || [];
  219. var cols = Math.max(1, Math.min(5, Math.ceil(Math.sqrt(Math.max(1, d1.length) * 1.2))));
  220. var blockW = cols * NODE_W + (cols - 1) * DGAP_X;
  221. pos[rootN.id] = {x: blockX + blockW / 2, y: 0};
  222. d1.forEach(function (c, i) {
  223. var col = i % cols, row = Math.floor(i / cols);
  224. pos[c] = {x: blockX + col * (NODE_W + DGAP_X) + NODE_W / 2, y: (row + 1) * (NODE_H + DGAP_Y)};
  225. });
  226. var rows = Math.ceil(d1.length / cols);
  227. var d2 = [];
  228. d1.forEach(function (c) {
  229. (kids[c] || []).forEach(function (k) { if (k !== rootN.id) d2.push(k); });
  230. });
  231. var y2 = (rows + 1) * (NODE_H + DGAP_Y) + 16;
  232. d2.forEach(function (id, i) {
  233. pos[id] = {x: blockX + (i % cols) * (NODE_W + DGAP_X) + NODE_W / 2, y: y2 + Math.floor(i / cols) * (NODE_H + DGAP_Y)};
  234. });
  235. maxBlockH = Math.max(maxBlockH, y2 + Math.ceil(d2.length / cols) * (NODE_H + DGAP_Y));
  236. blockX += blockW + 70;
  237. });
  238. var missing = [];
  239. nodes.forEach(function (n) { if (!pos[n.id]) missing.push(n.id); });
  240. missing.forEach(function (id, i) {
  241. pos[id] = {x: (i % 6) * (NODE_W + DGAP_X) + NODE_W / 2, y: maxBlockH + 70 + Math.floor(i / 6) * (NODE_H + DGAP_Y)};
  242. });
  243. var totalW = Math.max(NODE_W, blockX - 70);
  244. var ghosts = [], ghostSeen = {};
  245. external.forEach(function (e) {
  246. var inside = ids[e.source] ? e.source : e.target;
  247. var outside = ids[e.source] ? e.target : e.source;
  248. if (!ghostSeen[outside]) {
  249. ghostSeen[outside] = {id: outside, to: inside, node: byId[outside], speed: e.speed, state: e.state};
  250. ghosts.push(ghostSeen[outside]);
  251. }
  252. });
  253. var ghostAt = {};
  254. ghosts.forEach(function (g) {
  255. var base = pos[g.to] || {x: 80};
  256. var k = ghostAt[g.to] || 0;
  257. ghostAt[g.to] = k + 1;
  258. g.x = base.x + (k ? (k % 2 ? 1 : -1) * 95 * Math.ceil(k / 2) : 0);
  259. g.y = -86 - Math.floor(k / 2) * 48;
  260. });
  261. var maxY = 0, minX = 0, maxX2 = totalW;
  262. Object.keys(pos).forEach(function (id) {
  263. maxY = Math.max(maxY, pos[id].y + NODE_H);
  264. minX = Math.min(minX, pos[id].x - NODE_W / 2);
  265. maxX2 = Math.max(maxX2, pos[id].x + NODE_W / 2);
  266. });
  267. ghosts.forEach(function (g) {
  268. minX = Math.min(minX, g.x - 95);
  269. maxX2 = Math.max(maxX2, g.x + 95);
  270. });
  271. var top = ghosts.length ? -168 : -120;
  272. var root = {x: minX - 38, y: top, w: (maxX2 - minX) + 76, h: maxY - top + 54};
  273. root.cx = root.x + root.w / 2; root.cy = root.y + root.h / 2;
  274. return {nodes: nodes, byId: byId, pos: pos, ghosts: ghosts, root: root, totalW: totalW, ids: ids, internal: internal};
  275. }
  276. /* ---------------- speed colour ---------------- */
  277. function speedColor(speed, state) {
  278. if (state && state !== 'up') return colors.down;
  279. var s = parseFloat(speed);
  280. if (!isFinite(s) || s <= 0) return colors.unknown;
  281. if (s <= 100) return colors['100'];
  282. if (s <= 1000) return colors['1000'];
  283. if (s <= 10000) return colors['10000'];
  284. return colors['20000'];
  285. }
  286. function speedWidth(speed) {
  287. var s = parseFloat(speed) || 0;
  288. if (s >= 20000) return 4.5;
  289. if (s >= 10000) return 3.6;
  290. if (s >= 1000) return 2.6;
  291. return 1.8;
  292. }
  293. function speedText(speed) {
  294. var s = parseFloat(speed) || 0;
  295. if (!s) return '?';
  296. if (s >= 1000) return (s / 1000) + 'G';
  297. return s + 'M';
  298. }
  299. /* ---------------- style ---------------- */
  300. function styleFor(d) {
  301. var bg = d ? TIER_BG_DARK : TIER_BG;
  302. return [
  303. {selector: 'node', style: {
  304. 'width': 'data(w)', 'height': 'data(h)', 'shape': 'round-rectangle',
  305. 'background-color': 'data(bg)', 'border-width': 2, 'border-color': 'data(bd)',
  306. 'label': 'data(label)', 'color': d ? '#eaf1fb' : '#ffffff',
  307. 'font-size': 11.5, 'font-weight': 600, 'text-valign': 'center', 'text-halign': 'center',
  308. 'text-wrap': 'wrap', 'text-max-width': NODE_W - 16, 'padding': '2px',
  309. 'overlay-opacity': 0, 'z-index-compare': 'manual', 'z-index': 20
  310. }},
  311. {selector: 'node[type="frame"]', style: {
  312. 'background-opacity': d ? 0.05 : 0.035, 'background-color': d ? '#5b7bb0' : '#8fa6c4',
  313. 'border-width': 1.6, 'border-style': 'dashed', 'border-color': d ? '#3f5377' : '#c2ccdb',
  314. 'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center',
  315. 'font-size': 'data(fs)', 'font-weight': 800, 'color': d ? '#aebfd8' : '#526178',
  316. 'text-margin-y': 8, 'padding': '0px', 'events': 'no', 'z-index-compare': 'manual', 'z-index': 2
  317. }},
  318. {selector: 'node[type="root"]', style: {
  319. 'background-opacity': d ? 0.02 : 0.012, 'background-color': d ? '#7aa0d8' : '#8ea6c2',
  320. 'border-width': 2.2, 'border-style': 'solid', 'border-color': d ? '#476492' : '#9fb0c6',
  321. 'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center',
  322. 'font-size': 28, 'font-weight': 900, 'color': d ? '#cfe0ff' : '#33465f',
  323. 'text-margin-y': 10, 'events': 'no', 'z-index-compare': 'manual', 'z-index': 0
  324. }},
  325. {selector: 'node[type="tile"]', style: {
  326. 'width': 'data(w)', 'height': 'data(h)', 'shape': 'round-rectangle',
  327. 'background-color': 'data(bg)', 'background-opacity': 0.97,
  328. 'border-width': 2.5, 'border-color': 'data(bd)',
  329. 'label': 'data(label)', 'color': 'data(fg)',
  330. 'font-size': 16, 'font-weight': 700, 'text-valign': 'center', 'text-halign': 'center',
  331. 'text-wrap': 'wrap', 'text-max-width': TILE_W - 26, 'line-height': 1.45,
  332. 'padding': '6px', 'overlay-opacity': 0, 'z-index-compare': 'manual', 'z-index': 20
  333. }},
  334. {selector: 'node[type="tile"].alarm', style: {'border-width': 5}},
  335. {selector: 'node[type="tile"].core', style: {'border-width': 3.5}},
  336. {selector: 'node[type="ghost"]', style: {
  337. 'width': 190, 'height': 40, 'shape': 'round-rectangle',
  338. 'background-color': d ? '#52667f' : '#c8d3e2', 'background-opacity': 0.55,
  339. 'border-width': 1.2, 'border-style': 'dashed', 'border-color': d ? '#7d93ad' : '#8fa3bc',
  340. 'label': 'data(label)', 'color': d ? '#dce8fa' : '#3b4f68',
  341. 'font-size': 11, 'font-weight': 600, 'text-wrap': 'wrap', 'text-max-width': 176,
  342. 'z-index-compare': 'manual', 'z-index': 15
  343. }},
  344. {selector: 'node[type="term"]', style: {
  345. 'width': 12, 'height': 12, 'shape': 'ellipse', 'background-color': 'data(bg)',
  346. 'border-width': 1, 'border-color': d ? '#2a3648' : '#cfd8e3', 'label': '',
  347. 'z-index-compare': 'manual', 'z-index': 30
  348. }},
  349. {selector: 'edge[type="tilelink"]', style: {
  350. 'width': 'data(w)', 'line-color': 'data(color)', 'opacity': 0.55,
  351. 'curve-style': 'bezier', 'target-arrow-shape': 'none',
  352. 'z-index-compare': 'manual', 'z-index': 5
  353. }},
  354. {selector: 'edge[type="ghost"]', style: {
  355. 'width': 1.2, 'line-color': d ? '#6b809b' : '#a9b6c8', 'line-style': 'dashed', 'opacity': 0.7,
  356. 'curve-style': 'bezier', 'target-arrow-shape': 'none',
  357. 'z-index-compare': 'manual', 'z-index': 8
  358. }},
  359. {selector: 'edge[type="uplink"]', style: {
  360. 'width': 'data(w)', 'line-color': 'data(color)', 'line-style': 'data(ls)', 'opacity': 0.95,
  361. 'curve-style': 'bezier', 'target-arrow-shape': 'none',
  362. 'label': 'data(lbl)', 'font-size': 10, 'font-weight': 700,
  363. 'color': d ? '#dce8fa' : '#25405f',
  364. 'text-background-color': d ? '#0d1b2a' : '#ffffff', 'text-background-opacity': 0.88,
  365. 'text-background-padding': '2px', 'text-rotation': 'autorotate',
  366. 'z-index-compare': 'manual', 'z-index': 40
  367. }},
  368. {selector: 'edge', style: {
  369. 'width': 'data(w)', 'line-color': 'data(color)', 'line-style': 'data(ls)',
  370. 'curve-style': 'bezier', 'opacity': 0.92, 'target-arrow-shape': 'none',
  371. 'z-index-compare': 'manual', 'z-index': 12
  372. }},
  373. {selector: 'edge[type="term"]', style: {
  374. 'width': 0.7, 'line-color': d ? '#3b4a5e' : '#c5cfdb', 'opacity': 0.55,
  375. 'curve-style': 'bezier', 'z-index-compare': 'manual', 'z-index': 11
  376. }},
  377. {selector: 'node.alarm', style: {'border-width': 6}},
  378. {selector: 'node.dim', style: {'opacity': 0.12}},
  379. {selector: 'edge.dim', style: {'opacity': 0.08}},
  380. {selector: 'edge.hl', style: {'width': 5, 'line-color': '#FFD54A', 'opacity': 1, 'z-index-compare': 'manual', 'z-index': 45}},
  381. {selector: 'node.lat-warn', style: {'border-color': '#F39C12'}},
  382. {selector: 'node.lat-bad', style: {'border-color': '#E74C3C', 'border-width': 6}},
  383. {selector: 'node.hl', style: {'border-color': '#ff0', 'border-width': 7, 'overlay-color': '#ff0', 'overlay-opacity': 0.25, 'overlay-padding': 6}},
  384. {selector: 'node[type="switch"]', style: {'background-color': function (e) { return e.data('bg'); }}}
  385. ];
  386. }
  387. /* ---------------- render ---------------- */
  388. function applyElements(els) {
  389. if (!cy) {
  390. cy = cytoscape({
  391. container: el('l2-cy'),
  392. elements: els,
  393. style: styleFor(dark),
  394. layout: {name: 'preset', fit: true, padding: 40},
  395. wheelSensitivity: 0.2,
  396. minZoom: 0.15, maxZoom: 4
  397. });
  398. bindCy();
  399. } else {
  400. cy.elements().remove();
  401. cy.add(els);
  402. cy.style(styleFor(dark));
  403. cy.layout({name: 'preset', fit: true, padding: 40}).run();
  404. }
  405. }
  406. function render(snap) {
  407. if (!snap || !snap.nodes) {
  408. el('l2-cy').innerHTML = '<div class="l2-empty">' + t('l2.no_data', 'No data') + '</div>';
  409. return;
  410. }
  411. lastSnap = snap;
  412. if (viewGroup) {
  413. var exists = (snap.nodes || []).some(function (n) { return groupOf(n).label === viewGroup; });
  414. if (!exists) viewGroup = null;
  415. }
  416. if (viewGroup) renderDetail(snap); else renderOverview(snap);
  417. if (el('l2-back')) el('l2-back').style.display = viewGroup ? '' : 'none';
  418. updateStats(snap);
  419. buildLegend();
  420. buildAlarms(snap);
  421. buildOffline(snap);
  422. }
  423. function renderOverview(snap) {
  424. var O = computeOverview(snap), els = [], idx = {};
  425. if (O.root) {
  426. els.push({group: 'nodes', data: {id: '__root', type: 'root', label: CFG.root_label || 'COBCO', w: O.root.w, h: O.root.h},
  427. position: {x: O.root.cx, y: O.root.cy}, selectable: false, grabbable: false});
  428. }
  429. O.tiles.forEach(function (tile, i) {
  430. var s = O.stats[tile.label] || {nodes: [], alarms: 0, offline: 0, maxsev: -1};
  431. idx[tile.label] = 'grp_' + i;
  432. var isCore = /core/i.test(tile.label) || /data\s*center/i.test(tile.label);
  433. var bd = s.alarms ? '#E45959' : (dark ? '#41597c' : '#b9c8dc');
  434. var bg = isCore ? TIER_BG.core : (dark ? '#22344e' : '#eef3fb');
  435. var fg = isCore ? '#ffffff' : (dark ? '#dfe9fb' : '#1e3352');
  436. var lbl = tile.label + '\n' + s.nodes.length + ' ' + t('l2.unit_sw', 'sw') +
  437. (s.up ? ' ↑ ' + s.up : '') + (s.alarms ? ' ⚠ ' + s.alarms : '') + (s.offline ? ' ◌ ' + s.offline : '');
  438. var cls = [];
  439. if (s.alarms) cls.push('alarm');
  440. if (isCore) cls.push('core');
  441. els.push({group: 'nodes', data: {
  442. id: idx[tile.label], type: 'tile', group: tile.label,
  443. n: s.nodes.length, alarms: s.alarms, offline: s.offline, maxsev: s.maxsev, up: s.up,
  444. label: lbl, bg: bg, fg: fg, bd: bd, w: TILE_W, h: TILE_H
  445. }, position: {x: tile.x + tile.w / 2, y: tile.y + tile.h / 2}, classes: cls.join(' ')});
  446. });
  447. applyElements(els);
  448. }
  449. function renderDetail(snap) {
  450. var D = computeDetail(snap, viewGroup), els = [];
  451. if (D.root) {
  452. els.push({group: 'nodes', data: {id: '__root', type: 'root', label: (CFG.root_label || 'COBCO') + ' / ' + viewGroup, w: D.root.w, h: D.root.h},
  453. position: {x: D.root.cx, y: D.root.cy}, selectable: false, grabbable: false});
  454. }
  455. (D.ghosts || []).forEach(function (g) {
  456. var nm = (g.node && g.node.name) || g.id;
  457. var ip = (g.node && g.node.ip) || '';
  458. var tail = String(nm).split(/[-\s]+/).filter(Boolean).slice(-1)[0] || '';
  459. var short = (tail ? tail + ' · ' : '') + ip;
  460. els.push({group: 'nodes', data: {id: g.id, type: 'ghost', label: short}, position: {x: g.x, y: g.y}, selectable: false, grabbable: false});
  461. els.push({group: 'edges', data: {
  462. id: 'ghe_' + g.id, type: 'uplink', source: g.id, target: g.to,
  463. speed: g.speed, state: g.state, lbl: speedText(g.speed),
  464. color: speedColor(g.speed, g.state), ls: (g.state && g.state !== 'up') ? 'dashed' : 'solid',
  465. w: Math.max(2.6, speedWidth(g.speed))
  466. }});
  467. });
  468. var switchIds = {};
  469. D.nodes.forEach(function (n) {
  470. switchIds[n.id] = true;
  471. var p = D.pos[n.id] || {x: 0, y: 0};
  472. var tier = n.tier || 'access';
  473. var tkey = (tier.indexOf('dist') === 0) ? 'dist' : (tier.indexOf('core') === 0 ? 'core' : 'access');
  474. var cls = [];
  475. var bd = (dark ? TIER_BG_DARK[tkey] : TIER_BG[tkey]) || '#555';
  476. if (n.alarms && n.alarms.length) {
  477. cls.push('alarm');
  478. bd = '#E45959';
  479. }
  480. if (n.latency_ms != null) {
  481. if (+n.latency_ms > LATB) cls.push('lat-bad');
  482. else if (+n.latency_ms > LATW) cls.push('lat-warn');
  483. }
  484. els.push({group: 'nodes', data: {
  485. id: n.id, type: 'switch', name: n.name, ip: n.ip, tier: tier, depth: n.depth,
  486. hostid: n.hostid, oper: n.oper, maxsev: n.maxsev, alarms: n.alarms || [],
  487. term_online: n.term_online, term_offline: n.term_offline, latency_ms: n.latency_ms,
  488. label: labelFor(n), bg: (dark ? TIER_BG_DARK[tkey] : TIER_BG[tkey]) || '#777',
  489. bd: bd, w: NODE_W, h: NODE_H, group: viewGroup
  490. }, position: p, classes: cls.join(' ')});
  491. });
  492. D.internal.forEach(function (e, i) {
  493. els.push({group: 'edges', data: {
  494. id: 'e_' + i, source: e.source, target: e.target, speed: e.speed, state: e.state,
  495. a_port: e.a_port, b_port: e.b_port,
  496. color: speedColor(e.speed, e.state), ls: (e.state && e.state !== 'up') ? 'dashed' : 'solid',
  497. w: speedWidth(e.speed)
  498. }});
  499. });
  500. if (showTerm) {
  501. var bySw = {};
  502. (snap.terminals || []).forEach(function (tm) {
  503. if (!switchIds[tm.sw_ip]) return;
  504. if (onlyOff && tm.online) return;
  505. (bySw[tm.sw_ip] = bySw[tm.sw_ip] || []).push(tm);
  506. });
  507. Object.keys(bySw).forEach(function (sw) {
  508. var base = D.pos[sw]; if (!base) return;
  509. bySw[sw].sort(function (a, b) { return (a.online === b.online) ? 0 : (a.online ? -1 : 1); });
  510. bySw[sw].forEach(function (tm, k) {
  511. var col = k % 9, row = Math.floor(k / 9);
  512. var x = base.x - 54 + col * 13;
  513. var y = base.y + NODE_H / 2 + 16 + row * 13;
  514. els.push({group: 'nodes', data: {id: 't_' + tm.mac, type: 'term', bg: tm.online ? '#2ECC71' : '#95a5a6', mac: tm.mac, ip: tm.ip, online: tm.online},
  515. position: {x: x, y: y}, selectable: false});
  516. els.push({group: 'edges', data: {id: 'te_' + tm.mac, type: 'term', source: sw, target: 't_' + tm.mac}});
  517. });
  518. });
  519. }
  520. applyElements(els);
  521. }
  522. /* ---------------- side panels & stats ---------------- */
  523. function updateStats(snap) {
  524. var s = snap.stats || {};
  525. var txt = '● ' + (s.switches || 0) + ' | ⇄ ' + (s.edges || 0) + ' | ' +
  526. (s.term_total || 0) + '(' + (s.term_online || 0) + '/' + (s.term_offline || 0) + ') | ⚠ ' + (s.alarms || 0);
  527. var up = snap.generated_iso ? (' · ' + t('l2.updated', 'Updated') + ' ' + snap.generated_iso) : '';
  528. el('l2-stats').textContent = txt + up;
  529. }
  530. function buildLegend() {
  531. var h = '';
  532. function sw(c, label, dashed) { return '<div class="lg-row"><span class="lg-line' + (dashed ? ' lg-dash' : '') + '" style="border-top-color:' + c + '"></span>' + label + '</div>'; }
  533. h += '<div class="lg-title">' + t('l2.legend_speed', 'Link speed') + '</div>';
  534. h += sw(colors['100'], '100M') + sw(colors['1000'], '1G') + sw(colors['10000'], '10G') + sw(colors['20000'], '20G+');
  535. h += sw(colors.unknown, t('lbl.color_unknown', 'Unknown'));
  536. h += sw(colors.down, t('lbl.color_down', 'Down'), true);
  537. h += '<div class="lg-title">' + t('l2.legend_tier', 'Tier') + '</div>';
  538. h += '<div class="lg-row"><span class="lg-box" style="background:' + TIER_BG.core + '"></span>' + t('l2.core', 'Core') +
  539. '<span class="lg-box" style="background:' + TIER_BG.dist + ';margin-left:8px"></span>' + t('l2.dist', 'Distribution') +
  540. '<span class="lg-box" style="background:' + TIER_BG.access + ';margin-left:8px"></span>' + t('l2.access', 'Access') + '</div>';
  541. h += '<div class="lg-title">' + t('l2.legend_lat', 'Latency') + '</div>';
  542. h += '<div class="lg-row"><span class="lg-box" style="background:#F39C12;border-radius:2px"></span>&gt;' + LATW + 'ms' +
  543. '<span class="lg-box" style="background:#E74C3C;border-radius:2px;margin-left:8px"></span>&gt;' + LATB + 'ms</div>';
  544. h += '<div class="lg-row"><span class="lg-box" style="background:#E45959;border-radius:2px"></span>' + t('l2.alarms', 'Alarms') + '</div>';
  545. h += '<div class="lg-row"><span class="lg-box" style="background:#2ECC71;border-radius:50%"></span>' + t('l2.term_on', 'Online') +
  546. '<span class="lg-box" style="background:#95a5a6;border-radius:50%;margin-left:8px"></span>' + t('l2.term_off', 'Offline') + '</div>';
  547. h += '<div class="lg-row" style="opacity:.72;margin-top:4px">' + t('l2.click_hint', 'Click a switch to highlight its links') + '</div>';
  548. el('l2-legend').innerHTML = h;
  549. }
  550. function buildAlarms(snap) {
  551. var items = [];
  552. (snap.nodes || []).forEach(function (n) {
  553. if (n.maxsev != null && n.maxsev >= 0 && n.alarms && n.alarms.length) {
  554. n.alarms.forEach(function (a) {
  555. items.push({sev: n.maxsev, title: (a.name || a.description || ''), host: n.name, ip: n.ip, id: n.id, hostid: n.hostid});
  556. });
  557. }
  558. });
  559. el('l2-alarm-count').textContent = items.length;
  560. if (!items.length) { el('l2-alarm-list').innerHTML = '<div class="panel-empty">' + t('l2.alarm_empty', 'No active alarms.') + '</div>'; return; }
  561. items.sort(function (a, b) { return b.sev - a.sev; });
  562. var h = '';
  563. items.forEach(function (it) {
  564. var col = SEV_COLORS[Math.max(0, Math.min(5, it.sev))] || '#999';
  565. h += '<div class="alarm-row" data-id="' + (it.id || '') + '" data-hostid="' + (it.hostid || '') + '">' +
  566. '<span class="alarm-dot" style="background:' + col + '"></span>' +
  567. '<div class="alarm-main"><div class="alarm-title">' + esc(it.title) + '</div>' +
  568. '<div class="alarm-sub">' + esc(it.host || '') + (it.ip ? ' · ' + esc(it.ip) : '') + '</div></div></div>';
  569. });
  570. var box = el('l2-alarm-list');
  571. box.innerHTML = h;
  572. box.querySelectorAll('.alarm-row').forEach(function (r) {
  573. r.addEventListener('click', function () {
  574. var id = r.getAttribute('data-id');
  575. if (id) revealById(id);
  576. else { var hid = r.getAttribute('data-hostid'); if (hid) window.open(META.zabbixHostUrl.replace('__HOSTID__', hid), '_blank'); }
  577. });
  578. });
  579. }
  580. function buildOffline(snap) {
  581. var off = (snap.terminals || []).filter(function (tm) { return !tm.online; });
  582. el('l2-offline-count').textContent = off.length;
  583. if (!off.length) { el('l2-offline-list').innerHTML = '<div class="panel-empty">' + t('l2.offline_empty', 'No offline terminals.') + '</div>'; return; }
  584. var h = '';
  585. off.forEach(function (tm) {
  586. h += '<div class="off-row" data-mac="' + esc(tm.mac) + '">' +
  587. '<div class="off-main"><div class="off-mac">' + esc(tm.mac) + '</div>' +
  588. '<div class="off-sub">' + esc(tm.sw_name || tm.sw_ip) + ' · ' + esc(tm.port || '') + '</div></div></div>';
  589. });
  590. var box = el('l2-offline-list');
  591. box.innerHTML = h;
  592. box.querySelectorAll('.off-row').forEach(function (r) {
  593. r.addEventListener('click', function () {
  594. var mac = r.getAttribute('data-mac');
  595. revealById('t_' + mac);
  596. });
  597. });
  598. }
  599. function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]; }); }
  600. /* ---------------- interactions ---------------- */
  601. function focusNode(id) {
  602. if (!cy) return;
  603. var n = cy.getElementById(id);
  604. if (!n || n.empty() || n.length === 0) return;
  605. cy.elements().removeClass('hl');
  606. n.addClass('hl');
  607. cy.animate({center: {eles: n}, zoom: Math.max(cy.zoom(), 1.1), duration: 380});
  608. setTimeout(function () { n.removeClass('hl'); }, 2600);
  609. }
  610. function groupOfNodeId(id) {
  611. var hit = (lastSnap && lastSnap.nodes || []).filter(function (n) { return n.id === id; })[0];
  612. return hit ? groupOf(hit).label : null;
  613. }
  614. // Make sure a switch/terminal is visible (auto-opens its workshop from overview).
  615. function revealById(id) {
  616. if (!lastSnap) return;
  617. id = String(id);
  618. if (id.indexOf('t_') === 0) {
  619. var tm = (lastSnap.terminals || []).filter(function (x) { return 't_' + x.mac === id; })[0];
  620. if (!tm) return;
  621. var g = groupOfNodeId(tm.sw_ip);
  622. if (g) viewGroup = g;
  623. showTerm = true;
  624. if (el('l2-showterm')) el('l2-showterm').checked = true;
  625. render(lastSnap);
  626. setTimeout(function () { focusNode(id); }, 90);
  627. } else {
  628. var g2 = groupOfNodeId(id);
  629. if (g2 && viewGroup !== g2) { viewGroup = g2; render(lastSnap); }
  630. setTimeout(function () { focusNode(id); }, 90);
  631. }
  632. }
  633. function bindCy() {
  634. cy.on('mouseover', 'node[type="switch"]', function (e) {
  635. var d = e.target.data();
  636. var arr = d.alarms || [];
  637. var sev = (d.maxsev != null && d.maxsev >= 0) ? ('<span style="color:' + SEV_COLORS[d.maxsev] + '">● ' + SEV_NAMES[d.maxsev] + '</span>') : '—';
  638. var lat = (d.latency_ms != null) ? (d.latency_ms + ' ms') : '—';
  639. var html = '<b>' + esc(d.name) + '</b><br>' + esc(d.ip) + ' · ' + esc(d.group || '') + '<br>' +
  640. 'Tier: ' + esc(d.tier) + ' · ' + (d.oper || '') + '<br>' +
  641. 'Latency: ' + lat + '<br>' +
  642. 'Terminals: ' + (d.term_online || 0) + ' on / ' + (d.term_offline || 0) + ' off<br>' +
  643. 'Alarm: ' + sev;
  644. if (arr.length) html += '<hr>' + arr.map(function (a) { return esc(a.name || a.description || ''); }).join('<br>');
  645. showTip(e, html);
  646. });
  647. cy.on('mouseout', 'node[type="switch"]', hideTip);
  648. cy.on('mouseover', 'node[type="tile"]', function (e) {
  649. var d = e.target.data();
  650. var html = '<b>' + esc(d.group) + '</b><br>' + d.n + ' ' + t('l2.unit_sw', 'sw') + '<br>' +
  651. '↑ Core: ' + (d.up || 0) + ' · ⚠ ' + (d.alarms || 0) + ' · ◌ ' + (d.offline || 0) +
  652. '<br><span style="opacity:.7">' + t('l2.tile_hint', 'Click to open this workshop') + '</span>';
  653. showTip(e, html);
  654. });
  655. cy.on('mouseout', 'node[type="tile"]', hideTip);
  656. cy.on('tap', 'node[type="tile"]', function (e) {
  657. var g = e.target.data('group');
  658. if (g) { viewGroup = g; render(lastSnap); }
  659. });
  660. cy.on('mouseover', 'edge:not([type="term"])', function (e) {
  661. var d = e.target.data();
  662. var sp = (parseFloat(d.speed) || 0) >= 1000 ? ((parseFloat(d.speed) / 1000) + 'G') : (d.speed + 'M');
  663. showTip(e, 'Link ' + esc(d.a_port || '') + ' ↔ ' + esc(d.b_port || '') + '<br>' + sp + ' · ' + esc(d.state || ''));
  664. });
  665. cy.on('mouseout', 'edge', hideTip);
  666. cy.on('tap', 'node[type="switch"]', function (e) {
  667. var n = e.target;
  668. cy.elements().removeClass('dim hl');
  669. cy.elements().not(n).addClass('dim');
  670. n.removeClass('dim');
  671. var links = n.connectedEdges();
  672. links.removeClass('dim').addClass('hl');
  673. links.connectedNodes().removeClass('dim');
  674. });
  675. cy.on('tap', function (e) {
  676. if (e.target === cy) cy.elements().removeClass('dim hl');
  677. });
  678. cy.on('dbltap', 'node[type="switch"]', function (e) {
  679. var hid = e.target.data('hostid');
  680. if (hid) window.open(META.zabbixHostUrl.replace('__HOSTID__', hid), '_blank');
  681. });
  682. }
  683. var SEV_NAMES = ['未分类','信息','警告','一般严重','严重','灾难'];
  684. function showTip(e, html) {
  685. var tip = el('l2-tip');
  686. tip.innerHTML = html;
  687. tip.style.display = 'block';
  688. var p = e.renderedPosition || {x: 0, y: 0};
  689. var box = el('l2-cy').getBoundingClientRect();
  690. var x = box.left + p.x + 16, y = box.top + p.y + 16;
  691. tip.style.left = x + 'px'; tip.style.top = y + 'px';
  692. }
  693. function hideTip() { el('l2-tip').style.display = 'none'; }
  694. /* ---------------- search ---------------- */
  695. function buildIndex(snap) {
  696. var idx = [];
  697. (snap.nodes || []).forEach(function (n) { idx.push({id: n.id, label: n.name, sub: n.ip, kind: 'sw'}); });
  698. (snap.terminals || []).forEach(function (tm) { idx.push({id: 't_' + tm.mac, label: tm.ip || tm.mac, sub: tm.mac + ' · ' + (tm.sw_name || ''), kind: 'term', mac: tm.mac}); });
  699. return idx;
  700. }
  701. var INDEX = [];
  702. function doSearch(q) {
  703. var box = el('l2-sug');
  704. q = (q || '').trim().toLowerCase();
  705. if (!q) { box.style.display = 'none'; box.innerHTML = ''; return; }
  706. var hits = [];
  707. for (var i = 0; i < INDEX.length && hits.length < 14; i++) {
  708. var it = INDEX[i];
  709. if ((it.label && it.label.toLowerCase().indexOf(q) >= 0) || (it.sub && it.sub.toLowerCase().indexOf(q) >= 0)) hits.push(it);
  710. }
  711. if (!hits.length) { box.innerHTML = '<div class="sug-empty">—</div>'; box.style.display = 'block'; return; }
  712. box.innerHTML = hits.map(function (h) {
  713. return '<div class="sug-row" data-id="' + esc(h.id) + '"><span class="sug-kind ' + h.kind + '">' + (h.kind === 'sw' ? 'SW' : 'PC') + '</span>' +
  714. '<span class="sug-lbl">' + esc(h.label) + '</span><span class="sug-sub">' + esc(h.sub || '') + '</span></div>';
  715. }).join('');
  716. box.style.display = 'block';
  717. box.querySelectorAll('.sug-row').forEach(function (r) {
  718. r.addEventListener('click', function () {
  719. var id = r.getAttribute('data-id');
  720. revealById(id);
  721. box.style.display = 'none';
  722. });
  723. });
  724. }
  725. /* ---------------- toolbar wiring ---------------- */
  726. function setupControls() {
  727. dark = el('l2-wrap').classList.contains('l2-dark');
  728. el('l2-dark').checked = dark;
  729. var back = el('l2-back');
  730. if (back) {
  731. back.textContent = t('l2.back_overview', '← Overview');
  732. back.addEventListener('click', function () { viewGroup = null; render(lastSnap); });
  733. }
  734. el('l2-dark').addEventListener('change', function () {
  735. dark = el('l2-dark').checked;
  736. el('l2-wrap').classList.toggle('l2-dark', dark);
  737. render(lastSnap);
  738. });
  739. el('l2-showterm').addEventListener('change', function () { showTerm = el('l2-showterm').checked; render(lastSnap); });
  740. el('l2-onlyoff').addEventListener('change', function () {
  741. onlyOff = el('l2-onlyoff').checked;
  742. if (onlyOff && !showTerm) { showTerm = true; el('l2-showterm').checked = true; }
  743. render(lastSnap);
  744. });
  745. el('l2-zin').addEventListener('click', function () { if (cy) cy.zoom({level: cy.zoom() * 1.25, renderedPosition: {x: cy.width() / 2, y: cy.height() / 2}}); });
  746. el('l2-zout').addEventListener('click', function () { if (cy) cy.zoom({level: cy.zoom() / 1.25, renderedPosition: {x: cy.width() / 2, y: cy.height() / 2}}); });
  747. el('l2-fit').addEventListener('click', function () { if (cy) cy.fit(undefined, 50); });
  748. el('l2-refresh-btn').addEventListener('click', function () { reload(true); });
  749. var s = el('l2-search');
  750. s.addEventListener('input', function () { doSearch(s.value); });
  751. document.addEventListener('click', function (ev) {
  752. if (!ev.target.closest('.l2-search-wrap')) el('l2-sug').style.display = 'none';
  753. });
  754. el('l2-ts').addEventListener('change', function () {
  755. var v = el('l2-ts').value;
  756. var u = '/zabbix.php?action=l2.topology.view';
  757. if (v) u += '&ts=' + encodeURIComponent(v);
  758. window.location = u;
  759. });
  760. el('l2-pause').addEventListener('click', function () {
  761. paused = !paused;
  762. el('l2-pause').classList.toggle('on', paused);
  763. el('l2-pause').textContent = paused ? '▶' : '❚❚';
  764. el('l2-pause').title = paused ? t('l2.resume', 'Resume') : t('l2.pause', 'Pause');
  765. });
  766. }
  767. function reload(manual) {
  768. if (!META.isLive) return;
  769. fetch(META.snapshotUrl + '?t=' + Date.now(), {cache: 'no-store'})
  770. .then(function (r) { return r.json(); })
  771. .then(function (j) { if (j && j.nodes) { SNAP = j; INDEX = buildIndex(j); render(j); } })
  772. .catch(function () {})
  773. .then(function () { if (manual) resetCountdown(); });
  774. }
  775. function resetCountdown() { countdown = META.refreshSeconds || 300; }
  776. function startTimer() {
  777. if (!META.isLive) return;
  778. resetCountdown();
  779. timer = setInterval(function () {
  780. if (paused) { el('l2-countdown').textContent = '⏸'; return; }
  781. countdown--;
  782. if (countdown <= 0) { countdown = META.refreshSeconds || 300; reload(false); }
  783. var m = Math.floor(countdown / 60), s = countdown % 60;
  784. el('l2-countdown').textContent = '↻ ' + m + ':' + (s < 10 ? '0' + s : s);
  785. }, 1000);
  786. }
  787. /* ---------------- boot ---------------- */
  788. function boot() {
  789. setupControls();
  790. try {
  791. var qg = new URLSearchParams(window.location.search).get('g');
  792. if (qg) viewGroup = qg;
  793. } catch (e) {}
  794. if (!SNAP || !SNAP.nodes) {
  795. el('l2-cy').innerHTML = '<div class="l2-empty">' + t('l2.no_data', 'No data') + '</div>';
  796. } else {
  797. INDEX = buildIndex(SNAP);
  798. render(SNAP);
  799. }
  800. if (META.isLive) startTimer();
  801. else { el('l2-pause').disabled = true; el('l2-countdown').textContent = t('l2.snapshot', 'Snapshot'); }
  802. }
  803. if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
  804. else boot();
  805. })();