Przeglądaj źródła

feat(l2): network-split floor-list topology with exact left-edge straight links

clm0081 3 dni temu
rodzic
commit
03ab563e74
42 zmienionych plików z 516 dodań i 108 usunięć
  1. 4 2
      README.md
  2. 6 4
      docs/使用说明.md
  3. 349 97
      web-modules/WecomCenter/assets/l2topology/app.js
  4. 1 1
      web-modules/WecomCenter/assets/l2topology/config.json
  5. 10 0
      web-modules/WecomCenter/assets/l2topology/style.css
  6. 1 1
      web-modules/WecomCenter/lib/L2Store.php
  7. 4 0
      web-modules/WecomCenter/locale/bg_BG.php
  8. 4 0
      web-modules/WecomCenter/locale/ca_ES.php
  9. 4 0
      web-modules/WecomCenter/locale/cs_CZ.php
  10. 4 0
      web-modules/WecomCenter/locale/da_DK.php
  11. 4 0
      web-modules/WecomCenter/locale/de_DE.php
  12. 4 0
      web-modules/WecomCenter/locale/el_GR.php
  13. 5 1
      web-modules/WecomCenter/locale/en.php
  14. 4 0
      web-modules/WecomCenter/locale/en_GB.php
  15. 4 0
      web-modules/WecomCenter/locale/es_ES.php
  16. 4 0
      web-modules/WecomCenter/locale/fa_IR.php
  17. 4 0
      web-modules/WecomCenter/locale/fi_FI.php
  18. 4 0
      web-modules/WecomCenter/locale/fr_FR.php
  19. 4 0
      web-modules/WecomCenter/locale/he_IL.php
  20. 4 0
      web-modules/WecomCenter/locale/hu_HU.php
  21. 4 0
      web-modules/WecomCenter/locale/id_ID.php
  22. 4 0
      web-modules/WecomCenter/locale/it_IT.php
  23. 4 0
      web-modules/WecomCenter/locale/ja_JP.php
  24. 4 0
      web-modules/WecomCenter/locale/ka_GE.php
  25. 4 0
      web-modules/WecomCenter/locale/ko_KR.php
  26. 4 0
      web-modules/WecomCenter/locale/lt_LT.php
  27. 4 0
      web-modules/WecomCenter/locale/lv_LV.php
  28. 4 0
      web-modules/WecomCenter/locale/nb_NO.php
  29. 4 0
      web-modules/WecomCenter/locale/nl_NL.php
  30. 4 0
      web-modules/WecomCenter/locale/pl_PL.php
  31. 4 0
      web-modules/WecomCenter/locale/pt_BR.php
  32. 4 0
      web-modules/WecomCenter/locale/ro_RO.php
  33. 4 0
      web-modules/WecomCenter/locale/ru_RU.php
  34. 4 0
      web-modules/WecomCenter/locale/sk_SK.php
  35. 4 0
      web-modules/WecomCenter/locale/sv_SE.php
  36. 4 0
      web-modules/WecomCenter/locale/tr_TR.php
  37. 4 0
      web-modules/WecomCenter/locale/uk_UA.php
  38. 4 0
      web-modules/WecomCenter/locale/uz_UZ.php
  39. 4 0
      web-modules/WecomCenter/locale/vi_VN.php
  40. 5 1
      web-modules/WecomCenter/locale/zh_CN.php
  41. 4 0
      web-modules/WecomCenter/locale/zh_TW.php
  42. 3 1
      web-modules/WecomCenter/views/topology.view.php

+ 4 - 2
README.md

@@ -97,8 +97,10 @@ docker compose -p zabbix ps                                     # 查看状态
 
 模块 `web-modules/WecomCenter` 现包含 **L2 物理拓扑** 功能(展示页 + 配置页 + 采集器 + 历史库):
 
-- 展示页:`Monitoring → L2 Topology`(只读)。**两级视图**:车间卡片全景 → 点卡片进入车间详情,
-  核心交换机 ghost 对齐在汇聚正上方,核心→汇聚为带 `10G` 标注的蓝色竖线;含时间轴/深色/终端/搜索/速率&延迟&告警着色。
+- 展示页:`Monitoring → L2 Topology`(只读)。**两级视图**:车间卡片全景 → 点卡片进入车间详情;
+  详情先按 **OFFICE 办公网 / SURVE 监控网** 分页,同一车间/楼层也分网显示;左侧汇聚、右侧按 7F→GRD 楼层框单列接入,
+  汇聚→接入线从两侧标签左缘中点直连(出口允许被上级标签遮住,目标标签和线路不被遮挡、不交叉);
+  含时间轴/深色(默认开启)/终端/搜索/速率&延迟&告警着色。
 - 配置页:`Administration → ROMAN 定制中心 → 「L2 拓扑」`(采集模板、刷新/采集间隔、速率配色、
   延迟阈值、分组规则、根框名、默认深色等)。
 - 数据:每次全量采集把快照 gzip 写入 MySQL `l2_topology_history`(保留 7 天,可回看任意时间);

+ 6 - 4
docs/使用说明.md

@@ -228,15 +228,17 @@ zabbix-deploy/
 - **两级视图(默认全景)**:
   - **全景**:每个车间/DataCenter/Core 一张卡片,卡片上直接显示 `交换机数 / 核心上联数 ↑ / 告警 ⚠ / 离线终端 ◌`;
     27 个车间一屏排完,卡片之间不再画跨车间连线,彻底避免“蜘蛛网”。
-  - **车间详情**:点卡片进入。核心交换机以顶部灰色虚框(ghost)出现并**对齐在对应汇聚正上方**,
-    核心→汇聚是一条短的**蓝色 10G 实线(线中间标注 10G)**;汇聚下方按网格排列接入交换机,
-    1G 绿色线只在本车间内展开。点任意交换机可高亮它的全部链路,点空白处恢复;左上「← 返回全景」退出。
+  - **车间详情**:点卡片进入。先按 **OFFICE 办公网 / SURVE 监控网** 分页(同一车间、同一楼层也分开),一次只显示一张干净的树;
+    每个网络左侧一个汇聚,右侧按 **7F → 4F → 3F → 1F → GRD** 从上到下用楼层框分层,每层内接入交换机单列排列;
+    核心交换机以灰色虚框(ghost)出现在汇聚左侧,核心→汇聚为 10G 蓝色实线;
+    汇聚→接入的连线从**汇聚标签左缘中点**直直拉到**接入标签左缘中点**,线路出口允许被上级标签遮住,
+    但全程不穿过、不遮挡任何目标标签,也不与任何其他线路交叉。点任意交换机可高亮完整上联链路,点空白处恢复;左上「← 返回全景」退出。
 - **节点标签**:`车间 · 区域 · 楼层机柜U` + `角色 · IP` 两行;双击节点跳转该主机的问题页。
 - **告警面板只显示拓扑内交换机**,不再混入服务器/测试机等“拓扑外”主机;告警交换机边框统一红色。
 - **显示终端 / 只看离线**:勾选「显示终端」在交换机下方展开终端点(绿=在线、灰=离线);
   「只看离线」仅显示离线终端。右侧「离线终端」面板点击可联动定位。
 - **时间轴**:右上「快照时间」下拉可选 `实时` 或历史某分钟快照(保存 **7 天**,5 分钟一个点)。
-- **深色模式**:工具栏「深色模式」开关;配置页可设默认深色。
+- **深色模式**:工具栏「深色模式」开关;出厂默认开启(`dark_default=true`),配置页可改默认值。
 - **搜索**:输入 IP / MAC / 名称,下拉结果点击即居中定位。
 - 实时模式按配置的刷新秒数自动刷新,可暂停;支持缩放/适应窗口。
 

+ 349 - 97
web-modules/WecomCenter/assets/l2topology/app.js

@@ -35,6 +35,9 @@
   var timer = null;
   var lastSnap = null;
   var viewGroup = null;       // null = overview, otherwise workshop label
+  var detailNet = null;       // null/auto, 'SURVE' or 'OFFICE' inside a workshop
+  var pathParent = {};        // child -> parent chain for hover highlighting
+  var lockedPath = null;      // switch id whose path is locked by click
 
   function el(id) { return document.getElementById(id); }
   function t(k, d) { return (T[k] != null) ? T[k] : (d != null ? d : k); }
@@ -73,6 +76,31 @@
     return l1.join(' · ') + '\n' + l2.join(' · ');
   }
 
+  function detailLabel(n) {
+    var s = labelFor(n);
+    if (viewGroup) {
+      var p = viewGroup + ' · ';
+      if (s.indexOf(p) === 0) s = s.substring(p.length);
+    }
+    return s;
+  }
+
+  function rowLabel(n) {
+    var s = String((n && n.name) || (n && n.ip) || '');
+    s = s.replace(/^COBCO-/i, '');
+    if (viewGroup) {
+      try { s = s.replace(new RegExp('^' + viewGroup + '-', 'i'), ''); } catch (e) {}
+    }
+    return s;
+  }
+
+  function rootLabel(n) {
+    var s = rowLabel(n);
+    var m = /^(.+?)-(SURVE|OFFICE|OTHER)-(Dist|Access)-(.+)$/i.exec(s);
+    if (m) return m[2].toUpperCase() + ' · ' + m[1] + '\n' + m[3] + ' · ' + m[4];
+    return s;
+  }
+
   function expandLabel(tpl, m) {
     return String(tpl).replace(/\$(\d)/g, function (_, d) { return m && m[+d] != null ? m[+d] : ''; });
   }
@@ -188,11 +216,88 @@
     return Object.keys(pairs).map(function (k) { return pairs[k]; });
   }
 
-  /* ---------------- detail layout (one workshop, tidy tree) ---------------- */
+  /* ---------------- detail layout (one workshop, left-to-right tree) ---------------- */
+  function floorInfo(n) {
+    var toks = String((n && n.name) || '').split('-');
+    for (var i = 0; i < toks.length; i++) {
+      var tk = toks[i];
+      var bm = /^B(\d+)$/i.exec(tk);
+      if (bm) { var bn = parseInt(bm[1], 10); return {key: 'B' + bn, label: 'B' + bn, rank: -200 + bn}; }
+      if (/^GRD/i.test(tk)) return {key: 'GRD', label: 'GRD', rank: -100};
+      var fm = /^(\d+(?:\.\d+)?)F/i.exec(tk);
+      if (fm) {
+        var num = parseFloat(fm[1]);
+        var txt = (num % 1 ? num.toFixed(1) : String(num)) + 'F';
+        return {key: txt, label: txt, rank: num * 10};
+      }
+      var cm = /^(\d)([A-Z])(\w+)$/i.exec(tk);
+      if (cm) {
+        var n2 = parseInt(cm[1], 10);
+        return {key: n2 + 'F', label: n2 + 'F', rank: n2 * 10};
+      }
+    }
+    return {key: 'NA', label: t('l2.floor_na', 'Other'), rank: -500};
+  }
+
+  function baseNetworkOf(n) {
+    var s = String((n && n.name) || '').toUpperCase();
+    if (s.indexOf('SURVE') >= 0) return 'SURVE';
+    if (s.indexOf('OFFICE') >= 0) return 'OFFICE';
+    var ip = String((n && n.ip) || '');
+    if (/^10\.169\./.test(ip)) return 'SURVE';
+    if (/^10\.160\./.test(ip)) return 'OFFICE';
+    return 'OTHER';
+  }
+  function networkOf(n) { return baseNetworkOf(n); }
+
+  function networkLabel(key) {
+    if (key === 'SURVE') return t('l2.net_surve', 'SURVE 监控网');
+    if (key === 'OFFICE') return t('l2.net_office', 'OFFICE 办公网');
+    return t('l2.net_other', '其他网络');
+  }
+
   function computeDetail(snap, label) {
     var all = snap.nodes || [];
-    var byId = {}, nodes = [];
-    all.forEach(function (n) { byId[n.id] = n; if (groupOf(n).label === label) nodes.push(n); });
+    var byId = {}, groupNodes = [];
+    all.forEach(function (n) { byId[n.id] = n; if (groupOf(n).label === label) groupNodes.push(n); });
+
+    // Split one workshop by physical network first: office and surveillance never mix.
+    var gids = {}, rawAdj = {};
+    groupNodes.forEach(function (n) { gids[n.id] = true; rawAdj[n.id] = []; });
+    (snap.edges || []).forEach(function (e) {
+      if (gids[e.source] && gids[e.target]) { rawAdj[e.source].push(e.target); rawAdj[e.target].push(e.source); }
+    });
+    var netMap = {};
+    groupNodes.forEach(function (n) { netMap[n.id] = baseNetworkOf(n); });
+    // Unlabelled access switches inherit the network of the distribution they hang from.
+    for (var pass = 0; pass < 4; pass++) {
+      groupNodes.forEach(function (n) {
+        if (netMap[n.id] !== 'OTHER') return;
+        var votes = {};
+        rawAdj[n.id].forEach(function (id) {
+          var k = netMap[id];
+          if (k && k !== 'OTHER') votes[k] = (votes[k] || 0) + 1;
+        });
+        var best = null, bestN = 0;
+        Object.keys(votes).forEach(function (k) { if (votes[k] > bestN) { best = k; bestN = votes[k]; } });
+        if (best) netMap[n.id] = best;
+      });
+    }
+    var counts = {}, nets = [];
+    groupNodes.forEach(function (n) {
+      var k = netMap[n.id];
+      if (!counts[k]) { counts[k] = 0; nets.push(k); }
+      counts[k]++;
+    });
+    nets.sort(function (a, b) {
+      if (a === 'SURVE') return -1;
+      if (b === 'SURVE') return 1;
+      if (a === 'OFFICE') return -1;
+      if (b === 'OFFICE') return 1;
+      return counts[b] - counts[a];
+    });
+    var activeNet = (detailNet && counts[detailNet]) ? detailNet : (nets.length ? nets[0] : 'OTHER');
+    var nodes = groupNodes.filter(function (n) { return netMap[n.id] === activeNet; });
     var ids = {};
     nodes.forEach(function (n) { ids[n.id] = true; });
     var internal = [], external = [];
@@ -202,100 +307,139 @@
       else if (a || b) external.push(e);
     });
 
-    var adj = {}, kids = {};
-    nodes.forEach(function (n) { adj[n.id] = []; kids[n.id] = []; });
+    var adj = {};
+    nodes.forEach(function (n) { adj[n.id] = []; });
     internal.forEach(function (e) { adj[e.source].push(e.target); adj[e.target].push(e.source); });
 
-    function rankNode(n) {
-      var tier = n.tier || '';
-      if (/core/i.test(tier)) return 0;
-      if (/dist/i.test(tier)) return 1;
-      return 2;
+    // Distribution switches are the left-hand roots of the workshop tree.
+    var dists = nodes.filter(function (n) { return /dist/i.test(n.tier || '') && /dist/i.test(n.name || ''); })
+      .sort(function (a, b) { return (adj[b.id].length - adj[a.id].length) || String(a.name).localeCompare(String(b.name)); });
+    if (!dists.length) {
+      var cand = nodes.slice().sort(function (a, b) { return adj[b.id].length - adj[a.id].length; })[0];
+      if (cand) dists = [cand];
     }
-    var roots = nodes.slice().sort(function (a, b) {
-      var ra = rankNode(a), rb = rankNode(b);
-      if (ra !== rb) return ra - rb;
-      return adj[b.id].length - adj[a.id].length;
-    });
-    var seen = {}, orderedRoots = [];
-    roots.forEach(function (r) {
-      if (seen[r.id]) return;
-      orderedRoots.push(r);
-      var queue = [r.id]; seen[r.id] = true;
-      while (queue.length) {
-        var cur = queue.shift();
-        adj[cur].forEach(function (nb) {
-          if (seen[nb]) return;
-          seen[nb] = true;
-          kids[cur].push(nb);
-          queue.push(nb);
-        });
-      }
+    // One physical root per network tab; peer/redundant distribution switches are listed below it.
+    if (dists.length > 1) dists = [dists[0]];
+
+    // Group all non-distribution switches into floor buckets (GRD lowest, then 1F/2F/3F...).
+    var floorMap = {};
+    nodes.forEach(function (n) {
+      if (dists.indexOf(n) >= 0) return;
+      if (/core/i.test(n.tier || '')) return;
+      var fi = floorInfo(n);
+      var f = floorMap[fi.key] || (floorMap[fi.key] = {key: fi.key, label: fi.label, rank: fi.rank, nodes: []});
+      f.nodes.push(n);
     });
-    nodes.forEach(function (n) { if (!seen[n.id]) { seen[n.id] = true; orderedRoots.push(n); } });
-
-    // Compact blocks: each dist root gets a grid of access switches underneath it.
-    var pos = {}, blockX = 0, maxBlockH = 0;
-    orderedRoots.forEach(function (rootN) {
-      var d1 = kids[rootN.id] || [];
-      var cols = Math.max(1, Math.min(5, Math.ceil(Math.sqrt(Math.max(1, d1.length) * 1.2))));
-      var blockW = cols * NODE_W + (cols - 1) * DGAP_X;
-      pos[rootN.id] = {x: blockX + blockW / 2, y: 0};
-      d1.forEach(function (c, i) {
-        var col = i % cols, row = Math.floor(i / cols);
-        pos[c] = {x: blockX + col * (NODE_W + DGAP_X) + NODE_W / 2, y: (row + 1) * (NODE_H + DGAP_Y)};
+    var floors = Object.keys(floorMap).map(function (k) { return floorMap[k]; });
+    floors.forEach(function (f) {
+      f.nodes.sort(function (a, b) {
+        var pa = parseName(a.name), pb = parseName(b.name);
+        var ca = (pa.cab || '') + (pa.u || ''), cb = (pb.cab || '') + (pb.u || '');
+        if (ca !== cb) return ca.localeCompare(cb);
+        return String(a.name).localeCompare(String(b.name));
       });
-      var rows = Math.ceil(d1.length / cols);
-      var d2 = [];
-      d1.forEach(function (c) {
-        (kids[c] || []).forEach(function (k) { if (k !== rootN.id) d2.push(k); });
-      });
-      var y2 = (rows + 1) * (NODE_H + DGAP_Y) + 16;
-      d2.forEach(function (id, i) {
-        pos[id] = {x: blockX + (i % cols) * (NODE_W + DGAP_X) + NODE_W / 2, y: y2 + Math.floor(i / cols) * (NODE_H + DGAP_Y)};
+    });
+    // Highest floor at the top, GRD at the bottom.
+    floors.sort(function (a, b) { return b.rank - a.rank; });
+
+    var WORK_L = 300, FLOOR_X = 520, DIST_FRAME_W = 190, TOP_OFF = 34;
+    var ROW_W = 380, ROW_H = 24, ROW_GAP = 8, FLOOR_HEAD = 28, FLOOR_GAP = 12;
+    var floorW = ROW_W + PADX * 2 + 110;
+    floors.forEach(function (f) {
+      f.cols = 1;
+      f.rows = f.nodes.length;
+      f.innerW = ROW_W;
+      f.h = FLOOR_HEAD + f.rows * ROW_H + Math.max(0, f.rows - 1) * ROW_GAP + 10;
+    });
+    var workRight = FLOOR_X + floorW + 14;
+    var workW = workRight - WORK_L;
+    var pos = {}, top = 44 + TOP_OFF;
+    floors.forEach(function (f) {
+      f.frame = {x: FLOOR_X, y: top, w: floorW, h: f.h};
+      f.nodes.forEach(function (n, i) {
+        pos[n.id] = {
+          x: FLOOR_X + PADX + ROW_W / 2,
+          y: top + FLOOR_HEAD + i * (ROW_H + ROW_GAP) + ROW_H / 2
+        };
       });
-      maxBlockH = Math.max(maxBlockH, y2 + Math.ceil(d2.length / cols) * (NODE_H + DGAP_Y));
-      blockX += blockW + 70;
+      top += f.h + FLOOR_GAP;
     });
-    var missing = [];
-    nodes.forEach(function (n) { if (!pos[n.id]) missing.push(n.id); });
-    missing.forEach(function (id, i) {
-      pos[id] = {x: (i % 6) * (NODE_W + DGAP_X) + NODE_W / 2, y: maxBlockH + 70 + Math.floor(i / 6) * (NODE_H + DGAP_Y)};
+    var workH = Math.max(220, top + 6);
+    var distFrame = dists.length ? {x: WORK_L + 8, y: 44 + TOP_OFF, w: DIST_FRAME_W, h: workH - (44 + TOP_OFF) - 14} : null;
+    var distX = WORK_L + 8 + DIST_FRAME_W / 2;
+    if (dists.length) {
+      var childYs = floors.reduce(function (acc, f) {
+        return acc.concat(f.nodes.map(function (n) { return pos[n.id] ? pos[n.id].y : 0; }));
+      }, []);
+      var cy = childYs.length ? (Math.min.apply(null, childYs) + Math.max.apply(null, childYs)) / 2 : distFrame.y + distFrame.h / 2;
+      pos[dists[0].id] = {x: distX, y: cy};
+    }
+    var missing = nodes.filter(function (n) { return !pos[n.id]; });
+    missing.forEach(function (n, i) {
+      pos[n.id] = {x: distX, y: (distFrame ? distFrame.y + 30 : 80) + i * (NODE_H + 12)};
     });
-    var totalW = Math.max(NODE_W, blockX - 70);
 
+    // One ghost core per external core→distribution uplink, aligned left of its distribution switch.
     var ghosts = [], ghostSeen = {};
     external.forEach(function (e) {
       var inside = ids[e.source] ? e.source : e.target;
       var outside = ids[e.source] ? e.target : e.source;
-      if (!ghostSeen[outside]) {
-        ghostSeen[outside] = {id: outside, to: inside, node: byId[outside], speed: e.speed, state: e.state};
-        ghosts.push(ghostSeen[outside]);
-      }
+      if (!pos[inside] || !byId[outside]) return;
+      // Only a real core uplink gets a ghost; other cross-group edges are not drawn as cores.
+      if (!/core/i.test(byId[outside].tier || '') && !/core/i.test(byId[outside].name || '')) return;
+      var key = outside + '|' + inside;
+      if (ghostSeen[key]) return;
+      var g = {id: 'gh_' + outside + '_' + inside, to: inside, node: byId[outside], speed: e.speed, state: e.state};
+      ghostSeen[key] = g;
+      ghosts.push(g);
     });
     var ghostAt = {};
     ghosts.forEach(function (g) {
-      var base = pos[g.to] || {x: 80};
+      var base = pos[g.to];
       var k = ghostAt[g.to] || 0;
       ghostAt[g.to] = k + 1;
-      g.x = base.x + (k ? (k % 2 ? 1 : -1) * 95 * Math.ceil(k / 2) : 0);
-      g.y = -86 - Math.floor(k / 2) * 48;
+      g.x = 92;
+      g.y = base.y + (k ? (k % 2 ? 1 : -1) * 46 * Math.ceil(k / 2) : 0);
     });
 
-    var maxY = 0, minX = 0, maxX2 = totalW;
-    Object.keys(pos).forEach(function (id) {
-      maxY = Math.max(maxY, pos[id].y + NODE_H);
-      minX = Math.min(minX, pos[id].x - NODE_W / 2);
-      maxX2 = Math.max(maxX2, pos[id].x + NODE_W / 2);
+    // Root each tree at the distribution switch; remember every child's parent for path highlighting.
+    var parentOf = {}, ghostOfDist = {};
+    ghosts.forEach(function (g) { if (!ghostOfDist[g.to]) ghostOfDist[g.to] = g.id; });
+    var seen = {};
+    dists.forEach(function (d) {
+      if (seen[d.id]) return;
+      seen[d.id] = true;
+      parentOf[d.id] = ghostOfDist[d.id] || null;
+      var queue = [d.id];
+      while (queue.length) {
+        var cur = queue.shift();
+        adj[cur].forEach(function (nb) {
+          if (seen[nb]) return;
+          seen[nb] = true;
+          parentOf[nb] = cur;
+          queue.push(nb);
+        });
+      }
     });
-    ghosts.forEach(function (g) {
-      minX = Math.min(minX, g.x - 95);
-      maxX2 = Math.max(maxX2, g.x + 95);
+    nodes.forEach(function (n) { if (!seen[n.id]) seen[n.id] = true; });
+
+    var workshop = {x: WORK_L, y: 0, w: workW, h: workH, label: label + '  ·  ' + networkLabel(activeNet) + '  ·  ' + nodes.length + ' ' + t('l2.unit_sw', 'sw')};
+    var distFrameFull = distFrame ? {
+      x: distFrame.x, y: distFrame.y, w: distFrame.w, h: distFrame.h,
+      label: t('l2.dist', 'Distribution') + '  ·  ' + dists.length
+    } : null;
+    floors.forEach(function (f) {
+      f.frame.label = f.label + '  ·  ' + f.nodes.length + ' ' + t('l2.unit_sw', 'sw');
     });
-    var top = ghosts.length ? -168 : -120;
-    var root = {x: minX - 38, y: top, w: (maxX2 - minX) + 76, h: maxY - top + 54};
+
+    var root = {x: -20, y: -48, w: workRight + 40, h: workH + 80};
     root.cx = root.x + root.w / 2; root.cy = root.y + root.h / 2;
-    return {nodes: nodes, byId: byId, pos: pos, ghosts: ghosts, root: root, totalW: totalW, ids: ids, internal: internal};
+    return {
+      nodes: nodes, byId: byId, pos: pos, ghosts: ghosts, parentOf: parentOf,
+      dists: dists, floors: floors, frames: {workshop: workshop, dist: distFrameFull},
+      root: root, totalW: workRight, ids: ids, internal: internal,
+      nets: nets.map(function (k) { return {key: k, count: counts[k]}; }), activeNet: activeNet, totalNodes: groupNodes.length
+    };
   }
 
   /* ---------------- speed colour ---------------- */
@@ -341,6 +485,22 @@
         'font-size': 'data(fs)', 'font-weight': 800, 'color': d ? '#aebfd8' : '#526178',
         'text-margin-y': 8, 'padding': '0px', 'events': 'no', 'z-index-compare': 'manual', 'z-index': 2
       }},
+      {selector: 'node[type="wsframe"]', style: {
+        'background-opacity': d ? 0.025 : 0.018, 'background-color': d ? '#6f8fc4' : '#8fa6c4',
+        'border-width': 2.2, 'border-style': 'solid', 'border-color': d ? '#4f6790' : '#9fb0c6',
+        'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center', 'text-wrap': 'none',
+        'font-size': 19, 'font-weight': 900, 'color': d ? '#d7e6ff' : '#2f4560',
+        'text-margin-x': 0, 'text-margin-y': 8, 'padding': '0px', 'events': 'no',
+        'z-index-compare': 'manual', 'z-index': 1
+      }},
+      {selector: 'node[type="floorframe"]', style: {
+        'background-opacity': d ? 0.055 : 0.035, 'background-color': d ? '#5b7bb0' : '#8fa6c4',
+        'border-width': 1.2, 'border-style': 'dashed', 'border-color': d ? '#3f5377' : '#b9c6d8',
+        'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center', 'text-wrap': 'none',
+        'font-size': 13, 'font-weight': 800, 'color': d ? '#a9c1e3' : '#425a78',
+        'text-margin-x': 0, 'text-margin-y': 7, 'padding': '0px', 'events': 'no',
+        'z-index-compare': 'manual', 'z-index': 2
+      }},
       {selector: 'node[type="root"]', style: {
         'background-opacity': d ? 0.02 : 0.012, 'background-color': d ? '#7aa0d8' : '#8ea6c2',
         'border-width': 2.2, 'border-style': 'solid', 'border-color': d ? '#476492' : '#9fb0c6',
@@ -367,6 +527,9 @@
         'font-size': 11, 'font-weight': 600, 'text-wrap': 'wrap', 'text-max-width': 176,
         'z-index-compare': 'manual', 'z-index': 15
       }},
+      {selector: 'node[type="edgepoint"]', style: {
+        'width': 1, 'height': 1, 'opacity': 0, 'events': 'no'
+      }},
       {selector: 'node[type="term"]', style: {
         'width': 12, 'height': 12, 'shape': 'ellipse', 'background-color': 'data(bg)',
         'border-width': 1, 'border-color': d ? '#2a3648' : '#cfd8e3', 'label': '',
@@ -407,7 +570,15 @@
       {selector: 'node.lat-warn', style: {'border-color': '#F39C12'}},
       {selector: 'node.lat-bad', style: {'border-color': '#E74C3C', 'border-width': 6}},
       {selector: 'node.hl', style: {'border-color': '#ff0', 'border-width': 7, 'overlay-color': '#ff0', 'overlay-opacity': 0.25, 'overlay-padding': 6}},
-      {selector: 'node[type="switch"]', style: {'background-color': function (e) { return e.data('bg'); }}}
+      {selector: 'node.pathhl', style: {'border-width': 5, 'border-color': '#FFD54A', 'overlay-color': '#FFD54A', 'overlay-opacity': 0.2, 'overlay-padding': 4}},
+      {selector: 'edge.pathhl', style: {'width': 5.5, 'line-color': '#FFD54A', 'opacity': 1}},
+      {selector: 'edge.detail', style: {'opacity': 0.72, 'curve-style': 'straight', 'source-endpoint': '0% 50%', 'target-endpoint': '0% 50%'}},
+      {selector: 'node[type="switch"]', style: {
+        'background-color': function (e) { return e.data('bg'); },
+        'text-max-width': 'data(tw)', 'text-wrap': 'none',
+        'text-halign': 'center', 'font-size': 10.5
+      }},
+      {selector: 'node[type="switch"].root', style: {'text-wrap': 'wrap'}}
     ];
   }
 
@@ -451,6 +622,7 @@
 
   function renderOverview(snap) {
     var O = computeOverview(snap), els = [], idx = {};
+    var nb = el('l2-net-tabs'); if (nb) { nb.style.display = 'none'; nb.innerHTML = ''; }
     if (O.root) {
       els.push({group: 'nodes', data: {id: '__root', type: 'root', label: CFG.root_label || 'COBCO', w: O.root.w, h: O.root.h},
         position: {x: O.root.cx, y: O.root.cy}, selectable: false, grabbable: false});
@@ -476,12 +648,33 @@
     applyElements(els);
   }
 
+  function renderNetTabs(D) {
+    var box = el('l2-net-tabs');
+    if (!box) return;
+    box.innerHTML = '';
+    if (!D.nets || D.nets.length < 2) { box.style.display = 'none'; return; }
+    box.style.display = 'flex';
+    D.nets.forEach(function (n) {
+      var b = document.createElement('button');
+      b.type = 'button';
+      b.className = 'l2-net-tab' + (n.key === D.activeNet ? ' active' : '');
+      b.textContent = networkLabel(n.key) + ' · ' + n.count + ' ' + t('l2.unit_sw', 'sw');
+      b.addEventListener('click', function () { detailNet = n.key; render(lastSnap); });
+      box.appendChild(b);
+    });
+  }
+
   function renderDetail(snap) {
     var D = computeDetail(snap, viewGroup), els = [];
-    if (D.root) {
-      els.push({group: 'nodes', data: {id: '__root', type: 'root', label: (CFG.root_label || 'COBCO') + ' / ' + viewGroup, w: D.root.w, h: D.root.h},
-        position: {x: D.root.cx, y: D.root.cy}, selectable: false, grabbable: false});
+    renderNetTabs(D);
+    function frameEl(id, type, fr) {
+      els.push({group: 'nodes', data: {id: id, type: type, label: fr.label, w: fr.w, h: fr.h},
+        position: {x: fr.x + fr.w / 2, y: fr.y + fr.h / 2}, selectable: false, grabbable: false});
     }
+    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});
+    if (D.frames.dist) frameEl('__dist', 'floorframe', D.frames.dist);
+    D.floors.forEach(function (f, i) { frameEl('__floor_' + i, 'floorframe', f.frame); });
+
     (D.ghosts || []).forEach(function (g) {
       var nm = (g.node && g.node.name) || g.id;
       var ip = (g.node && g.node.ip) || '';
@@ -496,13 +689,16 @@
       }});
     });
     var switchIds = {};
-    D.nodes.forEach(function (n) {
+    D.nodes.forEach(function (n, ni) {
       switchIds[n.id] = true;
       var p = D.pos[n.id] || {x: 0, y: 0};
       var tier = n.tier || 'access';
       var tkey = (tier.indexOf('dist') === 0) ? 'dist' : (tier.indexOf('core') === 0 ? 'core' : 'access');
+      var isRoot = D.dists && D.dists.length && n.id === D.dists[0].id;
       var cls = [];
       var bd = (dark ? TIER_BG_DARK[tkey] : TIER_BG[tkey]) || '#555';
+      var bg = (dark ? TIER_BG_DARK[tkey] : TIER_BG[tkey]) || '#777';
+      if (!isRoot && ni % 2 === 1) bg = dark ? '#4f8a3a' : '#6ca75a';
       if (n.alarms && n.alarms.length) {
         cls.push('alarm');
         bd = '#E45959';
@@ -511,21 +707,42 @@
         if (+n.latency_ms > LATB) cls.push('lat-bad');
         else if (+n.latency_ms > LATW) cls.push('lat-warn');
       }
+      if (isRoot) cls.push('root');
       els.push({group: 'nodes', data: {
         id: n.id, type: 'switch', name: n.name, ip: n.ip, tier: tier, depth: n.depth,
         hostid: n.hostid, oper: n.oper, maxsev: n.maxsev, alarms: n.alarms || [],
         term_online: n.term_online, term_offline: n.term_offline, latency_ms: n.latency_ms,
-        label: labelFor(n), bg: (dark ? TIER_BG_DARK[tkey] : TIER_BG[tkey]) || '#777',
-        bd: bd, w: NODE_W, h: NODE_H, group: viewGroup
+        label: isRoot ? rootLabel(n) : rowLabel(n), tw: isRoot ? 150 : 360,
+        bg: bg, bd: isRoot ? bd : '#25501e',
+        w: isRoot ? 170 : 380, h: isRoot ? 50 : 24, group: viewGroup
       }, position: p, classes: cls.join(' ')});
     });
     D.internal.forEach(function (e, i) {
+      var root = D.dists && D.dists[0];
+      if (root && (e.source === root.id || e.target === root.id)) {
+        var childId = e.source === root.id ? e.target : e.source;
+        var pp = D.pos[root.id], cp = D.pos[childId];
+        if (pp && cp) {
+          var sp = 'sp_' + i, tp = 'tp_' + i;
+          // Exact left-edge midpoints, straight line. The parent label is
+          // allowed to cover the line where it exits the parent.
+          els.push({group: 'nodes', data: {id: sp, type: 'edgepoint'}, position: {x: pp.x - 86, y: pp.y}, selectable: false, grabbable: false});
+          els.push({group: 'nodes', data: {id: tp, type: 'edgepoint'}, position: {x: cp.x - 191, y: cp.y}, selectable: false, grabbable: false});
+          els.push({group: 'edges', data: {
+            id: 'e_' + i, type: 'detail', source: sp, target: tp, p: root.id, c: childId,
+            speed: e.speed, state: e.state, a_port: e.a_port, b_port: e.b_port,
+            color: speedColor(e.speed, e.state), ls: (e.state && e.state !== 'up') ? 'dashed' : 'solid',
+            w: Math.min(2.4, speedWidth(e.speed))
+          }, classes: 'detail'});
+          return;
+        }
+      }
       els.push({group: 'edges', data: {
         id: 'e_' + i, source: e.source, target: e.target, speed: e.speed, state: e.state,
         a_port: e.a_port, b_port: e.b_port,
         color: speedColor(e.speed, e.state), ls: (e.state && e.state !== 'up') ? 'dashed' : 'solid',
-        w: speedWidth(e.speed)
-      }});
+        w: Math.min(2.4, speedWidth(e.speed))
+      }, classes: 'detail'});
     });
     if (showTerm) {
       var bySw = {};
@@ -547,6 +764,8 @@
         });
       });
     }
+    pathParent = D.parentOf || {};
+    lockedPath = null;
     applyElements(els);
   }
 
@@ -576,7 +795,7 @@
     h += '<div class="lg-row"><span class="lg-box" style="background:#E45959;border-radius:2px"></span>' + t('l2.alarms', 'Alarms') + '</div>';
     h += '<div class="lg-row"><span class="lg-box" style="background:#2ECC71;border-radius:50%"></span>' + t('l2.term_on', 'Online') +
          '<span class="lg-box" style="background:#95a5a6;border-radius:50%;margin-left:8px"></span>' + t('l2.term_off', 'Offline') + '</div>';
-    h += '<div class="lg-row" style="opacity:.72;margin-top:4px">' + t('l2.click_hint', 'Click a switch to highlight its links') + '</div>';
+    h += '<div class="lg-row" style="opacity:.72;margin-top:4px">' + t('l2.click_hint', 'Hover a switch to highlight its full upstream path') + '</div>';
     el('l2-legend').innerHTML = h;
   }
 
@@ -658,19 +877,51 @@
       if (!tm) return;
       var g = groupOfNodeId(tm.sw_ip);
       if (g) viewGroup = g;
+      var swHit = (lastSnap.nodes || []).filter(function (n) { return n.id === tm.sw_ip; })[0];
+      if (swHit) detailNet = networkOf(swHit);
       showTerm = true;
       if (el('l2-showterm')) el('l2-showterm').checked = true;
       render(lastSnap);
       setTimeout(function () { focusNode(id); }, 90);
     } else {
       var g2 = groupOfNodeId(id);
-      if (g2 && viewGroup !== g2) { viewGroup = g2; render(lastSnap); }
+      if (g2 && viewGroup !== g2) { viewGroup = g2; detailNet = null; render(lastSnap); }
+      var hit2 = (lastSnap.nodes || []).filter(function (n) { return n.id === id; })[0];
+      if (hit2) detailNet = networkOf(hit2);
+      render(lastSnap);
       setTimeout(function () { focusNode(id); }, 90);
     }
   }
 
+  function clearPath() {
+    if (!cy) return;
+    cy.elements().removeClass('dim pathhl');
+    lockedPath = null;
+  }
+
+  function highlightPath(nodeId) {
+    if (!cy) return;
+    var chain = [], id = nodeId, guard = 0;
+    while (id && guard++ < 40) {
+      chain.unshift(id);
+      id = pathParent[id] || null;
+    }
+    cy.elements('node[type="switch"], node[type="ghost"], edge').addClass('dim').removeClass('pathhl');
+    var nodes = cy.collection();
+    chain.forEach(function (cid) { var n = cy.getElementById(cid); if (n && n.length) nodes = nodes.union(n); });
+    nodes.removeClass('dim').addClass('pathhl');
+    var edges = cy.edges().filter(function (e) {
+      var p = e.data('p'), c = e.data('c');
+      if (p && c) return chain.indexOf(p) >= 0 && chain.indexOf(c) >= 0;
+      var s = e.data('source'), t = e.data('target');
+      return chain.indexOf(s) >= 0 && chain.indexOf(t) >= 0;
+    });
+    edges.removeClass('dim').addClass('pathhl');
+  }
+
   function bindCy() {
     cy.on('mouseover', 'node[type="switch"]', function (e) {
+      if (viewGroup) highlightPath(e.target.id());
       var d = e.target.data();
       var arr = d.alarms || [];
       var sev = (d.maxsev != null && d.maxsev >= 0) ? ('<span style="color:' + SEV_COLORS[d.maxsev] + '">● ' + SEV_NAMES[d.maxsev] + '</span>') : '—';
@@ -683,7 +934,10 @@
       if (arr.length) html += '<hr>' + arr.map(function (a) { return esc(a.name || a.description || ''); }).join('<br>');
       showTip(e, html);
     });
-    cy.on('mouseout', 'node[type="switch"]', hideTip);
+    cy.on('mouseout', 'node[type="switch"]', function () {
+      if (!lockedPath) clearPath();
+      hideTip();
+    });
     cy.on('mouseover', 'node[type="tile"]', function (e) {
       var d = e.target.data();
       var html = '<b>' + esc(d.group) + '</b><br>' + d.n + ' ' + t('l2.unit_sw', 'sw') + '<br>' +
@@ -694,7 +948,7 @@
     cy.on('mouseout', 'node[type="tile"]', hideTip);
     cy.on('tap', 'node[type="tile"]', function (e) {
       var g = e.target.data('group');
-      if (g) { viewGroup = g; render(lastSnap); }
+      if (g) { viewGroup = g; detailNet = null; render(lastSnap); }
     });
     cy.on('mouseover', 'edge:not([type="term"])', function (e) {
       var d = e.target.data();
@@ -703,16 +957,11 @@
     });
     cy.on('mouseout', 'edge', hideTip);
     cy.on('tap', 'node[type="switch"]', function (e) {
-      var n = e.target;
-      cy.elements().removeClass('dim hl');
-      cy.elements().not(n).addClass('dim');
-      n.removeClass('dim');
-      var links = n.connectedEdges();
-      links.removeClass('dim').addClass('hl');
-      links.connectedNodes().removeClass('dim');
+      lockedPath = e.target.id();
+      highlightPath(lockedPath);
     });
     cy.on('tap', function (e) {
-      if (e.target === cy) cy.elements().removeClass('dim hl');
+      if (e.target === cy) clearPath();
     });
     cy.on('dbltap', 'node[type="switch"]', function (e) {
       var hid = e.target.data('hostid');
@@ -772,7 +1021,7 @@
     var back = el('l2-back');
     if (back) {
       back.textContent = t('l2.back_overview', '← Overview');
-      back.addEventListener('click', function () { viewGroup = null; render(lastSnap); });
+      back.addEventListener('click', function () { viewGroup = null; detailNet = null; render(lastSnap); });
     }
 
     el('l2-dark').addEventListener('change', function () {
@@ -838,8 +1087,11 @@
   function boot() {
     setupControls();
     try {
-      var qg = new URLSearchParams(window.location.search).get('g');
+      var qs = new URLSearchParams(window.location.search);
+      var qg = qs.get('g');
       if (qg) viewGroup = qg;
+      var qn = qs.get('net');
+      if (qn) detailNet = qn;
     } catch (e) {}
     if (!SNAP || !SNAP.nodes) {
       el('l2-cy').innerHTML = '<div class="l2-empty">' + t('l2.no_data', 'No data') + '</div>';

+ 1 - 1
web-modules/WecomCenter/assets/l2topology/config.json

@@ -1,6 +1,6 @@
 {
   "refresh_seconds": 300,
-  "dark_default": false,
+  "dark_default": true,
   "root_label": "COBCO",
   "templates": ["Huawei VRP by SNMP (walk)"],
   "full_interval_min": 5,

+ 10 - 0
web-modules/WecomCenter/assets/l2topology/style.css

@@ -89,6 +89,16 @@
   background: color-mix(in srgb, var(--panel) 88%, transparent);
   border: 1px solid var(--border); color: var(--text); backdrop-filter: blur(4px);
 }
+.l2-net-tabs {
+  position: absolute; right: 12px; top: 12px; z-index: 21; display: flex; gap: 8px;
+}
+.l2-net-tab {
+  border: 1px solid var(--border); background: color-mix(in srgb, var(--panel) 90%, transparent);
+  color: var(--muted); font-size: 12.5px; font-weight: 700; padding: 7px 12px; border-radius: 8px;
+  cursor: pointer; backdrop-filter: blur(4px);
+}
+.l2-net-tab:hover { color: var(--text); border-color: var(--brand); }
+.l2-net-tab.active { color: #fff; background: var(--brand); border-color: var(--brand); }
 .l2-legend {
   position: absolute; left: 12px; bottom: 12px; z-index: 20;
   padding: 10px 12px; font-size: 12px; border-radius: 10px; max-width: 240px;

+ 1 - 1
web-modules/WecomCenter/lib/L2Store.php

@@ -28,7 +28,7 @@ class L2Store {
 		return [
 			// Display page ------------------------------------------------------
 			'refresh_seconds'   => 300,          // auto-refresh of the live view
-			'dark_default'      => false,        // start in dark mode
+			'dark_default'      => true,         // start in dark mode
 			'root_label'        => 'COBCO',      // outermost frame label
 			// Collection --------------------------------------------------------
 			'templates'         => ['Huawei VRP by SNMP (walk)'],

+ 4 - 0
web-modules/WecomCenter/locale/bg_BG.php

@@ -198,4 +198,8 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/ca_ES.php

@@ -198,4 +198,8 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/cs_CZ.php

@@ -198,4 +198,8 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/da_DK.php

@@ -198,4 +198,8 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/de_DE.php

@@ -208,4 +208,8 @@ return [
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/el_GR.php

@@ -198,4 +198,8 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 5 - 1
web-modules/WecomCenter/locale/en.php

@@ -196,7 +196,11 @@ return [
 'l2.unit_sw'         => 'switches',
 'l2.tile_hint'       => 'Click to open this workshop',
 'l2.not_in_topo'     => 'not in topology',
-'l2.click_hint'      => 'Click a switch to highlight its links',
+'l2.click_hint'      => 'Hover a switch to highlight its full upstream path',
+'l2.net_surve'       => 'SURVE surveillance',
+'l2.net_office'      => 'OFFICE office',
+'l2.net_other'       => 'Other network',
+'l2.floor_na'        => 'Other',
 	'l2.search_ph'       => 'Search IP / MAC / name…',
 	'l2.show_term'       => 'Show terminals',
 	'l2.only_off'        => 'Only offline',

+ 4 - 0
web-modules/WecomCenter/locale/en_GB.php

@@ -198,4 +198,8 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/es_ES.php

@@ -208,4 +208,8 @@ return [
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/fa_IR.php

@@ -198,4 +198,8 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/fi_FI.php

@@ -198,4 +198,8 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/fr_FR.php

@@ -208,4 +208,8 @@ return [
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/he_IL.php

@@ -198,4 +198,8 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/hu_HU.php

@@ -198,4 +198,8 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/id_ID.php

@@ -198,4 +198,8 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/it_IT.php

@@ -208,4 +208,8 @@ return [
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/ja_JP.php

@@ -208,4 +208,8 @@ return [
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/ka_GE.php

@@ -198,4 +198,8 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/ko_KR.php

@@ -208,4 +208,8 @@ return [
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/lt_LT.php

@@ -198,4 +198,8 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/lv_LV.php

@@ -198,4 +198,8 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/nb_NO.php

@@ -198,4 +198,8 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/nl_NL.php

@@ -198,4 +198,8 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/pl_PL.php

@@ -208,4 +208,8 @@ return [
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/pt_BR.php

@@ -208,4 +208,8 @@ return [
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/ro_RO.php

@@ -198,4 +198,8 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/ru_RU.php

@@ -208,4 +208,8 @@ return [
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/sk_SK.php

@@ -198,4 +198,8 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/sv_SE.php

@@ -198,4 +198,8 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/tr_TR.php

@@ -208,4 +208,8 @@ return [
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/uk_UA.php

@@ -198,4 +198,8 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/uz_UZ.php

@@ -198,4 +198,8 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 4 - 0
web-modules/WecomCenter/locale/vi_VN.php

@@ -198,4 +198,8 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 5 - 1
web-modules/WecomCenter/locale/zh_CN.php

@@ -183,7 +183,11 @@ return [
 	'l2.unit_sw'         => '台',
 	'l2.tile_hint'       => '点击进入该车间',
 	'l2.not_in_topo'     => '不在拓扑内',
-	'l2.click_hint'      => '点击交换机可高亮其链路',
+	'l2.click_hint'      => '鼠标移到交换机上可高亮整条上行链路',
+	'l2.net_surve'       => 'SURVE 监控网',
+	'l2.net_office'      => 'OFFICE 办公网',
+	'l2.net_other'       => '其他网络',
+	'l2.floor_na'        => '其他',
 	'l2.search_ph'       => '搜索 IP / MAC / 名称…',
 	'l2.show_term'       => '显示终端',
 	'l2.only_off'        => '只看离线',

+ 4 - 0
web-modules/WecomCenter/locale/zh_TW.php

@@ -208,4 +208,8 @@ return [
 	'l2.tile_hint' => 'Click to open this workshop',
 	'l2.not_in_topo' => 'not in topology',
 	'l2.click_hint' => 'Click a switch to highlight its links',
+	'l2.net_surve' => 'SURVE surveillance',
+	'l2.net_office' => 'OFFICE office',
+	'l2.net_other' => 'Other network',
+	'l2.floor_na' => 'Other',
 ];

+ 3 - 1
web-modules/WecomCenter/views/topology.view.php

@@ -20,7 +20,8 @@ $i18nKeys = [
 	'l2.time_label','l2.dark','l2.no_data','l2.offline_empty','l2.alarm_empty','l2.updated',
 	'l2.pause','l2.resume','l2.legend_speed','l2.legend_tier','l2.legend_lat','l2.core','l2.dist','l2.access',
 	'l2.lat_norm','l2.term_on','l2.term_off','l2.zoom_in','l2.zoom_out','l2.snapshot','l2.showing','l2.live_tag',
-	'l2.back_overview','l2.unit_sw','l2.tile_hint','l2.not_in_topo','l2.click_hint'
+	'l2.back_overview','l2.unit_sw','l2.tile_hint','l2.not_in_topo','l2.click_hint',
+	'l2.net_surve','l2.net_office','l2.net_other'
 ];
 $i18n = [];
 foreach ($i18nKeys as $k) { $i18n[$k] = I18n::t($k); }
@@ -69,6 +70,7 @@ if (!function_exists('l2e')) { function l2e($v) { return htmlspecialchars((strin
     <div class="l2-canvas">
       <div id="l2-cy"></div>
       <div class="l2-stats" id="l2-stats"></div>
+      <div class="l2-net-tabs" id="l2-net-tabs" style="display:none"></div>
       <div class="l2-legend" id="l2-legend"></div>
     </div>