Bladeren bron

feat: physical port matrix on L2 topology

clm0081 2 dagen geleden
bovenliggende
commit
5b49fa60de

+ 75 - 0
collector/l2_snapshot.py

@@ -32,6 +32,47 @@ def load(path):
 def norm(s):
     return re.sub(r'\s+', ' ', (s or '').strip())
 
+PHYS_TYPES = {'6', '62', '117'}
+IFACE_SPEED_RE = re.compile(r'^(?:(100GE|50GE|40GE|25GE|10GE|XGE|2\.5GE|GE|FE))', re.I)
+
+def port_max_mbps(name, current=0):
+    """Best effort front-panel capability from the interface name.
+
+    ``ifSpeed``/``ifHighSpeed`` is the negotiated link rate, not the port's
+    maximum capability (a 10GE port with a 1G module reports 1000).  Huawei
+    interface names retain the hardware family, so use that prefix; fall back
+    to the observed rate when the name is not descriptive.
+    """
+    s = (name or '').strip().upper().replace(' ', '')
+    m = IFACE_SPEED_RE.match(s)
+    if m:
+        fam = m.group(1).upper()
+        return {
+            '100GE': 100000, '50GE': 50000, '40GE': 40000, '25GE': 25000,
+            '10GE': 10000, 'XGE': 10000, '2.5GE': 2500, 'GE': 1000, 'FE': 100
+        }.get(fam, current or 0)
+    if s.startswith('XGIGABITETHERNET'):
+        return 10000
+    if s.startswith('GIGABITETHERNET'):
+        return 1000
+    if s.startswith('FASTETHERNET'):
+        return 100
+    try:
+        return int(current or 0)
+    except Exception:
+        return 0
+
+def is_physical_port(row):
+    itype = str(row.get('ifType') or '').strip()
+    if itype not in PHYS_TYPES:
+        return False
+    name = (row.get('ifName') or row.get('ifDescr') or '').strip()
+    if not name:
+        return False
+    if re.search(r'(?i)^(ETH-TRUNK|VLANIF|NULL|INLOOPBACK|STACK-PORT|LOOPBACK|TUNNEL|METH|VBDIF)', name):
+        return False
+    return True
+
 def load_latency():
     lat = {}
     try:
@@ -111,6 +152,38 @@ def main():
     for r in ps:
         pmap[(r['switch_ip'], r['ifName'])] = r
 
+    # Full physical front-panel inventory, including unused ports.  This is
+    # what the topology page uses to draw the switch port matrix when the
+    # "show terminals" option is enabled.
+    ports_by_ip = collections.defaultdict(list)
+    for r in ps:
+        if not is_physical_port(r):
+            continue
+        ip = (r.get('switch_ip') or '').strip()
+        name = (r.get('ifName') or r.get('ifDescr') or '').strip()
+        if not ip or not name:
+            continue
+        try:
+            speed = int(float(r.get('speed_mbps') or 0))
+        except Exception:
+            speed = 0
+        try:
+            order = int(r.get('ifIndex') or 0)
+        except Exception:
+            order = 0
+        ports_by_ip[ip].append({
+            'name': name, 'order': order, 'speed': speed,
+            'max': port_max_mbps(name, speed),
+            'up': (r.get('oper_status') == 'up'),
+        })
+    for ip in ports_by_ip:
+        # Keep one row per interface name; sort by ifIndex so the front-panel
+        # order is deterministic across collection runs.
+        uniq = {}
+        for p in ports_by_ip[ip]:
+            uniq[p['name']] = p
+        ports_by_ip[ip] = sorted(uniq.values(), key=lambda p: (p['order'], p['name']))
+
     # ---------- switch identity ----------
     sname = {}
     for e in edges_raw:
@@ -293,6 +366,7 @@ def main():
             'term_online': tcnt[ip]['online'], 'term_offline': 0,
             'latency_ms': lat.get(ip) if online else pn.get('latency_ms'),
             'children': nchildren, 'poe': bool(poe.get(ip, pn.get('poe', False))),
+            'ports': ports_by_ip.get(ip, []),
             'last_online': last_online
         })
 
@@ -339,6 +413,7 @@ def main():
     stats = {
         'switches': len(nodes), 'switches_online': len(nodes_ip), 'switches_offline': len(offline_ips),
         'poe_switches': sum(1 for n in nodes if n.get('poe')),
+        'ports': sum(len(n.get('ports') or []) for n in nodes),
         'edges': len(edges),
         'term_total': len(terminals), 'term_online': len(terminals), 'term_offline': 0,
         'alarms': sum(len(n['alarms']) for n in nodes),

+ 2 - 2
docs/使用说明.md

@@ -242,8 +242,8 @@ zabbix-deploy/
     交换机名称最前面显示 `⚡️`;当前网络若没有 PoE 设备则不显示。
 - **节点标签**:`车间 · 楼层机柜U · 角色 · IP`;双击节点跳转该主机的问题页。
 - **告警/离线/延迟着色**:告警交换机整个节点填充红色,离线交换机同样整节点红色;延迟 **3–5ms 为橙色边框**,**大于 5ms 才为红色边框**。告警不会再只画一个红框。告警面板只显示拓扑内交换机,不混入服务器/测试机等“拓扑外”主机。
-- **显示终端 / 只看离线**:勾选「显示终端」在交换机下方展开终端点(绿=在线、灰=离线);
-  「只看离线」仅显示离线终端。右侧「离线终端」面板点击可联动定位。
+- **显示终端(端口矩阵)**:勾选「显示终端」后,接入/汇聚交换机下方按实际端口清单绘制圆点;外圈颜色表示该端口**支持的最大速率**(100M 橙、1G 绿、2.5G 青、10G 紫、20G+ 深紫、未知灰),圆心实心颜色表示该端口**当前实际链路速率**。例如:10G 口插 1G 线 = 紫色外圈 + 绿色实心;10G 口未插线 = 空心紫色圈;2.5G 口 = 青色圈。端口较多时按实际面板自动分两行/多行,上联端口单独成行;历史快照没有端口清单时退回原来的终端圆点。端口颜色与上方链路速率颜色是两套语义,不要混用。
+- 右侧「离线交换机」面板可查看离线交换机并点击定位;终端面板点击任意终端可定位到其接入交换机及具体端口。
 - **时间轴**:右上「快照时间」下拉可选 `实时` 或历史某分钟快照(保存 **7 天**,5 分钟一个点)。
 - **明暗主题**:自动跟随 Zabbix 当前主题(Zabbix 深色即深色、亮色即亮色),无手动开关。
 - **搜索**:输入 IP / MAC / 名称,下拉结果点击即居中定位。

+ 173 - 32
web-modules/WecomCenter/assets/l2topology/app.js

@@ -13,6 +13,7 @@
   var SNAP = window.L2SNAP || null;
 
   var colors = CFG.colors || {100:'#F39C12',1000:'#2ECC71',10000:'#3498DB',20000:'#9B59B6',unknown:'#E74C3C',down:'#C0392B'};
+if (!colors['2500']) colors['2500'] = '#17C3B2';
   var LATW = (CFG.latency_warn != null) ? +CFG.latency_warn : 3;
   var LATB = (CFG.latency_bad != null) ? +CFG.latency_bad : 5;
 
@@ -31,6 +32,7 @@
   var showTerm = false;
   var onlyOff = false;
   var pinnedTerm = null;
+  var termPortByMac = {};      // terminal MAC -> rendered port node id
   var paused = false;
   var countdown = 0;
   var timer = null;
@@ -468,6 +470,67 @@
   var ROW_H = 34, ROW_GAP = 14, WS_GAP = 36, SEC_GAP = 52;
   var FLOOR_GAP = 26;
 
+  // Front-panel port colours are deliberately separate from link colours:
+  // the ring shows the port's maximum capability, the inner fill shows the
+  // negotiated rate.  This is why a 10GE port with a 1G link is purple/green.
+  var PORT_COLORS = {
+    100: '#F39C12', 1000: '#2ECC71', 2500: '#17C3B2', 5000: '#17C3B2',
+    10000: '#9B59B6', 20000: '#8E44AD', 40000: '#7D3C98', 100000: '#6C3483',
+    unknown: '#95A5A6'
+  };
+  function portColor(v) {
+    var n = parseFloat(v);
+    if (!isFinite(n) || n <= 0) return PORT_COLORS.unknown;
+    if (n <= 100) return PORT_COLORS[100];
+    if (n <= 1000) return PORT_COLORS[1000];
+    if (n <= 2500) return PORT_COLORS[2500];
+    if (n <= 5000) return PORT_COLORS[5000];
+    if (n <= 10000) return PORT_COLORS[10000];
+    if (n <= 20000) return PORT_COLORS[20000];
+    if (n <= 40000) return PORT_COLORS[40000];
+    return PORT_COLORS[100000];
+  }
+  function portMaxFromName(name, fallback) {
+    var s = String(name || '').toUpperCase().replace(/\s+/g, '');
+    var m = s.match(/^(100GE|50GE|40GE|25GE|10GE|XGE|2\.5GE|GE|FE)/);
+    if (m) {
+      return { '100GE': 100000, '50GE': 50000, '40GE': 40000, '25GE': 25000,
+        '10GE': 10000, 'XGE': 10000, '2.5GE': 2500, 'GE': 1000, 'FE': 100 }[m[1]] || fallback || 0;
+    }
+    if (/^GIGABITETHERNET/.test(s)) return 1000;
+    if (/^XGIGABITETHERNET/.test(s)) return 10000;
+    if (/^FASTETHERNET/.test(s)) return 100;
+    return parseFloat(fallback) || 0;
+  }
+  function portFamily(name) {
+    var s = String(name || '').toUpperCase().replace(/\s+/g, '');
+    var m = s.match(/^(100GE|50GE|40GE|25GE|10GE|XGE|2\.5GE|GE|FE)/);
+    if (m) return m[1];
+    if (/^GIGABITETHERNET/.test(s)) return 'GE';
+    if (/^XGIGABITETHERNET/.test(s)) return 'XGE';
+    if (/^FASTETHERNET/.test(s)) return 'FE';
+    return 'OTHER';
+  }
+  function portRows(ports) {
+    var groups = {}, order = [];
+    (ports || []).slice().sort(function (a, b) {
+      return (parseInt(a.order, 10) || 0) - (parseInt(b.order, 10) || 0);
+    }).forEach(function (p) {
+      var f = portFamily(p.name);
+      if (!groups[f]) { groups[f] = []; order.push(f); }
+      groups[f].push(p);
+    });
+    var rows = [];
+    order.forEach(function (f) {
+      var arr = groups[f];
+      // A normal access panel is two rows: 24 ports => 12+12, 48 => 24+24.
+      // Uplink groups (usually 4-8 ports) stay on their own single row.
+      var per = arr.length <= 12 ? arr.length : Math.ceil(arr.length / 2);
+      for (var i = 0; i < arr.length; i += per) rows.push(arr.slice(i, i + per));
+    });
+    return rows;
+  }
+
   function makeNetworks() {
     var raw = CFG.networks;
     if (!raw || !raw.length) {
@@ -707,7 +770,18 @@
   }
 
   function layoutWorkshop(w, top, snap) {
-    var rowH = ROW_H + ROW_GAP + (showTerm ? 24 : 0);
+    var portRowMax = 0;
+    if (showTerm) {
+      w.all.forEach(function (n) {
+        var rows = portRows(n.ports).length;
+        if (rows > portRowMax) portRowMax = rows;
+      });
+    }
+    // Every port row is 14px high (9px circle + breathing room).  Keep a
+    // little space below the last row; when ports are unavailable the old
+    // 24px terminal area remains.
+    var portExtra = showTerm ? Math.max(24, portRowMax * 14 + 10) : 0;
+    var rowH = ROW_H + ROW_GAP + portExtra;
     var floorMap = {}, floors = [], accessFloor = {};
     w.access.forEach(function (n) {
       var fi = floorInfo(n);
@@ -930,6 +1004,7 @@
     if (!isFinite(s) || s <= 0) return colors.unknown;
     if (s <= 100) return colors['100'];
     if (s <= 1000) return colors['1000'];
+    if (s <= 2500) return colors['2500'];
     if (s <= 10000) return colors['10000'];
     return colors['20000'];
   }
@@ -1059,6 +1134,13 @@
       {selector: 'node[type="edgepoint"]', style: {
         'width': 1, 'height': 1, 'opacity': 0, 'events': 'no'
       }},
+      {selector: 'node[type="port"]', style: {
+        'width': 'data(size)', 'height': 'data(size)', 'shape': 'ellipse',
+        'background-color': 'data(fill)', 'background-opacity': 'data(fillOpacity)',
+        'border-width': 1.5, 'border-color': 'data(ring)', 'padding': '0px',
+        'label': '', 'overlay-opacity': 0, 'events': 'yes',
+        'z-index-compare': 'manual', 'z-index': 28
+      }},
       {selector: 'node[type="term"]', style: {
         'width': 7, 'height': 7, 'shape': 'ellipse', 'background-color': 'data(bg)',
         'border-width': 1, 'border-color': d ? '#2a3648' : '#cfd8e3',
@@ -1267,7 +1349,9 @@
       // orange frame.  Alarm must never be represented by a red border alone.
       if (n.alarms && n.alarms.length) { cls.push('alarm'); bg = '#E45959'; bd = '#E45959'; }
       if (n.offline || n.oper === 'down') { cls.push('offline'); bg = '#C0392B'; bd = '#C0392B'; }
-      if (n.latency_ms != null) {
+      // Alarm/offline already owns the whole fill; do not put a latency frame
+      // around a red alarm node (that would read as a contradictory state).
+      if (!cls.length && n.latency_ms != null) {
         if (+n.latency_ms > LATB) cls.push('lat-bad');
         else if (+n.latency_ms > LATW) cls.push('lat-warn');
       }
@@ -1322,35 +1406,76 @@
   }
 
   function addTerminals(L, byId, els) {
-    var bySw = {};
+    termPortByMac = {};
+    var bySw = {}, termBySwPort = {};
+    function pkey(s) { return String(s || '').toUpperCase(); }
     ((lastSnap && lastSnap.terminals) || []).forEach(function (tm) {
       if (!byId[tm.sw_ip]) return;
       if (!terminalOnline(tm)) return;
       (bySw[tm.sw_ip] = bySw[tm.sw_ip] || []).push(tm);
+      if (tm.port) termBySwPort[tm.sw_ip + '|' + pkey(tm.port)] = tm;
     });
-    Object.keys(bySw).forEach(function (sw) {
+    function pushTermDot(sw, p, tm, x, y, id) {
+      els.push({group: 'nodes', data: {
+        id: id, type: 'term', bg: terminalOnline(tm) ? '#2ECC71' : '#95a5a6',
+        mac: tm.mac, ip: tm.ip, online: terminalOnline(tm), speed: tm.speed,
+        sw_name: tm.sw_name, port: tm.port, label: '', termText: terminalText(tm)
+      }, position: {x: x, y: y}, selectable: false});
+      els.push({group: 'edges', data: {id: 'te_' + tm.mac, type: 'term', source: sw, target: id}});
+    }
+    Object.keys(byId).forEach(function (sw) {
       var p = byId[sw]; if (!p || p.role === 'core' || p.role === 'main') return;
-      bySw[sw].sort(function (a, b) { return (terminalOnline(a) === terminalOnline(b)) ? 0 : (terminalOnline(a) ? -1 : 1); });
-      // A search hit is pinned into the visible 24 so any MAC in the snapshot
-      // can be located even when its switch has more than 24 terminals.
-      if (pinnedTerm) {
-        for (var pi = 0; pi < bySw[sw].length; pi++) {
-          if (bySw[sw][pi].mac === pinnedTerm) {
-            if (pi > 0) { var pin = bySw[sw].splice(pi, 1)[0]; bySw[sw].unshift(pin); }
-            break;
-          }
-        }
+      var terms = bySw[sw] || [];
+      var rawPorts = (p.node && p.node.ports) || [];
+      if (!rawPorts.length) {
+        // Legacy/history snapshot without a port inventory: keep the compact
+        // terminal dots as a fallback instead of inventing unused ports.
+        terms.slice(0, 24).forEach(function (tm, k) {
+          var col = k % 12, row = Math.floor(k / 12);
+          pushTermDot(sw, p, tm, p.x - p.w / 2 + 16 + col * 9, p.y + 16 + row * 9, 't_' + tm.mac);
+        });
+        return;
       }
-      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: terminalOnline(tm) ? '#2ECC71' : '#95a5a6',
-          mac: tm.mac, ip: tm.ip, online: terminalOnline(tm), speed: tm.speed,
-          sw_name: tm.sw_name, port: tm.port, label: '', termText: terminalText(tm)
-        }, position: {x: x, y: y}, selectable: false});
-        els.push({group: 'edges', data: {id: 'te_' + tm.mac, type: 'term', source: sw, target: 't_' + tm.mac}});
+      var ports = rawPorts.slice();
+      var known = {};
+      ports.forEach(function (pt) { known[pkey(pt.name)] = true; });
+      // A terminal may sit on a logical interface (for example an Eth-Trunk)
+      // whose physical ports are not individually listed.  Keep it visible as
+      // an extra port-like circle rather than silently dropping it.
+      terms.forEach(function (tm) {
+        if (tm.port && !known[pkey(tm.port)]) {
+          ports.push({name: tm.port, order: 9999, speed: parseFloat(tm.speed) || 0,
+            max: portMaxFromName(tm.port, parseFloat(tm.speed) || 0), up: true, synthetic: true});
+          known[pkey(tm.port)] = true;
+        }
+      });
+      var rows = portRows(ports), maxCols = 0;
+      rows.forEach(function (r) { if (r.length > maxCols) maxCols = r.length; });
+      var step = 12.5;
+      var startX = p.x - ((maxCols - 1) * step) / 2;
+      var pidx = 0;
+      rows.forEach(function (row, ri) {
+        row.forEach(function (port, ci) {
+          var tm = termBySwPort[sw + '|' + pkey(port.name)] || null;
+          // The IF-MIB port speed is the negotiated physical link rate, so it
+          // is the primary source for the filled centre.  Terminal records are
+          // only a fallback when the switch did not report a speed.
+          var actual = parseFloat(port.speed) || 0;
+          if (!actual) actual = parseFloat(tm && tm.speed) || 0;
+          var max = parseFloat(port.max) || portMaxFromName(port.name, actual);
+          var filled = !!port.up && actual > 0;
+          var pid = 'p_' + sw + '_' + (port.order || pidx) + '_' + pidx;
+          pidx++;
+          els.push({group: 'nodes', data: {
+            id: pid, type: 'port', name: port.name, port: port.name,
+            sw_name: (p.node && p.node.name) || '', sw_ip: sw,
+            max: max, speed: actual, up: !!port.up, filled: filled ? 1 : 0,
+            fill: filled ? portColor(actual) : '#000000', fillOpacity: filled ? 1 : 0,
+            ring: portColor(max), size: 9,
+            term: tm || null, termText: tm ? terminalText(tm) : ''
+          }, position: {x: startX + ci * step, y: p.y + 18 + ri * 14}, selectable: false});
+          if (tm) termPortByMac[tm.mac] = pid;
+        });
       });
     });
   }
@@ -1444,7 +1569,7 @@
         bg = '#C0392B';
         bd = '#C0392B';
       }
-      if (n.latency_ms != null) {
+      if (!cls.length && n.latency_ms != null) {
         if (+n.latency_ms > LATB) cls.push('lat-bad');
         else if (+n.latency_ms > LATW) cls.push('lat-warn');
       }
@@ -1542,11 +1667,11 @@
 
   function buildLegend() {
     var h = '';
-    function sw(c, label, dashed) { return '<div class="lg-row"><span class="lg-line' + (dashed ? ' lg-dash' : '') + '" style="border-top-color:' + c + '"></span>' + label + '</div>'; }
+    function sw(c, label, dashed) { return '<span class="lg-item"><span class="lg-line' + (dashed ? ' lg-dash' : '') + '" style="border-top-color:' + c + '"></span>' + label + '</span>'; }
     h += '<div class="lg-title">' + t('l2.legend_speed', 'Link speed') + '</div>';
-    h += sw(colors['100'], '100M') + sw(colors['1000'], '1G') + sw(colors['10000'], '10G') + sw(colors['20000'], '20G+');
-    h += sw(colors.unknown, t('lbl.color_unknown', 'Unknown'));
-    h += sw(colors.down, t('lbl.color_down', 'Down'), true);
+    h += '<div class="lg-grid">' + sw(colors['100'], '100M') + sw(colors['1000'], '1G') +
+      sw(colors['2500'], '2.5G') + sw(colors['10000'], '10G') + sw(colors['20000'], '20G+') +
+      sw(colors.unknown, t('lbl.color_unknown', 'Unknown')) + sw(colors.down, t('lbl.color_down', 'Down'), true) + '</div>';
     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') +
@@ -1781,12 +1906,14 @@
       pinnedTerm = tm.mac;
       showTerm = true;
       if (el('l2-showterm')) el('l2-showterm').checked = true;
-      if (!cy || cy.getElementById(id).empty()) render(lastSnap);
+      var targetId = termPortByMac[tm.mac] || id;
+      if (!cy || cy.getElementById(targetId).empty()) render(lastSnap);
+      targetId = termPortByMac[tm.mac] || id;
       setTimeout(function () {
         // Center the switch that the terminal hangs under, then flash the
-        // terminal dot so the exact port location is obvious.
+        // exact physical port circle so the port location is obvious.
         focusNode(tm.sw_ip || id);
-        var tn = cy && cy.getElementById(id);
+        var tn = cy && cy.getElementById(targetId);
         if (tn && tn.length) {
           tn.addClass('hl');
           setTimeout(function () { tn.removeClass('hl'); }, 2600);
@@ -1906,6 +2033,20 @@
       e.target.data('label', '');
       hideTip();
     });
+    cy.on('mouseover', 'node[type="port"]', function (e) {
+      var d = e.target.data();
+      var html = '<b>' + esc(d.name || '') + '</b><br>' +
+        esc(t('l2.term_switch', 'Switch')) + ': ' + esc(d.sw_name || d.sw_ip || '') + '<br>' +
+        esc(t('l2.term_rate', 'Link rate')) + ': ' + (d.filled ? esc(speedText(d.speed)) : '—') + '<br>' +
+        'Port max: ' + esc(speedText(d.max));
+      if (d.term) {
+        html += '<hr><b>' + esc(t('l2.term_title', 'Terminal')) + '</b><br>' +
+          esc(t('l2.term_mac', 'MAC address')) + ': ' + esc(d.term.mac || '') + '<br>' +
+          esc(t('l2.term_ip', 'IP address')) + ': ' + esc(d.term.ip || '—');
+      }
+      showTip(e, html);
+    });
+    cy.on('mouseout', 'node[type="port"]', hideTip);
     cy.on('tap', 'node[type="switch"]', function (e) {
       lockedPath = e.target.id();
       highlightPath(lockedPath);

+ 3 - 1
web-modules/WecomCenter/assets/l2topology/style.css

@@ -113,7 +113,9 @@
 .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; }
 .lg-row { display: flex; align-items: center; gap: 6px; margin: 3px 0; }
-.lg-line { width: 22px; height: 0; border-top: 3px solid #999; display: inline-block; }
+.lg-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 3px 8px; margin: 3px 0; }
+.lg-item { display: flex; align-items: center; gap: 4px; white-space: nowrap; font-size: 11px; min-width: 0; }
+.lg-line { width: 20px; height: 0; border-top: 3px solid #999; display: inline-block; flex: 0 0 auto; }
 .lg-line.lg-dash { border-top-style: dashed; border-top-color: #E45959; }
 .lg-box { width: 12px; height: 12px; border-radius: 2px; display: inline-block; }
 

+ 2 - 2
web-modules/WecomCenter/views/topology.view.php

@@ -6,7 +6,7 @@
 */
 use Modules\WecomCenter\Lib\I18n;
 
-$this->addCssFile('modules/WecomCenter/assets/l2topology/style.css?v=20261108');
+$this->addCssFile('modules/WecomCenter/assets/l2topology/style.css?v=202611051950');
 $base = '/modules/WecomCenter/assets/l2topology/';
 
 $snapJson = (string) ($data['snap_json'] ?? 'null');
@@ -107,7 +107,7 @@ window.L2_META = {
 };
 </script>
 <script src="<?php echo $base; ?>cytoscape.min.js"></script>
-<script src="<?php echo $base; ?>app.js?v=202611051905"></script>
+<script src="<?php echo $base; ?>app.js?v=202611051950"></script>
 <script>
 (function(){
   var tabs = document.querySelectorAll('.side-tab');