Преглед изворни кода

fix(l2): two-level topology - workshop overview tiles + per-workshop core-to-dist tree; drop out-of-topology alarms

clm0081 пре 3 дана
родитељ
комит
6b26b98939
39 измењених фајлова са 595 додато и 114 уклоњено
  1. 2 1
      README.md
  2. 8 5
      docs/使用说明.md
  3. 385 107
      web-modules/WecomCenter/assets/l2topology/app.js
  4. 6 0
      web-modules/WecomCenter/locale/bg_BG.php
  5. 6 0
      web-modules/WecomCenter/locale/ca_ES.php
  6. 6 0
      web-modules/WecomCenter/locale/cs_CZ.php
  7. 6 0
      web-modules/WecomCenter/locale/da_DK.php
  8. 5 0
      web-modules/WecomCenter/locale/de_DE.php
  9. 6 0
      web-modules/WecomCenter/locale/el_GR.php
  10. 5 0
      web-modules/WecomCenter/locale/en.php
  11. 6 0
      web-modules/WecomCenter/locale/en_GB.php
  12. 5 0
      web-modules/WecomCenter/locale/es_ES.php
  13. 6 0
      web-modules/WecomCenter/locale/fa_IR.php
  14. 6 0
      web-modules/WecomCenter/locale/fi_FI.php
  15. 5 0
      web-modules/WecomCenter/locale/fr_FR.php
  16. 6 0
      web-modules/WecomCenter/locale/he_IL.php
  17. 6 0
      web-modules/WecomCenter/locale/hu_HU.php
  18. 6 0
      web-modules/WecomCenter/locale/id_ID.php
  19. 5 0
      web-modules/WecomCenter/locale/it_IT.php
  20. 5 0
      web-modules/WecomCenter/locale/ja_JP.php
  21. 6 0
      web-modules/WecomCenter/locale/ka_GE.php
  22. 5 0
      web-modules/WecomCenter/locale/ko_KR.php
  23. 6 0
      web-modules/WecomCenter/locale/lt_LT.php
  24. 6 0
      web-modules/WecomCenter/locale/lv_LV.php
  25. 6 0
      web-modules/WecomCenter/locale/nb_NO.php
  26. 6 0
      web-modules/WecomCenter/locale/nl_NL.php
  27. 5 0
      web-modules/WecomCenter/locale/pl_PL.php
  28. 5 0
      web-modules/WecomCenter/locale/pt_BR.php
  29. 6 0
      web-modules/WecomCenter/locale/ro_RO.php
  30. 5 0
      web-modules/WecomCenter/locale/ru_RU.php
  31. 6 0
      web-modules/WecomCenter/locale/sk_SK.php
  32. 6 0
      web-modules/WecomCenter/locale/sv_SE.php
  33. 5 0
      web-modules/WecomCenter/locale/tr_TR.php
  34. 6 0
      web-modules/WecomCenter/locale/uk_UA.php
  35. 6 0
      web-modules/WecomCenter/locale/uz_UZ.php
  36. 6 0
      web-modules/WecomCenter/locale/vi_VN.php
  37. 5 0
      web-modules/WecomCenter/locale/zh_CN.php
  38. 5 0
      web-modules/WecomCenter/locale/zh_TW.php
  39. 3 1
      web-modules/WecomCenter/views/topology.view.php

+ 2 - 1
README.md

@@ -97,7 +97,8 @@ docker compose -p zabbix ps                                     # 查看状态
 
 模块 `web-modules/WecomCenter` 现包含 **L2 物理拓扑** 功能(展示页 + 配置页 + 采集器 + 历史库):
 
-- 展示页:`Monitoring → L2 Topology`(只读,含时间轴/深色/终端/搜索/速率&延迟&告警着色)。
+- 展示页:`Monitoring → L2 Topology`(只读)。**两级视图**:车间卡片全景 → 点卡片进入车间详情,
+  核心交换机 ghost 对齐在汇聚正上方,核心→汇聚为带 `10G` 标注的蓝色竖线;含时间轴/深色/终端/搜索/速率&延迟&告警着色。
 - 配置页:`Administration → ROMAN 定制中心 → 「L2 拓扑」`(采集模板、刷新/采集间隔、速率配色、
   延迟阈值、分组规则、根框名、默认深色等)。
 - 数据:每次全量采集把快照 gzip 写入 MySQL `l2_topology_history`(保留 7 天,可回看任意时间);

+ 8 - 5
docs/使用说明.md

@@ -225,11 +225,14 @@ zabbix-deploy/
     20G+ = 紫色 `#9B59B6`,未知 = 红色 `#E74C3C`,中断 = 红色虚线 `#C0392B`。
 - **延迟着色**:默认 ping 交换机管理 IP(Zabbix 主机 → 交换机),
   >3ms 橙色、>5ms 红色(阈值可配)。注:本网实测 0.8ms 左右,绝大多数为正常(无色)。
-- **告警交换机**:节点边框按最高告警等级红色高亮(右侧「告警」面板可点击定位)。
-- **分组框**:最外层 `COBCO` 大框,内部按配置规则的「车间/DataCenter/Core」子框;
-  规则按顺序匹配(contains / regex),可用 `$1..$9` 反向引用;未命中归入 Other。
-- **节点标签**:`车间 · 区域 · 楼层机柜U` + `角色 · IP` 两行,宽度加大、对比度提高,便于快速找设备
-  (原名鼠标悬停显示;双击节点跳转该主机的问题页)。
+- **两级视图(默认全景)**:
+  - **全景**:每个车间/DataCenter/Core 一张卡片,卡片上直接显示 `交换机数 / 核心上联数 ↑ / 告警 ⚠ / 离线终端 ◌`;
+    27 个车间一屏排完,卡片之间不再画跨车间连线,彻底避免“蜘蛛网”。
+  - **车间详情**:点卡片进入。核心交换机以顶部灰色虚框(ghost)出现并**对齐在对应汇聚正上方**,
+    核心→汇聚是一条短的**蓝色 10G 实线(线中间标注 10G)**;汇聚下方按网格排列接入交换机,
+    1G 绿色线只在本车间内展开。点任意交换机可高亮它的全部链路,点空白处恢复;左上「← 返回全景」退出。
+- **节点标签**:`车间 · 区域 · 楼层机柜U` + `角色 · IP` 两行;双击节点跳转该主机的问题页。
+- **告警面板只显示拓扑内交换机**,不再混入服务器/测试机等“拓扑外”主机;告警交换机边框统一红色。
 - **显示终端 / 只看离线**:勾选「显示终端」在交换机下方展开终端点(绿=在线、灰=离线);
   「只看离线」仅显示离线终端。右侧「离线终端」面板点击可联动定位。
 - **时间轴**:右上「快照时间」下拉可选 `实时` 或历史某分钟快照(保存 **7 天**,5 分钟一个点)。

+ 385 - 107
web-modules/WecomCenter/assets/l2topology/app.js

@@ -20,8 +20,11 @@
   var TIER_BG_DARK = {core:'#1B4A72', dist:'#2A5E93', access:'#3E6B2E'};
   var SEV_COLORS = ['#97AAB3','#7499FF','#FFC859','#FFA059','#E97659','#E45959'];
 
-  var NODE_W = 190, NODE_H = 56, GX = 26, GY = 18, PADX = 20, PADY = 42;
+  var NODE_W = 156, NODE_H = 50, GX = 26, GY = 18, PADX = 20, PADY = 42;
+  var DGAP_X = 18, DGAP_Y = 26;
   var GROUP_GAP_X = 46, GROUP_GAP_Y = 48, MAXW = 2800;
+  // overview ("全景") tiles
+  var TILE_W = 232, TILE_H = 116, TILE_GAP_X = 24, TILE_GAP_Y = 22;
 
   var cy = null;
   var dark = false;
@@ -31,6 +34,7 @@
   var countdown = 0;
   var timer = null;
   var lastSnap = null;
+  var viewGroup = null;       // null = overview, otherwise workshop label
 
   function el(id) { return document.getElementById(id); }
   function t(k, d) { return (T[k] != null) ? T[k] : (d != null ? d : k); }
@@ -90,68 +94,208 @@
     return {label: CFG.other_label || 'Other', big: false};
   }
 
-  /* ---------------- layout ---------------- */
-  function computeLayout(snap) {
-    var groups = {};
+  /* ---------------- overview layout (workshop tiles) ---------------- */
+  function groupStats(snap) {
+    var map = {};
+    var nodeById = {};
     (snap.nodes || []).forEach(function (n) {
+      nodeById[n.id] = n;
       var g = groupOf(n);
-      if (!groups[g.label]) groups[g.label] = {label: g.label, big: g.big, nodes: []};
-      if (g.big) groups[g.label].big = true;
-      groups[g.label].nodes.push(n);
-    });
-
-    var order = Object.keys(groups).sort(function (a, b) {
-      function rank(l) {
-        if (/core/i.test(l)) return 0;
-        if (/data\s*center/i.test(l)) return 1;
-        if (/^\d+$/.test(l)) return 1000 + parseInt(l, 10);
-        return 5000;
+      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});
+      s.nodes.push(n);
+      s.offline += (+n.term_offline || 0);
+      if (n.alarms && n.alarms.length) {
+        s.alarms += n.alarms.length;
+        var sev = (n.maxsev != null && +n.maxsev >= 0) ? +n.maxsev : -1;
+        n.alarms.forEach(function (a) { if (a && a.sev != null && +a.sev > sev) sev = +a.sev; });
+        if (sev > s.maxsev) s.maxsev = sev;
       }
-      return rank(a) - rank(b);
+      if (n.latency_ms != null && +n.latency_ms > s.latmax) s.latmax = +n.latency_ms;
     });
+    (snap.edges || []).forEach(function (e) {
+      var a = nodeById[e.source], b = nodeById[e.target];
+      if (!a || !b) return;
+      var ga = groupOf(a), gb = groupOf(b);
+      if (ga.label === gb.label) return;
+      // count links that leave this workshop towards a core switch
+      if ((/core/i.test(a.tier || '') || /core/i.test(a.name || '')) && map[gb.label]) map[gb.label].up++;
+      if ((/core/i.test(b.tier || '') || /core/i.test(b.name || '')) && map[ga.label]) map[ga.label].up++;
+    });
+    return map;
+  }
 
-    var pos = {}, frames = [];
-    var cursorX = 0, cursorY = 0, rowH = 0, minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+  function groupRank(l) {
+    if (/core/i.test(l)) return 0;
+    if (/data\s*center/i.test(l)) return 1;
+    var m = /^(\d{4})([A-Za-z]?)$/.exec(l);
+    if (m) return 1000 + parseInt(m[1], 10) * 40 + (m[2] ? m[2].toUpperCase().charCodeAt(0) - 64 : 0);
+    return 5000;
+  }
 
-    order.forEach(function (label) {
-      var g = groups[label];
-      g.nodes.sort(function (a, b) {
-        var da = a.depth || 0, db = b.depth || 0;
-        if (da !== db) return da - db;
-        return (a.name || '').localeCompare(b.name || '');
+  function computeOverview(snap) {
+    var stats = groupStats(snap);
+    var labels = Object.keys(stats).sort(function (a, b) { return groupRank(a) - groupRank(b); });
+    var special = labels.filter(function (l) { return groupRank(l) < 1000; });
+    var others = labels.filter(function (l) { return groupRank(l) >= 1000; });
+
+    var COLS = Math.max(4, Math.min(7, Math.ceil(Math.sqrt(Math.max(1, others.length)))));
+    var gridW = COLS * TILE_W + (COLS - 1) * TILE_GAP_X;
+    var pos = {}, tiles = [], y = 0;
+
+    if (special.length) {
+      var sW = special.length * TILE_W + (special.length - 1) * TILE_GAP_X;
+      var sx = Math.max(0, (gridW - sW) / 2);
+      special.forEach(function (l) {
+        tiles.push({label: l, x: sx, y: y, w: TILE_W, h: TILE_H});
+        pos[l] = {x: sx + TILE_W / 2, y: y + TILE_H / 2};
+        sx += TILE_W + TILE_GAP_X;
       });
-      var n = g.nodes.length;
-      var cols = Math.min(4, Math.max(1, Math.ceil(Math.sqrt(n))));
-      var rows = Math.ceil(n / cols);
-      var innerW = cols * NODE_W + (cols - 1) * GX;
-      var innerH = rows * NODE_H + (rows - 1) * GY;
-      var boxW = innerW + PADX * 2;
-      var boxH = innerH + PADY + 12;
-
-      if (cursorX > 0 && cursorX + boxW > MAXW) { cursorX = 0; cursorY += rowH + GROUP_GAP_Y; rowH = 0; }
-
-      g.nodes.forEach(function (nd, i) {
-        var rr = Math.floor(i / cols), cc = i % cols;
-        pos[nd.id] = {
-          x: cursorX + PADX + cc * (NODE_W + GX) + NODE_W / 2,
-          y: cursorY + PADY + rr * (NODE_H + GY) + NODE_H / 2
-        };
+      y += TILE_H + 46;
+    }
+
+    var maxX = gridW, maxY = y;
+    for (var i = 0; i < others.length; i += COLS) {
+      var row = others.slice(i, i + COLS);
+      var rW = row.length * TILE_W + (row.length - 1) * TILE_GAP_X;
+      var rx = Math.max(0, (gridW - rW) / 2);
+      row.forEach(function (l, j) {
+        tiles.push({label: l, x: rx + j * (TILE_W + TILE_GAP_X), y: y, w: TILE_W, h: TILE_H});
+        pos[l] = {x: rx + j * (TILE_W + TILE_GAP_X) + TILE_W / 2, y: y + TILE_H / 2};
       });
-      frames.push({label: label, big: g.big, x: cursorX, y: cursorY, w: boxW, h: boxH});
-      minX = Math.min(minX, cursorX); minY = Math.min(minY, cursorY);
-      maxX = Math.max(maxX, cursorX + boxW); maxY = Math.max(maxY, cursorY + boxH);
+      y += TILE_H + TILE_GAP_Y;
+      maxY = y - TILE_GAP_Y;
+      maxX = Math.max(maxX, rx + rW);
+    }
+
+    var root = {x: -34, y: -64, w: maxX + 68, h: maxY + 64 + 40};
+    root.cx = root.x + root.w / 2; root.cy = root.y + root.h / 2;
+    return {stats: stats, labels: labels, pos: pos, tiles: tiles, root: root};
+  }
+
+  function aggregateEdges(snap) {
+    var gid = {};
+    (snap.nodes || []).forEach(function (n) { gid[n.id] = groupOf(n).label; });
+    var pairs = {};
+    (snap.edges || []).forEach(function (e) {
+      var a = gid[e.source], b = gid[e.target];
+      if (!a || !b || a === b) return;
+      var k = (a < b) ? (a + '\u0001' + b) : (b + '\u0001' + a);
+      var p = pairs[k] || (pairs[k] = {a: a, b: b, count: 0, speed: 0, down: false});
+      p.count++;
+      if ((parseFloat(e.speed) || 0) > p.speed) p.speed = parseFloat(e.speed) || 0;
+      if (e.state && e.state !== 'up') p.down = true;
+    });
+    return Object.keys(pairs).map(function (k) { return pairs[k]; });
+  }
 
-      cursorX += boxW + GROUP_GAP_X;
-      rowH = Math.max(rowH, boxH);
+  /* ---------------- detail layout (one workshop, tidy tree) ---------------- */
+  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 ids = {};
+    nodes.forEach(function (n) { ids[n.id] = true; });
+    var internal = [], external = [];
+    (snap.edges || []).forEach(function (e) {
+      var a = ids[e.source], b = ids[e.target];
+      if (a && b) internal.push(e);
+      else if (a || b) external.push(e);
     });
 
-    var root = null;
-    if (frames.length) {
-      var m = 28;
-      root = {x: minX - m, y: minY - m - 30, w: (maxX - minX) + 2 * m, h: (maxY - minY) + 2 * m + 30};
-      root.cx = root.x + root.w / 2; root.cy = root.y + root.h / 2;
+    var adj = {}, kids = {};
+    nodes.forEach(function (n) { adj[n.id] = []; kids[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;
     }
-    return {pos: pos, frames: frames, root: root};
+    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);
+        });
+      }
+    });
+    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 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)};
+      });
+      maxBlockH = Math.max(maxBlockH, y2 + Math.ceil(d2.length / cols) * (NODE_H + DGAP_Y));
+      blockX += blockW + 70;
+    });
+    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 totalW = Math.max(NODE_W, blockX - 70);
+
+    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]);
+      }
+    });
+    var ghostAt = {};
+    ghosts.forEach(function (g) {
+      var base = pos[g.to] || {x: 80};
+      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;
+    });
+
+    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);
+    });
+    ghosts.forEach(function (g) {
+      minX = Math.min(minX, g.x - 95);
+      maxX2 = Math.max(maxX2, g.x + 95);
+    });
+    var top = ghosts.length ? -168 : -120;
+    var root = {x: minX - 38, y: top, w: (maxX2 - minX) + 76, h: maxY - top + 54};
+    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};
   }
 
   /* ---------------- speed colour ---------------- */
@@ -171,6 +315,12 @@
     if (s >= 1000) return 2.6;
     return 1.8;
   }
+  function speedText(speed) {
+    var s = parseFloat(speed) || 0;
+    if (!s) return '?';
+    if (s >= 1000) return (s / 1000) + 'G';
+    return s + 'M';
+  }
 
   /* ---------------- style ---------------- */
   function styleFor(d) {
@@ -198,11 +348,49 @@
         'font-size': 28, 'font-weight': 900, 'color': d ? '#cfe0ff' : '#33465f',
         'text-margin-y': 10, 'events': 'no', 'z-index-compare': 'manual', 'z-index': 0
       }},
+      {selector: 'node[type="tile"]', style: {
+        'width': 'data(w)', 'height': 'data(h)', 'shape': 'round-rectangle',
+        'background-color': 'data(bg)', 'background-opacity': 0.97,
+        'border-width': 2.5, 'border-color': 'data(bd)',
+        'label': 'data(label)', 'color': 'data(fg)',
+        'font-size': 16, 'font-weight': 700, 'text-valign': 'center', 'text-halign': 'center',
+        'text-wrap': 'wrap', 'text-max-width': TILE_W - 26, 'line-height': 1.45,
+        'padding': '6px', 'overlay-opacity': 0, 'z-index-compare': 'manual', 'z-index': 20
+      }},
+      {selector: 'node[type="tile"].alarm', style: {'border-width': 5}},
+      {selector: 'node[type="tile"].core', style: {'border-width': 3.5}},
+      {selector: 'node[type="ghost"]', style: {
+        'width': 190, 'height': 40, 'shape': 'round-rectangle',
+        'background-color': d ? '#52667f' : '#c8d3e2', 'background-opacity': 0.55,
+        'border-width': 1.2, 'border-style': 'dashed', 'border-color': d ? '#7d93ad' : '#8fa3bc',
+        'label': 'data(label)', 'color': d ? '#dce8fa' : '#3b4f68',
+        'font-size': 11, 'font-weight': 600, 'text-wrap': 'wrap', 'text-max-width': 176,
+        'z-index-compare': 'manual', 'z-index': 15
+      }},
       {selector: 'node[type="term"]', style: {
         'width': 12, 'height': 12, 'shape': 'ellipse', 'background-color': 'data(bg)',
         'border-width': 1, 'border-color': d ? '#2a3648' : '#cfd8e3', 'label': '',
         'z-index-compare': 'manual', 'z-index': 30
       }},
+      {selector: 'edge[type="tilelink"]', style: {
+        'width': 'data(w)', 'line-color': 'data(color)', 'opacity': 0.55,
+        'curve-style': 'bezier', 'target-arrow-shape': 'none',
+        'z-index-compare': 'manual', 'z-index': 5
+      }},
+      {selector: 'edge[type="ghost"]', style: {
+        'width': 1.2, 'line-color': d ? '#6b809b' : '#a9b6c8', 'line-style': 'dashed', 'opacity': 0.7,
+        'curve-style': 'bezier', 'target-arrow-shape': 'none',
+        'z-index-compare': 'manual', 'z-index': 8
+      }},
+      {selector: 'edge[type="uplink"]', style: {
+        'width': 'data(w)', 'line-color': 'data(color)', 'line-style': 'data(ls)', 'opacity': 0.95,
+        'curve-style': 'bezier', 'target-arrow-shape': 'none',
+        'label': 'data(lbl)', 'font-size': 10, 'font-weight': 700,
+        'color': d ? '#dce8fa' : '#25405f',
+        'text-background-color': d ? '#0d1b2a' : '#ffffff', 'text-background-opacity': 0.88,
+        'text-background-padding': '2px', 'text-rotation': 'autorotate',
+        'z-index-compare': 'manual', 'z-index': 40
+      }},
       {selector: 'edge', style: {
         'width': 'data(w)', 'line-color': 'data(color)', 'line-style': 'data(ls)',
         'curve-style': 'bezier', 'opacity': 0.92, 'target-arrow-shape': 'none',
@@ -213,6 +401,9 @@
         'curve-style': 'bezier', 'z-index-compare': 'manual', 'z-index': 11
       }},
       {selector: 'node.alarm', style: {'border-width': 6}},
+      {selector: 'node.dim', style: {'opacity': 0.12}},
+      {selector: 'edge.dim', style: {'opacity': 0.08}},
+      {selector: 'edge.hl', style: {'width': 5, 'line-color': '#FFD54A', 'opacity': 1, 'z-index-compare': 'manual', 'z-index': 45}},
       {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}},
@@ -221,35 +412,100 @@
   }
 
   /* ---------------- render ---------------- */
+  function applyElements(els) {
+    if (!cy) {
+      cy = cytoscape({
+        container: el('l2-cy'),
+        elements: els,
+        style: styleFor(dark),
+        layout: {name: 'preset', fit: true, padding: 40},
+        wheelSensitivity: 0.2,
+        minZoom: 0.15, maxZoom: 4
+      });
+      bindCy();
+    } else {
+      cy.elements().remove();
+      cy.add(els);
+      cy.style(styleFor(dark));
+      cy.layout({name: 'preset', fit: true, padding: 40}).run();
+    }
+  }
+
   function render(snap) {
     if (!snap || !snap.nodes) {
       el('l2-cy').innerHTML = '<div class="l2-empty">' + t('l2.no_data', 'No data') + '</div>';
       return;
     }
     lastSnap = snap;
-    var L = computeLayout(snap);
-    var els = [];
+    if (viewGroup) {
+      var exists = (snap.nodes || []).some(function (n) { return groupOf(n).label === viewGroup; });
+      if (!exists) viewGroup = null;
+    }
+    if (viewGroup) renderDetail(snap); else renderOverview(snap);
+    if (el('l2-back')) el('l2-back').style.display = viewGroup ? '' : 'none';
+    updateStats(snap);
+    buildLegend();
+    buildAlarms(snap);
+    buildOffline(snap);
+  }
 
-    if (L.root) {
-      els.push({group: 'nodes', data: {id: '__root', type: 'root', label: CFG.root_label || 'COBCO', w: L.root.w, h: L.root.h},
-        position: {x: L.root.cx, y: L.root.cy}, selectable: false, grabbable: false});
+  function renderOverview(snap) {
+    var O = computeOverview(snap), els = [], idx = {};
+    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});
     }
-    L.frames.forEach(function (f, i) {
-      els.push({group: 'nodes', data: {id: 'grp_' + i, type: 'frame', label: f.label, w: f.w, h: f.h, fs: f.big ? 20 : 14},
-        position: {x: f.x + f.w / 2, y: f.y + f.h / 2}, selectable: false, grabbable: false});
+    O.tiles.forEach(function (tile, i) {
+      var s = O.stats[tile.label] || {nodes: [], alarms: 0, offline: 0, maxsev: -1};
+      idx[tile.label] = 'grp_' + i;
+      var isCore = /core/i.test(tile.label) || /data\s*center/i.test(tile.label);
+      var bd = s.alarms ? '#E45959' : (dark ? '#41597c' : '#b9c8dc');
+      var bg = isCore ? TIER_BG.core : (dark ? '#22344e' : '#eef3fb');
+      var fg = isCore ? '#ffffff' : (dark ? '#dfe9fb' : '#1e3352');
+      var lbl = tile.label + '\n' + s.nodes.length + ' ' + t('l2.unit_sw', 'sw') +
+        (s.up ? '   ↑ ' + s.up : '') + (s.alarms ? '   ⚠ ' + s.alarms : '') + (s.offline ? '   ◌ ' + s.offline : '');
+      var cls = [];
+      if (s.alarms) cls.push('alarm');
+      if (isCore) cls.push('core');
+      els.push({group: 'nodes', data: {
+        id: idx[tile.label], type: 'tile', group: tile.label,
+        n: s.nodes.length, alarms: s.alarms, offline: s.offline, maxsev: s.maxsev, up: s.up,
+        label: lbl, bg: bg, fg: fg, bd: bd, w: TILE_W, h: TILE_H
+      }, position: {x: tile.x + tile.w / 2, y: tile.y + tile.h / 2}, classes: cls.join(' ')});
     });
+    applyElements(els);
+  }
 
+  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});
+    }
+    (D.ghosts || []).forEach(function (g) {
+      var nm = (g.node && g.node.name) || g.id;
+      var ip = (g.node && g.node.ip) || '';
+      var tail = String(nm).split(/[-\s]+/).filter(Boolean).slice(-1)[0] || '';
+      var short = (tail ? tail + ' · ' : '') + ip;
+      els.push({group: 'nodes', data: {id: g.id, type: 'ghost', label: short}, position: {x: g.x, y: g.y}, selectable: false, grabbable: false});
+      els.push({group: 'edges', data: {
+        id: 'ghe_' + g.id, type: 'uplink', source: g.id, target: g.to,
+        speed: g.speed, state: g.state, lbl: speedText(g.speed),
+        color: speedColor(g.speed, g.state), ls: (g.state && g.state !== 'up') ? 'dashed' : 'solid',
+        w: Math.max(2.6, speedWidth(g.speed))
+      }});
+    });
     var switchIds = {};
-    (snap.nodes || []).forEach(function (n) {
+    D.nodes.forEach(function (n) {
       switchIds[n.id] = true;
-      var p = L.pos[n.id] || {x: 0, y: 0};
+      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 cls = [];
       var bd = (dark ? TIER_BG_DARK[tkey] : TIER_BG[tkey]) || '#555';
-      if (n.maxsev != null && n.maxsev >= 0 && (n.alarms && n.alarms.length)) {
+      if (n.alarms && n.alarms.length) {
         cls.push('alarm');
-        bd = SEV_COLORS[Math.max(0, Math.min(5, n.maxsev))];
+        bd = '#E45959';
       }
       if (n.latency_ms != null) {
         if (+n.latency_ms > LATB) cls.push('lat-bad');
@@ -260,12 +516,10 @@
         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, tw: NODE_W - 16, z: 20, group: groupOf(n).label
+        bd: bd, w: NODE_W, h: NODE_H, group: viewGroup
       }, position: p, classes: cls.join(' ')});
     });
-
-    (snap.edges || []).forEach(function (e, i) {
-      if (!switchIds[e.source] || !switchIds[e.target]) return;
+    D.internal.forEach(function (e, i) {
       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,
@@ -273,8 +527,6 @@
         w: speedWidth(e.speed)
       }});
     });
-
-    // terminals
     if (showTerm) {
       var bySw = {};
       (snap.terminals || []).forEach(function (tm) {
@@ -282,9 +534,8 @@
         if (onlyOff && tm.online) return;
         (bySw[tm.sw_ip] = bySw[tm.sw_ip] || []).push(tm);
       });
-      var cnt = 0;
       Object.keys(bySw).forEach(function (sw) {
-        var base = L.pos[sw]; if (!base) return;
+        var base = D.pos[sw]; if (!base) return;
         bySw[sw].sort(function (a, b) { return (a.online === b.online) ? 0 : (a.online ? -1 : 1); });
         bySw[sw].forEach(function (tm, k) {
           var col = k % 9, row = Math.floor(k / 9);
@@ -293,32 +544,10 @@
           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},
             position: {x: x, y: y}, selectable: false});
           els.push({group: 'edges', data: {id: 'te_' + tm.mac, type: 'term', source: sw, target: 't_' + tm.mac}});
-          cnt++;
         });
       });
     }
-
-    if (!cy) {
-      cy = cytoscape({
-        container: el('l2-cy'),
-        elements: els,
-        style: styleFor(dark),
-        layout: {name: 'preset', fit: true, padding: 50},
-        wheelSensitivity: 0.2,
-        minZoom: 0.05, maxZoom: 4
-      });
-      bindCy();
-    } else {
-      cy.elements().remove();
-      cy.add(els);
-      cy.style(styleFor(dark));
-      cy.layout({name: 'preset', fit: true, padding: 50}).run();
-    }
-
-    updateStats(snap);
-    buildLegend();
-    buildAlarms(snap);
-    buildOffline(snap);
+    applyElements(els);
   }
 
   /* ---------------- side panels & stats ---------------- */
@@ -347,6 +576,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>';
     el('l2-legend').innerHTML = h;
   }
 
@@ -359,9 +589,6 @@
         });
       }
     });
-    (snap.other_alarms || []).forEach(function (a) {
-      items.push({sev: a.sev, title: a.name, host: a.host, ip: a.ip, id: null, hostid: null, other: true});
-    });
     el('l2-alarm-count').textContent = items.length;
     if (!items.length) { el('l2-alarm-list').innerHTML = '<div class="panel-empty">' + t('l2.alarm_empty', 'No active alarms.') + '</div>'; return; }
     items.sort(function (a, b) { return b.sev - a.sev; });
@@ -371,14 +598,14 @@
       h += '<div class="alarm-row" data-id="' + (it.id || '') + '" data-hostid="' + (it.hostid || '') + '">' +
         '<span class="alarm-dot" style="background:' + col + '"></span>' +
         '<div class="alarm-main"><div class="alarm-title">' + esc(it.title) + '</div>' +
-        '<div class="alarm-sub">' + esc(it.host || '') + (it.ip ? ' · ' + esc(it.ip) : '') + (it.other ? ' · 拓扑外' : '') + '</div></div></div>';
+        '<div class="alarm-sub">' + esc(it.host || '') + (it.ip ? ' · ' + esc(it.ip) : '') + '</div></div></div>';
     });
     var box = el('l2-alarm-list');
     box.innerHTML = h;
     box.querySelectorAll('.alarm-row').forEach(function (r) {
       r.addEventListener('click', function () {
         var id = r.getAttribute('data-id');
-        if (id) focusNode(id);
+        if (id) revealById(id);
         else { var hid = r.getAttribute('data-hostid'); if (hid) window.open(META.zabbixHostUrl.replace('__HOSTID__', hid), '_blank'); }
       });
     });
@@ -399,8 +626,7 @@
     box.querySelectorAll('.off-row').forEach(function (r) {
       r.addEventListener('click', function () {
         var mac = r.getAttribute('data-mac');
-        if (!showTerm) { showTerm = true; el('l2-showterm').checked = true; render(lastSnap); }
-        setTimeout(function () { focusNode('t_' + mac); }, 60);
+        revealById('t_' + mac);
       });
     });
   }
@@ -418,6 +644,31 @@
     setTimeout(function () { n.removeClass('hl'); }, 2600);
   }
 
+  function groupOfNodeId(id) {
+    var hit = (lastSnap && lastSnap.nodes || []).filter(function (n) { return n.id === id; })[0];
+    return hit ? groupOf(hit).label : null;
+  }
+
+  // Make sure a switch/terminal is visible (auto-opens its workshop from overview).
+  function revealById(id) {
+    if (!lastSnap) return;
+    id = String(id);
+    if (id.indexOf('t_') === 0) {
+      var tm = (lastSnap.terminals || []).filter(function (x) { return 't_' + x.mac === id; })[0];
+      if (!tm) return;
+      var g = groupOfNodeId(tm.sw_ip);
+      if (g) viewGroup = g;
+      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); }
+      setTimeout(function () { focusNode(id); }, 90);
+    }
+  }
+
   function bindCy() {
     cy.on('mouseover', 'node[type="switch"]', function (e) {
       var d = e.target.data();
@@ -433,6 +684,18 @@
       showTip(e, html);
     });
     cy.on('mouseout', 'node[type="switch"]', 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>' +
+        '↑ Core: ' + (d.up || 0) + ' · ⚠ ' + (d.alarms || 0) + ' · ◌ ' + (d.offline || 0) +
+        '<br><span style="opacity:.7">' + t('l2.tile_hint', 'Click to open this workshop') + '</span>';
+      showTip(e, html);
+    });
+    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); }
+    });
     cy.on('mouseover', 'edge:not([type="term"])', function (e) {
       var d = e.target.data();
       var sp = (parseFloat(d.speed) || 0) >= 1000 ? ((parseFloat(d.speed) / 1000) + 'G') : (d.speed + 'M');
@@ -440,8 +703,16 @@
     });
     cy.on('mouseout', 'edge', hideTip);
     cy.on('tap', 'node[type="switch"]', function (e) {
-      var hid = e.target.data('hostid');
-      if (hid) { /* single click: focus only; double handled below */ }
+      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');
+    });
+    cy.on('tap', function (e) {
+      if (e.target === cy) cy.elements().removeClass('dim hl');
     });
     cy.on('dbltap', 'node[type="switch"]', function (e) {
       var hid = e.target.data('hostid');
@@ -488,8 +759,7 @@
     box.querySelectorAll('.sug-row').forEach(function (r) {
       r.addEventListener('click', function () {
         var id = r.getAttribute('data-id');
-        if (id.indexOf('t_') === 0 && !showTerm) { showTerm = true; el('l2-showterm').checked = true; render(lastSnap); setTimeout(function () { focusNode(id); }, 80); }
-        else focusNode(id);
+        revealById(id);
         box.style.display = 'none';
       });
     });
@@ -499,12 +769,16 @@
   function setupControls() {
     dark = el('l2-wrap').classList.contains('l2-dark');
     el('l2-dark').checked = dark;
+    var back = el('l2-back');
+    if (back) {
+      back.textContent = t('l2.back_overview', '← Overview');
+      back.addEventListener('click', function () { viewGroup = null; render(lastSnap); });
+    }
 
     el('l2-dark').addEventListener('change', function () {
       dark = el('l2-dark').checked;
       el('l2-wrap').classList.toggle('l2-dark', dark);
-      if (cy) cy.style(styleFor(dark));
-      buildLegend();
+      render(lastSnap);
     });
     el('l2-showterm').addEventListener('change', function () { showTerm = el('l2-showterm').checked; render(lastSnap); });
     el('l2-onlyoff').addEventListener('change', function () {
@@ -563,6 +837,10 @@
   /* ---------------- boot ---------------- */
   function boot() {
     setupControls();
+    try {
+      var qg = new URLSearchParams(window.location.search).get('g');
+      if (qg) viewGroup = qg;
+    } catch (e) {}
     if (!SNAP || !SNAP.nodes) {
       el('l2-cy').innerHTML = '<div class="l2-empty">' + t('l2.no_data', 'No data') + '</div>';
     } else {

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

@@ -192,4 +192,10 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'menu.title' => 'ROMAN Custom Center',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -192,4 +192,10 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'menu.title' => 'ROMAN Custom Center',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -192,4 +192,10 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'menu.title' => 'ROMAN Custom Center',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -192,4 +192,10 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'menu.title' => 'ROMAN Custom Center',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -203,4 +203,9 @@ return [
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -192,4 +192,10 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'menu.title' => 'ROMAN Custom Center',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -192,6 +192,11 @@ return [
 
 	// L2 display page
 	'l2.title'           => 'L2 Physical Topology',
+'l2.back_overview'   => '← Overview',
+'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.search_ph'       => 'Search IP / MAC / name…',
 	'l2.show_term'       => 'Show terminals',
 	'l2.only_off'        => 'Only offline',

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

@@ -192,4 +192,10 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'menu.title' => 'ROMAN Custom Center',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -203,4 +203,9 @@ return [
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -192,4 +192,10 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'menu.title' => 'ROMAN Custom Center',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -192,4 +192,10 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'menu.title' => 'ROMAN Custom Center',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -203,4 +203,9 @@ return [
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -192,4 +192,10 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'menu.title' => 'ROMAN Custom Center',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -192,4 +192,10 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'menu.title' => 'ROMAN Custom Center',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -192,4 +192,10 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'menu.title' => 'ROMAN Custom Center',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -203,4 +203,9 @@ return [
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -203,4 +203,9 @@ return [
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -192,4 +192,10 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'menu.title' => 'ROMAN Custom Center',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -203,4 +203,9 @@ return [
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -192,4 +192,10 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'menu.title' => 'ROMAN Custom Center',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -192,4 +192,10 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'menu.title' => 'ROMAN Custom Center',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -192,4 +192,10 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'menu.title' => 'ROMAN Custom Center',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -192,4 +192,10 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'menu.title' => 'ROMAN Custom Center',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -203,4 +203,9 @@ return [
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -203,4 +203,9 @@ return [
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -192,4 +192,10 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'menu.title' => 'ROMAN Custom Center',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -203,4 +203,9 @@ return [
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -192,4 +192,10 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'menu.title' => 'ROMAN Custom Center',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -192,4 +192,10 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'menu.title' => 'ROMAN Custom Center',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -203,4 +203,9 @@ return [
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -192,4 +192,10 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'menu.title' => 'ROMAN Custom Center',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -192,4 +192,10 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'menu.title' => 'ROMAN Custom Center',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -192,4 +192,10 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'menu.title' => 'ROMAN Custom Center',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -179,6 +179,11 @@ return [
 
 	// L2 展示页
 	'l2.title'           => 'L2 物理拓扑图',
+	'l2.back_overview'   => '← 返回全景',
+	'l2.unit_sw'         => '台',
+	'l2.tile_hint'       => '点击进入该车间',
+	'l2.not_in_topo'     => '不在拓扑内',
+	'l2.click_hint'      => '点击交换机可高亮其链路',
 	'l2.search_ph'       => '搜索 IP / MAC / 名称…',
 	'l2.show_term'       => '显示终端',
 	'l2.only_off'        => '只看离线',

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

@@ -203,4 +203,9 @@ return [
 	'l2.snapshot' => 'Snapshot',
 	'l2.showing' => 'Showing',
 	'l2.live_tag' => 'LIVE',
+	'l2.back_overview' => '← Overview',
+	'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',
 ];

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

@@ -19,7 +19,8 @@ $i18nKeys = [
 	'l2.refresh_now','l2.fit','l2.alarms','l2.offline','l2.time_live','l2.time_btn',
 	'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.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'
 ];
 $i18n = [];
 foreach ($i18nKeys as $k) { $i18n[$k] = I18n::t($k); }
@@ -40,6 +41,7 @@ if (!function_exists('l2e')) { function l2e($v) { return htmlspecialchars((strin
 <div class="l2-wrap<?php echo !empty($data['cfg_dark']) ? ' l2-dark' : ''; ?>" id="l2-wrap">
   <div class="l2-toolbar">
     <span class="tb-title"><?php echo l2e(I18n::t('l2.title')); ?></span>
+    <button class="l2-btn" id="l2-back" style="display:none"><?php echo l2e(I18n::t('l2.back_overview')); ?></button>
 
     <div class="l2-search-wrap">
       <input id="l2-search" type="text" placeholder="<?php echo l2e(I18n::t('l2.search_ph')); ?>" autocomplete="off"/>