فهرست منبع

feat(l2): global straight topology with network + multi-core configuration

Add configurable physical networks and addable core/root switch rules (multiple cores per network, leftmost core column). Rework L2 view to one global chart with one column per switch tier, workshop/floor frames, straight label-edge links, automatic Zabbix light/dark theme, and no manual dark toggle. Update all locale files and operation docs.
clm0081 3 روز پیش
والد
کامیت
cc40ceb77a
46فایلهای تغییر یافته به همراه1084 افزوده شده و 220 حذف شده
  1. 9 5
      README.md
  2. 14 11
      docs/使用说明.md
  3. 1 2
      web-modules/WecomCenter/actions/L2TopologyView.php
  4. 40 1
      web-modules/WecomCenter/actions/WecomCenterSave.php
  5. 578 65
      web-modules/WecomCenter/assets/l2topology/app.js
  6. 8 1
      web-modules/WecomCenter/assets/l2topology/config.json
  7. 19 13
      web-modules/WecomCenter/assets/l2topology/style.css
  8. 2 1
      web-modules/WecomCenter/assets/wecom.center.css
  9. 13 1
      web-modules/WecomCenter/lib/L2Store.php
  10. 10 3
      web-modules/WecomCenter/locale/bg_BG.php
  11. 10 3
      web-modules/WecomCenter/locale/ca_ES.php
  12. 10 3
      web-modules/WecomCenter/locale/cs_CZ.php
  13. 10 3
      web-modules/WecomCenter/locale/da_DK.php
  14. 10 3
      web-modules/WecomCenter/locale/de_DE.php
  15. 10 3
      web-modules/WecomCenter/locale/el_GR.php
  16. 10 3
      web-modules/WecomCenter/locale/en.php
  17. 10 3
      web-modules/WecomCenter/locale/en_GB.php
  18. 10 3
      web-modules/WecomCenter/locale/es_ES.php
  19. 10 3
      web-modules/WecomCenter/locale/fa_IR.php
  20. 10 3
      web-modules/WecomCenter/locale/fi_FI.php
  21. 10 3
      web-modules/WecomCenter/locale/fr_FR.php
  22. 10 3
      web-modules/WecomCenter/locale/he_IL.php
  23. 10 3
      web-modules/WecomCenter/locale/hu_HU.php
  24. 10 3
      web-modules/WecomCenter/locale/id_ID.php
  25. 10 3
      web-modules/WecomCenter/locale/it_IT.php
  26. 10 3
      web-modules/WecomCenter/locale/ja_JP.php
  27. 10 3
      web-modules/WecomCenter/locale/ka_GE.php
  28. 10 3
      web-modules/WecomCenter/locale/ko_KR.php
  29. 10 3
      web-modules/WecomCenter/locale/lt_LT.php
  30. 10 3
      web-modules/WecomCenter/locale/lv_LV.php
  31. 10 3
      web-modules/WecomCenter/locale/nb_NO.php
  32. 10 3
      web-modules/WecomCenter/locale/nl_NL.php
  33. 10 3
      web-modules/WecomCenter/locale/pl_PL.php
  34. 10 3
      web-modules/WecomCenter/locale/pt_BR.php
  35. 10 3
      web-modules/WecomCenter/locale/ro_RO.php
  36. 10 3
      web-modules/WecomCenter/locale/ru_RU.php
  37. 10 3
      web-modules/WecomCenter/locale/sk_SK.php
  38. 10 3
      web-modules/WecomCenter/locale/sv_SE.php
  39. 10 3
      web-modules/WecomCenter/locale/tr_TR.php
  40. 10 3
      web-modules/WecomCenter/locale/uk_UA.php
  41. 10 3
      web-modules/WecomCenter/locale/uz_UZ.php
  42. 10 3
      web-modules/WecomCenter/locale/vi_VN.php
  43. 10 3
      web-modules/WecomCenter/locale/zh_CN.php
  44. 10 3
      web-modules/WecomCenter/locale/zh_TW.php
  45. 7 10
      web-modules/WecomCenter/views/topology.view.php
  46. 43 5
      web-modules/WecomCenter/views/wecom.center.view.php

+ 9 - 5
README.md

@@ -97,12 +97,16 @@ docker compose -p zabbix ps                                     # 查看状态
 
 模块 `web-modules/WecomCenter` 现包含 **L2 物理拓扑** 功能(展示页 + 配置页 + 采集器 + 历史库):
 
-- 展示页:`Monitoring → L2 Topology`(只读)。**两级视图**:车间卡片全景 → 点卡片进入车间详情;
-  详情先按 **OFFICE 办公网 / SURVE 监控网** 分页,同一车间/楼层也分网显示;左侧汇聚、右侧按 7F→GRD 楼层框单列接入,
-  汇聚→接入线从两侧标签左缘中点直连(出口允许被上级标签遮住,目标标签和线路不被遮挡、不交叉);
-  含时间轴/深色(默认开启)/终端/搜索/速率&延迟&告警着色。
+- 展示页:`Monitoring → L2 Topology`(只读)。**一张全局大拓扑图**:从左到右固定为
+  核心(可多个)→ 各网络主交换 → 各车间汇聚/分布 → 接入,一级交换机一列,不再拆成卡片或车间详情页。
+  每个车间一个框(包住该车间的汇聚+接入),框内再按 GRD→1F→3F→4F→7F 楼层画小框;同一车间若跨网络
+  则拆成两个独立框,只连接各自网络的上游。所有链路均为单段直线,从上级标签左缘中点画到下级标签左缘中点,
+  上级标签可遮盖出口,目标标签不被遮挡;含时间轴/终端/离线/搜索/速率&延迟&告警着色。
+  主题自动跟随 Zabbix 明暗模式,无手动深色开关。
 - 配置页:`Administration → ROMAN 定制中心 → 「L2 拓扑」`(采集模板、刷新/采集间隔、速率配色、
-  延迟阈值、分组规则、根框名、默认深色等)。
+  延迟阈值、分组规则、根框名、**物理网络定义**、**核心交换机定义**等)。物理网络按每行
+  `KEY | 显示名称 | 名称/IP关键字 | 颜色` 配置,交换机归入第一个名称或 IP 包含关键字的网络;
+  核心交换机按行选择网络并填写名称/IP 匹配规则,同一网络可定义多个核心,全部固定在左侧核心列。
 - 数据:每次全量采集把快照 gzip 写入 MySQL `l2_topology_history`(保留 7 天,可回看任意时间);
   配置存 `l2_topology_config`,并镜像 `assets/l2topology/config.json`。
 - 表结构:`L2Store::ensureSchema()` 幂等建表;`wecom-module-init` 侧车在 `up` 时也会建表。

+ 14 - 11
docs/使用说明.md

@@ -217,7 +217,11 @@ zabbix-deploy/
 - 展示页:**Monitoring → L2 Topology**,或直接
   `http://192.168.87.20:8080/zabbix.php?action=l2.topology.view`
 - 配置页:**Administration → ROMAN 定制中心 → 「L2 拓扑」标签**(模块改名为“ROMAN 定制中心”,
-  配置项全部集中在配置页,展示页只读)。
+  配置项全部集中在配置页,展示页只读)。物理网络在「物理网络定义」中按每行
+  `KEY | 显示名称 | 名称/IP关键字 | #RRGGBB` 配置,例如
+  `SURVE | SURVE 监控网 | SURVE,10.169. | #2E75B6`;新增/减少网络只改这里。
+- 「核心交换机」与分组规则一样可逐行添加:每行选择网络(或全部网络)、匹配方式(contains/regex)、
+  名称/IP 规则。命中的交换机全部画在最左侧核心列;同一网络可定义多个核心,顺序不限。
 
 ### 10.2 展示页功能
 - **链路速率配色**(可在配置页改):
@@ -225,20 +229,19 @@ zabbix-deploy/
     20G+ = 紫色 `#9B59B6`,未知 = 红色 `#E74C3C`,中断 = 红色虚线 `#C0392B`。
 - **延迟着色**:默认 ping 交换机管理 IP(Zabbix 主机 → 交换机),
   >3ms 橙色、>5ms 红色(阈值可配)。注:本网实测 0.8ms 左右,绝大多数为正常(无色)。
-- **两级视图(默认全景)**:
-  - **全景**:每个车间/DataCenter/Core 一张卡片,卡片上直接显示 `交换机数 / 核心上联数 ↑ / 告警 ⚠ / 离线终端 ◌`;
-    27 个车间一屏排完,卡片之间不再画跨车间连线,彻底避免“蜘蛛网”。
-  - **车间详情**:点卡片进入。先按 **OFFICE 办公网 / SURVE 监控网** 分页(同一车间、同一楼层也分开),一次只显示一张干净的树;
-    每个网络左侧一个汇聚,右侧按 **7F → 4F → 3F → 1F → GRD** 从上到下用楼层框分层,每层内接入交换机单列排列;
-    核心交换机以灰色虚框(ghost)出现在汇聚左侧,核心→汇聚为 10G 蓝色实线;
-    汇聚→接入的连线从**汇聚标签左缘中点**直直拉到**接入标签左缘中点**,线路出口允许被上级标签遮住,
-    但全程不穿过、不遮挡任何目标标签,也不与任何其他线路交叉。点任意交换机可高亮完整上联链路,点空白处恢复;左上「← 返回全景」退出。
-- **节点标签**:`车间 · 区域 · 楼层机柜U` + `角色 · IP` 两行;双击节点跳转该主机的问题页。
+- **一张全局大拓扑图**(不再有卡片全景/车间详情两级):
+  - 从左到右固定为 **核心(可多个)→ 各网络主交换 → 各车间汇聚/分布 → 接入**,一级交换机一列;列只按层级区分,
+    不按车间拆成多列。
+  - 每个车间一个框(包住该车间的汇聚+接入),框内按 **GRD → 1F → 3F → 4F → 7F** 从下到上画楼层小框,
+    每层接入交换机在该楼层框内单列排列。同一车间跨网络时拆成两个独立框,只连接各自网络的上游。
+  - 所有链路均为单段直线,从**上级标签左缘中点**画到**下级标签左缘中点**;上级标签可以遮住线路出口,
+    但任何目标标签和其他线路不被遮挡;同一车间内部线路互不交叉,跨区直连核心的链路除外。点任意交换机高亮完整上联链路,点空白恢复。
+- **节点标签**:`车间 · 楼层机柜U · 角色 · IP`;双击节点跳转该主机的问题页。
 - **告警面板只显示拓扑内交换机**,不再混入服务器/测试机等“拓扑外”主机;告警交换机边框统一红色。
 - **显示终端 / 只看离线**:勾选「显示终端」在交换机下方展开终端点(绿=在线、灰=离线);
   「只看离线」仅显示离线终端。右侧「离线终端」面板点击可联动定位。
 - **时间轴**:右上「快照时间」下拉可选 `实时` 或历史某分钟快照(保存 **7 天**,5 分钟一个点)。
-- **深色模式**:工具栏「深色模式」开关;出厂默认开启(`dark_default=true`),配置页可改默认值。
+- **明暗主题**:自动跟随 Zabbix 当前主题(Zabbix 深色即深色、亮色即亮色),无手动开关。
 - **搜索**:输入 IP / MAC / 名称,下拉结果点击即居中定位。
 - 实时模式按配置的刷新秒数自动刷新,可暂停;支持缩放/适应窗口。
 

+ 1 - 2
web-modules/WecomCenter/actions/L2TopologyView.php

@@ -43,8 +43,7 @@ class L2TopologyView extends CController {
 			'history'    => L2Store::listHistory(500),
 			'used_ts'    => $used_ts,
 			'is_live'    => ($ts === null),
-			'cfg_refresh'=> (int) $cfg['refresh_seconds'],
-			'cfg_dark'   => !empty($cfg['dark_default'])
+			'cfg_refresh'=> (int) $cfg['refresh_seconds']
 		]));
 	}
 }

+ 40 - 1
web-modules/WecomCenter/actions/WecomCenterSave.php

@@ -258,7 +258,46 @@ class WecomCenterSave extends CController {
 		if ($cfg['root_label'] === '') { $cfg['root_label'] = 'COBCO'; }
 		$cfg['other_label']        = trim((string) ($_REQUEST['other_label'] ?? $cfg['other_label']));
 		if ($cfg['other_label'] === '') { $cfg['other_label'] = 'Other'; }
-		$cfg['dark_default']       = isset($_REQUEST['dark_default']);
+
+		// Networks: one line per network, pipe separated:
+		//   KEY | display label | keyword1,keyword2 | #RRGGBB
+		$netText = trim((string) ($_REQUEST['networks_text'] ?? ''));
+		if ($netText !== '') {
+			$networks = [];
+			foreach (preg_split('/\R/u', $netText) as $line) {
+				$line = trim($line);
+				if ($line === '') { continue; }
+				$p = array_map('trim', explode('|', $line));
+				$key = strtoupper((string) ($p[0] ?? ''));
+				$label = (string) ($p[1] ?? $key);
+				if ($key === '' || $label === '') { continue; }
+				$kws = array_values(array_filter(array_map('trim', preg_split('/[,,;;\s]+/u', (string) ($p[2] ?? $key))), 'strlen'));
+				$color = (string) ($p[3] ?? '#607D8B');
+				if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) { $color = '#607D8B'; }
+				$networks[] = ['key' => $key, 'label' => $label, 'keywords' => $kws, 'color' => $color];
+			}
+			if ($networks) { $cfg['networks'] = $networks; }
+		}
+
+		// Core/root switches: one row per switch, arrays are parallel:
+		//   core_net[] | core_match[] | core_pattern[]
+		if (isset($_REQUEST['core_rules_present'])) {
+			$coreNets = (array) ($_REQUEST['core_net'] ?? []);
+			$coreMatch = (array) ($_REQUEST['core_match'] ?? []);
+			$corePattern = (array) ($_REQUEST['core_pattern'] ?? []);
+			$netKeys = array_map(function ($n) { return (string) ($n['key'] ?? ''); }, (array) $cfg['networks']);
+			$coreRules = [];
+			foreach ($coreNets as $i => $net) {
+				$net = strtoupper(trim((string) $net));
+				if ($net !== '' && !in_array($net, $netKeys, true)) { $net = ''; }
+				$match = (string) ($coreMatch[$i] ?? 'contains');
+				if ($match !== 'regex') { $match = 'contains'; }
+				$pattern = trim((string) ($corePattern[$i] ?? ''));
+				if ($pattern === '') { continue; }
+				$coreRules[] = ['net' => $net, 'match' => $match, 'pattern' => $pattern];
+			}
+			$cfg['core_rules'] = $coreRules;
+		}
 		$cfg['latency_warn']       = (float) ($_REQUEST['latency_warn'] ?? $cfg['latency_warn']);
 		$cfg['latency_bad']        = (float) ($_REQUEST['latency_bad'] ?? $cfg['latency_bad']);
 

+ 578 - 65
web-modules/WecomCenter/assets/l2topology/app.js

@@ -34,8 +34,9 @@
   var countdown = 0;
   var timer = null;
   var lastSnap = null;
-  var viewGroup = null;       // null = overview, otherwise workshop label
+  var viewGroup = null;       // direct-link focus group, never hides the full chart
   var detailNet = null;       // null/auto, 'SURVE' or 'OFFICE' inside a workshop
+  var focusGroupLabel = null; // ?g= target, zoomed after the full chart is built
   var pathParent = {};        // child -> parent chain for hover highlighting
   var lockedPath = null;      // switch id whose path is locked by click
 
@@ -50,11 +51,12 @@
     for (var i = 0; i < toks.length; i++) {
       var tk = toks[i];
       if (/^\d{1,3}(\.\d{1,3}){3}$/.test(tk)) { r.ip = tk; continue; }
-      if (!r.workshop && /^\d{4}$/.test(tk)) { r.workshop = tk; continue; }
-      if (!r.floor && /^\d[A-Z]\w*$/i.test(tk)) {
-        var m = /^(\d)([A-Z])(\w+)$/i.exec(tk);
-        if (m) { r.floor = m[1]; r.cab = m[2]; r.u = m[3]; continue; }
+      if (!r.workshop && /^\d{4}[A-Za-z]?$/.test(tk)) { r.workshop = tk; continue; }
+      if (!r.floor) {
+        var fm = /^(GRD|\d{1,2}F)([A-Z])?(\d+)?$/i.exec(tk);
+        if (fm) { r.floor = fm[1].toUpperCase(); r.cab = fm[2] || ''; r.u = fm[3] || ''; continue; }
       }
+      if (r.floor && !r.u && /^\d{1,3}$/.test(tk)) { r.u = tk; continue; }
       if (!r.area && /^(OFFICE|SURVE|GRDA|DC|IDC|LAB|PROD)$/i.test(tk)) { r.area = tk.toUpperCase(); continue; }
       if (!r.role && /^(Dist|Access|Core)$/i.test(tk)) { r.role = tk; }
     }
@@ -442,6 +444,350 @@
     };
   }
 
+  /* ---------------- one-chart layout: fixed columns by tier ----------------
+     Left to right: physical core → each network's main switch → every
+     workshop distribution switch → access switches.  A workshop frame spans
+     the distribution + access columns; each floor is a small frame inside it.
+     Same workshop on two networks is always two separate frames.            */
+  var CORE_X = 130, MAIN_X = 430, DIST_X = 790, ACCESS_X = 1300;
+  var CORE_W = 200, MAIN_W = 300, NODE_W3 = 300;
+  var WORK_LEFT = 610, WORK_RIGHT = 1530;
+  var FLOOR_LEFT = 1130, FLOOR_RIGHT = 1525;
+  var CHART_W = 1560;
+  var SEC_HEAD = 46, CORE_ROW = 92, WORK_TOP = 148;
+  var ROW_H = 24, ROW_GAP = 3, WS_GAP = 10, SEC_GAP = 34;
+
+  function makeNetworks() {
+    var raw = CFG.networks;
+    if (!raw || !raw.length) {
+      raw = [
+        {key: 'SURVE', label: t('l2.net_surve', 'SURVE 监控网'), keywords: ['SURVE', '10.169.'], color: '#2E75B6'},
+        {key: 'OFFICE', label: t('l2.net_office', 'OFFICE 办公网'), keywords: ['OFFICE', '10.160.'], color: '#548235'}
+      ];
+    }
+    return raw.map(function (n, i) {
+      var kws = n.keywords;
+      if (!(kws instanceof Array)) kws = String(kws || '').split(/[,,;;\s]+/);
+      return {
+        key: String(n.key || ('NET' + i)).trim().toUpperCase(),
+        label: String(n.label || n.key || ('NET' + i)),
+        keywords: kws.map(function (s) { return String(s).trim(); }).filter(Boolean),
+        color: /^#[0-9a-fA-F]{6}$/.test(String(n.color || '')) ? n.color : '#607D8B'
+      };
+    });
+  }
+  var NETWORKS = makeNetworks();
+
+  function netLabel(key) {
+    for (var i = 0; i < NETWORKS.length; i++) if (NETWORKS[i].key === key) return NETWORKS[i].label;
+    return key === 'OTHER' ? t('l2.net_other', '其他网络') : String(key || '');
+  }
+  function netColor(key) {
+    for (var i = 0; i < NETWORKS.length; i++) if (NETWORKS[i].key === key) return NETWORKS[i].color;
+    return '#607D8B';
+  }
+  function rawNetOf(n) {
+    var hay = (String((n && n.name) || '') + ' ' + String((n && n.ip) || '')).toUpperCase();
+    for (var i = 0; i < NETWORKS.length; i++) {
+      for (var j = 0; j < NETWORKS[i].keywords.length; j++) {
+        if (hay.indexOf(String(NETWORKS[i].keywords[j]).toUpperCase()) >= 0) return NETWORKS[i].key;
+      }
+    }
+    return 'OTHER';
+  }
+
+  // Core/root switch rules are configured from the L2 tab, one row per core.
+  // Multiple cores in the same network are supported and all of them are
+  // placed in the left-most core column.
+  function coreRules() {
+    var r = CFG.core_rules;
+    return (r instanceof Array) ? r : [];
+  }
+  function coreRuleHit(rule, n) {
+    var pattern = String(rule.pattern || '');
+    if (!pattern) return false;
+    var hay = String((n && n.name) || '') + ' ' + String((n && n.ip) || '');
+    if (String(rule.match || 'contains') === 'regex') {
+      try { return new RegExp(pattern, 'i').test(hay); } catch (e) { return false; }
+    }
+    return hay.toLowerCase().indexOf(pattern.toLowerCase()) >= 0;
+  }
+  function configuredCore(n) {
+    var rules = coreRules();
+    for (var i = 0; i < rules.length; i++) if (coreRuleHit(rules[i], n)) return true;
+    return false;
+  }
+  function configuredCoreNet(n) {
+    var rules = coreRules();
+    for (var i = 0; i < rules.length; i++) {
+      if (coreRuleHit(rules[i], n) && String(rules[i].net || '').trim() !== '') return String(rules[i].net).toUpperCase();
+    }
+    return '';
+  }
+
+  // Network is decided first by name/IP keyword, then by the rest of the
+  // workshop, then by graph adjacency. No switch is ever shown under the
+  // wrong upstream network.
+  function assignNetworks(snap) {
+    var nodes = snap.nodes || [], byId = {}, net = {}, adj = {};
+    nodes.forEach(function (n) {
+      byId[n.id] = n; net[n.id] = rawNetOf(n); adj[n.id] = [];
+      var cn = configuredCoreNet(n);
+      if (cn) net[n.id] = cn;
+    });
+    (snap.edges || []).forEach(function (e) {
+      if (adj[e.source] && adj[e.target]) { adj[e.source].push(e.target); adj[e.target].push(e.source); }
+    });
+    var groupVote = {};
+    nodes.forEach(function (n) {
+      var k = groupOf(n).label, v = net[n.id];
+      if (v && v !== 'OTHER') (groupVote[k] = groupVote[k] || {})[v] = true;
+    });
+    nodes.forEach(function (n) {
+      if (net[n.id] !== 'OTHER') return;
+      var v = groupVote[groupOf(n).label], keys = v ? Object.keys(v) : [];
+      if (keys.length === 1) net[n.id] = keys[0];
+    });
+    for (var pass = 0; pass < 8; pass++) {
+      var changed = false;
+      nodes.forEach(function (n) {
+        if (net[n.id] !== 'OTHER') return;
+        var votes = {};
+        adj[n.id].forEach(function (id) { if (net[id] && net[id] !== 'OTHER') votes[net[id]] = (votes[net[id]] || 0) + 1; });
+        var best = null, bn = 0;
+        Object.keys(votes).forEach(function (k) { if (votes[k] > bn) { best = k; bn = votes[k]; } });
+        if (best) { net[n.id] = best; changed = true; }
+      });
+      if (!changed) break;
+    }
+    return net;
+  }
+
+  function roleOf(n) {
+    var p = parseName(n.name || '');
+    var r = String(p.role || '').toLowerCase();
+    var tier = String(n.tier || '').toLowerCase();
+    if (configuredCore(n)) return 'core';
+    // Backward-compatible fallback only when the core list is absent (old
+    // config).  An explicitly empty list means the administrator wants no
+    // configured core column.
+    if (!(CFG.core_rules instanceof Array) && (/^core(\s|-)/i.test(String(n.name || '')) || groupOf(n).label === 'Core')) return 'core';
+    if (tier === 'core' || r === 'core') return 'main';
+    if (r === 'dist') return 'dist';
+    if (r === 'access') return 'access';
+    if (tier.indexOf('dist') === 0) return 'dist';
+    return 'access';
+  }
+
+  function roleName(r) {
+    if (r === 'core') return t('l2.core', '核心');
+    if (r === 'main') return t('l2.main', '主交换');
+    if (r === 'dist') return t('l2.dist', '汇聚');
+    return t('l2.access', '接入');
+  }
+
+  function shortLoc(n) {
+    var p = parseName(n.name || '');
+    if (!p.floor) return '';
+    return p.floor + (p.cab || '') + (p.u ? '-' + p.u : '');
+  }
+
+  function switchLabel(n, role, netKey) {
+    var g = groupOf(n).label;
+    if (role === 'core') return roleName('core') + ' · ' + (netKey || rawNetOf(n)) + ' · ' + (n.ip || '');
+    if (role === 'main') return roleName('main') + ' · ' + g + ' · ' + (n.ip || '');
+    var loc = shortLoc(n);
+    return [g, loc, roleName(role)].filter(Boolean).join(' · ') + ' · ' + (n.ip || '');
+  }
+
+  // A workshop is the physical area served by one distribution switch, not
+  // the four-digit prefix printed on each access switch.  The same dist can
+  // serve access switches in several rooms/buildings; they must share one
+  // frame, otherwise their uplink line would cross unrelated frames.
+  function workshopKeys(snap) {
+    var byId = {}, adj = {}, keys = {};
+    (snap.nodes || []).forEach(function (n) { byId[n.id] = n; adj[n.id] = []; });
+    (snap.edges || []).forEach(function (e) {
+      if (adj[e.source] && adj[e.target]) { adj[e.source].push(e.target); adj[e.target].push(e.source); }
+    });
+    (snap.nodes || []).forEach(function (n) {
+      var r = roleOf(n);
+      if (r === 'core' || r === 'main') return;
+      if (r === 'dist') { keys[n.id] = groupOf(n).label; return; }
+      var ds = (adj[n.id] || []).map(function (id) { return byId[id]; })
+        .filter(function (d) { return d && roleOf(d) === 'dist'; });
+      var same = ds.filter(function (d) { return groupOf(d).label === groupOf(n).label; });
+      var pick = same[0] || ds[0];
+      keys[n.id] = pick ? groupOf(pick).label : groupOf(n).label;
+    });
+    return keys;
+  }
+
+  function workshopList(snap, netMap, netKey, wsKeys) {
+    var map = {}, order = [];
+    (snap.nodes || []).forEach(function (n) {
+      if (netMap[n.id] !== netKey) return;
+      var r = roleOf(n);
+      if (r === 'core' || r === 'main') return;
+      var g = (wsKeys && wsKeys[n.id]) || groupOf(n).label;
+      if (!map[g]) { map[g] = {key: g, label: g, extra: [], net: netKey, dists: [], access: [], all: []}; order.push(g); }
+      map[g].all.push(n);
+      if (r === 'dist') map[g].dists.push(n); else map[g].access.push(n);
+    });
+    return order.map(function (g) { return map[g]; }).filter(function (w) { return w.all.length > 0; })
+      .map(function (w) {
+        w.access.forEach(function (a) {
+          var raw = groupOf(a).label;
+          if (raw !== w.key && w.extra.indexOf(raw) < 0) w.extra.push(raw);
+        });
+        w.label = w.key + (w.extra.length ? ' + ' + w.extra.slice(0, 3).join('/') + (w.extra.length > 3 ? '…' : '') : '');
+        return w;
+      });
+  }
+
+  function layoutWorkshop(w, top) {
+    var rowH = ROW_H + (showTerm ? 24 : 0);
+    var floorMap = {}, floors = [];
+    w.access.forEach(function (n) {
+      var fi = floorInfo(n);
+      var k = fi.key;
+      if (!floorMap[k]) { floorMap[k] = {key: k, label: fi.label, rank: (fi.key === 'NA' ? 999 : fi.rank), nodes: [], rows: []}; floors.push(floorMap[k]); }
+      floorMap[k].nodes.push(n);
+    });
+    floors.sort(function (a, b) { return a.rank - b.rank || String(a.label).localeCompare(String(b.label)); });
+    var accessH = 0;
+    floors.forEach(function (f) {
+      f.nodes.sort(function (a, b) { return String(a.name).localeCompare(String(b.name)); });
+      f.h = 22 + f.nodes.length * rowH + 6;
+      accessH += f.h;
+    });
+    if (floors.length > 1) accessH += (floors.length - 1) * 5;
+    var distH = w.dists.length ? (w.dists.length * 64 + (w.dists.length - 1) * 8) : 0;
+    var inner = Math.max(accessH, distH, 54);
+    var h = 38 + inner + 10;
+    w.top = top; w.h = h; w.rowH = rowH;
+    // Floors are stacked bottom-up: GRD lowest, then 1F → 3F → 4F → 7F.
+    var cursor = top + h - 8;
+    floors.forEach(function (f) {
+      cursor -= f.h;
+      f.frame = {x: FLOOR_LEFT, y: cursor, w: FLOOR_RIGHT - FLOOR_LEFT, h: f.h};
+      f.nodes.forEach(function (n, i) {
+        f.rows.push({id: n.id, x: ACCESS_X, y: cursor + 22 + rowH / 2 + i * rowH});
+      });
+      cursor -= 5;
+    });
+    var dstart = top + 38 + (inner - distH) / 2;
+    w.distPos = w.dists.map(function (n, i) {
+      return {id: n.id, x: DIST_X, y: dstart + 32 + i * 72};
+    });
+    w.floors = floors;
+    w.title = w.label + '  ·  ' + netLabel(w.net) + '  ·  ' + w.all.length + ' ' + t('l2.unit_sw', '台');
+    // A workshop without a distribution switch only needs a box around its
+    // access column; keeping the full dist-column width would leave a large
+    // empty rectangle and make the chart look sparse.
+    var frameLeft = w.dists.length ? WORK_LEFT : FLOOR_LEFT - 40;
+    w.frame = {x: frameLeft, y: top, w: WORK_RIGHT - frameLeft, h: h};
+    return h;
+  }
+
+  function computeFull(snap) {
+    var nodes = snap.nodes || [];
+    var netMap = assignNetworks(snap);
+    var sections = [], placements = [], frames = [], roleMap = {};
+    var wsKeys = workshopKeys(snap);
+    var y = 100;
+    NETWORKS.forEach(function (net) {
+      var secNodes = nodes.filter(function (n) { return netMap[n.id] === net.key; });
+      if (!secNodes.length) return;
+      var cores = [], main = null;
+      secNodes.forEach(function (n) {
+        roleMap[n.id] = roleOf(n);
+        if (roleMap[n.id] === 'core') cores.push(n);
+        if (roleMap[n.id] === 'main' && !main) main = n;
+      });
+      var ws = workshopList(snap, netMap, net.key, wsKeys);
+      var sec = {
+        key: net.key, label: netLabel(net.key) + '  ·  ' + secNodes.length + ' ' + t('l2.unit_sw', '台'),
+        x: 10, y: y, w: CHART_W - 20, h: 0, color: netColor(net.key),
+        core: cores[0] || null, cores: cores, main: main, workshops: ws
+      };
+      var cur = y + WORK_TOP;
+      ws.forEach(function (w) {
+        layoutWorkshop(w, cur);
+        cur += w.h + WS_GAP;
+        frames.push({id: 'ws_' + net.key + '_' + w.key, type: 'wsframe', x: w.frame.x, y: w.frame.y, w: w.frame.w, h: w.frame.h,
+          label: w.title, group: w.key, net: net.key, color: net.color});
+        w.floors.forEach(function (f, fi) {
+          frames.push({id: 'ff_' + net.key + '_' + w.key + '_' + fi, type: 'floorframe', x: f.frame.x, y: f.frame.y,
+            w: f.frame.w, h: f.frame.h, label: f.label + '  ·  ' + f.nodes.length + ' ' + t('l2.unit_sw', '台'), net: net.key, color: net.color});
+        });
+        w.distPos.forEach(function (p) {
+          var n = nodes.filter(function (x) { return x.id === p.id; })[0];
+          if (n) placements.push({node: n, role: 'dist', x: p.x, y: p.y, w: NODE_W3, h: 26, net: net.key, group: w.key, ws: net.key + '|' + w.key});
+        });
+        w.floors.forEach(function (f) {
+          f.rows.forEach(function (p) {
+            var n = nodes.filter(function (x) { return x.id === p.id; })[0];
+            if (n) placements.push({node: n, role: 'access', x: p.x, y: p.y, w: NODE_W3, h: 26, net: net.key, group: w.key, ws: net.key + '|' + w.key});
+          });
+        });
+      });
+      sec.h = (cur - y) + 16;
+      // The network main switch stays at the top of its tier column (its access
+      // children sit directly under it, as in the reference chart); the core
+      // sits lower in the column so its uplinks fan symmetrically.
+      sec.mainY = y + SEC_HEAD + 46;
+      sec.coreY = y + Math.max(CORE_ROW + SEC_HEAD, Math.round(sec.h * 0.55));
+      if (cores.length) {
+        var coreStep = 64;
+        var coreTop = sec.coreY - ((cores.length - 1) * coreStep) / 2;
+        cores.forEach(function (cn, ci) {
+          placements.push({node: cn, role: 'core', x: CORE_X, y: coreTop + ci * coreStep, w: CORE_W, h: 54, net: net.key, group: 'Core'});
+        });
+      }
+      if (main) placements.push({node: main, role: 'main', x: MAIN_X, y: sec.mainY, w: MAIN_W, h: 46, net: net.key, group: groupOf(main).label});
+      sections.push(sec);
+      y += sec.h + SEC_GAP;
+    });
+    var root = {x: 0, y: 0, w: CHART_W, h: y + 6};
+    return {sections: sections, placements: placements, frames: frames, netMap: netMap, roleMap: roleMap, root: root};
+  }
+
+  // Immediate upstream parent per switch: access -> its workshop dist;
+  // dist -> core when directly attached, otherwise the network main switch.
+  function buildParents(snap, L) {
+    var byId = {}, adj = {}, edgeSpeed = {};
+    L.placements.forEach(function (p) { byId[p.node.id] = p; });
+    (snap.nodes || []).forEach(function (n) { adj[n.id] = []; });
+    (snap.edges || []).forEach(function (e) {
+      if (!adj[e.source] || !adj[e.target]) return;
+      adj[e.source].push(e.target); adj[e.target].push(e.source);
+      edgeSpeed[e.source + '|' + e.target] = edgeSpeed[e.target + '|' + e.source] = parseFloat(e.speed) || 0;
+    });
+    var parent = {};
+    Object.keys(byId).forEach(function (id) {
+      var role = L.roleMap[id] || 'access';
+      if (role === 'core') return;
+      var p = byId[id], cands = (adj[id] || []).map(function (x) { return {id: x, role: L.roleMap[x] || 'access', p: byId[x]}; })
+        .filter(function (c) { return c.p; });
+      var want = role === 'main' ? ['core'] : (role === 'dist' ? ['core', 'main'] : ['dist', 'main', 'core']);
+      var best = null;
+      for (var wi = 0; wi < want.length && !best; wi++) {
+        var list = cands.filter(function (c) {
+          if (c.role !== want[wi]) return false;
+          if (role === 'access' && want[wi] === 'dist' && c.p.ws && p.ws && c.p.ws !== p.ws) return false;
+          return true;
+        });
+        if (list.length) {
+          list.sort(function (a, b) { return (edgeSpeed[id + '|' + b.id] || 0) - (edgeSpeed[id + '|' + a.id] || 0); });
+          best = list[0];
+        }
+      }
+      if (best) parent[id] = best.id;
+    });
+    return parent;
+  }
+
   /* ---------------- speed colour ---------------- */
   function speedColor(speed, state) {
     if (state && state !== 'up') return colors.down;
@@ -485,22 +831,30 @@
         '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',
+      {selector: 'node[type="netframe"]', style: {
+        'background-opacity': d ? 0.03 : 0.02, 'background-color': d ? '#7b91b8' : '#adc0d8',
+        'border-width': 3, 'border-style': 'solid', 'border-color': 'data(bd)',
+        'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center',
+        'font-size': 22, 'font-weight': 900, 'color': d ? '#dbe9ff' : '#29425f',
+        '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',
+      {selector: 'node[type="wsframe"]', style: {
+        'background-opacity': d ? 0.02 : 0.014, 'background-color': d ? '#6f8fc4' : '#8fa6c4',
+        'border-width': 2.2, 'border-style': 'solid', 'border-color': 'data(bd)',
         'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center', 'text-wrap': 'none',
-        'font-size': 13, 'font-weight': 800, 'color': d ? '#a9c1e3' : '#425a78',
+        'font-size': 15.5, 'font-weight': 900, 'color': d ? '#d7e6ff' : '#2f4560',
         'text-margin-x': 0, 'text-margin-y': 7, 'padding': '0px', 'events': 'no',
         'z-index-compare': 'manual', 'z-index': 2
       }},
+      {selector: 'node[type="floorframe"]', style: {
+        'background-opacity': d ? 0.04 : 0.025, 'background-color': d ? '#5b7bb0' : '#8fa6c4',
+        'border-width': 1.1, 'border-style': 'dashed', 'border-color': 'data(bd)',
+        'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center', 'text-wrap': 'none',
+        'font-size': 11.5, 'font-weight': 800, 'color': d ? '#a9c1e3' : '#425a78',
+        'text-margin-x': 0, 'text-margin-y': 6, 'padding': '0px', 'events': 'no',
+        'z-index-compare': 'manual', 'z-index': 3
+      }},
       {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',
@@ -508,6 +862,30 @@
         '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="roottitle"]', style: {
+        'width': 'data(w)', 'height': 20, 'shape': 'rectangle', 'background-opacity': 0, 'border-width': 0,
+        'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'none',
+        'font-size': 24, 'font-weight': 900, 'color': d ? '#cfe0ff' : '#33465f', 'events': 'no',
+        'z-index-compare': 'manual', 'z-index': 5
+      }},
+      {selector: 'node[type="nettitle"]', style: {
+        'width': 'data(w)', 'height': 20, 'shape': 'rectangle', 'background-opacity': 0, 'border-width': 0,
+        'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'none',
+        'font-size': 20, 'font-weight': 900, 'color': d ? '#dbe9ff' : '#29425f', 'events': 'no',
+        'z-index-compare': 'manual', 'z-index': 5
+      }},
+      {selector: 'node[type="wslabel"]', style: {
+        'width': 'data(w)', 'height': 16, 'shape': 'rectangle', 'background-opacity': 0, 'border-width': 0,
+        'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'none',
+        'font-size': 15, 'font-weight': 900, 'color': d ? '#d7e6ff' : '#2f4560', 'events': 'no',
+        'z-index-compare': 'manual', 'z-index': 5
+      }},
+      {selector: 'node[type="floorlabel"]', style: {
+        'width': 'data(w)', 'height': 16, 'shape': 'rectangle', 'background-opacity': 0, 'border-width': 0,
+        'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'none',
+        'font-size': 11, 'font-weight': 800, 'color': d ? '#a9c1e3' : '#425a78', 'events': 'no',
+        'z-index-compare': 'manual', 'z-index': 5
+      }},
       {selector: 'node[type="tile"]', style: {
         'width': 'data(w)', 'height': 'data(h)', 'shape': 'round-rectangle',
         'background-color': 'data(bg)', 'background-opacity': 0.97,
@@ -531,7 +909,7 @@
         'width': 1, 'height': 1, 'opacity': 0, 'events': 'no'
       }},
       {selector: 'node[type="term"]', style: {
-        'width': 12, 'height': 12, 'shape': 'ellipse', 'background-color': 'data(bg)',
+        'width': 7, 'height': 7, 'shape': 'ellipse', 'background-color': 'data(bg)',
         'border-width': 1, 'border-color': d ? '#2a3648' : '#cfd8e3', 'label': '',
         'z-index-compare': 'manual', 'z-index': 30
       }},
@@ -559,6 +937,12 @@
         'curve-style': 'bezier', 'opacity': 0.92, 'target-arrow-shape': 'none',
         'z-index-compare': 'manual', 'z-index': 12
       }},
+      {selector: 'edge[type="link"]', style: {
+        'width': 'data(w)', 'line-color': 'data(color)', 'line-style': 'data(ls)',
+        'opacity': 0.62, 'curve-style': 'straight',
+        'source-endpoint': '0% 50%', 'target-endpoint': '0% 50%',
+        'target-arrow-shape': 'none', 'z-index-compare': 'manual', 'z-index': 1
+      }},
       {selector: 'edge[type="term"]', style: {
         'width': 0.7, 'line-color': d ? '#3b4a5e' : '#c5cfdb', 'opacity': 0.55,
         'curve-style': 'bezier', 'z-index-compare': 'manual', 'z-index': 11
@@ -583,43 +967,158 @@
   }
 
   /* ---------------- render ---------------- */
-  function applyElements(els) {
+  // One tall column chart cannot be squeezed into a wide window without
+  // wasting half the canvas.  The initial view fits the chart WIDTH and
+  // anchors at the top (core switches first); the user can pan down or press
+  // "适应窗口" to see the entire graph.  This keeps labels readable.
+  function initialView() {
+    if (!cy) return;
+    var bb = cy.elements().boundingBox();
+    if (!bb || !bb.w || !bb.h) return;
+    var zw = (cy.width() - 30) / bb.w;
+    var zh = (cy.height() - 30) / bb.h;
+    var tall = (bb.h * zw > cy.height() * 1.5);
+    var z = tall ? zw * 0.95 : Math.min(zw, zh);
+    cy.zoom({level: z});
+    if (tall) {
+      cy.pan({x: 15 - bb.x * z, y: 28 - bb.y * z});
+    } else {
+      cy.pan({x: (cy.width() - bb.w * z) / 2 - bb.x * z, y: (cy.height() - bb.h * z) / 2 - bb.y * z});
+    }
+  }
+
+  function applyElements(els, keepView) {
     if (!cy) {
       cy = cytoscape({
         container: el('l2-cy'),
         elements: els,
         style: styleFor(dark),
-        layout: {name: 'preset', fit: true, padding: 40},
+        layout: {name: 'preset', fit: false},
         wheelSensitivity: 0.2,
-        minZoom: 0.15, maxZoom: 4
+        minZoom: 0.02, maxZoom: 4
       });
       bindCy();
+      setTimeout(initialView, 0);
     } else {
+      var z = cy.zoom(), p = cy.pan();
       cy.elements().remove();
       cy.add(els);
       cy.style(styleFor(dark));
-      cy.layout({name: 'preset', fit: true, padding: 40}).run();
+      cy.layout({name: 'preset', fit: false}).run();
+      if (keepView) { cy.zoom(z); cy.pan(p); }
+      else { initialView(); }
     }
   }
 
+  function fitGroup(label) {
+    if (!cy || !label) return;
+    var eles = cy.nodes().filter(function (n) {
+      return n.data('blockGroup') === label || n.data('group') === label;
+    });
+    if (eles.length) cy.fit(eles, 70);
+  }
+
   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;
-    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';
+    renderFull(snap);
     updateStats(snap);
     buildLegend();
     buildAlarms(snap);
     buildOffline(snap);
   }
 
+  function renderFull(snap) {
+    var L = computeFull(snap), els = [];
+    if (!L.placements.length) { applyElements([], false); return; }
+    var byId = {};
+    L.placements.forEach(function (p) { byId[p.node.id] = p; });
+
+    els.push({group: 'nodes', data: {id: '__root', type: 'root', label: '', w: L.root.w, h: L.root.h},
+      position: {x: L.root.w / 2, y: L.root.h / 2}, selectable: false, grabbable: false});
+    els.push({group: 'nodes', data: {id: '__roottitle', type: 'roottitle', label: CFG.root_label || 'COBCO', w: L.root.w, h: 20},
+      position: {x: L.root.w / 2, y: 22}, selectable: false, grabbable: false});
+    L.sections.forEach(function (sec, si) {
+      els.push({group: 'nodes', data: {
+        id: '__net_' + si, type: 'netframe', label: '', w: sec.w, h: sec.h, net: sec.key, bd: sec.color
+      }, position: {x: sec.x + sec.w / 2, y: sec.y + sec.h / 2}, selectable: false, grabbable: false});
+      els.push({group: 'nodes', data: {id: '__nettitle_' + si, type: 'nettitle', label: sec.label, w: sec.w, h: 20},
+        position: {x: sec.x + sec.w / 2, y: sec.y + 21}, selectable: false, grabbable: false});
+    });
+    L.frames.forEach(function (f) {
+      els.push({group: 'nodes', data: {
+        id: f.id, type: f.type, label: '', w: f.w, h: f.h, group: f.group || '', net: f.net, bd: f.color || netColor(f.net)
+      }, position: {x: f.x + f.w / 2, y: f.y + f.h / 2}, selectable: false, grabbable: false});
+      var tt = f.type === 'floorframe' ? 'floorlabel' : 'wslabel';
+      var ty = f.type === 'floorframe' ? 11 : 18;
+      els.push({group: 'nodes', data: {id: 'label_' + f.id, type: tt, label: f.label, w: f.w, h: 16},
+        position: {x: f.x + f.w / 2, y: f.y + ty}, selectable: false, grabbable: false});
+    });
+
+    L.placements.forEach(function (p) {
+      var n = p.node, cls = [];
+      var bg = p.role === 'core' ? TIER_BG.core : (p.role === 'main' ? '#2E75B6' : (p.role === 'dist' ? TIER_BG.dist : TIER_BG.access));
+      var bd = bg;
+      if (n.alarms && n.alarms.length) { cls.push('alarm'); bd = '#E45959'; }
+      if (n.latency_ms != null) {
+        if (+n.latency_ms > LATB) cls.push('lat-bad');
+        else if (+n.latency_ms > LATW) cls.push('lat-warn');
+      }
+      if (p.role === 'core') cls.push('root');
+      els.push({group: 'nodes', data: {
+        id: n.id, type: 'switch', name: n.name, ip: n.ip, tier: n.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: switchLabel(n, p.role, p.net), tw: p.w - 12, bg: bg, bd: bd, w: p.w, h: p.h,
+        group: p.group + ' · ' + netLabel(p.net), blockGroup: p.group, net: p.net, role: p.role
+      }, position: {x: p.x, y: p.y}, classes: cls.join(' ')});
+    });
+
+    // Every protocol-discovered link = one straight segment. Endpoints sit at
+    // the label left-edge midpoint; the labels paint on top, so a parent line
+    // starts under the parent label and ends exactly at the child's left edge.
+    pathParent = buildParents(snap, L);
+    (snap.edges || []).forEach(function (e, i) {
+      if (!byId[e.source] || !byId[e.target]) return;
+      var col = speedColor(e.speed, e.state);
+      els.push({group: 'edges', data: {
+        id: 'e_' + i, type: 'link', source: e.source, target: e.target, p: e.source, c: e.target,
+        speed: e.speed, state: e.state, a_port: e.a_port, b_port: e.b_port,
+        color: col, ls: (e.state && e.state !== 'up') ? 'dashed' : 'solid', w: Math.min(4, speedWidth(e.speed))
+      }});
+    });
+
+    if (showTerm) addTerminals(L, byId, els);
+    applyElements(els, true);
+    if (focusGroupLabel) {
+      setTimeout(function () { fitGroup(focusGroupLabel); }, 80);
+      focusGroupLabel = null;
+    }
+  }
+
+  function addTerminals(L, byId, els) {
+    var bySw = {};
+    ((lastSnap && lastSnap.terminals) || []).forEach(function (tm) {
+      if (!byId[tm.sw_ip]) return;
+      if (onlyOff && tm.online) return;
+      (bySw[tm.sw_ip] = bySw[tm.sw_ip] || []).push(tm);
+    });
+    Object.keys(bySw).forEach(function (sw) {
+      var p = byId[sw]; if (!p || p.role === 'core' || p.role === 'main') return;
+      bySw[sw].sort(function (a, b) { return (a.online === b.online) ? 0 : (a.online ? -1 : 1); });
+      bySw[sw].slice(0, 24).forEach(function (tm, k) {
+        var col = k % 12, row = Math.floor(k / 12);
+        var x = p.x - p.w / 2 + 16 + col * 9;
+        var y = p.y + 16 + row * 9;
+        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}});
+      });
+    });
+  }
+
   function renderOverview(snap) {
     var O = computeOverview(snap), els = [], idx = {};
     var nb = el('l2-net-tabs'); if (nb) { nb.style.display = 'none'; nb.innerHTML = ''; }
@@ -787,6 +1286,7 @@
     h += sw(colors.down, t('lbl.color_down', 'Down'), true);
     h += '<div class="lg-title">' + t('l2.legend_tier', 'Tier') + '</div>';
     h += '<div class="lg-row"><span class="lg-box" style="background:' + TIER_BG.core + '"></span>' + t('l2.core', 'Core') +
+         '<span class="lg-box" style="background:#2E75B6;margin-left:8px"></span>' + t('l2.main', 'Main switch') +
          '<span class="lg-box" style="background:' + TIER_BG.dist + ';margin-left:8px"></span>' + t('l2.dist', 'Distribution') +
          '<span class="lg-box" style="background:' + TIER_BG.access + ';margin-left:8px"></span>' + t('l2.access', 'Access') + '</div>';
     h += '<div class="lg-title">' + t('l2.legend_lat', 'Latency') + '</div>';
@@ -875,21 +1375,12 @@
     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;
-      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);
+      if (!cy || cy.getElementById(id).empty()) render(lastSnap);
       setTimeout(function () { focusNode(id); }, 90);
     } else {
-      var g2 = groupOfNodeId(id);
-      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);
+      focusNode(id);
     }
   }
 
@@ -921,7 +1412,7 @@
 
   function bindCy() {
     cy.on('mouseover', 'node[type="switch"]', function (e) {
-      if (viewGroup) highlightPath(e.target.id());
+      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>') : '—';
@@ -938,18 +1429,6 @@
       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>' +
-        '↑ 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; detailNet = null; 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');
@@ -960,6 +1439,9 @@
       lockedPath = e.target.id();
       highlightPath(lockedPath);
     });
+    cy.on('tap', 'node[type="wsframe"]', function (e) {
+      cy.fit(e.target, 60);
+    });
     cy.on('tap', function (e) {
       if (e.target === cy) clearPath();
     });
@@ -1015,20 +1497,50 @@
   }
 
   /* ---------------- toolbar wiring ---------------- */
+  function isZabbixDark() {
+    var html = document.documentElement;
+    var cs = String(html.getAttribute('color-scheme') || '').toLowerCase();
+    if (cs === 'dark') return true;
+    if (cs === 'light') return false;
+    var body = document.body;
+    var theme = [
+      html.getAttribute('theme'), html.getAttribute('data-theme'), html.className,
+      body ? body.getAttribute('theme') : '', body ? body.getAttribute('data-theme') : '',
+      body ? body.className : ''
+    ].join(' ');
+    return /dark/i.test(theme);
+  }
+
+  function applyTheme() {
+    var nd = isZabbixDark();
+    var changed = nd !== dark;
+    dark = nd;
+    var wrap = el('l2-wrap');
+    if (wrap) wrap.classList.toggle('l2-dark', dark);
+    if (changed && cy) cy.style(styleFor(dark));
+  }
+
+  function fitViewport() {
+    var wrap = el('l2-wrap');
+    if (!wrap) return;
+    var top = Math.max(0, wrap.getBoundingClientRect().top);
+    var h = Math.max(520, window.innerHeight - top - 8);
+    wrap.style.height = h + 'px';
+    if (cy) cy.resize();
+  }
+
   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; detailNet = null; render(lastSnap); });
+    applyTheme();
+    if (window.MutationObserver) {
+      var mo = new MutationObserver(applyTheme);
+      mo.observe(document.documentElement, {attributes: true, attributeFilter: ['theme', 'data-theme', 'color-scheme', 'class']});
+      if (document.body) {
+        mo.observe(document.body, {attributes: true, attributeFilter: ['theme', 'data-theme', 'color-scheme', 'class']});
+      }
     }
+    var back = el('l2-back');
+    if (back) back.style.display = 'none';
 
-    el('l2-dark').addEventListener('change', function () {
-      dark = el('l2-dark').checked;
-      el('l2-wrap').classList.toggle('l2-dark', dark);
-      render(lastSnap);
-    });
     el('l2-showterm').addEventListener('change', function () { showTerm = el('l2-showterm').checked; render(lastSnap); });
     el('l2-onlyoff').addEventListener('change', function () {
       onlyOff = el('l2-onlyoff').checked;
@@ -1037,8 +1549,9 @@
     });
     el('l2-zin').addEventListener('click', function () { if (cy) cy.zoom({level: cy.zoom() * 1.25, renderedPosition: {x: cy.width() / 2, y: cy.height() / 2}}); });
     el('l2-zout').addEventListener('click', function () { if (cy) cy.zoom({level: cy.zoom() / 1.25, renderedPosition: {x: cy.width() / 2, y: cy.height() / 2}}); });
-    el('l2-fit').addEventListener('click', function () { if (cy) cy.fit(undefined, 50); });
+    el('l2-fit').addEventListener('click', function () { if (cy) cy.fit(cy.elements(), 50); });
     el('l2-refresh-btn').addEventListener('click', function () { reload(true); });
+    window.addEventListener('resize', fitViewport);
 
     var s = el('l2-search');
     s.addEventListener('input', function () { doSearch(s.value); });
@@ -1086,12 +1599,11 @@
   /* ---------------- boot ---------------- */
   function boot() {
     setupControls();
+    fitViewport();
     try {
       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;
+      if (qg) focusGroupLabel = qg;
     } catch (e) {}
     if (!SNAP || !SNAP.nodes) {
       el('l2-cy').innerHTML = '<div class="l2-empty">' + t('l2.no_data', 'No data') + '</div>';
@@ -1099,6 +1611,7 @@
       INDEX = buildIndex(SNAP);
       render(SNAP);
     }
+    setTimeout(fitViewport, 60);
     if (META.isLive) startTimer();
     else { el('l2-pause').disabled = true; el('l2-countdown').textContent = t('l2.snapshot', 'Snapshot'); }
   }

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

@@ -1,7 +1,14 @@
 {
   "refresh_seconds": 300,
-  "dark_default": true,
   "root_label": "COBCO",
+  "networks": [
+    {"key": "SURVE", "label": "SURVE 监控网", "keywords": ["SURVE", "10.169."], "color": "#2E75B6"},
+    {"key": "OFFICE", "label": "OFFICE 办公网", "keywords": ["OFFICE", "10.160."], "color": "#548235"}
+  ],
+  "core_rules": [
+    {"net": "SURVE", "match": "contains", "pattern": "core surve"},
+    {"net": "OFFICE", "match": "contains", "pattern": "core office"}
+  ],
   "templates": ["Huawei VRP by SNMP (walk)"],
   "full_interval_min": 5,
   "alarm_interval_min": 1,

+ 19 - 13
web-modules/WecomCenter/assets/l2topology/style.css

@@ -10,7 +10,10 @@
   --muted: #7b8694;
   --brand: #2f6fdd;
   position: relative;
-  padding: 8px 2px 18px;
+  display: flex; flex-direction: column;
+  height: calc(100vh - 120px); min-height: 520px;
+  box-sizing: border-box;
+  padding: 4px 2px 4px;
   color: var(--text);
 }
 .l2-wrap.l2-dark {
@@ -26,7 +29,8 @@
 /* toolbar */
 .l2-toolbar {
   display: flex; align-items: center; flex-wrap: wrap; gap: 10px;
-  padding: 10px 14px; margin-bottom: 10px;
+  flex: 0 0 auto;
+  padding: 8px 14px; margin-bottom: 8px;
   background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
   box-shadow: 0 1px 3px rgba(20,30,50,.05);
 }
@@ -67,9 +71,9 @@
 .l2-btn.on { background: #E97659; border-color: #E97659; color: #fff; }
 
 /* main split */
-.l2-main { display: flex; gap: 12px; align-items: stretch; min-height: 70vh; }
+.l2-main { display: flex; gap: 10px; align-items: stretch; flex: 1 1 auto; min-height: 0; }
 .l2-canvas {
-  position: relative; flex: 1 1 auto; min-width: 0; height: 74vh;
+  position: relative; flex: 1 1 auto; min-width: 0; min-height: 0; height: auto;
   background:
     linear-gradient(rgba(120,140,170,.06) 1px, transparent 1px) 0 0 / 26px 26px,
     linear-gradient(90deg, rgba(120,140,170,.06) 1px, transparent 1px) 0 0 / 26px 26px,
@@ -100,10 +104,11 @@
 .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;
-  background: color-mix(in srgb, var(--panel) 90%, transparent);
-  border: 1px solid var(--border); color: var(--text); backdrop-filter: blur(4px);
+  position: static; flex: 0 0 auto; z-index: 20;
+  max-height: 300px; overflow: auto;
+  padding: 10px 12px; font-size: 12px; border-radius: 0;
+  background: var(--panel-2);
+  border: 0; border-top: 1px solid var(--border); color: var(--text);
 }
 .lg-title { font-weight: 700; color: var(--muted); font-size: 10.5px; letter-spacing: .6px; text-transform: uppercase; margin: 8px 0 4px; }
 .lg-title:first-child { margin-top: 0; }
@@ -113,12 +118,12 @@
 .lg-box { width: 12px; height: 12px; border-radius: 2px; display: inline-block; }
 
 /* side panel */
-.l2-side { flex: 0 0 320px; display: flex; flex-direction: column; background: var(--panel); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
-.side-tabs { display: flex; border-bottom: 1px solid var(--border); background: var(--panel-2); }
+.l2-side { flex: 0 0 330px; display: flex; flex-direction: column; min-height: 0; background: var(--panel); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
+.side-tabs { display: flex; flex: 0 0 auto; border-bottom: 1px solid var(--border); background: var(--panel-2); }
 .side-tab { flex: 1; text-align: center; padding: 11px 8px; font-size: 13px; font-weight: 600; cursor: pointer; color: var(--muted); border-bottom: 2px solid transparent; }
 .side-tab.active { color: var(--brand); border-bottom-color: var(--brand); background: var(--panel); }
 .badge { display: inline-block; min-width: 18px; padding: 0 5px; border-radius: 9px; font-size: 11px; color: #fff; background: #E45959; line-height: 18px; }
-.side-body { flex: 1; overflow: auto; }
+.side-body { flex: 1 1 auto; min-height: 0; overflow: auto; }
 .panel-sec { display: none; }
 .panel-sec.active { display: block; }
 .panel-empty { padding: 22px 14px; color: var(--muted); text-align: center; font-size: 13px; }
@@ -142,7 +147,8 @@
 .l2-tip hr { border: none; border-top: 1px solid rgba(255,255,255,.15); margin: 6px 0; }
 
 @media (max-width: 900px) {
+  .l2-wrap { height: auto; min-height: 0; }
   .l2-main { flex-direction: column; }
-  .l2-side { flex-basis: auto; height: 300px; }
-  .l2-canvas { height: 60vh; }
+  .l2-side { flex-basis: auto; height: 360px; }
+  .l2-canvas { height: 64vh; }
 }

+ 2 - 1
web-modules/WecomCenter/assets/wecom.center.css

@@ -205,7 +205,8 @@ select.wc-input option { color: #1f2c33; }
 }
 .wc-rule .wc-input { min-width: 0; }
 .wc-rule input[name="rule_pattern[]"],
-.wc-rule input[name="rule_label[]"] { flex: 1 1 0; }
+.wc-rule input[name="rule_label[]"],
+.wc-rule input[name="core_pattern[]"] { flex: 1 1 0; }
 .wc-rule-del { flex: 0 0 auto; padding: 4px 10px; line-height: 1; }
 :is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-rule { background: #1b2432; border-color: #2b3644; }
 

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

@@ -28,8 +28,20 @@ class L2Store {
 		return [
 			// Display page ------------------------------------------------------
 			'refresh_seconds'   => 300,          // auto-refresh of the live view
-			'dark_default'      => true,         // start in dark mode
 			'root_label'        => 'COBCO',      // outermost frame label
+			// Physical networks. A switch belongs to the first network whose
+			// keyword appears in its name or IP; adjacency is used as fallback.
+			// key | label | keyword1,keyword2 | colour
+			'networks' => [
+				['key' => 'SURVE',  'label' => 'SURVE 监控网',  'keywords' => ['SURVE', '10.169.'],  'color' => '#2E75B6'],
+				['key' => 'OFFICE', 'label' => 'OFFICE 办公网', 'keywords' => ['OFFICE', '10.160.'], 'color' => '#548235']
+			],
+			// Root/core switches.  A network may have more than one core; every
+			// match is drawn in the left-most core column.  match = contains|regex.
+			'core_rules' => [
+				['net' => 'SURVE',  'match' => 'contains', 'pattern' => 'core surve'],
+				['net' => 'OFFICE', 'match' => 'contains', 'pattern' => 'core office']
+			],
 			// Collection --------------------------------------------------------
 			'templates'         => ['Huawei VRP by SNMP (walk)'],
 			'full_interval_min' => 5,            // full topology/port collection

+ 10 - 3
web-modules/WecomCenter/locale/bg_BG.php

@@ -135,14 +135,22 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -171,7 +179,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -182,6 +189,7 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -193,7 +201,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'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',

+ 10 - 3
web-modules/WecomCenter/locale/ca_ES.php

@@ -135,14 +135,22 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -171,7 +179,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -182,6 +189,7 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -193,7 +201,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'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',

+ 10 - 3
web-modules/WecomCenter/locale/cs_CZ.php

@@ -135,14 +135,22 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -171,7 +179,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -182,6 +189,7 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -193,7 +201,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'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',

+ 10 - 3
web-modules/WecomCenter/locale/da_DK.php

@@ -135,14 +135,22 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -171,7 +179,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -182,6 +189,7 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -193,7 +201,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'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',

+ 10 - 3
web-modules/WecomCenter/locale/de_DE.php

@@ -146,14 +146,22 @@ return [
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -182,7 +190,6 @@ return [
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -193,6 +200,7 @@ return [
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -203,7 +211,6 @@ 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',

+ 10 - 3
web-modules/WecomCenter/locale/el_GR.php

@@ -135,14 +135,22 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -171,7 +179,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -182,6 +189,7 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -193,7 +201,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'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',

+ 10 - 3
web-modules/WecomCenter/locale/en.php

@@ -166,14 +166,22 @@ return [
 	'sec.l2_colors'      => 'Link speed colours',
 	'sec.l2_latency'     => 'Latency thresholds',
 	'sec.l2_group'       => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates'   => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh'     => 'Display auto-refresh (seconds)',
 	'lbl.l2_full'        => 'Full collection interval (minutes)',
 	'lbl.l2_alarm'       => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root'        => 'Outermost frame label',
 	'lbl.l2_other'       => 'Fallback group label',
-	'lbl.l2_dark'        => 'Open in dark mode by default',
 	'lbl.color_100'      => '100M',
 	'lbl.color_1000'     => '1G',
 	'lbl.color_10000'    => '10G',
@@ -192,7 +200,6 @@ 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',
@@ -213,7 +220,6 @@ return [
 	'l2.time_live'       => 'Live',
 	'l2.time_btn'        => 'Go',
 	'l2.time_label'      => 'Snapshot time',
-	'l2.dark'            => 'Dark mode',
 	'l2.no_data'         => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty'   => 'No offline terminals.',
 	'l2.alarm_empty'     => 'No active alarms.',
@@ -224,6 +230,7 @@ return [
 	'l2.legend_tier'     => 'Tier',
 	'l2.legend_lat'      => 'Latency',
 	'l2.core'            => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist'            => 'Distribution',
 	'l2.access'          => 'Access',
 	'l2.lat_norm'        => 'Normal',

+ 10 - 3
web-modules/WecomCenter/locale/en_GB.php

@@ -135,14 +135,22 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -171,7 +179,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -182,6 +189,7 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -193,7 +201,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'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',

+ 10 - 3
web-modules/WecomCenter/locale/es_ES.php

@@ -146,14 +146,22 @@ return [
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -182,7 +190,6 @@ return [
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -193,6 +200,7 @@ return [
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -203,7 +211,6 @@ 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',

+ 10 - 3
web-modules/WecomCenter/locale/fa_IR.php

@@ -135,14 +135,22 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -171,7 +179,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -182,6 +189,7 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -193,7 +201,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'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',

+ 10 - 3
web-modules/WecomCenter/locale/fi_FI.php

@@ -135,14 +135,22 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -171,7 +179,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -182,6 +189,7 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -193,7 +201,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'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',

+ 10 - 3
web-modules/WecomCenter/locale/fr_FR.php

@@ -146,14 +146,22 @@ return [
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -182,7 +190,6 @@ return [
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -193,6 +200,7 @@ return [
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -203,7 +211,6 @@ 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',

+ 10 - 3
web-modules/WecomCenter/locale/he_IL.php

@@ -135,14 +135,22 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -171,7 +179,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -182,6 +189,7 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -193,7 +201,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'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',

+ 10 - 3
web-modules/WecomCenter/locale/hu_HU.php

@@ -135,14 +135,22 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -171,7 +179,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -182,6 +189,7 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -193,7 +201,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'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',

+ 10 - 3
web-modules/WecomCenter/locale/id_ID.php

@@ -135,14 +135,22 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -171,7 +179,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -182,6 +189,7 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -193,7 +201,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'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',

+ 10 - 3
web-modules/WecomCenter/locale/it_IT.php

@@ -146,14 +146,22 @@ return [
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -182,7 +190,6 @@ return [
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -193,6 +200,7 @@ return [
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -203,7 +211,6 @@ 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',

+ 10 - 3
web-modules/WecomCenter/locale/ja_JP.php

@@ -146,14 +146,22 @@ return [
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -182,7 +190,6 @@ return [
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -193,6 +200,7 @@ return [
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -203,7 +211,6 @@ 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',

+ 10 - 3
web-modules/WecomCenter/locale/ka_GE.php

@@ -135,14 +135,22 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -171,7 +179,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -182,6 +189,7 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -193,7 +201,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'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',

+ 10 - 3
web-modules/WecomCenter/locale/ko_KR.php

@@ -146,14 +146,22 @@ return [
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -182,7 +190,6 @@ return [
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -193,6 +200,7 @@ return [
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -203,7 +211,6 @@ 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',

+ 10 - 3
web-modules/WecomCenter/locale/lt_LT.php

@@ -135,14 +135,22 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -171,7 +179,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -182,6 +189,7 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -193,7 +201,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'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',

+ 10 - 3
web-modules/WecomCenter/locale/lv_LV.php

@@ -135,14 +135,22 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -171,7 +179,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -182,6 +189,7 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -193,7 +201,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'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',

+ 10 - 3
web-modules/WecomCenter/locale/nb_NO.php

@@ -135,14 +135,22 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -171,7 +179,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -182,6 +189,7 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -193,7 +201,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'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',

+ 10 - 3
web-modules/WecomCenter/locale/nl_NL.php

@@ -135,14 +135,22 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -171,7 +179,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -182,6 +189,7 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -193,7 +201,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'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',

+ 10 - 3
web-modules/WecomCenter/locale/pl_PL.php

@@ -146,14 +146,22 @@ return [
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -182,7 +190,6 @@ return [
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -193,6 +200,7 @@ return [
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -203,7 +211,6 @@ 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',

+ 10 - 3
web-modules/WecomCenter/locale/pt_BR.php

@@ -146,14 +146,22 @@ return [
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -182,7 +190,6 @@ return [
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -193,6 +200,7 @@ return [
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -203,7 +211,6 @@ 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',

+ 10 - 3
web-modules/WecomCenter/locale/ro_RO.php

@@ -135,14 +135,22 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -171,7 +179,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -182,6 +189,7 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -193,7 +201,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'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',

+ 10 - 3
web-modules/WecomCenter/locale/ru_RU.php

@@ -146,14 +146,22 @@ return [
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -182,7 +190,6 @@ return [
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -193,6 +200,7 @@ return [
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -203,7 +211,6 @@ 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',

+ 10 - 3
web-modules/WecomCenter/locale/sk_SK.php

@@ -135,14 +135,22 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -171,7 +179,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -182,6 +189,7 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -193,7 +201,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'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',

+ 10 - 3
web-modules/WecomCenter/locale/sv_SE.php

@@ -135,14 +135,22 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -171,7 +179,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -182,6 +189,7 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -193,7 +201,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'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',

+ 10 - 3
web-modules/WecomCenter/locale/tr_TR.php

@@ -146,14 +146,22 @@ return [
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -182,7 +190,6 @@ return [
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -193,6 +200,7 @@ return [
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -203,7 +211,6 @@ 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',

+ 10 - 3
web-modules/WecomCenter/locale/uk_UA.php

@@ -135,14 +135,22 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -171,7 +179,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -182,6 +189,7 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -193,7 +201,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'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',

+ 10 - 3
web-modules/WecomCenter/locale/uz_UZ.php

@@ -135,14 +135,22 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -171,7 +179,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -182,6 +189,7 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -193,7 +201,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'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',

+ 10 - 3
web-modules/WecomCenter/locale/vi_VN.php

@@ -135,14 +135,22 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => 'Physical networks',
+	'sec.l2_cores' => 'Core switches',
+	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'lbl.core_net' => 'Network',
+	'lbl.core_any' => 'All networks',
+	'lbl.core_pattern' => 'Name or IP pattern',
+	'btn.add_core' => 'Add core switch',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
+	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -171,7 +179,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -182,6 +189,7 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => 'Main switch',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -193,7 +201,6 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'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',

+ 10 - 3
web-modules/WecomCenter/locale/zh_CN.php

@@ -153,14 +153,22 @@ return [
 	'sec.l2_colors'      => '链路速率配色',
 	'sec.l2_latency'     => '延迟阈值',
 	'sec.l2_group'       => '分组规则',
+	'sec.l2_networks'     => '物理网络定义',
+	'sec.l2_cores' => '核心交换机',
+	'lbl.l2_cores_hint' => '定义根交换机(最左侧一列)。每行一个核心:网络 | 匹配方式 | 名称或IP规则;同一网络可配置多个核心,顺序不限。',
+	'lbl.core_net' => '网络',
+	'lbl.core_any' => '全部网络',
+	'lbl.core_pattern' => '名称或IP规则',
+	'btn.add_core' => '添加核心交换',
 	'lbl.l2_templates'   => '采集使用这些模板的主机',
 	'lbl.l2_templates_hint' => '只采集关联了所选模板的主机。默认推荐:Huawei VRP by SNMP (walk)。',
+	'lbl.l2_networks' => '物理网络(每行一个:KEY | 显示名称 | 名称或IP关键字 | 颜色)',
+	'lbl.l2_networks_hint' => '交换机归入第一个名称或IP中包含关键字的网络;例如:SURVE | SURVE 监控网 | SURVE,10.169. | #2E75B6。',
 	'lbl.l2_refresh'     => '展示页自动刷新(秒)',
 	'lbl.l2_full'        => '全量采集间隔(分钟)',
 	'lbl.l2_alarm'       => '仅告警刷新(分钟)',
 	'lbl.l2_root'        => '最外层框名',
 	'lbl.l2_other'       => '兜底分组名',
-	'lbl.l2_dark'        => '默认以深色模式打开',
 	'lbl.color_100'      => '100M',
 	'lbl.color_1000'     => '1G',
 	'lbl.color_10000'    => '10G',
@@ -179,7 +187,6 @@ return [
 
 	// L2 展示页
 	'l2.title'           => 'L2 物理拓扑图',
-	'l2.back_overview'   => '← 返回全景',
 	'l2.unit_sw'         => '台',
 	'l2.tile_hint'       => '点击进入该车间',
 	'l2.not_in_topo'     => '不在拓扑内',
@@ -200,7 +207,6 @@ return [
 	'l2.time_live'       => '实时',
 	'l2.time_btn'        => '查看',
 	'l2.time_label'      => '快照时间',
-	'l2.dark'            => '深色模式',
 	'l2.no_data'         => '暂无快照——采集尚未运行。',
 	'l2.offline_empty'   => '没有离线终端。',
 	'l2.alarm_empty'     => '当前没有活动告警。',
@@ -211,6 +217,7 @@ return [
 	'l2.legend_tier'     => '层级',
 	'l2.legend_lat'      => '延迟',
 	'l2.core'            => '核心',
+	'l2.main'            => '主交换',
 	'l2.dist'            => '汇聚',
 	'l2.access'          => '接入',
 	'l2.lat_norm'        => '正常',

+ 10 - 3
web-modules/WecomCenter/locale/zh_TW.php

@@ -146,14 +146,22 @@ return [
 	'sec.l2_colors' => 'Link speed colours',
 	'sec.l2_latency' => 'Latency thresholds',
 	'sec.l2_group' => 'Grouping rules',
+	'sec.l2_networks'     => '實體網路定義',
+	'sec.l2_cores' => '核心交換機',
+	'lbl.l2_cores_hint' => '定義根交換機(最左側一列)。每行一個核心:網路 | 匹配方式 | 名稱或IP規則;同一網路可配置多個核心,順序不限。',
+	'lbl.core_net' => '網路',
+	'lbl.core_any' => '全部網路',
+	'lbl.core_pattern' => '名稱或IP規則',
+	'btn.add_core' => '新增核心交換',
 	'lbl.l2_templates' => 'Collect hosts using these template(s)',
 	'lbl.l2_templates_hint' => 'Only hosts linked to the selected templates are collected. Recommended/default: Huawei VRP by SNMP (walk).',
+	'lbl.l2_networks' => '實體網路(每行一個:KEY | 顯示名稱 | 名稱或IP關鍵字 | 顏色)',
+	'lbl.l2_networks_hint' => '交換機歸入第一個名稱或IP中包含關鍵字的網路;例如:SURVE | SURVE 監控網 | SURVE,10.169. | #2E75B6。',
 	'lbl.l2_refresh' => 'Display auto-refresh (seconds)',
 	'lbl.l2_full' => 'Full collection interval (minutes)',
 	'lbl.l2_alarm' => 'Alarm-only refresh (minutes)',
 	'lbl.l2_root' => 'Outermost frame label',
 	'lbl.l2_other' => 'Fallback group label',
-	'lbl.l2_dark' => 'Open in dark mode by default',
 	'lbl.color_100' => '100M',
 	'lbl.color_1000' => '1G',
 	'lbl.color_10000' => '10G',
@@ -182,7 +190,6 @@ return [
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
-	'l2.dark' => 'Dark mode',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty' => 'No offline terminals.',
 	'l2.alarm_empty' => 'No active alarms.',
@@ -193,6 +200,7 @@ return [
 	'l2.legend_tier' => 'Tier',
 	'l2.legend_lat' => 'Latency',
 	'l2.core' => 'Core',
+	'l2.main'            => '主交換',
 	'l2.dist' => 'Distribution',
 	'l2.access' => 'Access',
 	'l2.lat_norm' => 'Normal',
@@ -203,7 +211,6 @@ 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',

+ 7 - 10
web-modules/WecomCenter/views/topology.view.php

@@ -6,7 +6,7 @@
 */
 use Modules\WecomCenter\Lib\I18n;
 
-$this->addCssFile('modules/WecomCenter/assets/l2topology/style.css');
+$this->addCssFile('modules/WecomCenter/assets/l2topology/style.css?v=20261105');
 $base = '/modules/WecomCenter/assets/l2topology/';
 
 $snapJson = (string) ($data['snap_json'] ?? 'null');
@@ -17,10 +17,10 @@ $cfgSafe  = str_replace('</', '<\/', $cfgJson);
 $i18nKeys = [
 	'l2.title','l2.search_ph','l2.show_term','l2.only_off','l2.auto_refresh','l2.min',
 	'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.time_label','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.main','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.unit_sw','l2.tile_hint','l2.not_in_topo','l2.click_hint','l2.floor_na',
 	'l2.net_surve','l2.net_office','l2.net_other'
 ];
 $i18n = [];
@@ -39,10 +39,9 @@ $refreshSec = (int) ($data['cfg_refresh'] ?? 300);
 
 if (!function_exists('l2e')) { function l2e($v) { return htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); } }
 ?>
-<div class="l2-wrap<?php echo !empty($data['cfg_dark']) ? ' l2-dark' : ''; ?>" id="l2-wrap">
+<div class="l2-wrap" 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"/>
@@ -51,7 +50,6 @@ if (!function_exists('l2e')) { function l2e($v) { return htmlspecialchars((strin
 
     <label class="l2-chk"><input type="checkbox" id="l2-showterm"/> <?php echo l2e(I18n::t('l2.show_term')); ?></label>
     <label class="l2-chk"><input type="checkbox" id="l2-onlyoff"/> <?php echo l2e(I18n::t('l2.only_off')); ?></label>
-    <label class="l2-chk"><input type="checkbox" id="l2-dark"/> <?php echo l2e(I18n::t('l2.dark')); ?></label>
 
     <label class="l2-ts-label"><?php echo l2e(I18n::t('l2.time_label')); ?>
       <select id="l2-ts" class="l2-btn"><?php echo $tsOpts; ?></select>
@@ -70,8 +68,6 @@ 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>
 
     <div class="l2-side">
@@ -83,6 +79,7 @@ if (!function_exists('l2e')) { function l2e($v) { return htmlspecialchars((strin
         <div class="panel-sec active" id="sec-alarms"><div id="l2-alarm-list"></div></div>
         <div class="panel-sec" id="sec-offline"><div id="l2-offline-list"></div></div>
       </div>
+      <div class="l2-legend" id="l2-legend"></div>
     </div>
   </div>
 </div>
@@ -103,7 +100,7 @@ window.L2_META = {
 };
 </script>
 <script src="<?php echo $base; ?>cytoscape.min.js"></script>
-<script src="<?php echo $base; ?>app.js"></script>
+<script src="<?php echo $base; ?>app.js?v=20261105"></script>
 <script>
 (function(){
   var tabs = document.querySelectorAll('.side-tab');

+ 43 - 5
web-modules/WecomCenter/views/wecom.center.view.php

@@ -349,10 +349,46 @@ $displayBody =
 	.'<div class="wc-field"><label>'.$t('lbl.l2_refresh').'</label><input class="wc-input" type="number" min="30" step="30" name="refresh_seconds" value="'.$e($l2['refresh_seconds']).'"></div>'
 	.'<div class="wc-field"><label>'.$t('lbl.l2_root').'</label><input class="wc-input" type="text" name="root_label" value="'.$e($l2['root_label']).'"></div>'
 	.'<div class="wc-field"><label>'.$t('lbl.l2_other').'</label><input class="wc-input" type="text" name="other_label" value="'.$e($l2['other_label']).'"></div>'
-	.'</div>'
-	.'<div class="wc-field"><label class="wc-checkrow"><input type="checkbox" name="dark_default"'.(!empty($l2['dark_default']) ? ' checked' : '').'> '.$t('lbl.l2_dark').'</label></div>';
+	.'</div>';
 $displayCard = $card('&#128421;&#65039;', $t('sec.l2_display'), $displayBody);
 
+$netLines = '';
+foreach ((array) ($l2['networks'] ?? []) as $nw) {
+	$netLines .= ($nw['key'] ?? '').' | '.($nw['label'] ?? '').' | '.implode(',', (array) ($nw['keywords'] ?? [])).' | '.($nw['color'] ?? '#607D8B')."\n";
+}
+$networkBody =
+	'<div class="wc-hint">'.$t('lbl.l2_networks_hint').'</div>'
+	.'<div class="wc-field"><label>'.$t('lbl.l2_networks').'</label>'
+	.'<textarea class="wc-input wc-input--mono" name="networks_text" rows="5" style="min-height:120px">'.$e(trim($netLines)).'</textarea></div>';
+$networkCard = $card('&#127760;', $t('sec.l2_networks'), $networkBody, ' wc-card--wide');
+
+$coreRowHtml = function (array $r) use ($e, $t, $l2) {
+	$netSel = '<select class="wc-input" name="core_net[]" style="max-width:230px">'
+		.'<option value="">'.$t('lbl.core_any').'</option>';
+	foreach ((array) $l2['networks'] as $nw) {
+		$k = (string) ($nw['key'] ?? '');
+		$netSel .= '<option value="'.$e($k).'"'.((string) ($r['net'] ?? '') === $k ? ' selected' : '').'>'.$e($k).' · '.$e($nw['label'] ?? $k).'</option>';
+	}
+	$netSel .= '</select>';
+	$isRegex = (($r['match'] ?? 'contains') === 'regex');
+	return '<div class="wc-rule">'.$netSel
+		.'<select class="wc-input" name="core_match[]" style="max-width:120px">'
+		.'<option value="contains"'.($isRegex ? '' : ' selected').'>contains</option>'
+		.'<option value="regex"'.($isRegex ? ' selected' : '').'>regex</option></select>'
+		.'<input class="wc-input wc-input--mono" name="core_pattern[]" value="'.$e($r['pattern'] ?? '').'" placeholder="'.$t('lbl.core_pattern').'">'
+		.'<button type="button" class="wc-btn wc-btn--ghost wc-rule-del" title="remove">&times;</button></div>';
+};
+$coreRows = '';
+foreach ((array) ($l2['core_rules'] ?? []) as $r) {
+	$coreRows .= $coreRowHtml($r);
+}
+$coreBody =
+	'<div class="wc-hint">'.$t('lbl.l2_cores_hint').'</div>'
+	.'<div class="wc-rules" id="wc-cores">'.$coreRows.'</div>'
+	.'<div class="wc-actions"><button type="button" class="wc-btn wc-btn--ghost" id="wc-core-add">'.$t('btn.add_core').'</button></div>'
+	.'<script type="text/template" id="wc-core-tpl">'.$coreRowHtml(['net' => '', 'match' => 'contains', 'pattern' => '']).'</script>';
+$coreCard = $card('&#128273;', $t('sec.l2_cores'), $coreBody, ' wc-card--wide');
+
 $colorFields = '';
 foreach (['100' => 'lbl.color_100', '1000' => 'lbl.color_1000', '10000' => 'lbl.color_10000',
 		'20000' => 'lbl.color_20000', 'unknown' => 'lbl.color_unknown', 'down' => 'lbl.color_down'] as $ck => $lab) {
@@ -396,12 +432,14 @@ $groupCard = $card('&#128193;', $t('sec.l2_group'), $groupBody, ' wc-card--wide'
 
 $tab_l2 = '<form method="post" action="'.$save.'" id="wc-l2-form">'
 	.'<input type="hidden" name="op" value="save_l2"><input type="hidden" name="tab" value="l2">'
-	.'<div class="wc-grid">'.$collectCard.$displayCard.$colorCard.$latCard.$groupCard.'</div>'
+	.'<input type="hidden" name="core_rules_present" value="1">'
+	.'<div class="wc-grid">'.$collectCard.$displayCard.$networkCard.$coreCard.$colorCard.$latCard.$groupCard.'</div>'
 	.'<div class="wc-actions" style="margin-top:18px"><button class="wc-btn wc-btn--primary" type="submit">'.$t('btn.save').'</button></div>'
 	.'</form>'
-	.'<script>(function(){var add=document.getElementById("wc-rule-add"),box=document.getElementById("wc-rules"),tpl=document.getElementById("wc-rule-tpl");'
+	.'<script>(function(){function wire(addId,boxId,tplId){var add=document.getElementById(addId),box=document.getElementById(boxId),tpl=document.getElementById(tplId);'
 	.'if(add&&box&&tpl){add.addEventListener("click",function(){var w=document.createElement("div");w.innerHTML=tpl.innerHTML.trim();box.appendChild(w.firstChild);});}'
-	.'box&&box.addEventListener("click",function(ev){if(ev.target.classList.contains("wc-rule-del")){var r=ev.target.closest(".wc-rule");if(r&&box.querySelectorAll(".wc-rule").length>1){r.remove();}}});'
+	.'if(box){box.addEventListener("click",function(ev){if(ev.target.classList.contains("wc-rule-del")){var r=ev.target.closest(".wc-rule");if(r&&box.querySelectorAll(".wc-rule").length>1){r.remove();}}});}}'
+	.'wire("wc-rule-add","wc-rules","wc-rule-tpl");wire("wc-core-add","wc-cores","wc-core-tpl");'
 	.'})();</script>';
 
 /* ============================ assemble =============================== */