소스 검색

feat(l2): PoE switch badges and animated data flow

Detect PoE capability via Huawei POE MIB / RFC3621 in port_status.py, propagate it through snapshot nodes, and prefix PoE switch names with a lightning mark. Add a screen-only downstream flow overlay whose dash speed follows link rate; keep protocol edges untouched and hide the overlay in day-mode PNG exports. Refresh docs and cache-bust app.js.
clm0081 2 일 전
부모
커밋
d03520552d
6개의 변경된 파일과 150개의 추가작업 그리고 18개의 파일을 삭제
  1. 3 1
      README.md
  2. 15 1
      collector/l2_snapshot.py
  3. 16 1
      collector/port_status.py
  4. 6 1
      docs/使用说明.md
  5. 109 13
      web-modules/WecomCenter/assets/l2topology/app.js
  6. 1 1
      web-modules/WecomCenter/views/topology.view.php

+ 3 - 1
README.md

@@ -102,7 +102,9 @@ docker compose -p zabbix ps                                     # 查看状态
   每个车间一个框(包住该车间的汇聚+接入),框内再按 GRD→1F→3F→4F→7F 楼层画小框;同一车间若跨网络
   则拆成两个独立框,只连接各自网络的上游。所有链路均为单段直线,从上级标签左缘中点画到下级标签左缘中点,
   上级标签可遮盖出口,目标标签不被遮挡;接入下还有接入时按层级向右加一列(不改协议连接关系)。
-  含时间轴/终端/离线/搜索/速率&延迟&告警着色,右上角「导出图片」始终按日间模式导出 PNG。
+  含时间轴/终端/离线/搜索/速率&延迟&告警着色;正常在线链路带从核心向下游流动的数据动画(速率越高流动越快),
+  右上角「导出图片」始终按日间模式导出 PNG(导出图为静态实线,不把动画虚线烘焙进图片)。
+  PoE 交换机由采集器读取华为 POE MIB / RFC3621 判定,名称前自动加 ⚡️。
   主题自动跟随 Zabbix 明暗模式,无手动深色开关。
 - 配置页:`Administration → ROMAN 定制中心 → 「L2 拓扑」`(采集模板、刷新/采集间隔、速率配色、
   延迟阈值、分组规则、根框名、**物理网络定义**、**核心交换机定义**等)。物理网络按每行

+ 15 - 1
collector/l2_snapshot.py

@@ -12,6 +12,7 @@ STP = '/tmp/stp_topology.tsv'
 EP  = '/tmp/fdb_endpoints.tsv'
 PS  = '/tmp/port_status.tsv'
 LAT = '/tmp/latency.tsv'
+POE = '/tmp/poe_switches.tsv'
 MYSQL_CONTAINER = os.environ.get('L2_MYSQL_CONTAINER', 'zabbix-mysql')
 MYSQL_USER = os.environ.get('L2_MYSQL_USER', 'root')
 MYSQL_PASS = os.environ.get('L2_MYSQL_PASS', 'CobcoIT132')
@@ -46,6 +47,17 @@ def load_latency():
         pass
     return lat
 
+def load_poe():
+    out = {}
+    try:
+        for row in load(POE):
+            ip = (row.get('switch_ip') or '').strip()
+            if ip:
+                out[ip] = (row.get('poe') or '0').strip() == '1'
+    except Exception:
+        pass
+    return out
+
 def mysql(sql):
     return subprocess.run(
         ['docker', 'exec', '-i', MYSQL_CONTAINER, 'mysql',
@@ -78,6 +90,7 @@ def main():
     now = int(time.time())
     edges_raw = load(STP); eps = load(EP); ps = load(PS)
     lat = load_latency()
+    poe = load_poe()
 
     # Previous snapshot is the switch inventory / last-known-position source.
     # Terminals are deliberately NOT compared with it: a terminal is shown
@@ -279,7 +292,7 @@ def main():
             'alarms': sorted(al, key=lambda a: -a['sev']), 'maxsev': maxsev,
             'term_online': tcnt[ip]['online'], 'term_offline': 0,
             'latency_ms': lat.get(ip) if online else pn.get('latency_ms'),
-            'children': nchildren,
+            'children': nchildren, 'poe': bool(poe.get(ip, pn.get('poe', False))),
             'last_online': last_online
         })
 
@@ -325,6 +338,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')),
         'edges': len(edges),
         'term_total': len(terminals), 'term_online': len(terminals), 'term_offline': 0,
         'alarms': sum(len(n['alarms']) for n in nodes),

+ 16 - 1
collector/port_status.py

@@ -42,6 +42,8 @@ O = {
  'ifAlias':'1.3.6.1.2.1.31.1.1.1.18','ifConnector':'1.3.6.1.2.1.31.1.1.1.17','duplex':'1.3.6.1.2.1.10.7.2.1.19',
  'fdbPort':'1.3.6.1.2.1.17.4.3.1.2','bpIf':'1.3.6.1.2.1.17.1.4.1.2','fdbStatus':'1.3.6.1.2.1.17.4.3.1.3',
 }
+POE_STD = '1.3.6.1.2.1.105.1.1.1.1.1'                   # RFC 3621 pethPsePortAdminEnable
+POE_HW_JUDGE = '1.3.6.1.4.1.2011.5.25.195.4.1.2'        # HUAWEI-POE-MIB hwPoePortJudgeIsSupport (TruthValue)
 def parse(lines, base):
     out = []
     for ln in lines:
@@ -64,6 +66,13 @@ def collect(t):
     iftype = n1('ifType'); ifadmin = n1('ifAdmin'); ifoper = n1('ifOper')
     ifspeed = n1('ifSpeed'); ifhigh = n1('ifHighSpeed'); lastchg = n1('ifLastChange')
     conn = n1('ifConnector'); dup = n1('duplex')
+    # PoE capability: standard RFC 3621 PSE table, plus Huawei's TruthValue
+    # capability table.  A row in the standard table means a PSE port exists;
+    # Huawei reports true(1)/false(2) for each interface.  This is capability,
+    # not current PD power, so it stays stable when no device is attached.
+    std_poe = parse(walk(args, ip, POE_STD), POE_STD)
+    hw_poe = parse(walk(args, ip, POE_HW_JUDGE), POE_HW_JUDGE)
+    poe = bool(std_poe) or any(num(v) == 1 for _, v in hw_poe)
     # FDB per port -> count and sample macs
     bpif = {num(s.split('.')[-1]): num(v) for s, v in parse(walk(args, ip, O['bpIf']), O['bpIf'])}
     st = {num(s.split('.')[-1]): num(v) for s, v in parse(walk(args, ip, O['fdbStatus']), O['fdbStatus'])}
@@ -81,7 +90,7 @@ def collect(t):
     d.update({'ok': True, 'ifname': ifname, 'ifdescr': ifdescr, 'ifalias': ifalias, 'iftype': iftype,
               'ifadmin': ifadmin, 'ifoper': ifoper, 'ifspeed': ifspeed, 'ifhigh': ifhigh,
               'lastchg': lastchg, 'conn': conn, 'dup': dup, 'mac_cnt': dict(mac_cnt),
-              'mac_by_if': {k: v for k, v in mac_by_if.items()}})
+              'mac_by_if': {k: v for k, v in mac_by_if.items()}, 'poe': poe})
     return d
 
 def main():
@@ -116,6 +125,12 @@ def main():
         f.write('switch_name\tswitch_ip\tifIndex\tifName\tifAlias\tifType\tadmin_status\toper_status\tspeed_mbps\tduplex\tconnector_present\tlast_change_ticks\tmac_count\tsample_macs\n')
         for row in rows:
             f.write('\t'.join(row) + '\n')
+    with open('/tmp/poe_switches.tsv', 'w', encoding='utf-8') as f:
+        f.write('switch_ip\tpoe\n')
+        for r in ok:
+            f.write('%s\t%s\n' % (r['ip'], '1' if r.get('poe') else '0'))
+    poe_count = sum(1 for r in ok if r.get('poe'))
+    print('poe_switches=%d' % poe_count)
     # summary
     phys = [r for r in rows if r[5] in ('6', '62', '117', '161', '117')]
     up = [r for r in rows if r[7] == 'up']

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

@@ -236,6 +236,10 @@ zabbix-deploy/
     每层接入交换机在该楼层框内排列;接入下还有接入时按层级向右加一列(不改协议连接关系)。同一车间跨网络时拆成两个独立框,只连接各自网络的上游。
   - 所有链路均为单段直线,从**上级标签左缘中点**画到**下级标签左缘中点**;上级标签可以遮住线路出口,
     但任何目标标签和其他线路不被遮挡;同一车间内部线路互不交叉,跨区直连核心的链路除外。点任意交换机高亮完整上联链路,点空白恢复。
+  - **数据流动效果**:正常在线链路叠加从核心向下游流动的细虚线(下行/离线链路不流动),
+    100M/1G/10G/20G+ 的流动速度依次加快,仅作视觉提示,不改变任何连接关系。
+  - **PoE 交换机**:采集器通过华为 `hwPoePortJudgeIsSupport` 或 RFC3621 `pethPsePortAdminEnable` 判断 PoE 能力,
+    交换机名称最前面显示 `⚡️`;当前网络若没有 PoE 设备则不显示。
 - **节点标签**:`车间 · 楼层机柜U · 角色 · IP`;双击节点跳转该主机的问题页。
 - **告警面板只显示拓扑内交换机**,不再混入服务器/测试机等“拓扑外”主机;告警交换机边框统一红色。
 - **显示终端 / 只看离线**:勾选「显示终端」在交换机下方展开终端点(绿=在线、灰=离线);
@@ -243,7 +247,8 @@ zabbix-deploy/
 - **时间轴**:右上「快照时间」下拉可选 `实时` 或历史某分钟快照(保存 **7 天**,5 分钟一个点)。
 - **明暗主题**:自动跟随 Zabbix 当前主题(Zabbix 深色即深色、亮色即亮色),无手动开关。
 - **搜索**:输入 IP / MAC / 名称,下拉结果点击即居中定位。
-- **导出图片**:右上角「导出图片」把当前完整拓扑导出为 PNG;无论当前 Zabbix 是明色还是深色,导出图固定使用日间模式(白底)。
+- **导出图片**:右上角「导出图片」把当前完整拓扑导出为 PNG;无论当前 Zabbix 是明色还是深色,导出图固定使用日间模式(白底),
+  且导出时隐藏流动虚线,保证静态图上的实线/红虚线含义清晰。
 - 实时模式按配置的刷新秒数自动刷新,可暂停;支持缩放/适应窗口。
 
 ### 10.3 数据与存储

+ 109 - 13
web-modules/WecomCenter/assets/l2topology/app.js

@@ -44,6 +44,9 @@
 
   function el(id) { return document.getElementById(id); }
   function t(k, d) { return (T[k] != null) ? T[k] : (d != null ? d : k); }
+  // PoE is a capability flag written by the collector's SNMP probe.  The
+  // lightning mark is display-only, like the topology itself.
+  function poePrefix(n) { return (n && n.poe) ? '\u26A1\uFE0F ' : ''; }
 
   /* ---------------- name parsing / grouping ---------------- */
   function parseName(name) {
@@ -77,16 +80,18 @@
     if (p.floor) { var loc = p.floor + 'F' + (p.cab || '') + (p.u ? '-' + p.u + 'U' : ''); l1.push(loc); }
     if (p.role) l2.push(p.role);
     l2.push(n.ip);
-    return l1.join(' · ') + '\n' + l2.join(' · ');
+    return poePrefix(n) + l1.join(' · ') + '\n' + l2.join(' · ');
   }
 
   function detailLabel(n) {
+    var pre = poePrefix(n);
     var s = labelFor(n);
+    if (pre && s.indexOf(pre) === 0) s = s.slice(pre.length);
     if (viewGroup) {
       var p = viewGroup + ' · ';
       if (s.indexOf(p) === 0) s = s.substring(p.length);
     }
-    return s;
+    return pre + s;
   }
 
   function rowLabel(n) {
@@ -95,14 +100,16 @@
     if (viewGroup) {
       try { s = s.replace(new RegExp('^' + viewGroup + '-', 'i'), ''); } catch (e) {}
     }
-    return s;
+    return poePrefix(n) + s;
   }
 
   function rootLabel(n) {
+    var pre = poePrefix(n);
     var s = rowLabel(n);
+    if (pre && s.indexOf(pre) === 0) s = s.slice(pre.length);
     var m = /^(.+?)-(SURVE|OFFICE|OTHER)-(Dist|Access)-(.+)$/i.exec(s);
-    if (m) return m[2].toUpperCase() + ' · ' + m[1] + '\n' + m[3] + ' · ' + m[4];
-    return s;
+    if (m) return pre + m[2].toUpperCase() + ' · ' + m[1] + '\n' + m[3] + ' · ' + m[4];
+    return pre + s;
   }
 
   function expandLabel(tpl, m) {
@@ -622,10 +629,14 @@
 
   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 || '');
+    var s;
+    if (role === 'core') s = roleName('core') + ' · ' + (netKey || rawNetOf(n)) + ' · ' + (n.ip || '');
+    else if (role === 'main') s = roleName('main') + ' · ' + g + ' · ' + (n.ip || '');
+    else {
+      var loc = shortLoc(n);
+      s = [g, loc, roleName(role)].filter(Boolean).join(' · ') + ' · ' + (n.ip || '');
+    }
+    return poePrefix(n) + s;
   }
 
   // A workshop is the physical area served by one distribution switch, not
@@ -929,6 +940,16 @@
     if (s >= 1000) return 2.6;
     return 1.8;
   }
+  // Animation speed (pixels per 60Hz frame) by link rate.  The visible
+  // flow is only an overlay; the protocol/display link itself never changes.
+  function flowRate(speed) {
+    var s = parseFloat(speed) || 0;
+    if (s >= 20000) return 4.6;
+    if (s >= 10000) return 3.4;
+    if (s >= 1000) return 2.0;
+    if (s > 0) return 0.9;
+    return 0.6;
+  }
   function speedText(speed) {
     var s = parseFloat(speed) || 0;
     if (!s) return '?';
@@ -1079,6 +1100,15 @@
         'source-endpoint': '0% 50%', 'target-endpoint': '0% 50%',
         'target-arrow-shape': 'none', 'z-index-compare': 'manual', 'z-index': 1
       }},
+      // Moving data-flow overlay.  The base line keeps its speed colour; this
+      // thin dashed line runs on top and is animated downstream (core -> leaf).
+      {selector: 'edge[type="flow"]', style: {
+        'width': 'data(fw)', 'line-color': d ? '#EDFFFC' : '#0E4B46',
+        'line-style': 'dashed', 'line-dash-pattern': [4, 10], 'line-dash-offset': 0,
+        'opacity': d ? 0.68 : 0.52, 'curve-style': 'straight',
+        'source-endpoint': '0% 50%', 'target-endpoint': '0% 50%',
+        'target-arrow-shape': 'none', 'events': 'no', 'z-index-compare': 'manual', 'z-index': 3
+      }},
       {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
@@ -1147,6 +1177,34 @@
       if (keepView) { cy.zoom(z); cy.pan(p); }
       else { initialView(); }
     }
+    startFlow();
+  }
+
+  // Data-flow animation.  Each flow overlay has its own rate (link speed), so
+  // a 10G/20G link visibly moves faster than a 100M/1G link.  This only
+  // changes the visual dash phase; no topology/state is touched.
+  var flowRaf = null, flowLast = 0;
+  function startFlow() {
+    if (flowRaf != null) return;
+    flowRaf = requestAnimationFrame(flowTick);
+  }
+  function flowTick(ts) {
+    if (!cy) { flowRaf = null; return; }
+    var dt = flowLast ? Math.min(80, ts - flowLast) : 16;
+    flowLast = ts;
+    if (!document.hidden) {
+      cy.batch(function () {
+        cy.edges('[type="flow"]').forEach(function (e) {
+          var rate = parseFloat(e.data('flowRate')) || 0.6;
+          var off = (e.scratch('flowOffset') || 0) - rate * dt / 16;
+          off = off % 14;
+          if (off < 0) off += 14;
+          e.scratch('flowOffset', off);
+          e.style('line-dash-offset', off);
+        });
+      });
+    }
+    flowRaf = requestAnimationFrame(flowTick);
   }
 
   function fitGroup(label) {
@@ -1215,6 +1273,7 @@
         id: n.id, type: 'switch', name: n.name, ip: n.ip, tier: n.tier, depth: n.depth, hostid: n.hostid, oper: n.oper,
         offline: !!(n.offline || n.oper === 'down'), last_online: n.last_online,
         maxsev: n.maxsev, alarms: n.alarms || [], term_online: n.term_online, term_offline: 0, latency_ms: n.latency_ms,
+        poe: !!n.poe,
         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(' ')});
@@ -1238,6 +1297,17 @@
         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 (e.state === 'up') {
+        var from = e.source, to = e.target;
+        var sd = parseInt(ps.node && ps.node.depth, 10), td = parseInt(pt.node && pt.node.depth, 10);
+        if (td < sd || (td === sd && pt.y < ps.y)) { from = e.target; to = e.source; }
+        var fsp = (from === e.source) ? sp : tp, ftp = (to === e.source) ? sp : tp;
+        els.push({group: 'edges', data: {
+          id: 'f_' + i, type: 'flow', source: fsp, target: ftp, p: e.source, c: e.target,
+          speed: e.speed, state: e.state, a_port: e.a_port, b_port: e.b_port,
+          flowRate: flowRate(e.speed), fw: Math.max(0.9, Math.min(1.5, speedWidth(e.speed) * 0.35))
+        }});
+      }
     });
 
     if (showTerm) addTerminals(L, byId, els);
@@ -1374,6 +1444,7 @@
         id: n.id, type: 'switch', name: n.name, ip: n.ip, tier: tier, depth: n.depth,
         hostid: n.hostid, oper: n.oper, maxsev: n.maxsev, alarms: n.alarms || [],
         term_online: n.term_online, term_offline: n.term_offline, latency_ms: n.latency_ms,
+        poe: !!n.poe,
         label: isRoot ? rootLabel(n) : rowLabel(n), tw: isRoot ? 150 : 360,
         bg: bg, bd: isRoot ? bd : '#25501e',
         w: isRoot ? 170 : 380, h: isRoot ? 50 : 24, group: viewGroup
@@ -1396,6 +1467,13 @@
             color: speedColor(e.speed, e.state), ls: (e.state && e.state !== 'up') ? 'dashed' : 'solid',
             w: Math.min(2.4, speedWidth(e.speed))
           }, classes: 'detail'});
+          if (e.state === 'up') {
+            els.push({group: 'edges', data: {
+              id: 'f_' + i, type: 'flow', source: sp, target: tp, p: root.id, c: childId,
+              speed: e.speed, state: e.state, a_port: e.a_port, b_port: e.b_port,
+              flowRate: flowRate(e.speed), fw: Math.max(0.9, Math.min(1.4, speedWidth(e.speed) * 0.35))
+            }});
+          }
           return;
         }
       }
@@ -1405,6 +1483,18 @@
         color: speedColor(e.speed, e.state), ls: (e.state && e.state !== 'up') ? 'dashed' : 'solid',
         w: Math.min(2.4, speedWidth(e.speed))
       }, classes: 'detail'});
+      if (e.state === 'up') {
+        var from = e.source, to = e.target;
+        var ap = D.pos[e.source], bp = D.pos[e.target], po = D.parentOf || {};
+        if (po[e.source] === e.target) { from = e.target; to = e.source; }
+        else if (po[e.target] === e.source) { from = e.source; to = e.target; }
+        else if (ap && bp && bp.y < ap.y) { from = e.target; to = e.source; }
+        els.push({group: 'edges', data: {
+          id: 'f_' + i, type: 'flow', source: from, target: to, p: e.source, c: e.target,
+          speed: e.speed, state: e.state, a_port: e.a_port, b_port: e.b_port,
+          flowRate: flowRate(e.speed), fw: Math.max(0.9, Math.min(1.4, speedWidth(e.speed) * 0.35))
+        }});
+      }
     });
     if (showTerm) {
       var bySw = {};
@@ -1518,7 +1608,7 @@
       var roleTxt = role === 'dist' ? t('l2.dist', 'Distribution') : t('l2.access', 'Access');
       h += '<div class="off-row sw-row" data-id="' + esc(n.id) + '">' +
         '<span class="term-dot" style="background:#C0392B"></span>' +
-        '<div class="off-main"><div class="off-mac">' + esc(n.name || n.ip) + '</div>' +
+        '<div class="off-main"><div class="off-mac">' + esc(poePrefix(n) + (n.name || n.ip)) + '</div>' +
         '<div class="off-sub">' + esc(roleTxt) + ' · ' + esc(n.ip || '') +
         ' · ' + t('l2.last_online', 'last online') + ': ' + esc(fmtTs(n.last_online)) + '</div></div></div>';
     });
@@ -1635,10 +1725,14 @@
     var btn = el('l2-export'), oldText = btn ? btn.textContent : '';
     if (btn) { btn.disabled = true; btn.textContent = t('l2.exporting', '正在导出…'); }
     var wasDark = dark;
+    var flowEdges = cy.edges('[type="flow"]');
     try {
       // The exported file must always be the day palette, even when the
-      // operator is currently using Zabbix dark mode.
+      // operator is currently using Zabbix dark mode.  The moving flow
+      // overlay is transient screen decoration, so keep the exported static
+      // diagram on solid speed-coloured links.
       cy.elements().removeClass('hl dim pathhl');
+      flowEdges.style('display', 'none');
       cy.style(styleFor(false));
       if (typeof cy.forceRender === 'function') cy.forceRender();
       var data = cy.png({full: true, scale: 1, maxWidth: 2600, maxHeight: 18000, bg: '#ffffff'});
@@ -1656,6 +1750,7 @@
     } catch (e) {
       window.alert(t('l2.export_fail', '导出失败,请重试。'));
     } finally {
+      flowEdges.style('display', 'element');
       cy.style(styleFor(wasDark));
       if (typeof cy.forceRender === 'function') cy.forceRender();
       if (btn) { btn.disabled = false; btn.textContent = oldText; }
@@ -1754,6 +1849,7 @@
     });
     nodes.removeClass('dim').addClass('pathhl');
     var edges = cy.edges().filter(function (e) {
+      if (e.data('type') === 'flow') return false;
       var p = e.data('p'), c = e.data('c');
       return p && c && (up.edges[p + '|' + c] || up.edges[c + '|' + p]);
     });
@@ -1768,7 +1864,7 @@
       var sev = (d.maxsev != null && d.maxsev >= 0) ? ('<span style="color:' + SEV_COLORS[d.maxsev] + '">● ' + SEV_NAMES[d.maxsev] + '</span>') : '—';
       var lat = (d.latency_ms != null) ? (d.latency_ms + ' ms') : '—';
       var stat = (d.offline || d.oper === 'down') ? ('<span style="color:#E45959">' + esc(t('l2.offline', 'Offline')) + '</span>') : esc(d.oper || 'up');
-      var html = '<b>' + esc(d.name) + '</b><br>' + esc(d.ip) + ' · ' + esc(d.group || '') + '<br>' +
+      var html = '<b>' + esc(poePrefix({poe: d.poe}) + d.name) + '</b><br>' + esc(d.ip) + ' · ' + esc(d.group || '') + '<br>' +
         'Tier: ' + esc(d.tier) + ' · ' + stat + '<br>' +
         'Latency: ' + lat + '<br>' +
         'Terminals: ' + (d.term_online || 0) + '<br>' +
@@ -1833,7 +1929,7 @@
   /* ---------------- search ---------------- */
   function buildIndex(snap) {
     var idx = [];
-    (snap.nodes || []).forEach(function (n) { idx.push({id: n.id, label: n.name, sub: n.ip, kind: 'sw'}); });
+    (snap.nodes || []).forEach(function (n) { idx.push({id: n.id, label: poePrefix(n) + n.name, sub: n.ip, kind: 'sw'}); });
     (snap.terminals || []).forEach(function (tm) {
       if (!terminalOnline(tm)) return;
       idx.push({id: 't_' + tm.mac, label: tm.mac, sub: (tm.ip || '') + ' · ' + speedText(tm.speed) + ' · ' + (tm.sw_name || ''), kind: 'term', mac: tm.mac});

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

@@ -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=202611051801"></script>
+<script src="<?php echo $base; ?>app.js?v=202611051845"></script>
 <script>
 (function(){
   var tabs = document.querySelectorAll('.side-tab');