Browse Source

L2 topology: DB-backed 7-day history + time selector, grouped frames layout, speed/latency/alarm colours, dark mode, working terminal toggles, configurable templates/rules in ROMAN config center

COBCO IT 3 days ago
parent
commit
e058c52f50

+ 6 - 0
.gitignore

@@ -0,0 +1,6 @@
+# runtime artefacts (generated by the collector, not source)
+web-modules/WecomCenter/assets/l2topology/snapshot.json
+
+# python
+__pycache__/
+*.pyc

+ 20 - 0
README.md

@@ -92,3 +92,23 @@ docker compose -p zabbix ps                                     # 查看状态
 - Zabbix Server:`10051`
 - Web(nginx):容器内 `8080` / `8443`
 - MySQL:容器内 `3306`(不对外暴露)
+
+## L2 物理拓扑(WecomCenter 模块扩展)
+
+模块 `web-modules/WecomCenter` 现包含 **L2 物理拓扑** 功能(展示页 + 配置页 + 采集器 + 历史库):
+
+- 展示页:`Monitoring → L2 Topology`(只读,含时间轴/深色/终端/搜索/速率&延迟&告警着色)。
+- 配置页:`Administration → ROMAN 定制中心 → 「L2 拓扑」`(采集模板、刷新/采集间隔、速率配色、
+  延迟阈值、分组规则、根框名、默认深色等)。
+- 数据:每次全量采集把快照 gzip 写入 MySQL `l2_topology_history`(保留 7 天,可回看任意时间);
+  配置存 `l2_topology_config`,并镜像 `assets/l2topology/config.json`。
+- 表结构:`L2Store::ensureSchema()` 幂等建表;`wecom-module-init` 侧车在 `up` 时也会建表。
+
+### docker-compose 一键启用
+`up -d` 时:`web-modules/` 挂入 web 容器 → `wecom-module-init` 自动登记并启用模块、创建 L2 表。
+全新环境无需进 UI 点启用。`WEB_MODULES_DIR` 可覆盖挂载源路径。
+
+### 采集器(宿主机)
+采集器在 `collector/`,需装到 `/opt/zabbix/l2collector/` 并用 cron 调度(容器无法直接读交换机 SNMP)。
+详见 `docs/使用说明.md` 第 10 章。只采集配置页所选模板(默认 `Huawei VRP by SNMP (walk)`)的主机。
+

+ 69 - 0
collector/fdb_analyze3.py

@@ -0,0 +1,69 @@
+import csv,re,collections
+rows=[]
+with open('/tmp/fdb_hosts.tsv',encoding='utf-8') as f:
+    for x in csv.DictReader(f,delimiter='\t'): rows.append(x)
+devnames={}
+for x in rows:
+    devnames[x['switch_name']]=x['switch_ip']
+def is_device(name,ip):
+    n=name.lower()
+    if re.search(r'(core|dist|access|switch|ap-|-ap\b|surve|office-access)',n): return True
+    if ip in devnames.values(): return True
+    return False
+def peer_from_alias(alias):
+    a=alias.strip()
+    m=re.search(r'TO[-_ ]?(.+)$',a,re.I)
+    return m.group(1).strip() if m else None
+def find_dev(tok):
+    tok=tok.strip().strip('-')
+    if not tok: return None
+    tl=tok.lower()
+    # exact-ish prefix match on device names
+    best=None
+    for nm in devnames:
+        nl=nm.lower()
+        if tl[:10] in nl or nl[:10] in tl:
+            return nm
+        # token like 3101-GRDA27-OFFICE
+        key=tl.split('-')[0]
+        if key and key in nl:
+            if best is None: best=nm
+    return best
+links=[];seenset=set()
+endpoints=[];dev_edges=[]
+for x in rows:
+    alias=x['port_alias']; sw=x['switch_name']
+    ptok=peer_from_alias(alias)
+    if ptok:
+        b=find_dev(ptok)
+        if b:
+            key=tuple(sorted([sw,b]))
+            if key not in seenset and b!=sw:
+                seenset.add(key); links.append((sw,x['port'],alias,b))
+            continue
+    if is_device(x['zabbix_name'],x['ip']):
+        dev_edges.append(x)
+    else:
+        endpoints.append(x)
+print("rows=",len(rows))
+print("alias_links=",len(links))
+print("device_edges(no alias)=",len(dev_edges))
+print("endpoint_edges=",len(endpoints))
+print("\n-- 45 alias-based switch links --")
+for s,p,a,b in links[:45]:
+    print(f"  {s[:44]:44s}[{p:12s} {a[:22]:22s}] <-> {b[:44]}")
+# also show device_edges sample (switch mgmt seen on a port, no alias)
+print("\n-- 20 device_edges (no alias) --")
+for x in dev_edges[:20]:
+    print(f"  {x['zabbix_name'][:40]:40s} -> {x['switch_name'][:38]:38s} {x['port']:12s} macs={x['port_mac_count']}")
+with open('/tmp/fdb_links_alias.tsv','w',encoding='utf-8') as f:
+    f.write('a_switch\ta_port\ta_alias\tb_switch\n')
+    for r in links: f.write('\t'.join(r)+'\n')
+with open('/tmp/fdb_endpoints.tsv','w',encoding='utf-8') as f:
+    f.write('ip\tname\tmac\tswitch_name\tswitch_ip\tport\tport_alias\tport_mac_count\n')
+    for x in endpoints:
+        f.write('\t'.join([x['ip'],x['zabbix_name'],x['mac'],x['switch_name'],x['switch_ip'],x['port'],x['port_alias'],x['port_mac_count']])+'\n')
+with open('/tmp/fdb_devedges.tsv','w',encoding='utf-8') as f:
+    f.write('ip\tname\tmac\tswitch_name\tswitch_ip\tport\tport_alias\tport_mac_count\n')
+    for x in dev_edges:
+        f.write('\t'.join([x['ip'],x['zabbix_name'],x['mac'],x['switch_name'],x['switch_ip'],x['port'],x['port_alias'],x['port_mac_count']])+'\n')

+ 247 - 0
collector/fdb_collect.py

@@ -0,0 +1,247 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# Read-only L2 topology collector: ARP + BRIDGE-MIB FDB + ifName/ifAlias.
+# No device config changes. Outputs /tmp/fdb_hosts.tsv, /tmp/fdb_links.tsv, /tmp/fdb_summary.txt
+import json, urllib.request, subprocess, re, sys, os, time
+import concurrent.futures as cf
+import l2cfg
+
+TOK = 'd3abb125eb301a021ef80a2847a803a7cd2d0b1d3c8337a14d09db9d571369b5'
+API = 'http://192.168.87.20:8080/api_jsonrpc.php'
+
+def call(m, p):
+    d = json.dumps({"jsonrpc": "2.0", "method": m, "params": p, "id": 1}).encode()
+    q = urllib.request.Request(API, data=d, headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + TOK})
+    return json.loads(urllib.request.urlopen(q, timeout=60).read().decode())
+
+AUTH = {0: 'MD5', 1: 'SHA', 2: 'SHA-224', 3: 'SHA-256', 4: 'SHA-384', 5: 'SHA-512'}
+PRIV = {0: 'DES', 1: 'AES', 2: 'AES-192', 3: 'AES-256', 4: 'AES', 5: 'AES-256'}
+
+def get_hosts():
+    hs = l2cfg.hosts(call, output=['hostid', 'name'])
+    res = []
+    for h in hs:
+        for i in h['interfaces']:
+            if i.get('type') != '2':
+                continue
+            det = i.get('details') or {}
+            ver = str(det.get('version', '1'))
+            args = None
+            if ver == '3':
+                lvl = int(det.get('securitylevel', 0))
+                args = ['-v3', '-l', {0: 'noAuthNoPriv', 1: 'authNoPriv', 2: 'authPriv'}.get(lvl, 'authPriv'),
+                        '-u', det.get('securityname', '')]
+                if lvl >= 1:
+                    args += ['-a', AUTH.get(int(det.get('authprotocol', 0)), 'SHA'), '-A', det.get('authpassphrase', '')]
+                if lvl >= 2:
+                    args += ['-x', PRIV.get(int(det.get('privprotocol', 0)), 'AES'), '-X', det.get('privpassphrase', '')]
+            else:
+                args = ['-v2c', '-c', det.get('community', 'public')]
+            res.append({'ip': i['ip'], 'zname': h['name'], 'args': args})
+            break
+    return res
+
+def walk(args, ip, oid):
+    try:
+        r = subprocess.run(['snmpbulkwalk', '-On'] + args + ['-t', '3', '-r', '1', ip, oid],
+                           capture_output=True, text=True, timeout=30)
+        return r.stdout
+    except Exception:
+        return ''
+
+def parse(text, base):
+    out = []
+    for line in text.splitlines():
+        if ' = ' not in line:
+            continue
+        left, right = line.split(' = ', 1)
+        o = left.strip().lstrip('.')
+        if not o.startswith(base):
+            continue
+        suf = o[len(base):].lstrip('.')
+        out.append((suf, right.strip()))
+    return out
+
+def num(v):
+    m = re.search(r'-?\d+', v)
+    return int(m.group()) if m else None
+
+def strval(v):
+    # strip type prefix like STRING: / INTEGER: / Hex-STRING:
+    if ':' in v:
+        return v.split(':', 1)[1].strip().strip('"')
+    return v.strip()
+
+def normmac(s):
+    s = s.strip().lower().replace('-', ':').replace(' ', ':')
+    parts = re.split(r'[:.]', s)
+    if len(parts) == 6:
+        try:
+            return ':'.join('%02x' % int(p, 16) for p in parts)
+        except Exception:
+            return s
+    return s
+
+BASE_FDB = '1.3.6.1.2.1.17.4.3.1'
+BASE_BPIF = '1.3.6.1.2.1.17.1.4.1.2'
+OID_IFNAME = '1.3.6.1.2.1.31.1.1.1.1'
+OID_IFALIAS = '1.3.6.1.2.1.31.1.1.1.18'
+OID_ARP = '1.3.6.1.2.1.4.22.1.2'
+OID_SYSNAME = '1.3.6.1.2.1.1.5.0'
+OID_BRIDGE = '1.3.6.1.2.1.17.1.1.0'
+
+def collect(h):
+    ip = h['ip']; args = h['args']
+    d = {'ip': ip, 'zname': h['zname'], 'name': h['zname'], 'ok': False}
+    sn = walk(args, ip, OID_SYSNAME)
+    v = parse(sn, OID_SYSNAME)
+    if v:
+        d['name'] = strval(v[0][1])
+    bm = walk(args, ip, OID_BRIDGE)
+    vb = parse(bm, OID_BRIDGE)
+    d['bridge_mac'] = normmac(strval(vb[0][1])) if vb else ''
+    # base port -> ifIndex
+    bp = {}
+    for suf, val in parse(walk(args, ip, BASE_BPIF), BASE_BPIF):
+        port = num(suf.split('.')[-1])
+        ifx = num(val)
+        if port is not None and ifx is not None:
+            bp[port] = ifx
+    # ifName / ifAlias
+    ifn = {}
+    for suf, val in parse(walk(args, ip, OID_IFNAME), OID_IFNAME):
+        ifn[num(suf.split('.')[-1])] = strval(val)
+    ifa = {}
+    for suf, val in parse(walk(args, ip, OID_IFALIAS), OID_IFALIAS):
+        ifa[num(suf.split('.')[-1])] = strval(val)
+    # FDB: address table .1 idx=mac, port table .2 idx=mac -> bridgePort
+    fdb_addr = {}
+    for suf, val in parse(walk(args, ip, BASE_FDB + '.1'), BASE_FDB + '.1'):
+        mac = normmac(strval(val))
+        fdb_addr[suf] = mac
+    fdb = {}  # mac -> ifIndex
+    portcnt = {}
+    for suf, val in parse(walk(args, ip, BASE_FDB + '.2'), BASE_FDB + '.2'):
+        port = num(val)
+        mac = fdb_addr.get(suf)
+        if mac is None:
+            mac = normmac(suf)  # suf is m1.m2..m6
+        if port is None:
+            continue
+        portcnt[port] = portcnt.get(port, 0) + 1
+        ifx = bp.get(port)
+        if ifx is None:
+            continue
+        fdb[mac] = ifx
+    # ARP
+    arp = {}
+    for suf, val in parse(walk(args, ip, OID_ARP), OID_ARP):
+        parts = suf.split('.')
+        if len(parts) >= 5:
+            ipx = '.'.join(parts[-4:])
+            ipx = '.'.join(str(int(x)) for x in parts[-4:])
+            mac = normmac(strval(val))
+            if mac and mac != '0:0:0:0:0:0':
+                arp[ipx] = mac
+    ifx_cnt = {}
+    for mac, ifx in fdb.items():
+        ifx_cnt[ifx] = ifx_cnt.get(ifx, 0) + 1
+    d.update({'ok': True, 'bp': bp, 'ifn': ifn, 'ifa': ifa, 'fdb': fdb,
+              'portcnt': portcnt, 'ifx_cnt': ifx_cnt, 'arp': arp})
+    return d
+
+def main():
+    hosts = get_hosts()
+    sys.stderr.write("hosts=%d\n" % len(hosts))
+    results = []
+    with cf.ThreadPoolExecutor(max_workers=24) as ex:
+        futs = {ex.submit(collect, h): h for h in hosts}
+        for k, f in enumerate(cf.as_completed(futs)):
+            try:
+                r = f.result()
+            except Exception as e:
+                h = futs[f]; r = {'ip': h['ip'], 'zname': h['zname'], 'name': h['zname'], 'ok': False}
+            results.append(r)
+            if (k + 1) % 20 == 0:
+                sys.stderr.write("done %d/%d\n" % (k + 1, len(hosts)))
+    ok = [r for r in results if r.get('ok')]
+    # switch bridge mac index
+    sw_by_bmac = {}
+    for r in ok:
+        bm = r.get('bridge_mac')
+        if bm:
+            sw_by_bmac[bm] = r
+    # mac -> locations
+    mac_loc = {}
+    for r in ok:
+        for mac, ifx in r['fdb'].items():
+            mac_loc.setdefault(mac, []).append((r, ifx, r['ifx_cnt'].get(ifx, 0)))
+    def portname(r, ifx):
+        return r['ifn'].get(ifx, 'if%d' % ifx)
+    def portalias(r, ifx):
+        return r['ifa'].get(ifx, '')
+    # helper to get port num for ifIndex (reverse bp)
+    # ip -> mac globally
+    ip2mac = {}
+    for r in ok:
+        for ip, mac in r['arp'].items():
+            ip2mac.setdefault(ip, mac)
+    # zname by ip
+    zname = {h['ip']: h['zname'] for h in hosts}
+    # host edges: ip -> mac -> edge switch port (min port mac count)
+    host_rows = []
+    for ip, mac in sorted(ip2mac.items()):
+        if mac in sw_by_bmac:
+            continue
+        locs = mac_loc.get(mac, [])
+        if not locs:
+            continue
+        best = min(locs, key=lambda t: t[2])
+        r, ifx, pc = best
+        host_rows.append([ip, zname.get(ip, ''), mac, r['name'], r['ip'], portname(r, ifx), portalias(r, ifx), str(pc)])
+    # switch links: for switch S, fdb mac that is another switch bridge mac
+    links = []
+    seen = set()
+    for r in ok:
+        for mac, ifx in r['fdb'].items():
+            tgt = sw_by_bmac.get(mac)
+            if tgt is None or tgt['ip'] == r['ip']:
+                continue
+            key = tuple(sorted([r['ip'], tgt['ip']])) + (portname(r, ifx),)
+            if key in seen:
+                continue
+            seen.add(key)
+            links.append([r['name'], r['ip'], portname(r, ifx), portalias(r, ifx), tgt['name'], tgt['ip']])
+    # candidate uplinks: ports (non-loop) with high mac count
+    uplinks = []
+    for r in ok:
+        # invert fdb to port -> count already in portcnt per bridge port; map to ifIndex
+        inv = {}
+        for mac, ifx in r['fdb'].items():
+            inv[ifx] = inv.get(ifx, 0) + 1
+        for ifx, c in inv.items():
+            if c >= 8:
+                uplinks.append([r['name'], r['ip'], portname(r, ifx), portalias(r, ifx), str(c)])
+    uplinks.sort(key=lambda x: -int(x[4]))
+    with open('/tmp/fdb_hosts.tsv', 'w', encoding='utf-8') as f:
+        f.write('ip\tzabbix_name\tmac\tswitch_name\tswitch_ip\tport\tport_alias\tport_mac_count\n')
+        for row in host_rows:
+            f.write('\t'.join(row) + '\n')
+    with open('/tmp/fdb_links.tsv', 'w', encoding='utf-8') as f:
+        f.write('a_name\ta_ip\ta_port\ta_alias\tb_name\tb_ip\n')
+        for row in links:
+            f.write('\t'.join(row) + '\n')
+    with open('/tmp/fdb_uplinks.tsv', 'w', encoding='utf-8') as f:
+        f.write('switch_name\tswitch_ip\tport\tport_alias\tmac_count\n')
+        for row in uplinks:
+            f.write('\t'.join(row) + '\n')
+    with open('/tmp/fdb_summary.txt', 'w', encoding='utf-8') as f:
+        f.write('switches_ok=%d / total=%d\n' % (len(ok), len(hosts)))
+        f.write('host_edges=%d\n' % len(host_rows))
+        f.write('switch_links=%d\n' % len(links))
+        f.write('uplink_ports(>=8mac)=%d\n' % len(uplinks))
+        f.write('ips_with_mac=%d\n' % len(ip2mac))
+    print(open('/tmp/fdb_summary.txt').read())
+
+if __name__ == '__main__':
+    main()

+ 266 - 0
collector/l2_snapshot.py

@@ -0,0 +1,266 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""Merge collectors' TSVs + Zabbix alarms -> snapshot.json for the L2 topology page."""
+import csv, json, os, re, collections, time, datetime, urllib.request, gzip, base64, subprocess
+
+import l2cfg
+
+TOK = 'd3abb125eb301a021ef80a2847a803a7cd2d0b1d3c8337a14d09db9d571369b5'
+API = 'http://192.168.87.20:8080/api_jsonrpc.php'
+OUT = '/opt/zabbix/zabbix-server/web-modules/WecomCenter/assets/l2topology/snapshot.json'
+STP = '/tmp/stp_topology.tsv'
+EP  = '/tmp/fdb_endpoints.tsv'
+PS  = '/tmp/port_status.tsv'
+LAT = '/tmp/latency.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')
+MYSQL_DB = os.environ.get('L2_MYSQL_DB', 'zabbix')
+SEV = ['NC', 'INFO', 'WARN', 'AVG', 'HIGH', 'DISASTER']  # names localized on the frontend
+PHYS = {'6', '62', '117'}
+
+def api(m, p):
+    d = json.dumps({"jsonrpc": "2.0", "method": m, "params": p, "id": 1}).encode()
+    q = urllib.request.Request(API, data=d, headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + TOK})
+    return json.loads(urllib.request.urlopen(q, timeout=60).read())
+
+def load(path):
+    with open(path, encoding='utf-8') as f:
+        return list(csv.DictReader(f, delimiter='\t'))
+
+def norm(s):
+    return re.sub(r'\s+', ' ', (s or '').strip())
+
+def load_latency():
+    lat = {}
+    try:
+        with open(LAT, encoding='utf-8') as f:
+            for line in f:
+                p = line.rstrip('\n').split('\t')
+                if len(p) >= 2 and p[0] and p[1]:
+                    try:
+                        lat[p[0]] = float(p[1])
+                    except ValueError:
+                        pass
+    except Exception:
+        pass
+    return lat
+
+def mysql(sql):
+    return subprocess.run(
+        ['docker', 'exec', '-i', MYSQL_CONTAINER, 'mysql',
+         '-u' + MYSQL_USER, '-p' + MYSQL_PASS, MYSQL_DB,
+         '--default-character-set=utf8mb4'],
+        input=sql.encode('utf-8'), capture_output=True, timeout=120)
+
+def write_history(snap, now):
+    cfg = l2cfg.config()
+    days = int(cfg.get('history_days') or 7)
+    raw = json.dumps(snap, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
+    b64 = base64.b64encode(gzip.compress(raw, 9)).decode('ascii')
+    st = json.dumps(snap.get('stats', {}), ensure_ascii=False)
+    cap = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+    sql = (
+        "CREATE TABLE IF NOT EXISTS l2_topology_history ("
+        "id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, ts BIGINT NOT NULL, "
+        "captured_at DATETIME NOT NULL, stats TEXT, snapshot MEDIUMBLOB, "
+        "PRIMARY KEY (id), KEY idx_l2_ts (ts)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;\n"
+        "INSERT INTO l2_topology_history (ts,captured_at,stats,snapshot) VALUES "
+        "(%d,'%s','%s',FROM_BASE64('%s'));\n"
+        "DELETE FROM l2_topology_history WHERE ts < %d;\n"
+        % (now, cap, st, b64, now - days * 86400)
+    )
+    r = mysql(sql)
+    if r.returncode != 0:
+        print('history write failed:', r.stderr.decode('utf-8', 'ignore')[:400])
+
+def main():
+    now = int(time.time())
+    edges_raw = load(STP); eps = load(EP); ps = load(PS)
+    lat = load_latency()
+
+    # ---------- port status maps ----------
+    pmap = {}          # (ip, ifName) -> row
+    for r in ps:
+        pmap[(r['switch_ip'], r['ifName'])] = r
+
+    # ---------- switch identity ----------
+    sname = {}
+    for e in edges_raw:
+        sname[e['down_ip']] = norm(e['down_switch'])
+        sname[e['up_ip']] = norm(e['up_switch'])
+
+    # ---------- undirected graph & depth ----------
+    adj = collections.defaultdict(set); pair = {}
+    for e in edges_raw:
+        a, b = e['down_ip'], e['up_ip']
+        if a == b: continue
+        adj[a].add(b); adj[b].add(a)
+        k = tuple(sorted([a, b]))
+        pair.setdefault(k, {}).setdefault('ports', {})
+        pair[k]['ports'].setdefault(a, {})['down'] = e['down_port']
+        pair[k]['ports'].setdefault(b, {})['up'] = e['up_port']
+    downcnt = collections.Counter(e['down_ip'] for e in edges_raw)
+    nodes_ip = set(adj)
+    # components + true root (fewest times as downstream) -> depth, parent, children
+    depth = {}; parent = {}; children = collections.defaultdict(list); roots = []
+    seen = set()
+    for start in nodes_ip:
+        if start in seen: continue
+        comp = []; st = [start]
+        while st:
+            u = st.pop()
+            if u in seen: continue
+            seen.add(u); comp.append(u); st.extend(adj[u] - seen)
+        root = min(comp, key=lambda n: (downcnt.get(n, 0), -len(adj[n]), n))
+        roots.append(root); depth[root] = 0; parent[root] = None
+        order = [root]; qi = 0
+        while qi < len(order):
+            u = order[qi]; qi += 1
+            for v in sorted(adj[u]):
+                if v not in depth:
+                    depth[v] = depth[u] + 1; parent[v] = u; children[u].append(v); order.append(v)
+
+    def tier(ip):
+        d = depth.get(ip, 99)
+        if d == 0: return 'core'
+        if children.get(ip): return 'distribution'
+        return 'access'
+
+    # ---------- alarms ----------
+    problems = api('problem.get', {'output': ['eventid', 'objectid', 'name', 'severity', 'clock', 'acknowledged'], 'recent': False})['result']
+    trigids = sorted(set(p['objectid'] for p in problems))
+    trig = {}
+    if trigids:
+        tg = api('trigger.get', {'triggerids': trigids, 'output': ['triggerid'], 'selectHosts': ['hostid', 'name', 'interfaces']})['result']
+        for t in tg:
+            trig[t['triggerid']] = t.get('hosts', [])
+    ip_alarms = collections.defaultdict(list)
+    # hosts & availability (need hostid -> ip for alarms)
+    hs = api('host.get', {'output': ['hostid', 'name'], 'selectInterfaces': ['ip', 'type', 'available', 'error']})['result']
+    hid_ip = {}; ip_name = {}; hid_by_ip = {}; avail = {}
+    for h in hs:
+        cand = [i for i in h['interfaces'] if i.get('ip')]
+        if not cand: continue
+        snmp = [i for i in cand if i['type'] == '2']
+        pick = (snmp or cand)[0]
+        hid_ip.setdefault(h['hostid'], pick['ip'])
+        ip_name[pick['ip']] = h['name']; hid_by_ip[pick['ip']] = h['hostid']
+        for i in snmp:
+            avail[i['ip']] = i.get('available', '0')
+    for p in problems:
+        for h in trig.get(p['objectid'], []):
+            ip = hid_ip.get(h['hostid'])
+            if not ip: continue
+            ip_alarms[ip].append({'sev': int(p['severity']), 'name': p['name'], 'clock': int(p['clock']), 'ack': p['acknowledged'] == '1'})
+    for ip, av in avail.items():
+        if av == '2':
+            ip_alarms[ip].append({'sev': 4, 'name': 'SNMP unreachable / host unavailable', 'clock': now, 'ack': False})
+
+    # ---------- terminals (with history for offline detection) ----------
+    prev_term = {}
+    try:
+        prev = json.load(open(OUT, encoding='utf-8'))
+        for t in prev.get('terminals', []):
+            prev_term[t['mac']] = t
+    except Exception:
+        pass
+    seen_mac = set(); terminals = []
+    for t in eps:
+        mac = (t['mac'] or '').lower()
+        if not mac: continue
+        seen_mac.add(mac)
+        st = pmap.get((t['switch_ip'], t['port']), {})
+        oper = st.get('oper_status', '')
+        try: mc = int(st.get('mac_count', '0') or 0)
+        except: mc = 0
+        online = (oper == 'up' and mc > 0)
+        terminals.append({'mac': mac, 'ip': t['ip'], 'name': t['name'], 'sw_ip': t['switch_ip'],
+                          'sw_name': norm(t['switch_name']), 'port': t['port'], 'alias': t.get('port_alias', ''),
+                          'speed': st.get('speed_mbps', ''), 'online': online,
+                          'last_seen': now if online else (prev_term.get(mac, {}).get('last_seen') or None)})
+    # carry previously-known but now-absent terminals as offline
+    for mac, pt in prev_term.items():
+        if mac not in seen_mac:
+            nt = dict(pt); nt['online'] = False
+            terminals.append(nt)
+
+    # terminal counts per switch
+    tcnt = collections.defaultdict(lambda: {'online': 0, 'offline': 0})
+    for t in terminals:
+        c = tcnt[t['sw_ip']]
+        c['online' if t['online'] else 'offline'] += 1
+
+    # ---------- nodes ----------
+    nodes = []
+    for ip in sorted(nodes_ip, key=lambda x: (depth.get(x, 99), x)):
+        al = ip_alarms.get(ip, [])
+        maxsev = max([a['sev'] for a in al], default=-1)
+        nodes.append({
+            'id': ip, 'name': sname.get(ip, ip), 'ip': ip, 'tier': tier(ip), 'depth': depth.get(ip, 99),
+            'hostid': hid_by_ip.get(ip),
+            'oper': 'down' if avail.get(ip) == '2' else 'up',
+            'alarms': sorted(al, key=lambda a: -a['sev']), 'maxsev': maxsev,
+            'term_online': tcnt[ip]['online'], 'term_offline': tcnt[ip]['offline'],
+            'latency_ms': lat.get(ip),
+            'children': len(children.get(ip, []))
+        })
+
+    # ---------- edges ----------
+    edges = []
+    for k, d in pair.items():
+        a, b = k
+        p = d['ports']
+        pa = p.get(a, {}); pb = p.get(b, {})
+        a_port = pa.get('down') or pa.get('up') or ''
+        b_port = pb.get('up') or pb.get('down') or ''
+        speeds = []
+        for ip, port in ((a, a_port), (b, b_port)):
+            row = pmap.get((ip, port), {})
+            try: sp = int(row.get('speed_mbps', '0') or 0)
+            except: sp = 0
+            speeds.append(sp)
+        speed = max(speeds) if speeds else 0
+        ops = []
+        for ip, port in ((a, a_port), (b, b_port)):
+            row = pmap.get((ip, port))
+            ops.append(row.get('oper_status') if row else None)
+        state = 'down' if ('down' in ops) else 'up'
+        edges.append({'source': a, 'target': b, 'speed': speed, 'state': state,
+                      'a_port': a_port, 'b_port': b_port})
+
+    stats = {
+        'switches': len(nodes), 'edges': len(edges),
+        'term_total': len(terminals), 'term_online': sum(1 for t in terminals if t['online']),
+        'term_offline': sum(1 for t in terminals if not t['online']),
+        'alarms': sum(len(n['alarms']) for n in nodes),
+        'devices_alarm': sum(1 for n in nodes if n['alarms']),
+        'maxsev': max([n['maxsev'] for n in nodes], default=-1)
+    }
+    in_nodes = set(nodes_ip)
+    other = []
+    for ip, al in ip_alarms.items():
+        if ip in in_nodes:
+            continue
+        for a in al:
+            other.append({'ip': ip, 'host': ip_name.get(ip, ip), 'sev': a['sev'], 'name': a['name'], 'clock': a['clock']})
+    other.sort(key=lambda x: -x['sev'])
+    stats['other_alarms'] = len(other)
+    snap = {'generated': now, 'generated_iso': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
+            'refresh_default': 300, 'roots': roots, 'stats': stats,
+            'other_alarms': other,
+            'nodes': nodes, 'edges': edges, 'terminals': terminals}
+    os.makedirs(os.path.dirname(OUT), exist_ok=True)
+    tmp = OUT + '.tmp'
+    with open(tmp, 'w', encoding='utf-8') as f:
+        json.dump(snap, f, ensure_ascii=False, separators=(',', ':'))
+    os.replace(tmp, OUT)
+    print('snapshot', OUT, os.path.getsize(OUT), 'bytes;', stats)
+    if os.environ.get('L2_WRITE_HISTORY', '0') == '1':
+        try:
+            write_history(snap, now)
+        except Exception as e:
+            print('history write error:', e)
+
+if __name__ == '__main__':
+    main()

+ 47 - 0
collector/l2cfg.py

@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+"""Shared helper for the L2 collectors: read the module config, resolve the
+selected templates to template ids, and list the hosts that should be collected.
+"""
+import os
+import json
+
+DEFAULT_PATHS = [
+    os.environ.get('L2_CONFIG') or '',
+    '/opt/zabbix/zabbix-server/web-modules/WecomCenter/assets/l2topology/config.json',
+    '/usr/share/zabbix/modules/WecomCenter/assets/l2topology/config.json',
+    os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json'),
+]
+
+
+def config():
+    for p in DEFAULT_PATHS:
+        if p and os.path.isfile(p):
+            try:
+                with open(p, encoding='utf-8') as f:
+                    return json.load(f)
+            except Exception:
+                pass
+    return {}
+
+
+def template_ids(call):
+    """Resolve configured template names -> [templateid]. Empty list => no filter."""
+    cfg = config()
+    names = [str(n).strip() for n in (cfg.get('templates') or []) if str(n).strip()]
+    if not names:
+        return []
+    try:
+        tpls = call('template.get', {'output': ['templateid', 'name']})['result']
+    except Exception:
+        return []
+    want = {n.lower() for n in names}
+    return [t['templateid'] for t in tpls if t.get('name', '').strip().lower() in want]
+
+
+def hosts(call, output=None, interfaces='extend'):
+    """host.get restricted to the configured templates (when resolvable)."""
+    p = {'output': output or ['hostid', 'name'], 'selectInterfaces': interfaces}
+    tids = template_ids(call)
+    if tids:
+        p['templateids'] = tids
+    return call('host.get', p)['result']

+ 61 - 0
collector/latency_collect.py

@@ -0,0 +1,61 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""Ping every collected switch's management IP in parallel.
+Zabbix host -> switch latency is the only latency that can be measured without
+touching device config, so this is what drives the node latency colouring.
+Writes /tmp/latency.tsv  ->  <ip>\\t<avg_ms or empty>
+"""
+import json
+import re
+import subprocess
+import concurrent.futures as cf
+import urllib.request
+
+import l2cfg
+
+TOK = 'd3abb125eb301a021ef80a2847a803a7cd2d0b1d3c8337a14d09db9d571369b5'
+API = 'http://192.168.87.20:8080/api_jsonrpc.php'
+OUT = '/tmp/latency.tsv'
+
+
+def call(m, p):
+    d = json.dumps({"jsonrpc": "2.0", "method": m, "params": p, "id": 1}).encode()
+    q = urllib.request.Request(API, data=d, headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + TOK})
+    return json.loads(urllib.request.urlopen(q, timeout=60).read().decode())
+
+
+def ping(ip):
+    try:
+        r = subprocess.run(['ping', '-c', '3', '-W', '1', '-q', ip],
+                           capture_output=True, text=True, timeout=10)
+        m = re.search(r'=\s*[\d.]+/([\d.]+)/', r.stdout)
+        if m:
+            return ip, float(m.group(1))
+        m = re.search(r'time=([\d.]+)\s*ms', r.stdout)
+        if m:
+            return ip, float(m.group(1))
+    except Exception:
+        pass
+    return ip, None
+
+
+def main():
+    hs = l2cfg.hosts(call)
+    ips = sorted({i['ip'] for h in hs for i in (h.get('interfaces') or [])
+                  if i.get('ip') and i.get('type') == '2'})
+    if not ips:
+        ips = sorted({i['ip'] for h in hs for i in (h.get('interfaces') or []) if i.get('ip')})
+    res = {}
+    with cf.ThreadPoolExecutor(max_workers=40) as ex:
+        for ip, v in ex.map(ping, ips):
+            res[ip] = v
+    with open(OUT, 'w', encoding='utf-8') as f:
+        for ip in ips:
+            v = res.get(ip)
+            f.write('%s\t%s\n' % (ip, '' if v is None else '%.3f' % v))
+    ok = sum(1 for v in res.values() if v is not None)
+    print('latency: %d/%d reachable' % (ok, len(ips)))
+
+
+if __name__ == '__main__':
+    main()

+ 131 - 0
collector/port_status.py

@@ -0,0 +1,131 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# Read-only port status collector: IF-MIB oper/admin/speed + FDB learned MAC per port.
+import json, urllib.request, subprocess, re, sys, collections
+import concurrent.futures as cf
+import l2cfg
+
+TOK = 'd3abb125eb301a021ef80a2847a803a7cd2d0b1d3c8337a14d09db9d571369b5'
+API = 'http://192.168.87.20:8080/api_jsonrpc.php'
+def call(m, p):
+    d = json.dumps({"jsonrpc": "2.0", "method": m, "params": p, "id": 1}).encode()
+    q = urllib.request.Request(API, data=d, headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + TOK})
+    return json.loads(urllib.request.urlopen(q, timeout=60).read().decode())
+AUTH = {0: 'MD5', 1: 'SHA', 2: 'SHA-224', 3: 'SHA-256', 4: 'SHA-384', 5: 'SHA-512'}
+PRIV = {0: 'DES', 1: 'AES', 2: 'AES-192', 3: 'AES-256', 4: 'AES', 5: 'AES-256'}
+
+def creds(i):
+    det = i.get('details') or {}; ver = str(det.get('version', '1'))
+    if ver == '3':
+        lvl = int(det.get('securitylevel', 0))
+        a = ['-v3', '-l', {0: 'noAuthNoPriv', 1: 'authNoPriv', 2: 'authPriv'}.get(lvl, 'authPriv'), '-u', det.get('securityname', '')]
+        if lvl >= 1: a += ['-a', AUTH.get(int(det.get('authprotocol', 0)), 'SHA'), '-A', det.get('authpassphrase', '')]
+        if lvl >= 2: a += ['-x', PRIV.get(int(det.get('privprotocol', 0)), 'AES'), '-X', det.get('privpassphrase', '')]
+        return a
+    return ['-v2c', '-c', det.get('community', 'public')]
+
+def walk(args, ip, oid):
+    try:
+        r = subprocess.run(['snmpbulkwalk', '-On'] + args + ['-t', '3', '-r', '1', ip, oid], capture_output=True, text=True, timeout=40)
+        return [l for l in r.stdout.splitlines() if ' = ' in l and 'No Such' not in l]
+    except Exception:
+        return []
+def num(v):
+    m = re.search(r'-?\d+', v); return int(m.group()) if m else None
+def strval(v):
+    return v.split(':', 1)[1].strip().strip('"') if ':' in v else v.strip()
+
+O = {
+ 'ifIndex':'1.3.6.1.2.1.2.2.1.1','ifDescr':'1.3.6.1.2.1.2.2.1.2','ifType':'1.3.6.1.2.1.2.2.1.3',
+ 'ifSpeed':'1.3.6.1.2.1.2.2.1.5','ifAdmin':'1.3.6.1.2.1.2.2.1.7','ifOper':'1.3.6.1.2.1.2.2.1.8',
+ 'ifLastChange':'1.3.6.1.2.1.2.2.1.9','ifName':'1.3.6.1.2.1.31.1.1.1.1','ifHighSpeed':'1.3.6.1.2.1.31.1.1.1.15',
+ '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',
+}
+def parse(lines, base):
+    out = []
+    for ln in lines:
+        l, r = ln.split(' = ', 1); o = l.strip().lstrip('.')
+        if o.startswith(base): out.append((o[len(base):].lstrip('.'), r.strip()))
+    return out
+def collect(t):
+    ip, nm, iface = t; args = creds(iface)
+    d = {'ip': ip, 'name': nm, 'ok': False}
+    idx = [n for s, v in parse(walk(args, ip, O['ifIndex']), O['ifIndex']) for n in [num(s.split('.')[-1])] if n is not None]
+    if not idx:
+        # fall back to ifName walk presence
+        fn = parse(walk(args, ip, O['ifName']), O['ifName'])
+        if not fn: return d
+    def m1(key):
+        return {num(s.split('.')[-1]): strval(v) for s, v in parse(walk(args, ip, O[key]), O[key])}
+    def n1(key):
+        return {num(s.split('.')[-1]): num(strval(v)) for s, v in parse(walk(args, ip, O[key]), O[key])}
+    ifname = m1('ifName'); ifdescr = m1('ifDescr'); ifalias = m1('ifAlias')
+    iftype = n1('ifType'); ifadmin = n1('ifAdmin'); ifoper = n1('ifOper')
+    ifspeed = n1('ifSpeed'); ifhigh = n1('ifHighSpeed'); lastchg = n1('ifLastChange')
+    conn = n1('ifConnector'); dup = n1('duplex')
+    # 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'])}
+    mac_cnt = collections.Counter(); mac_by_if = collections.defaultdict(list)
+    for s, v in parse(walk(args, ip, O['fdbPort']), O['fdbPort']):
+        port = num(v)
+        if port is None: continue
+        ifx = bpif.get(port)
+        if ifx is None: continue
+        mac_cnt[ifx] = mac_cnt.get(ifx, 0) + 1
+        if len(mac_by_if[ifx]) < 6:
+            parts = s.split('.')
+            macs = ':'.join('%02x' % int(p) for p in parts[-6:]) if len(parts) >= 6 else ''
+            mac_by_if[ifx].append(macs)
+    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()}})
+    return d
+
+def main():
+    hs = l2cfg.hosts(call, output=['hostid', 'name'])
+    tasks = []
+    for h in hs:
+        for i in h['interfaces']:
+            if i.get('type') == '2': tasks.append((i['ip'], h['name'], i)); break
+    res = []
+    with cf.ThreadPoolExecutor(max_workers=30) as ex:
+        for r in ex.map(collect, tasks): res.append(r)
+    ok = [r for r in res if r.get('ok')]
+    rows = []
+    for r in ok:
+        allif = set(r['ifname']) | set(r['ifdescr'])
+        for ifx in allif:
+            it = r['iftype'].get(ifx, 0)
+            admin = r['ifadmin'].get(ifx); oper = r['ifoper'].get(ifx)
+            sp = r['ifhigh'].get(ifx)
+            if sp is None or sp == 0:
+                s_bps = r['ifspeed'].get(ifx) or 0
+                sp = int(s_bps / 1000000) if s_bps else 0
+            rows.append([r['name'], r['ip'], str(ifx), r['ifname'].get(ifx, '') or r['ifdescr'].get(ifx, ''),
+                         r['ifalias'].get(ifx, ''), str(it),
+                         {1:'up',2:'down',3:'testing',4:'unknown',5:'dormant',6:'notPresent',7:'lowerLayerDown'}.get(admin, str(admin)),
+                         {1:'up',2:'down',3:'testing',4:'unknown',5:'dormant',6:'notPresent',7:'lowerLayerDown'}.get(oper, str(oper)),
+                         str(sp if sp is not None else ''),
+                         {1:'half',2:'full',3:'unknown'}.get(r['dup'].get(ifx), ''),
+                         str(r['conn'].get(ifx, '')), str(r['lastchg'].get(ifx, '')),
+                         str(r['mac_cnt'].get(ifx, 0)), ' '.join(r['mac_by_if'].get(ifx, [])[:3])])
+    with open('/tmp/port_status.tsv', 'w', encoding='utf-8') as f:
+        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')
+    # 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']
+    spd = collections.Counter(r[8] for r in up)
+    with open('/tmp/port_status_summary.txt', 'w', encoding='utf-8') as f:
+        f.write('switches_ok=%d/%d\n' % (len(ok), len(tasks)))
+        f.write('total_if=%d\n' % len(rows))
+        f.write('oper_up=%d oper_down=%d\n' % (len(up), sum(1 for r in rows if r[7] == 'down')))
+        f.write('up_speed_dist=%s\n' % dict(spd))
+        f.write('up_with_mac=%d\n' % sum(1 for r in rows if r[7]=='up' and int(r[12] or 0) > 0))
+    print(open('/tmp/port_status_summary.txt').read())
+if __name__ == '__main__':
+    main()

+ 5 - 0
collector/refresh_alarms.sh

@@ -0,0 +1,5 @@
+#!/bin/bash
+L=/opt/zabbix/l2collector
+exec 7>/tmp/l2_full.lock
+flock -n 7 || exit 0            # skip if a full collection is running
+flock -n /tmp/l2_snap.lock /usr/bin/python3 $L/l2_snapshot.py >> /var/log/l2collector.log 2>&1 || true

+ 13 - 0
collector/run_full.sh

@@ -0,0 +1,13 @@
+#!/bin/bash
+L=/opt/zabbix/l2collector
+LOG=/var/log/l2collector.log
+exec 9>/tmp/l2_full.lock
+flock -n 9 || { echo "[$(date -u +%FT%TZ)] skip full: already running" >> $LOG; exit 0; }
+echo "[$(date -u +%FT%TZ)] FULL start" >> $LOG
+/usr/bin/python3 $L/stp_collect.py     >> $LOG 2>&1 || echo "stp failed" >> $LOG
+/usr/bin/python3 $L/fdb_collect.py     >> $LOG 2>&1 || echo "fdb failed" >> $LOG
+/usr/bin/python3 $L/fdb_analyze3.py    >> $LOG 2>&1 || echo "analyze failed" >> $LOG
+/usr/bin/python3 $L/port_status.py     >> $LOG 2>&1 || echo "port failed" >> $LOG
+/usr/bin/python3 $L/latency_collect.py >> $LOG 2>&1 || echo "latency failed" >> $LOG
+L2_WRITE_HISTORY=1 flock /tmp/l2_snap.lock /usr/bin/python3 $L/l2_snapshot.py >> $LOG 2>&1 || echo "snapshot failed" >> $LOG
+echo "[$(date -u +%FT%TZ)] FULL done" >> $LOG

+ 111 - 0
collector/stp_collect.py

@@ -0,0 +1,111 @@
+import json,urllib.request,subprocess,re,collections
+import concurrent.futures as cf
+import l2cfg
+TOK='d3abb125eb301a021ef80a2847a803a7cd2d0b1d3c8337a14d09db9d571369b5'
+API='http://192.168.87.20:8080/api_jsonrpc.php'
+def call(m,p):
+    d=json.dumps({"jsonrpc":"2.0","method":m,"params":p,"id":1}).encode()
+    q=urllib.request.Request(API,data=d,headers={'Content-Type':'application/json','Authorization':'Bearer '+TOK})
+    return json.loads(urllib.request.urlopen(q,timeout=60).read().decode())
+AUTH={0:'MD5',1:'SHA',2:'SHA-224',3:'SHA-256',4:'SHA-384',5:'SHA-512'};PRIV={0:'DES',1:'AES',2:'AES-192',3:'AES-256',4:'AES',5:'AES-256'}
+hs=l2cfg.hosts(call, output=['hostid','name'])
+def creds(i):
+    det=i.get('details') or {};ver=str(det.get('version','1'))
+    if ver=='3':
+        lvl=int(det.get('securitylevel',0));a=['-v3','-l',{0:'noAuthNoPriv',1:'authNoPriv',2:'authPriv'}.get(lvl,'authPriv'),'-u',det.get('securityname','')]
+        if lvl>=1:a+=['-a',AUTH.get(int(det.get('authprotocol',0)),'SHA'),'-A',det.get('authpassphrase','')]
+        if lvl>=2:a+=['-x',PRIV.get(int(det.get('privprotocol',0)),'AES'),'-X',det.get('privpassphrase','')]
+        return a
+    return ['-v2c','-c',det.get('community','public')]
+tasks=[]
+for h in hs:
+    for i in h['interfaces']:
+        if i.get('type')=='2': tasks.append((i['ip'],h['name'],i));break
+def walk(args,ip,oid):
+    try:
+        r=subprocess.run(['snmpbulkwalk','-On']+args+['-t','3','-r','1',ip,oid],capture_output=True,text=True,timeout=40)
+        return [l for l in r.stdout.splitlines() if ' = ' in l and 'No Such' not in l]
+    except: return []
+def num(v):
+    m=re.search(r'-?\d+',v);return int(m.group()) if m else None
+def hexmac(v):
+    # value like Hex-STRING: 80 00 6C 71 D2 10 F6 00
+    if ':' in v: v=v.split(':',1)[1]
+    parts=re.findall(r'[0-9A-Fa-f]{2}',v)
+    return [int(p,16) for p in parts]
+def mac(p): return ':'.join('%02x'%b for b in p)
+def strval(v):
+    return v.split(':',1)[1].strip().strip('"') if ':' in v else v.strip()
+def parse(lines,base):
+    out=[]
+    for ln in lines:
+        l,r=ln.split(' = ',1);o=l.strip().lstrip('.')
+        if o.startswith(base): out.append((o[len(base):].lstrip('.'),r.strip()))
+    return out
+def collect(t):
+    ip,nm,iface=t;args=creds(iface)
+    d={'ip':ip,'name':nm,'ok':False}
+    bb=walk(args,ip,'1.3.6.1.2.1.17.1.1.0')
+    if not bb: return d
+    hb=hexmac(bb[0].split(' = ',1)[1])
+    selfmac=mac(hb[-6:]) if len(hb)>=6 else ''
+    d['selfmac']=selfmac
+    bp={}
+    for suf,val in parse(walk(args,ip,'1.3.6.1.2.1.17.1.4.1.2'),'1.3.6.1.2.1.17.1.4.1.2'):
+        bp[num(suf.split('.')[-1])]=num(val)
+    ifn={}
+    for suf,val in parse(walk(args,ip,'1.3.6.1.2.1.31.1.1.1.1'),'1.3.6.1.2.1.31.1.1.1.1'):
+        ifn[num(suf.split('.')[-1])]=strval(val)
+    st={}
+    for suf,val in parse(walk(args,ip,'1.3.6.1.2.1.17.2.15.1.3'),'1.3.6.1.2.1.17.2.15.1.3'):
+        st[num(suf.split('.')[-1])]=num(val)
+    db={}
+    for suf,val in parse(walk(args,ip,'1.3.6.1.2.1.17.2.15.1.8'),'1.3.6.1.2.1.17.2.15.1.8'):
+        b=hexmac(val)
+        if len(b)>=8: db[num(suf.split('.')[-1])]=(mac(b[2:8]), b[0]*256+b[1])
+    dp={}
+    for suf,val in parse(walk(args,ip,'1.3.6.1.2.1.17.2.15.1.9'),'1.3.6.1.2.1.17.2.15.1.9'):
+        b=hexmac(val)
+        if len(b)>=2: dp[num(suf.split('.')[-1])]=b[-1]
+    d.update({'ok':True,'bp':bp,'ifn':ifn,'st':st,'db':db,'dp':dp})
+    return d
+res=[]
+with cf.ThreadPoolExecutor(max_workers=30) as ex:
+    for r in ex.map(collect,tasks): res.append(r)
+ok=[r for r in res if r.get('ok') and r.get('selfmac')]
+sw_by_mac={r['selfmac']:r for r in ok}
+print("devices=",len(tasks)," ok_with_stp=",len(ok)," no_bridge=",len(tasks)-len(ok))
+edges=[];seen=set()
+for r in ok:
+    for port,(dbmac,dbpri) in r['db'].items():
+        s=r['st'].get(port,0)
+        nb=sw_by_mac.get(dbmac)
+        if nb is None or dbmac==r['selfmac']: continue
+        if s not in (4,5): continue
+        dpn=r['dp'].get(port)
+        # our side port name
+        ifx=r['bp'].get(port); myport=r['ifn'].get(ifx,'brport%d'%port)
+        # neighbor side port from designated port number
+        nbifx=nb['bp'].get(dpn); nbport=nb['ifn'].get(nbifx,'brport%s'%dpn) if dpn is not None else '?'
+        key=(r['selfmac'],dbmac,dpn)
+        if key in seen: continue
+        seen.add(key)
+        edges.append([r['name'],r['ip'],myport,r['st'].get(port,''),nb['name'],nb['ip'],nbport,str(dpn)])
+# root detection: switches never appear as downstream with a parent? compute which macs never have a parent (their all desig=self)
+has_parent=set(e[1] for e in edges)  # nb mac (parent) appears as to_switch ip
+# degree
+deg=collections.Counter()
+for e in edges: deg[e[1]]+=1;deg[e[5]]+=1
+print("stp_edges=",len(edges))
+print("degree_dist=",dict(sorted(collections.Counter(deg.values()).items())))
+print("\n-- sample 60 edges --")
+for e in edges[:60]:
+    print(f"  {e[0][:38]:38s}[{e[2]:14s} s{e[3]}] --> {e[4][:34]:34s}[{e[6]:14s}] dp={e[7]}")
+with open('/tmp/stp_topology.tsv','w',encoding='utf-8') as f:
+    f.write('down_switch\tdown_ip\tdown_port\tdown_state\tup_switch\tup_ip\tup_port\tdesig_port\n')
+    for e in edges: f.write('\t'.join(map(str,e))+'\n')
+# switches with no STP edges
+withe=set([e[1] for e in edges]+[e[5] for e in edges])
+noedge=[r for r in ok if r['ip'] not in withe]
+print("\nswitches_with_no_STP_edge=",len(noedge))
+for r in noedge[:15]: print("   ",r['name'],r['ip'])

+ 15 - 1
docker-compose.yaml

@@ -163,8 +163,22 @@ services:
         SELECT @nid, 'wecom_center', 'modules/WecomCenter', 1, '[]'
         FROM DUAL
         WHERE NOT EXISTS (SELECT 1 FROM module WHERE id = 'wecom_center');
+        CREATE TABLE IF NOT EXISTS l2_topology_history (
+          id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+          ts BIGINT UNSIGNED NOT NULL,
+          captured_at DATETIME NOT NULL,
+          stats TEXT NOT NULL,
+          snapshot MEDIUMBLOB NOT NULL,
+          PRIMARY KEY (id), KEY idx_l2_ts (ts)
+        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
+        CREATE TABLE IF NOT EXISTS l2_topology_config (
+          id TINYINT UNSIGNED NOT NULL,
+          config LONGTEXT NOT NULL,
+          updated DATETIME NOT NULL,
+          PRIMARY KEY (id)
+        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
         SQL
-        echo "[wecom-init] module 'wecom_center' ensured (enabled)."
+        echo "[wecom-init] module 'wecom_center' ensured (enabled) + L2 tables ensured."
     networks:
       - zabbix
     logging:

+ 74 - 0
docs/使用说明.md

@@ -208,3 +208,77 @@ zabbix-deploy/
 # 用 API token 导出媒介/动作/宏,保存为 JSON,需要时用对应 API 还原
 ```
 页面「预览」永远是 dry-run,先看影响面再应用,是最稳的做法。
+
+---
+
+## 10. L2 物理拓扑(WecomCenter 模块内置)
+
+### 10.1 访问入口
+- 展示页:**Monitoring → L2 Topology**,或直接
+  `http://192.168.87.20:8080/zabbix.php?action=l2.topology.view`
+- 配置页:**Administration → ROMAN 定制中心 → 「L2 拓扑」标签**(模块改名为“ROMAN 定制中心”,
+  配置项全部集中在配置页,展示页只读)。
+
+### 10.2 展示页功能
+- **链路速率配色**(可在配置页改):
+  - 100M = 橙色 `#F39C12`,1G = 绿色 `#2ECC71`,10G = 蓝色 `#3498DB`,
+    20G+ = 紫色 `#9B59B6`,未知 = 红色 `#E74C3C`,中断 = 红色虚线 `#C0392B`。
+- **延迟着色**:默认 ping 交换机管理 IP(Zabbix 主机 → 交换机),
+  >3ms 橙色、>5ms 红色(阈值可配)。注:本网实测 0.8ms 左右,绝大多数为正常(无色)。
+- **告警交换机**:节点边框按最高告警等级红色高亮(右侧「告警」面板可点击定位)。
+- **分组框**:最外层 `COBCO` 大框,内部按配置规则的「车间/DataCenter/Core」子框;
+  规则按顺序匹配(contains / regex),可用 `$1..$9` 反向引用;未命中归入 Other。
+- **节点标签**:`车间 · 区域 · 楼层机柜U` + `角色 · IP` 两行,宽度加大、对比度提高,便于快速找设备
+  (原名鼠标悬停显示;双击节点跳转该主机的问题页)。
+- **显示终端 / 只看离线**:勾选「显示终端」在交换机下方展开终端点(绿=在线、灰=离线);
+  「只看离线」仅显示离线终端。右侧「离线终端」面板点击可联动定位。
+- **时间轴**:右上「快照时间」下拉可选 `实时` 或历史某分钟快照(保存 **7 天**,5 分钟一个点)。
+- **深色模式**:工具栏「深色模式」开关;配置页可设默认深色。
+- **搜索**:输入 IP / MAC / 名称,下拉结果点击即居中定位。
+- 实时模式按配置的刷新秒数自动刷新,可暂停;支持缩放/适应窗口。
+
+### 10.3 数据与存储
+- 采集结果 → `assets/l2topology/snapshot.json`(展示页实时读取)。
+- 每次**全量采集**同时把快照 gzip 后写入 MySQL 表 `l2_topology_history`
+  (字段 ts、captured_at、stats、snapshot),并自动清理超过 `history_days`(默认7) 天的旧数据。
+- 配置存 `l2_topology_config`(单行 id=1),并镜像到 `assets/l2topology/config.json` 供采集脚本读取。
+- 两张表由 `L2Store::ensureSchema()` 在页面加载时自动 `CREATE TABLE IF NOT EXISTS`;
+  `docker-compose.yaml` 的 `wecom-module-init` 一次性容器也会在 `up` 时建表,全新环境零手工。
+
+### 10.4 采集器(宿主机运行)
+采集器代码位于仓库 `collector/`,安装到 `/opt/zabbix/l2collector/`:
+```
+stp_collect.py        # STP 指定桥 + MAC 学习 -> 交换机级联关系
+fdb_collect.py        # ARP + BRIDGE-MIB FDB + ifName -> 终端/链路
+fdb_analyze3.py       # 归并出链路与终端清单
+port_status.py        # IF-MIB 端口 up/down/速率/每口 MAC 数
+latency_collect.py    # 并发 ping 交换机管理 IP -> /tmp/latency.tsv
+l2_snapshot.py        # 合成 snapshot.json + 写历史库(仅全量时)
+l2cfg.py              # 读取 config.json,按模板过滤要采集的主机
+run_full.sh           # 全量:stp->fdb->analyze->port->latency->snapshot(写历史)
+refresh_alarms.sh     # 仅刷新告警(每分钟,不写历史)
+```
+- **只采集配置页所选模板**(默认 `Huawei VRP by SNMP (walk)`,id 10962)的主机,避免误采其它设备。
+- 安装(一次性,幂等):
+  ```bash
+  cd /opt/zabbix/l2collector && chmod +x run_full.sh refresh_alarms.sh
+  { crontab -l 2>/dev/null | grep -v l2collector || true
+    echo '*/5 * * * * /bin/bash /opt/zabbix/l2collector/run_full.sh >/dev/null 2>&1'
+    echo '* * * * * /bin/bash /opt/zabbix/l2collector/refresh_alarms.sh >/dev/null 2>&1'
+  } | crontab -
+  ```
+- 手动跑一次全量:`bash /opt/zabbix/l2collector/run_full.sh`,日志 `/var/log/l2collector.log`。
+
+### 10.5 docker-compose 一键启用
+- `docker-compose.yaml` 已把 `web-modules/` 挂进 `zabbix-web` 容器,
+  并由 `wecom-module-init` 一次性容器自动登记(启用)模块、创建 L2 两张表。
+  全新机器 `docker compose up -d` 后插件即已启用,无需手工去「Administration → Modules」点启用。
+- 若 `web-modules` 目录不在 compose 同级,用 `.env` 的 `WEB_MODULES_DIR` 指向绝对路径。
+- 采集器为宿主机进程(需 SNMP 工具、且要读交换机网络),不在容器内运行,按 10.4 安装。
+
+### 10.6 故障排查
+- 展示页空白:确认 `assets/l2topology/snapshot.json` 存在(跑一次 `run_full.sh`)。
+- 时间轴没有历史:只有**全量采集**写历史;确认 cron 在跑,`l2_topology_history` 行数在增长。
+- 交换机没被采集:检查配置页模板是否勾选正确、该主机是否挂了该模板、SNMP 可达。
+- 颜色/阈值不生效:改完配置页保存后,`config.json` 会同步;采集侧下个周期生效。
+

+ 21 - 7
web-modules/WecomCenter/Module.php

@@ -1,8 +1,9 @@
 <?php declare(strict_types = 0);
 /*
-** WeCom Center - a portable, multi-language Zabbix frontend module.
-** Adds an administration page for alert optimization, WeCom robot/key/media
-** configuration, template import and map coordinate adjustment.
+** WeCom Center / ROMAN 定制中心 - a portable, multi-language Zabbix module.
+** Adds a configuration center for alert optimisation, WeCom robot/key/media,
+** template import, map coordinates, and the L2 physical topology feature
+** (DB-backed history, configurable collection & display, live web map).
 */
 
 namespace Modules\WecomCenter;
@@ -12,24 +13,37 @@ use APP;
 use CMenuItem;
 use CView;
 use Modules\WecomCenter\Lib\I18n;
+use Modules\WecomCenter\Lib\L2Store;
 
 class Module extends CModule {
 
 	public function init(): void {
 		// Register this module's own view directory so CView can find:
-		//   modules/WecomCenter/views/wecom.center.view.php
+		//   modules/WecomCenter/views/wecom.center.view.php, topology.view.php
 		CView::registerDirectory($this->getRelativePath().'/views');
 
 		// Load translations for the current language (falls back to English).
 		I18n::init($this->getRelativePath().'/locale');
 
-		// Add "WeCom Center" as a submenu item under Administration.
-		APP::Component()->get('menu.main')
-			->findOrAdd(_('Administration'))
+		// Make sure the L2 tables exist (idempotent, never fatal).
+		L2Store::ensureSchema();
+
+		$menu = APP::Component()->get('menu.main');
+
+		// Configuration center under Administration.
+		$menu->findOrAdd(_('Administration'))
 			->getSubMenu()
 			->add(
 				(new CMenuItem(I18n::t('menu.title')))
 					->setAction('wecom.center.view')
 			);
+
+		// L2 physical topology under Monitoring.
+		$menu->findOrAdd(_('Monitoring'))
+			->getSubMenu()
+			->add(
+				(new CMenuItem(I18n::t('menu.l2')))
+					->setAction('l2.topology.view')
+			);
 	}
 }

+ 50 - 0
web-modules/WecomCenter/actions/L2TopologyView.php

@@ -0,0 +1,50 @@
+<?php declare(strict_types = 0);
+/*
+** L2 Topology - GET controller.
+** Renders either the live snapshot or any stored snapshot chosen by ?ts=<epoch>.
+*/
+
+namespace Modules\WecomCenter\Actions;
+
+use CController;
+use CControllerResponseData;
+use CWebUser;
+use Modules\WecomCenter\Lib\L2Store;
+
+class L2TopologyView extends CController {
+
+	protected function init() {
+		$this->disableCsrfValidation();
+	}
+
+	protected function checkInput() {
+		return true;
+	}
+
+	protected function checkPermissions() {
+		return CWebUser::getType() >= USER_TYPE_ZABBIX_USER;
+	}
+
+	protected function doAction() {
+		L2Store::ensureSchema();
+		$cfg = L2Store::getConfig();
+
+		$ts_raw = $_REQUEST['ts'] ?? '';
+		$ts = ($ts_raw === '' || $ts_raw === null) ? null : (int) $ts_raw;
+
+		$snap = L2Store::snapshotAt($ts);
+		$json = $snap['json'] ?? null;
+		$used_ts = $snap['ts'];
+
+		$this->setResponse(new CControllerResponseData([
+			'title'      => _('L2 Topology'),
+			'snap_json'  => ($json !== null && $json !== '') ? $json : 'null',
+			'cfg_json'   => json_encode($cfg, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+			'history'    => L2Store::listHistory(500),
+			'used_ts'    => $used_ts,
+			'is_live'    => ($ts === null),
+			'cfg_refresh'=> (int) $cfg['refresh_seconds'],
+			'cfg_dark'   => !empty($cfg['dark_default'])
+		]));
+	}
+}

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

@@ -12,6 +12,7 @@ use CWebUser;
 use Exception;
 use Modules\WecomCenter\Lib\Helper;
 use Modules\WecomCenter\Lib\I18n;
+use Modules\WecomCenter\Lib\L2Store;
 
 class WecomCenterSave extends CController {
 
@@ -47,6 +48,7 @@ class WecomCenterSave extends CController {
 					case 'save_map':        $this->saveMap();               break;
 					case 'apply_switch':    $this->applySwitch();           break;
 					case 'init_env':        $this->initEnv();               break;
+					case 'save_l2':         $this->saveL2();                break;
 					default: error(I18n::t('msg.unknown_op'));
 				}
 			}
@@ -60,7 +62,7 @@ class WecomCenterSave extends CController {
 			$url->setArgument('mediatypeid', $mediatypeid);
 		}
 		$tab = (string) ($_REQUEST['tab'] ?? '');
-		if (in_array($tab, ['alert', 'opt', 'maint'], true)) {
+		if (in_array($tab, ['alert', 'opt', 'maint', 'l2'], true)) {
 			$url->setArgument('tab', $tab);
 			if ($tab === 'maint') {
 				$ts = (string) ($_REQUEST['trig_search'] ?? '');
@@ -238,4 +240,60 @@ class WecomCenterSave extends CController {
 		}
 		info(I18n::t('msg.init_done', [implode(' · ', $parts)]));
 	}
+
+	private function saveL2(): void {
+		$cfg = L2Store::getConfig();
+
+		// templates (multi-select)
+		$tpls = $_REQUEST['templates'] ?? [];
+		if (is_array($tpls)) {
+			$tpls = array_values(array_filter(array_map('strval', $tpls), function ($s) { return trim($s) !== ''; }));
+			$cfg['templates'] = $tpls;
+		}
+
+		$cfg['refresh_seconds']    = max(30, (int) ($_REQUEST['refresh_seconds'] ?? $cfg['refresh_seconds']));
+		$cfg['full_interval_min']  = max(1, (int) ($_REQUEST['full_interval_min'] ?? $cfg['full_interval_min']));
+		$cfg['alarm_interval_min'] = max(1, (int) ($_REQUEST['alarm_interval_min'] ?? $cfg['alarm_interval_min']));
+		$cfg['root_label']         = trim((string) ($_REQUEST['root_label'] ?? $cfg['root_label']));
+		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']);
+		$cfg['latency_warn']       = (float) ($_REQUEST['latency_warn'] ?? $cfg['latency_warn']);
+		$cfg['latency_bad']        = (float) ($_REQUEST['latency_bad'] ?? $cfg['latency_bad']);
+
+		$colors = $cfg['colors'];
+		foreach (['100', '1000', '10000', '20000', 'unknown', 'down'] as $k) {
+			$v = trim((string) ($_REQUEST['color_'.$k] ?? ''));
+			if ($v !== '' && preg_match('/^#[0-9a-fA-F]{6}$/', $v)) {
+				$colors[$k] = $v;
+			}
+		}
+		$cfg['colors'] = $colors;
+
+		// grouping rules (parallel arrays)
+		$rm = $_REQUEST['rule_match']   ?? [];
+		$rp = $_REQUEST['rule_pattern'] ?? [];
+		$rl = $_REQUEST['rule_label']   ?? [];
+		$rb = $_REQUEST['rule_big']     ?? [];
+		$rules = [];
+		if (is_array($rm)) {
+			$n = count($rm);
+			for ($i = 0; $i < $n; $i++) {
+				$pat = trim((string) ($rp[$i] ?? ''));
+				$lab = trim((string) ($rl[$i] ?? ''));
+				if ($pat === '' || $lab === '') { continue; }
+				$rules[] = [
+					'match'   => ((string) ($rm[$i] ?? 'contains') === 'regex') ? 'regex' : 'contains',
+					'pattern' => $pat,
+					'label'   => $lab,
+					'big'     => !empty($rb[$i])
+				];
+			}
+		}
+		$cfg['group_rules'] = $rules;
+
+		L2Store::saveConfig($cfg);
+		info(I18n::t('msg.l2_saved'));
+	}
 }

+ 15 - 0
web-modules/WecomCenter/actions/WecomCenterView.php

@@ -10,6 +10,7 @@ use CControllerResponseData;
 use CWebUser;
 use Modules\WecomCenter\Lib\Helper;
 use Modules\WecomCenter\Lib\I18n;
+use Modules\WecomCenter\Lib\L2Store;
 
 class WecomCenterView extends CController {
 
@@ -106,8 +107,22 @@ class WecomCenterView extends CController {
 			$switch_preview = Helper::switchPreview($switch_src, $switch_dst);
 		}
 
+		// ---- L2 topology configuration ----
+		L2Store::ensureSchema();
+		$l2 = L2Store::getConfig();
+		$l2_templates = [];
+		try {
+			$tpls = \API::Template()->get(['output' => ['templateid', 'name'], 'sortfield' => 'name']);
+			foreach ($tpls as $tp) {
+				$l2_templates[] = $tp['name'];
+			}
+		}
+		catch (\Throwable $e) { /* ignore */ }
+
 		$data = [
 			'title'           => I18n::t('page.title'),
+			'l2'              => $l2,
+			'l2_templates'    => $l2_templates,
 			'mediatypes'      => $mediatypes,
 			'mediatypeid'     => $mediatypeid,
 			'mediatype'       => $mediatype,

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

@@ -0,0 +1,578 @@
+/* =====================================================================
+   L2 Physical Topology — display-only interactive map.
+   Groups switches into frames (COBCO outer → workshop/DataCenter/Core),
+   colours links by speed, nodes by latency & alarm, supports dark mode,
+   terminal show / only-offline, live + historical snapshots, MAC/IP search.
+   ===================================================================== */
+(function () {
+  'use strict';
+
+  var CFG = window.L2_CFG || {};
+  var T = window.L2_I18N || {};
+  var META = window.L2_META || {};
+  var SNAP = window.L2SNAP || null;
+
+  var colors = CFG.colors || {100:'#F39C12',1000:'#2ECC71',10000:'#3498DB',20000:'#9B59B6',unknown:'#E74C3C',down:'#C0392B'};
+  var LATW = (CFG.latency_warn != null) ? +CFG.latency_warn : 3;
+  var LATB = (CFG.latency_bad != null) ? +CFG.latency_bad : 5;
+
+  var TIER_BG = {core:'#1F4E78', dist:'#2E75B6', access:'#548235'};
+  var TIER_BG_DARK = {core:'#1B4A72', dist:'#2A5E93', access:'#3E6B2E'};
+  var SEV_COLORS = ['#97AAB3','#7499FF','#FFC859','#FFA059','#E97659','#E45959'];
+
+  var NODE_W = 190, NODE_H = 56, GX = 26, GY = 18, PADX = 20, PADY = 42;
+  var GROUP_GAP_X = 46, GROUP_GAP_Y = 48, MAXW = 2800;
+
+  var cy = null;
+  var dark = false;
+  var showTerm = false;
+  var onlyOff = false;
+  var paused = false;
+  var countdown = 0;
+  var timer = null;
+  var lastSnap = null;
+
+  function el(id) { return document.getElementById(id); }
+  function t(k, d) { return (T[k] != null) ? T[k] : (d != null ? d : k); }
+
+  /* ---------------- name parsing / grouping ---------------- */
+  function parseName(name) {
+    var r = {workshop:'', floor:'', cab:'', u:'', area:'', role:'', ip:''};
+    if (!name) return r;
+    var toks = String(name).split('-');
+    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.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; }
+    }
+    return r;
+  }
+
+  function labelFor(n) {
+    var p = parseName(n.name);
+    var l1 = [];
+    if (p.workshop) l1.push(p.workshop);
+    if (p.area) l1.push(p.area);
+    if (!l1.length) {
+      l1.push((n.name || '').split('-')[0] || n.ip);
+    }
+    var l2 = [];
+    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(' · ');
+  }
+
+  function expandLabel(tpl, m) {
+    return String(tpl).replace(/\$(\d)/g, function (_, d) { return m && m[+d] != null ? m[+d] : ''; });
+  }
+
+  function groupOf(n) {
+    var name = n.name || n.ip || '';
+    var rules = CFG.group_rules || [];
+    for (var i = 0; i < rules.length; i++) {
+      var r = rules[i];
+      if (r.match === 'regex') {
+        var re;
+        try { re = new RegExp(r.pattern); } catch (e) { continue; }
+        var m = re.exec(name);
+        if (m) return {label: expandLabel(r.label, m) || (CFG.other_label || 'Other'), big: !!r.big};
+      } else {
+        if (name.indexOf(r.pattern) >= 0) return {label: r.label || (CFG.other_label || 'Other'), big: !!r.big};
+      }
+    }
+    return {label: CFG.other_label || 'Other', big: false};
+  }
+
+  /* ---------------- layout ---------------- */
+  function computeLayout(snap) {
+    var groups = {};
+    (snap.nodes || []).forEach(function (n) {
+      var g = groupOf(n);
+      if (!groups[g.label]) groups[g.label] = {label: g.label, big: g.big, nodes: []};
+      if (g.big) groups[g.label].big = true;
+      groups[g.label].nodes.push(n);
+    });
+
+    var order = Object.keys(groups).sort(function (a, b) {
+      function rank(l) {
+        if (/core/i.test(l)) return 0;
+        if (/data\s*center/i.test(l)) return 1;
+        if (/^\d+$/.test(l)) return 1000 + parseInt(l, 10);
+        return 5000;
+      }
+      return rank(a) - rank(b);
+    });
+
+    var pos = {}, frames = [];
+    var cursorX = 0, cursorY = 0, rowH = 0, minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+
+    order.forEach(function (label) {
+      var g = groups[label];
+      g.nodes.sort(function (a, b) {
+        var da = a.depth || 0, db = b.depth || 0;
+        if (da !== db) return da - db;
+        return (a.name || '').localeCompare(b.name || '');
+      });
+      var n = g.nodes.length;
+      var cols = Math.min(4, Math.max(1, Math.ceil(Math.sqrt(n))));
+      var rows = Math.ceil(n / cols);
+      var innerW = cols * NODE_W + (cols - 1) * GX;
+      var innerH = rows * NODE_H + (rows - 1) * GY;
+      var boxW = innerW + PADX * 2;
+      var boxH = innerH + PADY + 12;
+
+      if (cursorX > 0 && cursorX + boxW > MAXW) { cursorX = 0; cursorY += rowH + GROUP_GAP_Y; rowH = 0; }
+
+      g.nodes.forEach(function (nd, i) {
+        var rr = Math.floor(i / cols), cc = i % cols;
+        pos[nd.id] = {
+          x: cursorX + PADX + cc * (NODE_W + GX) + NODE_W / 2,
+          y: cursorY + PADY + rr * (NODE_H + GY) + NODE_H / 2
+        };
+      });
+      frames.push({label: label, big: g.big, x: cursorX, y: cursorY, w: boxW, h: boxH});
+      minX = Math.min(minX, cursorX); minY = Math.min(minY, cursorY);
+      maxX = Math.max(maxX, cursorX + boxW); maxY = Math.max(maxY, cursorY + boxH);
+
+      cursorX += boxW + GROUP_GAP_X;
+      rowH = Math.max(rowH, boxH);
+    });
+
+    var root = null;
+    if (frames.length) {
+      var m = 28;
+      root = {x: minX - m, y: minY - m - 30, w: (maxX - minX) + 2 * m, h: (maxY - minY) + 2 * m + 30};
+      root.cx = root.x + root.w / 2; root.cy = root.y + root.h / 2;
+    }
+    return {pos: pos, frames: frames, root: root};
+  }
+
+  /* ---------------- speed colour ---------------- */
+  function speedColor(speed, state) {
+    if (state && state !== 'up') return colors.down;
+    var s = parseFloat(speed);
+    if (!isFinite(s) || s <= 0) return colors.unknown;
+    if (s <= 100) return colors['100'];
+    if (s <= 1000) return colors['1000'];
+    if (s <= 10000) return colors['10000'];
+    return colors['20000'];
+  }
+  function speedWidth(speed) {
+    var s = parseFloat(speed) || 0;
+    if (s >= 20000) return 4.5;
+    if (s >= 10000) return 3.6;
+    if (s >= 1000) return 2.6;
+    return 1.8;
+  }
+
+  /* ---------------- style ---------------- */
+  function styleFor(d) {
+    var bg = d ? TIER_BG_DARK : TIER_BG;
+    return [
+      {selector: 'node', style: {
+        'width': 'data(w)', 'height': 'data(h)', 'shape': 'round-rectangle',
+        'background-color': 'data(bg)', 'border-width': 2, 'border-color': 'data(bd)',
+        'label': 'data(label)', 'color': d ? '#eaf1fb' : '#ffffff',
+        'font-size': 11.5, 'font-weight': 600, 'text-valign': 'center', 'text-halign': 'center',
+        'text-wrap': 'wrap', 'text-max-width': NODE_W - 16, 'padding': '2px',
+        'overlay-opacity': 0, 'z-index-compare': 'manual', 'z-index': 20
+      }},
+      {selector: 'node[type="frame"]', style: {
+        'background-opacity': d ? 0.05 : 0.035, 'background-color': d ? '#5b7bb0' : '#8fa6c4',
+        'border-width': 1.6, 'border-style': 'dashed', 'border-color': d ? '#3f5377' : '#c2ccdb',
+        'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center',
+        '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="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',
+        'label': 'data(label)', 'text-valign': 'top', 'text-halign': 'center',
+        '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="term"]', style: {
+        'width': 12, 'height': 12, 'shape': 'ellipse', 'background-color': 'data(bg)',
+        'border-width': 1, 'border-color': d ? '#2a3648' : '#cfd8e3', 'label': '',
+        'z-index-compare': 'manual', 'z-index': 30
+      }},
+      {selector: 'edge', style: {
+        'width': 'data(w)', 'line-color': 'data(color)', 'line-style': 'data(ls)',
+        'curve-style': 'bezier', 'opacity': 0.92, 'target-arrow-shape': 'none',
+        'z-index-compare': 'manual', 'z-index': 12
+      }},
+      {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
+      }},
+      {selector: 'node.alarm', style: {'border-width': 6}},
+      {selector: 'node.lat-warn', style: {'border-color': '#F39C12'}},
+      {selector: 'node.lat-bad', style: {'border-color': '#E74C3C', 'border-width': 6}},
+      {selector: 'node.hl', style: {'border-color': '#ff0', 'border-width': 7, 'overlay-color': '#ff0', 'overlay-opacity': 0.25, 'overlay-padding': 6}},
+      {selector: 'node[type="switch"]', style: {'background-color': function (e) { return e.data('bg'); }}}
+    ];
+  }
+
+  /* ---------------- render ---------------- */
+  function render(snap) {
+    if (!snap || !snap.nodes) {
+      el('l2-cy').innerHTML = '<div class="l2-empty">' + t('l2.no_data', 'No data') + '</div>';
+      return;
+    }
+    lastSnap = snap;
+    var L = computeLayout(snap);
+    var els = [];
+
+    if (L.root) {
+      els.push({group: 'nodes', data: {id: '__root', type: 'root', label: CFG.root_label || 'COBCO', w: L.root.w, h: L.root.h},
+        position: {x: L.root.cx, y: L.root.cy}, selectable: false, grabbable: false});
+    }
+    L.frames.forEach(function (f, i) {
+      els.push({group: 'nodes', data: {id: 'grp_' + i, type: 'frame', label: f.label, w: f.w, h: f.h, fs: f.big ? 20 : 14},
+        position: {x: f.x + f.w / 2, y: f.y + f.h / 2}, selectable: false, grabbable: false});
+    });
+
+    var switchIds = {};
+    (snap.nodes || []).forEach(function (n) {
+      switchIds[n.id] = true;
+      var p = L.pos[n.id] || {x: 0, y: 0};
+      var tier = n.tier || 'access';
+      var tkey = (tier.indexOf('dist') === 0) ? 'dist' : (tier.indexOf('core') === 0 ? 'core' : 'access');
+      var cls = [];
+      var bd = (dark ? TIER_BG_DARK[tkey] : TIER_BG[tkey]) || '#555';
+      if (n.maxsev != null && n.maxsev >= 0 && (n.alarms && n.alarms.length)) {
+        cls.push('alarm');
+        bd = SEV_COLORS[Math.max(0, Math.min(5, n.maxsev))];
+      }
+      if (n.latency_ms != null) {
+        if (+n.latency_ms > LATB) cls.push('lat-bad');
+        else if (+n.latency_ms > LATW) cls.push('lat-warn');
+      }
+      els.push({group: 'nodes', data: {
+        id: n.id, type: 'switch', name: n.name, ip: n.ip, tier: tier, depth: n.depth,
+        hostid: n.hostid, oper: n.oper, maxsev: n.maxsev, alarms: n.alarms || [],
+        term_online: n.term_online, term_offline: n.term_offline, latency_ms: n.latency_ms,
+        label: labelFor(n), bg: (dark ? TIER_BG_DARK[tkey] : TIER_BG[tkey]) || '#777',
+        bd: bd, w: NODE_W, h: NODE_H, tw: NODE_W - 16, z: 20, group: groupOf(n).label
+      }, position: p, classes: cls.join(' ')});
+    });
+
+    (snap.edges || []).forEach(function (e, i) {
+      if (!switchIds[e.source] || !switchIds[e.target]) return;
+      els.push({group: 'edges', data: {
+        id: 'e_' + i, source: e.source, target: e.target, speed: e.speed, state: e.state,
+        a_port: e.a_port, b_port: e.b_port,
+        color: speedColor(e.speed, e.state), ls: (e.state && e.state !== 'up') ? 'dashed' : 'solid',
+        w: speedWidth(e.speed)
+      }});
+    });
+
+    // terminals
+    if (showTerm) {
+      var bySw = {};
+      (snap.terminals || []).forEach(function (tm) {
+        if (!switchIds[tm.sw_ip]) return;
+        if (onlyOff && tm.online) return;
+        (bySw[tm.sw_ip] = bySw[tm.sw_ip] || []).push(tm);
+      });
+      var cnt = 0;
+      Object.keys(bySw).forEach(function (sw) {
+        var base = L.pos[sw]; if (!base) return;
+        bySw[sw].sort(function (a, b) { return (a.online === b.online) ? 0 : (a.online ? -1 : 1); });
+        bySw[sw].forEach(function (tm, k) {
+          var col = k % 9, row = Math.floor(k / 9);
+          var x = base.x - 54 + col * 13;
+          var y = base.y + NODE_H / 2 + 16 + row * 13;
+          els.push({group: 'nodes', data: {id: 't_' + tm.mac, type: 'term', bg: tm.online ? '#2ECC71' : '#95a5a6', mac: tm.mac, ip: tm.ip, online: tm.online},
+            position: {x: x, y: y}, selectable: false});
+          els.push({group: 'edges', data: {id: 'te_' + tm.mac, type: 'term', source: sw, target: 't_' + tm.mac}});
+          cnt++;
+        });
+      });
+    }
+
+    if (!cy) {
+      cy = cytoscape({
+        container: el('l2-cy'),
+        elements: els,
+        style: styleFor(dark),
+        layout: {name: 'preset', fit: true, padding: 50},
+        wheelSensitivity: 0.2,
+        minZoom: 0.05, maxZoom: 4
+      });
+      bindCy();
+    } else {
+      cy.elements().remove();
+      cy.add(els);
+      cy.style(styleFor(dark));
+      cy.layout({name: 'preset', fit: true, padding: 50}).run();
+    }
+
+    updateStats(snap);
+    buildLegend();
+    buildAlarms(snap);
+    buildOffline(snap);
+  }
+
+  /* ---------------- side panels & stats ---------------- */
+  function updateStats(snap) {
+    var s = snap.stats || {};
+    var txt = '● ' + (s.switches || 0) + '  |  ⇄ ' + (s.edges || 0) + '  |  ' +
+      (s.term_total || 0) + '(' + (s.term_online || 0) + '/' + (s.term_offline || 0) + ')  |  ⚠ ' + (s.alarms || 0);
+    var up = snap.generated_iso ? ('  ·  ' + t('l2.updated', 'Updated') + ' ' + snap.generated_iso) : '';
+    el('l2-stats').textContent = txt + up;
+  }
+
+  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>'; }
+    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-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:' + 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>';
+    h += '<div class="lg-row"><span class="lg-box" style="background:#F39C12;border-radius:2px"></span>&gt;' + LATW + 'ms' +
+         '<span class="lg-box" style="background:#E74C3C;border-radius:2px;margin-left:8px"></span>&gt;' + LATB + 'ms</div>';
+    h += '<div class="lg-row"><span class="lg-box" style="background:#E45959;border-radius:2px"></span>' + t('l2.alarms', 'Alarms') + '</div>';
+    h += '<div class="lg-row"><span class="lg-box" style="background:#2ECC71;border-radius:50%"></span>' + t('l2.term_on', 'Online') +
+         '<span class="lg-box" style="background:#95a5a6;border-radius:50%;margin-left:8px"></span>' + t('l2.term_off', 'Offline') + '</div>';
+    el('l2-legend').innerHTML = h;
+  }
+
+  function buildAlarms(snap) {
+    var items = [];
+    (snap.nodes || []).forEach(function (n) {
+      if (n.maxsev != null && n.maxsev >= 0 && n.alarms && n.alarms.length) {
+        n.alarms.forEach(function (a) {
+          items.push({sev: n.maxsev, title: (a.name || a.description || ''), host: n.name, ip: n.ip, id: n.id, hostid: n.hostid});
+        });
+      }
+    });
+    (snap.other_alarms || []).forEach(function (a) {
+      items.push({sev: a.sev, title: a.name, host: a.host, ip: a.ip, id: null, hostid: null, other: true});
+    });
+    el('l2-alarm-count').textContent = items.length;
+    if (!items.length) { el('l2-alarm-list').innerHTML = '<div class="panel-empty">' + t('l2.alarm_empty', 'No active alarms.') + '</div>'; return; }
+    items.sort(function (a, b) { return b.sev - a.sev; });
+    var h = '';
+    items.forEach(function (it) {
+      var col = SEV_COLORS[Math.max(0, Math.min(5, it.sev))] || '#999';
+      h += '<div class="alarm-row" data-id="' + (it.id || '') + '" data-hostid="' + (it.hostid || '') + '">' +
+        '<span class="alarm-dot" style="background:' + col + '"></span>' +
+        '<div class="alarm-main"><div class="alarm-title">' + esc(it.title) + '</div>' +
+        '<div class="alarm-sub">' + esc(it.host || '') + (it.ip ? ' · ' + esc(it.ip) : '') + (it.other ? ' · 拓扑外' : '') + '</div></div></div>';
+    });
+    var box = el('l2-alarm-list');
+    box.innerHTML = h;
+    box.querySelectorAll('.alarm-row').forEach(function (r) {
+      r.addEventListener('click', function () {
+        var id = r.getAttribute('data-id');
+        if (id) focusNode(id);
+        else { var hid = r.getAttribute('data-hostid'); if (hid) window.open(META.zabbixHostUrl.replace('__HOSTID__', hid), '_blank'); }
+      });
+    });
+  }
+
+  function buildOffline(snap) {
+    var off = (snap.terminals || []).filter(function (tm) { return !tm.online; });
+    el('l2-offline-count').textContent = off.length;
+    if (!off.length) { el('l2-offline-list').innerHTML = '<div class="panel-empty">' + t('l2.offline_empty', 'No offline terminals.') + '</div>'; return; }
+    var h = '';
+    off.forEach(function (tm) {
+      h += '<div class="off-row" data-mac="' + esc(tm.mac) + '">' +
+        '<div class="off-main"><div class="off-mac">' + esc(tm.mac) + '</div>' +
+        '<div class="off-sub">' + esc(tm.sw_name || tm.sw_ip) + ' · ' + esc(tm.port || '') + '</div></div></div>';
+    });
+    var box = el('l2-offline-list');
+    box.innerHTML = h;
+    box.querySelectorAll('.off-row').forEach(function (r) {
+      r.addEventListener('click', function () {
+        var mac = r.getAttribute('data-mac');
+        if (!showTerm) { showTerm = true; el('l2-showterm').checked = true; render(lastSnap); }
+        setTimeout(function () { focusNode('t_' + mac); }, 60);
+      });
+    });
+  }
+
+  function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]; }); }
+
+  /* ---------------- interactions ---------------- */
+  function focusNode(id) {
+    if (!cy) return;
+    var n = cy.getElementById(id);
+    if (!n || n.empty() || n.length === 0) return;
+    cy.elements().removeClass('hl');
+    n.addClass('hl');
+    cy.animate({center: {eles: n}, zoom: Math.max(cy.zoom(), 1.1), duration: 380});
+    setTimeout(function () { n.removeClass('hl'); }, 2600);
+  }
+
+  function bindCy() {
+    cy.on('mouseover', 'node[type="switch"]', function (e) {
+      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>') : '—';
+      var lat = (d.latency_ms != null) ? (d.latency_ms + ' ms') : '—';
+      var html = '<b>' + esc(d.name) + '</b><br>' + esc(d.ip) + ' · ' + esc(d.group || '') + '<br>' +
+        'Tier: ' + esc(d.tier) + ' · ' + (d.oper || '') + '<br>' +
+        'Latency: ' + lat + '<br>' +
+        'Terminals: ' + (d.term_online || 0) + ' on / ' + (d.term_offline || 0) + ' off<br>' +
+        'Alarm: ' + sev;
+      if (arr.length) html += '<hr>' + arr.map(function (a) { return esc(a.name || a.description || ''); }).join('<br>');
+      showTip(e, html);
+    });
+    cy.on('mouseout', 'node[type="switch"]', hideTip);
+    cy.on('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');
+      showTip(e, 'Link ' + esc(d.a_port || '') + ' ↔ ' + esc(d.b_port || '') + '<br>' + sp + ' · ' + esc(d.state || ''));
+    });
+    cy.on('mouseout', 'edge', hideTip);
+    cy.on('tap', 'node[type="switch"]', function (e) {
+      var hid = e.target.data('hostid');
+      if (hid) { /* single click: focus only; double handled below */ }
+    });
+    cy.on('dbltap', 'node[type="switch"]', function (e) {
+      var hid = e.target.data('hostid');
+      if (hid) window.open(META.zabbixHostUrl.replace('__HOSTID__', hid), '_blank');
+    });
+  }
+
+  var SEV_NAMES = ['未分类','信息','警告','一般严重','严重','灾难'];
+
+  function showTip(e, html) {
+    var tip = el('l2-tip');
+    tip.innerHTML = html;
+    tip.style.display = 'block';
+    var p = e.renderedPosition || {x: 0, y: 0};
+    var box = el('l2-cy').getBoundingClientRect();
+    var x = box.left + p.x + 16, y = box.top + p.y + 16;
+    tip.style.left = x + 'px'; tip.style.top = y + 'px';
+  }
+  function hideTip() { el('l2-tip').style.display = 'none'; }
+
+  /* ---------------- 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.terminals || []).forEach(function (tm) { idx.push({id: 't_' + tm.mac, label: tm.ip || tm.mac, sub: tm.mac + ' · ' + (tm.sw_name || ''), kind: 'term', mac: tm.mac}); });
+    return idx;
+  }
+  var INDEX = [];
+  function doSearch(q) {
+    var box = el('l2-sug');
+    q = (q || '').trim().toLowerCase();
+    if (!q) { box.style.display = 'none'; box.innerHTML = ''; return; }
+    var hits = [];
+    for (var i = 0; i < INDEX.length && hits.length < 14; i++) {
+      var it = INDEX[i];
+      if ((it.label && it.label.toLowerCase().indexOf(q) >= 0) || (it.sub && it.sub.toLowerCase().indexOf(q) >= 0)) hits.push(it);
+    }
+    if (!hits.length) { box.innerHTML = '<div class="sug-empty">—</div>'; box.style.display = 'block'; return; }
+    box.innerHTML = hits.map(function (h) {
+      return '<div class="sug-row" data-id="' + esc(h.id) + '"><span class="sug-kind ' + h.kind + '">' + (h.kind === 'sw' ? 'SW' : 'PC') + '</span>' +
+        '<span class="sug-lbl">' + esc(h.label) + '</span><span class="sug-sub">' + esc(h.sub || '') + '</span></div>';
+    }).join('');
+    box.style.display = 'block';
+    box.querySelectorAll('.sug-row').forEach(function (r) {
+      r.addEventListener('click', function () {
+        var id = r.getAttribute('data-id');
+        if (id.indexOf('t_') === 0 && !showTerm) { showTerm = true; el('l2-showterm').checked = true; render(lastSnap); setTimeout(function () { focusNode(id); }, 80); }
+        else focusNode(id);
+        box.style.display = 'none';
+      });
+    });
+  }
+
+  /* ---------------- toolbar wiring ---------------- */
+  function setupControls() {
+    dark = el('l2-wrap').classList.contains('l2-dark');
+    el('l2-dark').checked = dark;
+
+    el('l2-dark').addEventListener('change', function () {
+      dark = el('l2-dark').checked;
+      el('l2-wrap').classList.toggle('l2-dark', dark);
+      if (cy) cy.style(styleFor(dark));
+      buildLegend();
+    });
+    el('l2-showterm').addEventListener('change', function () { showTerm = el('l2-showterm').checked; render(lastSnap); });
+    el('l2-onlyoff').addEventListener('change', function () {
+      onlyOff = el('l2-onlyoff').checked;
+      if (onlyOff && !showTerm) { showTerm = true; el('l2-showterm').checked = true; }
+      render(lastSnap);
+    });
+    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-refresh-btn').addEventListener('click', function () { reload(true); });
+
+    var s = el('l2-search');
+    s.addEventListener('input', function () { doSearch(s.value); });
+    document.addEventListener('click', function (ev) {
+      if (!ev.target.closest('.l2-search-wrap')) el('l2-sug').style.display = 'none';
+    });
+
+    el('l2-ts').addEventListener('change', function () {
+      var v = el('l2-ts').value;
+      var u = '/zabbix.php?action=l2.topology.view';
+      if (v) u += '&ts=' + encodeURIComponent(v);
+      window.location = u;
+    });
+
+    el('l2-pause').addEventListener('click', function () {
+      paused = !paused;
+      el('l2-pause').classList.toggle('on', paused);
+      el('l2-pause').textContent = paused ? '▶' : '❚❚';
+      el('l2-pause').title = paused ? t('l2.resume', 'Resume') : t('l2.pause', 'Pause');
+    });
+  }
+
+  function reload(manual) {
+    if (!META.isLive) return;
+    fetch(META.snapshotUrl + '?t=' + Date.now(), {cache: 'no-store'})
+      .then(function (r) { return r.json(); })
+      .then(function (j) { if (j && j.nodes) { SNAP = j; INDEX = buildIndex(j); render(j); } })
+      .catch(function () {})
+      .then(function () { if (manual) resetCountdown(); });
+  }
+
+  function resetCountdown() { countdown = META.refreshSeconds || 300; }
+  function startTimer() {
+    if (!META.isLive) return;
+    resetCountdown();
+    timer = setInterval(function () {
+      if (paused) { el('l2-countdown').textContent = '⏸'; return; }
+      countdown--;
+      if (countdown <= 0) { countdown = META.refreshSeconds || 300; reload(false); }
+      var m = Math.floor(countdown / 60), s = countdown % 60;
+      el('l2-countdown').textContent = '↻ ' + m + ':' + (s < 10 ? '0' + s : s);
+    }, 1000);
+  }
+
+  /* ---------------- boot ---------------- */
+  function boot() {
+    setupControls();
+    if (!SNAP || !SNAP.nodes) {
+      el('l2-cy').innerHTML = '<div class="l2-empty">' + t('l2.no_data', 'No data') + '</div>';
+    } else {
+      INDEX = buildIndex(SNAP);
+      render(SNAP);
+    }
+    if (META.isLive) startTimer();
+    else { el('l2-pause').disabled = true; el('l2-countdown').textContent = t('l2.snapshot', 'Snapshot'); }
+  }
+
+  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
+  else boot();
+})();

+ 26 - 0
web-modules/WecomCenter/assets/l2topology/config.json

@@ -0,0 +1,26 @@
+{
+  "refresh_seconds": 300,
+  "dark_default": false,
+  "root_label": "COBCO",
+  "templates": ["Huawei VRP by SNMP (walk)"],
+  "full_interval_min": 5,
+  "alarm_interval_min": 1,
+  "history_days": 7,
+  "colors": {
+    "100": "#F39C12",
+    "1000": "#2ECC71",
+    "10000": "#3498DB",
+    "20000": "#9B59B6",
+    "unknown": "#E74C3C",
+    "down": "#C0392B"
+  },
+  "latency_warn": 3.0,
+  "latency_bad": 5.0,
+  "group_rules": [
+    {"match": "contains", "pattern": "DataCenter", "label": "DataCenter", "big": true},
+    {"match": "contains", "pattern": "Core", "label": "Core", "big": true},
+    {"match": "regex", "pattern": "COBCO-(\\d{4})-", "label": "$1", "big": false},
+    {"match": "regex", "pattern": "^(\\d{4})-", "label": "$1", "big": false}
+  ],
+  "other_label": "Other"
+}

File diff suppressed because it is too large
+ 22 - 0
web-modules/WecomCenter/assets/l2topology/cytoscape.min.js


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

@@ -0,0 +1,138 @@
+/* =====================================================================
+   L2 Physical Topology page styles (light + dark, no external deps)
+   ===================================================================== */
+.l2-wrap {
+  --bg: #f4f6f9;
+  --panel: #ffffff;
+  --panel-2: #fafbfd;
+  --border: #e2e7ee;
+  --text: #23272e;
+  --muted: #7b8694;
+  --brand: #2f6fdd;
+  position: relative;
+  padding: 8px 2px 18px;
+  color: var(--text);
+}
+.l2-wrap.l2-dark {
+  --bg: #0f1521;
+  --panel: #161f2e;
+  --panel-2: #1b2536;
+  --border: #263143;
+  --text: #dbe4f0;
+  --muted: #8296ad;
+  --brand: #4f8bf0;
+}
+
+/* toolbar */
+.l2-toolbar {
+  display: flex; align-items: center; flex-wrap: wrap; gap: 10px;
+  padding: 10px 14px; margin-bottom: 10px;
+  background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
+  box-shadow: 0 1px 3px rgba(20,30,50,.05);
+}
+.tb-title { font-size: 16px; font-weight: 750; letter-spacing: .3px; margin-right: 6px; }
+.l2-muted { color: var(--muted); font-size: 12px; }
+.l2-spacer { flex: 1 1 auto; }
+
+.l2-search-wrap { position: relative; }
+#l2-search {
+  width: 240px; height: 34px; padding: 0 12px; border-radius: 8px;
+  border: 1px solid var(--border); background: var(--panel-2); color: var(--text); font-size: 13px;
+}
+#l2-search:focus { outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px rgba(79,139,240,.15); }
+#l2-sug {
+  display: none; position: absolute; z-index: 50; top: 38px; left: 0; width: 340px; max-height: 340px;
+  overflow: auto; background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
+  box-shadow: 0 10px 30px rgba(10,20,40,.18);
+}
+.sug-row { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; font-size: 12.5px; }
+.sug-row:hover { background: var(--panel-2); }
+.sug-kind { flex: 0 0 auto; font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: 4px; color: #fff; }
+.sug-kind.sw { background: #2E75B6; }
+.sug-kind.term { background: #2ECC71; }
+.sug-lbl { color: var(--text); }
+.sug-sub { color: var(--muted); margin-left: auto; font-size: 11.5px; }
+.sug-empty { padding: 10px 12px; color: var(--muted); }
+
+.l2-chk { display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; color: var(--text); cursor: pointer; user-select: none; }
+.l2-ts-label { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--text); }
+
+.l2-btn {
+  height: 32px; padding: 0 12px; border-radius: 8px; cursor: pointer; font-size: 12.5px;
+  border: 1px solid var(--border); background: var(--panel-2); color: var(--text); line-height: 30px;
+}
+.l2-btn:hover { border-color: var(--brand); color: var(--brand); }
+.l2-btn.primary { background: var(--brand); border-color: var(--brand); color: #fff; }
+.l2-btn.primary:hover { filter: brightness(1.08); color: #fff; }
+.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-canvas {
+  position: relative; flex: 1 1 auto; min-width: 0; height: 74vh;
+  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,
+    var(--bg);
+  border: 1px solid var(--border); border-radius: 12px; overflow: hidden;
+}
+.l2-dark .l2-canvas { background:
+    linear-gradient(rgba(120,150,200,.05) 1px, transparent 1px) 0 0 / 26px 26px,
+    linear-gradient(90deg, rgba(120,150,200,.05) 1px, transparent 1px) 0 0 / 26px 26px,
+    var(--bg); }
+#l2-cy { position: absolute; inset: 0; }
+.l2-empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 14px; }
+
+.l2-stats {
+  position: absolute; left: 12px; top: 12px; z-index: 20;
+  padding: 6px 12px; font-size: 12px; border-radius: 8px;
+  background: color-mix(in srgb, var(--panel) 88%, transparent);
+  border: 1px solid var(--border); color: var(--text); backdrop-filter: blur(4px);
+}
+.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);
+}
+.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-line.lg-dash { border-top-style: dashed; border-top-color: #E45959; }
+.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); }
+.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; }
+.panel-sec { display: none; }
+.panel-sec.active { display: block; }
+.panel-empty { padding: 22px 14px; color: var(--muted); text-align: center; font-size: 13px; }
+
+.alarm-row, .off-row { display: flex; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--border); cursor: pointer; }
+.alarm-row:hover, .off-row:hover { background: var(--panel-2); }
+.alarm-dot { flex: 0 0 auto; width: 9px; height: 9px; border-radius: 50%; margin-top: 4px; }
+.alarm-main, .off-main { min-width: 0; }
+.alarm-title { font-size: 12.5px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; }
+.alarm-sub { font-size: 11.5px; color: var(--muted); margin-top: 2px; }
+.off-mac { font-size: 12.5px; font-family: ui-monospace, Menlo, Consolas, monospace; color: var(--text); }
+.off-sub { font-size: 11.5px; color: var(--muted); margin-top: 2px; }
+
+/* tooltip */
+.l2-tip {
+  display: none; position: fixed; z-index: 9999; max-width: 340px;
+  padding: 10px 12px; font-size: 12.5px; line-height: 1.55; border-radius: 10px;
+  background: rgba(18,26,40,.96); color: #eef4ff; box-shadow: 0 10px 28px rgba(0,0,0,.35);
+  pointer-events: none;
+}
+.l2-tip hr { border: none; border-top: 1px solid rgba(255,255,255,.15); margin: 6px 0; }
+
+@media (max-width: 900px) {
+  .l2-main { flex-direction: column; }
+  .l2-side { flex-basis: auto; height: 300px; }
+  .l2-canvas { height: 60vh; }
+}

+ 17 - 0
web-modules/WecomCenter/assets/wecom.center.css

@@ -192,3 +192,20 @@ select.wc-input option { color: #1f2c33; }
 }
 .wc-trow select.wc-input { width: 150px; max-width: 150px; flex: 0 0 150px; }
 :is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-trow { border-bottom-color: #28313d; }
+
+/* ---- L2 config tab: multi-select, colour pickers, grouping rules ---- */
+.wc-input--multi { height: auto; min-height: 120px; padding: 6px; }
+.wc-input--multi option { padding: 3px 6px; }
+.wc-input--color { height: 34px; padding: 2px; cursor: pointer; }
+
+.wc-rules { display: flex; flex-direction: column; gap: 8px; }
+.wc-rule {
+	display: flex; align-items: center; gap: 8px; min-width: 0;
+	padding: 8px; border: 1px solid #e3e6ea; border-radius: 8px; background: #fafbfd;
+}
+.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-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; }
+

+ 220 - 0
web-modules/WecomCenter/lib/L2Store.php

@@ -0,0 +1,220 @@
+<?php declare(strict_types = 0);
+/*
+** L2 Topology - persistence & configuration layer.
+**
+** Two tables are auto-created (portable: works on any Zabbix DB the web user
+** can write to; the compose init sidecar also creates them as root):
+**
+**   l2_topology_history : one compressed snapshot per collection, kept 7 days
+**   l2_topology_config  : single row (id=1) holding every display/collection knob
+**
+** Snapshots are gzip-compressed MEDIUMBLOBs so a 5-minute cadence for a week
+** stays around ~100-150 MB instead of >1 GB of raw JSON.
+*/
+
+namespace Modules\WecomCenter\Lib;
+
+use DB;
+use Throwable;
+
+class L2Store {
+
+	public const HISTORY_TABLE = 'l2_topology_history';
+	public const CONFIG_TABLE  = 'l2_topology_config';
+	public const CONFIG_ID     = 1;
+
+	/** Full default configuration. Every key can be overridden from the UI. */
+	public static function defaults(): array {
+		return [
+			// Display page ------------------------------------------------------
+			'refresh_seconds'   => 300,          // auto-refresh of the live view
+			'dark_default'      => false,        // start in dark mode
+			'root_label'        => 'COBCO',      // outermost frame label
+			// Collection --------------------------------------------------------
+			'templates'         => ['Huawei VRP by SNMP (walk)'],
+			'full_interval_min' => 5,            // full topology/port collection
+			'alarm_interval_min'=> 1,            // alarm-only refresh
+			'history_days'      => 7,
+			// Link colours (bits per second bucket) -----------------------------
+			'colors' => [
+				'100'     => '#F39C12', // 100M  橙色
+				'1000'    => '#2ECC71', // 1G    绿色
+				'10000'   => '#3498DB', // 10G   蓝色
+				'20000'   => '#9B59B6', // 20G+  紫色
+				'unknown' => '#E74C3C', // 未知  红色
+				'down'    => '#C0392B'  // 中断  红色
+			],
+			// Latency thresholds (ms) applied to switch nodes (host->switch ping)
+			'latency_warn'      => 3.0,
+			'latency_bad'       => 5.0,
+			// Grouping rules: first matching rule wins, else "Other".
+			// match = contains | regex ; label may contain $1..$9 backrefs.
+			'group_rules' => [
+				['match' => 'contains', 'pattern' => 'DataCenter', 'label' => 'DataCenter', 'big' => true],
+				['match' => 'contains', 'pattern' => 'Core',       'label' => 'Core',       'big' => true],
+				['match' => 'regex',    'pattern' => 'COBCO-(\\d{4})-', 'label' => '$1',     'big' => false],
+				['match' => 'regex',    'pattern' => '^(\\d{4})-',      'label' => '$1',     'big' => false]
+			],
+			'other_label'       => 'Other'
+		];
+	}
+
+	/** Create tables if absent. Safe to call on every request (idempotent). */
+	public static function ensureSchema(): void {
+		try {
+			DB::execute(
+				'CREATE TABLE IF NOT EXISTS '.self::HISTORY_TABLE.' ('.
+				' id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,'.
+				' ts BIGINT UNSIGNED NOT NULL,'.
+				' captured_at DATETIME NOT NULL,'.
+				' stats TEXT NOT NULL,'.
+				' snapshot MEDIUMBLOB NOT NULL,'.
+				' PRIMARY KEY (id),'.
+				' KEY idx_l2_ts (ts)'.
+				') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin'
+			);
+			DB::execute(
+				'CREATE TABLE IF NOT EXISTS '.self::CONFIG_TABLE.' ('.
+				' id TINYINT UNSIGNED NOT NULL,'.
+				' config LONGTEXT NOT NULL,'.
+				' updated DATETIME NOT NULL,'.
+				' PRIMARY KEY (id)'.
+				') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin'
+			);
+		}
+		catch (Throwable $e) {
+			// Table may already exist or the DB user may lack DDL rights; the
+			// compose init sidecar / install doc handles that case. Never fail a page.
+		}
+	}
+
+	public static function getConfig(): array {
+		$cfg = self::defaults();
+		try {
+			$rows = DB::select('SELECT config FROM '.self::CONFIG_TABLE.' WHERE id='.self::CONFIG_ID);
+			if ($rows && isset($rows[0]['config'])) {
+				$saved = json_decode((string) $rows[0]['config'], true);
+				if (is_array($saved)) {
+					$cfg = self::mergeDeep($cfg, $saved);
+				}
+			}
+		}
+		catch (Throwable $e) {
+			// fall back to defaults
+		}
+		// Make sure the out-of-process collectors can always read the config
+		// (write it once if the mirror file is still missing).
+		$mirror = dirname(__DIR__).'/assets/l2topology/config.json';
+		if (!is_file($mirror)) {
+			self::writeMirror($cfg);
+		}
+		return $cfg;
+	}
+
+	public static function saveConfig(array $cfg): void {
+		$cfg = self::mergeDeep(self::defaults(), $cfg);
+		$json = json_encode($cfg, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+		try {
+			DB::execute(
+				'INSERT INTO '.self::CONFIG_TABLE.' (id, config, updated) VALUES ('.self::CONFIG_ID.', ?, UTC_TIMESTAMP())'.
+				' ON DUPLICATE KEY UPDATE config=VALUES(config), updated=VALUES(updated)',
+				[$json]
+			);
+		}
+		catch (Throwable $e) {
+		}
+		self::writeMirror($cfg);
+	}
+
+	/** Mirror config to a JSON file so the out-of-process collectors can read it. */
+	public static function writeMirror(array $cfg): void {
+		$file = dirname(__DIR__).'/assets/l2topology/config.json';
+		try {
+			$tmp = $file.'.tmp';
+			file_put_contents($tmp, json_encode($cfg, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
+			@chmod($tmp, 0644);
+			@rename($tmp, $file);
+		}
+		catch (Throwable $e) {
+		}
+	}
+
+	/** Newest-first list of stored snapshots (for the time selector). */
+	public static function listHistory(int $limit = 500): array {
+		$out = [];
+		try {
+			$rows = DB::select(
+				'SELECT ts, captured_at, stats FROM '.self::HISTORY_TABLE.' ORDER BY ts DESC LIMIT '.max(1, $limit)
+			);
+			foreach ($rows as $r) {
+				$out[] = [
+					'ts'          => (int) $r['ts'],
+					'captured_at' => (string) $r['captured_at'],
+					'stats'       => json_decode((string) $r['stats'], true)
+				];
+			}
+		}
+		catch (Throwable $e) {
+		}
+		return $out;
+	}
+
+	/**
+	 * Snapshot JSON at (or just before) $ts. When $ts is null the newest stored
+	 * snapshot is used, falling back to the live snapshot.json file.
+	 */
+	public static function snapshotAt(?int $ts): array {
+		$json = null;
+		$used = $ts;
+		try {
+			if ($ts === null) {
+				$rows = DB::select('SELECT ts, snapshot FROM '.self::HISTORY_TABLE.' ORDER BY ts DESC LIMIT 1');
+			}
+			else {
+				$rows = DB::select(
+					'SELECT ts, snapshot FROM '.self::HISTORY_TABLE.' WHERE ts<=? ORDER BY ts DESC LIMIT 1',
+					[$ts]
+				);
+				if (!$rows) {
+					$rows = DB::select('SELECT ts, snapshot FROM '.self::HISTORY_TABLE.' ORDER BY ts ASC LIMIT 1');
+				}
+			}
+			if ($rows) {
+				$blob = $rows[0]['snapshot'];
+				$raw = @gzdecode($blob);
+				if ($raw !== false && $raw !== '') {
+					$json = $raw;
+					$used = (int) $rows[0]['ts'];
+				}
+			}
+		}
+		catch (Throwable $e) {
+		}
+		if ($json === null) {
+			$file = dirname(__DIR__).'/assets/l2topology/snapshot.json';
+			if (is_readable($file)) {
+				$raw = file_get_contents($file);
+				if ($raw !== false && $raw !== '') {
+					$json = $raw;
+				}
+			}
+		}
+		return ['json' => $json, 'ts' => $used];
+	}
+
+	private static function mergeDeep(array $base, array $over): array {
+		foreach ($over as $k => $v) {
+			if (is_array($v) && isset($base[$k]) && is_array($base[$k]) && self::isAssoc($base[$k])) {
+				$base[$k] = self::mergeDeep($base[$k], $v);
+			}
+			else {
+				$base[$k] = $v;
+			}
+		}
+		return $base;
+	}
+
+	private static function isAssoc(array $a): bool {
+		return array_keys($a) !== range(0, count($a) - 1);
+	}
+}

+ 70 - 2
web-modules/WecomCenter/locale/en.php

@@ -1,11 +1,12 @@
 <?php
 // WeCom Center - English (base language). Copy this file to <lang>.php to add a language.
 return [
-	'menu.title'      => 'WeCom Center',
-	'page.title'      => 'WeCom Center',
+	'menu.title'      => 'ROMAN Custom Center',
+	'page.title'      => 'ROMAN Custom Center',
 	'tab.alert'       => 'Alert',
 	'tab.opt'         => 'Optimization',
 	'tab.maint'       => 'Maintenance',
+	'tab.l2'          => 'L2 Topology',
 
 	// section headings
 	'sec.robot'       => 'WeCom group robot',
@@ -157,4 +158,71 @@ return [
 	'msg.init_action_new'    => 'trigger action created',
 	'msg.init_action_ok'     => 'trigger action exists',
 	'msg.init_key_set'       => 'key saved',
+
+	// ============================ L2 topology ============================
+	'menu.l2'            => 'L2 Topology',
+	'sec.l2_collect'     => 'L2 collection',
+	'sec.l2_display'     => 'L2 display',
+	'sec.l2_colors'      => 'Link speed colours',
+	'sec.l2_latency'     => 'Latency thresholds',
+	'sec.l2_group'       => 'Grouping rules',
+	'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_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',
+	'lbl.color_20000'    => '20G+',
+	'lbl.color_unknown'  => 'Unknown',
+	'lbl.color_down'     => 'Down',
+	'lbl.lat_warn'       => 'Orange above (ms)',
+	'lbl.lat_bad'        => 'Red above (ms)',
+	'lbl.rule_match'     => 'Match',
+	'lbl.rule_pattern'   => 'Pattern',
+	'lbl.rule_label'     => 'Frame label',
+	'lbl.rule_big'       => 'Big',
+	'lbl.rule_hint'      => 'First matching rule wins; unmatched devices go to the fallback group. For regex rules the label may use $1..$9 back-references.',
+	'btn.add_rule'       => 'Add rule',
+	'msg.l2_saved'       => 'L2 topology settings saved.',
+
+	// L2 display page
+	'l2.title'           => 'L2 Physical Topology',
+	'l2.search_ph'       => 'Search IP / MAC / name…',
+	'l2.show_term'       => 'Show terminals',
+	'l2.only_off'        => 'Only offline',
+	'l2.auto_refresh'    => 'Auto refresh',
+	'l2.min'             => 'min',
+	'l2.refresh_now'     => 'Refresh now',
+	'l2.fit'             => 'Fit to window',
+	'l2.alarms'          => 'Alarms',
+	'l2.offline'         => 'Offline terminals',
+	'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.',
+	'l2.updated'         => 'Updated',
+	'l2.pause'           => 'Pause auto-refresh',
+	'l2.resume'          => 'Resume auto-refresh',
+	'l2.legend_speed'    => 'Link speed',
+	'l2.legend_tier'     => 'Tier',
+	'l2.legend_lat'      => 'Latency',
+	'l2.core'            => 'Core',
+	'l2.dist'            => 'Distribution',
+	'l2.access'          => 'Access',
+	'l2.lat_norm'        => 'Normal',
+	'l2.term_on'         => 'Online',
+	'l2.term_off'        => 'Offline',
+	'l2.zoom_in'         => 'Zoom in',
+	'l2.zoom_out'        => 'Zoom out',
+	'l2.snapshot'        => 'Snapshot',
+	'l2.showing'         => 'Showing',
+	'l2.live_tag'        => 'LIVE',
 ];

+ 70 - 2
web-modules/WecomCenter/locale/zh_CN.php

@@ -1,11 +1,12 @@
 <?php
 // WeCom Center - 简体中文
 return [
-	'menu.title'      => '企业微信告警中心',
-	'page.title'      => '企业微信告警中心',
+	'menu.title'      => 'ROMAN 定制中心',
+	'page.title'      => 'ROMAN 定制中心',
 	'tab.alert'       => '告警',
 	'tab.opt'         => '优化',
 	'tab.maint'       => '日常维护',
+	'tab.l2'          => 'L2 拓扑',
 
 	'sec.robot'       => '企业微信群机器人',
 	'sec.strategy'    => '告警策略(先选,再应用)',
@@ -144,4 +145,71 @@ return [
 	'msg.init_action_new'    => '触发器动作已创建',
 	'msg.init_action_ok'     => '触发器动作已存在',
 	'msg.init_key_set'       => 'Key 已保存',
+
+	// ============================ L2 拓扑 ============================
+	'menu.l2'            => 'L2 拓扑',
+	'sec.l2_collect'     => 'L2 采集',
+	'sec.l2_display'     => 'L2 展示',
+	'sec.l2_colors'      => '链路速率配色',
+	'sec.l2_latency'     => '延迟阈值',
+	'sec.l2_group'       => '分组规则',
+	'lbl.l2_templates'   => '采集使用这些模板的主机',
+	'lbl.l2_templates_hint' => '只采集关联了所选模板的主机。默认推荐:Huawei VRP by SNMP (walk)。',
+	'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',
+	'lbl.color_20000'    => '20G+',
+	'lbl.color_unknown'  => '未知',
+	'lbl.color_down'     => '中断',
+	'lbl.lat_warn'       => '高于该值显示橙色(毫秒)',
+	'lbl.lat_bad'        => '高于该值显示红色(毫秒)',
+	'lbl.rule_match'     => '匹配方式',
+	'lbl.rule_pattern'   => '匹配内容',
+	'lbl.rule_label'     => '框名',
+	'lbl.rule_big'       => '大框',
+	'lbl.rule_hint'      => '按顺序匹配,第一条命中即用;都不命中归入兜底分组。正则规则可用 $1..$9 反向引用。',
+	'btn.add_rule'       => '添加规则',
+	'msg.l2_saved'       => 'L2 拓扑设置已保存。',
+
+	// L2 展示页
+	'l2.title'           => 'L2 物理拓扑图',
+	'l2.search_ph'       => '搜索 IP / MAC / 名称…',
+	'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.lat_norm'        => '正常',
+	'l2.term_on'         => '在线',
+	'l2.term_off'        => '离线',
+	'l2.zoom_in'         => '放大',
+	'l2.zoom_out'        => '缩小',
+	'l2.snapshot'        => '快照',
+	'l2.showing'         => '当前',
+	'l2.live_tag'        => '实时',
 ];

+ 8 - 3
web-modules/WecomCenter/manifest.json

@@ -4,10 +4,10 @@
 	"type": "module",
 	"name": "WeCom Center",
 	"namespace": "WecomCenter",
-	"version": "1.0.0",
+	"version": "2.0.0",
 	"author": "COBCO IT / FlyingRome",
-	"description": "One-stop customization center for Zabbix: alert optimization switches & presets, WeCom robot / key / media configuration, one-click template import, and map coordinate adjustment.",
-	"url": "https://gogs.example/FlyingRome/zabbix-deploy",
+	"description": "ROMAN customisation center for Zabbix: alert optimisation, WeCom robot/media config, template import, map coordinates, and a DB-backed L2 physical topology explorer.",
+	"url": "https://git.flyingrome.net/FlyingRome/zabbix-deploy",
 	"actions": {
 		"wecom.center.view": {
 			"class": "WecomCenterView",
@@ -16,6 +16,11 @@
 		},
 		"wecom.center.save": {
 			"class": "WecomCenterSave"
+		},
+		"l2.topology.view": {
+			"class": "L2TopologyView",
+			"layout": "layout.htmlpage",
+			"view": "topology.view"
 		}
 	},
 	"assets": {

+ 116 - 0
web-modules/WecomCenter/views/topology.view.php

@@ -0,0 +1,116 @@
+<?php declare(strict_types = 0);
+/*
+** L2 Topology page — display-only map (all settings live in the config center).
+** @var CView $this
+** @var array  $data
+*/
+use Modules\WecomCenter\Lib\I18n;
+
+$this->addCssFile('modules/WecomCenter/assets/l2topology/style.css');
+$base = '/modules/WecomCenter/assets/l2topology/';
+
+$snapJson = (string) ($data['snap_json'] ?? 'null');
+$cfgJson  = (string) ($data['cfg_json'] ?? '{}');
+$snapSafe = str_replace('</', '<\/', $snapJson);
+$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.lat_norm','l2.term_on','l2.term_off','l2.zoom_in','l2.zoom_out','l2.snapshot','l2.showing','l2.live_tag'
+];
+$i18n = [];
+foreach ($i18nKeys as $k) { $i18n[$k] = I18n::t($k); }
+$i18nJson = str_replace('</', '<\/', json_encode($i18n, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
+
+$hist = $data['history'] ?? [];
+$isLive = !empty($data['is_live']);
+$usedTs = (int) ($data['used_ts'] ?? 0);
+$tsOpts = '<option value=""'.($isLive ? ' selected' : '').'>'.htmlspecialchars(I18n::t('l2.time_live')).'</option>';
+foreach ($hist as $h) {
+	$sel = (!$isLive && $usedTs === (int) $h['ts']) ? ' selected' : '';
+	$tsOpts .= '<option value="'.(int) $h['ts'].'"'.$sel.'>'.date('Y-m-d H:i:s', (int) $h['ts']).'</option>';
+}
+$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-toolbar">
+    <span class="tb-title"><?php echo l2e(I18n::t('l2.title')); ?></span>
+
+    <div class="l2-search-wrap">
+      <input id="l2-search" type="text" placeholder="<?php echo l2e(I18n::t('l2.search_ph')); ?>" autocomplete="off"/>
+      <div id="l2-sug"></div>
+    </div>
+
+    <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>
+    </label>
+
+    <span class="l2-spacer"></span>
+    <span id="l2-countdown" class="l2-muted"></span>
+    <button class="l2-btn" id="l2-pause" title="pause">❚❚</button>
+    <button class="l2-btn primary" id="l2-refresh-btn"><?php echo l2e(I18n::t('l2.refresh_now')); ?></button>
+    <button class="l2-btn" id="l2-zin">+</button>
+    <button class="l2-btn" id="l2-zout">-</button>
+    <button class="l2-btn" id="l2-fit"><?php echo l2e(I18n::t('l2.fit')); ?></button>
+  </div>
+
+  <div class="l2-main">
+    <div class="l2-canvas">
+      <div id="l2-cy"></div>
+      <div class="l2-stats" id="l2-stats"></div>
+      <div class="l2-legend" id="l2-legend"></div>
+    </div>
+
+    <div class="l2-side">
+      <div class="side-tabs">
+        <div class="side-tab active" data-sec="alarms"><?php echo l2e(I18n::t('l2.alarms')); ?> <span class="badge" id="l2-alarm-count">0</span></div>
+        <div class="side-tab" data-sec="offline"><?php echo l2e(I18n::t('l2.offline')); ?> <span class="badge" id="l2-offline-count" style="background:#78909c">0</span></div>
+      </div>
+      <div class="side-body">
+        <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>
+  </div>
+</div>
+
+<div class="l2-tip" id="l2-tip"></div>
+
+<script>
+window.L2SNAP = <?php echo $snapSafe; ?>;
+window.L2_CFG  = <?php echo $cfgSafe; ?>;
+window.L2_I18N = <?php echo $i18nJson; ?>;
+window.L2_META = {
+  base: '<?php echo $base; ?>',
+  snapshotUrl: '<?php echo $base; ?>snapshot.json',
+  isLive: <?php echo $isLive ? 'true' : 'false'; ?>,
+  usedTs: <?php echo $usedTs; ?>,
+  refreshSeconds: <?php echo $refreshSec; ?>,
+  zabbixHostUrl: '/zabbix.php?action=problem.view&hostids%5B%5D=__HOSTID__'
+};
+</script>
+<script src="<?php echo $base; ?>cytoscape.min.js"></script>
+<script src="<?php echo $base; ?>app.js"></script>
+<script>
+(function(){
+  var tabs = document.querySelectorAll('.side-tab');
+  tabs.forEach(function(t){
+    t.addEventListener('click', function(){
+      tabs.forEach(function(x){x.classList.remove('active');});
+      t.classList.add('active');
+      document.querySelectorAll('.panel-sec').forEach(function(s){s.classList.remove('active');});
+      var sec = document.getElementById('sec-' + t.getAttribute('data-sec'));
+      if (sec) sec.classList.add('active');
+    });
+  });
+})();
+</script>

+ 82 - 2
web-modules/WecomCenter/views/wecom.center.view.php

@@ -27,7 +27,7 @@ $view_url = (new CUrl('zabbix.php'))->setArgument('action', 'wecom.center.view')
 $mtid = $e($data['mediatypeid']);
 
 $active_tab = (string) ($_REQUEST['tab'] ?? 'alert');
-if (!in_array($active_tab, ['alert', 'opt', 'maint'], true)) {
+if (!in_array($active_tab, ['alert', 'opt', 'maint', 'l2'], true)) {
 	$active_tab = 'alert';
 }
 $tabHref = function (string $id) use ($data) {
@@ -325,6 +325,85 @@ if ($data['trig_search'] !== '') {
 $trigCard = $card('&#128295;', $t('sec.triggers'), $maintBody, ' wc-card--wide');
 $tab_maint = '<div class="wc-grid">'.$classCard.$trigCard.'</div>';
 
+/* ======================== TAB 4: L2 TOPOLOGY CONFIG ================== */
+$l2 = $data['l2'];
+
+// templates multi-select
+$tplSel = '';
+foreach ($data['l2_templates'] as $tn) {
+	$sel = in_array($tn, (array) $l2['templates'], true) ? ' selected' : '';
+	$tplSel .= '<option value="'.$e($tn).'"'.$sel.'>'.$e($tn).'</option>';
+}
+$collectBody =
+	'<div class="wc-hint">'.$t('lbl.l2_templates_hint').'</div>'
+	.'<div class="wc-field"><label>'.$t('lbl.l2_templates').'</label>'
+	.'<select class="wc-input wc-input--multi" name="templates[]" multiple size="8">'.$tplSel.'</select></div>'
+	.'<div class="wc-inline">'
+	.'<div class="wc-field"><label>'.$t('lbl.l2_full').'</label><input class="wc-input" type="number" min="1" name="full_interval_min" value="'.$e($l2['full_interval_min']).'"></div>'
+	.'<div class="wc-field"><label>'.$t('lbl.l2_alarm').'</label><input class="wc-input" type="number" min="1" name="alarm_interval_min" value="'.$e($l2['alarm_interval_min']).'"></div>'
+	.'</div>';
+$collectCard = $card('&#128225;', $t('sec.l2_collect'), $collectBody, ' wc-card--wide');
+
+$displayBody =
+	'<div class="wc-inline">'
+	.'<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>';
+$displayCard = $card('&#128421;&#65039;', $t('sec.l2_display'), $displayBody);
+
+$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) {
+	$colorFields .= '<div class="wc-field" style="flex:1;min-width:110px"><label>'.$t($lab).'</label>'
+		.'<input class="wc-input wc-input--color" type="color" name="color_'.$ck.'" value="'.$e($l2['colors'][$ck] ?? '#000000').'"></div>';
+}
+$colorCard = $card('&#127912;', $t('sec.l2_colors'), '<div class="wc-inline">'.$colorFields.'</div>', ' wc-card--wide');
+
+$latBody =
+	'<div class="wc-inline">'
+	.'<div class="wc-field"><label>'.$t('lbl.lat_warn').'</label><input class="wc-input" type="number" step="0.1" min="0" name="latency_warn" value="'.$e($l2['latency_warn']).'"></div>'
+	.'<div class="wc-field"><label>'.$t('lbl.lat_bad').'</label><input class="wc-input" type="number" step="0.1" min="0" name="latency_bad" value="'.$e($l2['latency_bad']).'"></div>'
+	.'</div>';
+$latCard = $card('&#9201;&#65039;', $t('sec.l2_latency'), $latBody);
+
+$ruleRowHtml = function (array $r) use ($e, $t) {
+	$isRegex = (($r['match'] ?? 'contains') === 'regex');
+	$big = !empty($r['big']);
+	return '<div class="wc-rule">'
+		.'<select class="wc-input" name="rule_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="rule_pattern[]" value="'.$e($r['pattern'] ?? '').'" placeholder="e.g. 4401">'
+		.'<input class="wc-input" name="rule_label[]" value="'.$e($r['label'] ?? '').'" placeholder="Frame label">'
+		.'<select class="wc-input" name="rule_big[]" style="max-width:90px">'
+		.'<option value="0"'.($big ? '' : ' selected').'>'.$t('lbl.rule_big').' −</option>'
+		.'<option value="1"'.($big ? ' selected' : '').'>'.$t('lbl.rule_big').' +</option></select>'
+		.'<button type="button" class="wc-btn wc-btn--ghost wc-rule-del" title="remove">&times;</button>'
+		.'</div>';
+};
+$ruleRows = '';
+foreach ((array) $l2['group_rules'] as $r) {
+	$ruleRows .= $ruleRowHtml($r);
+}
+$groupBody =
+	'<div class="wc-hint">'.$t('lbl.rule_hint').'</div>'
+	.'<div class="wc-rules" id="wc-rules">'.$ruleRows.'</div>'
+	.'<div class="wc-actions"><button type="button" class="wc-btn wc-btn--ghost" id="wc-rule-add">'.$t('btn.add_rule').'</button></div>'
+	.'<script type="text/template" id="wc-rule-tpl">'.$ruleRowHtml(['match' => 'contains', 'pattern' => '', 'label' => '', 'big' => false]).'</script>';
+$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>'
+	.'<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");'
+	.'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();}}});'
+	.'})();</script>';
+
 /* ============================ assemble =============================== */
 $hero = '<div class="wc-hero"><div class="wc-hero__title">'.$t('page.title').'</div>'
 	.'<div class="wc-hero__sub">'.$t('hero.sub').'</div></div>';
@@ -333,9 +412,10 @@ $tabbar = '<div class="wc-tabs">'
 	.'<a class="wc-tab'.($active_tab === 'alert' ? ' is-active' : '').'" href="'.$tabHref('alert').'">'.$t('tab.alert').'</a>'
 	.'<a class="wc-tab'.($active_tab === 'opt' ? ' is-active' : '').'" href="'.$tabHref('opt').'">'.$t('tab.opt').'</a>'
 	.'<a class="wc-tab'.($active_tab === 'maint' ? ' is-active' : '').'" href="'.$tabHref('maint').'">'.$t('tab.maint').'</a>'
+	.'<a class="wc-tab'.($active_tab === 'l2' ? ' is-active' : '').'" href="'.$tabHref('l2').'">'.$t('tab.l2').'</a>'
 	.'</div>';
 
-$content = ($active_tab === 'opt') ? $tab_opt : (($active_tab === 'maint') ? $tab_maint : $tab_alert);
+$content = ($active_tab === 'opt') ? $tab_opt : (($active_tab === 'maint') ? $tab_maint : (($active_tab === 'l2') ? $tab_l2 : $tab_alert));
 
 $html_page->addItem($raw('<div class="wc-wrap">'.$hero.$tabbar.$content.'</div>'));
 $html_page->show();

+ 83 - 15
web-modules/install.sh

@@ -1,25 +1,41 @@
 #!/usr/bin/env bash
 # ------------------------------------------------------------------
-# WeCom Center — one-step installer for a new Zabbix frontend
+# WeComCenter / ROMAN 定制中心 — one-step installer
 #
 # Usage:
-#   ./install.sh                 # auto-detect a running zabbix-* web container
-#   ./install.sh /path/to/modules# copy into a plain (non-docker) modules dir
+#   ./install.sh                       # auto-detect the running zabbix web container
+#   ./install.sh /path/to/modules      # copy into a plain (non-docker) modules dir
 #
-# After this script finishes, open Zabbix -> Administration -> Modules,
-# press "Scan directory", then Enable "WecomCenter". Then open the
-# WeCom Center page and use the "First-time setup / Initialise" button.
+# It will:
+#   1) copy the WecomCenter module next to the Zabbix frontend,
+#   2) fix permissions (module readable, assets/l2topology writable for the config mirror),
+#   3) create the two L2 tables if a MySQL container is reachable (idempotent),
+#   4) install the L2 collector into /opt/zabbix/l2collector and (optionally) crontab.
+#
+# Env knobs:
+#   MYSQL_CONTAINER (default: auto / zabbix-mysql)
+#   MYSQL_ROOT_PASSWORD (default: CobcoIT132)
+#   MYSQL_DATABASE (default: zabbix)
+#   COLLECTOR_DIR (default: /opt/zabbix/l2collector)
+#   INSTALL_CRON=0 to skip installing crontab entries.
 # ------------------------------------------------------------------
 set -euo pipefail
 
 HERE="$(cd "$(dirname "$0")" && pwd)"
 SRC="$HERE/WecomCenter"
+MYSQL_CONTAINER="${MYSQL_CONTAINER:-}"
+MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-CobcoIT132}"
+MYSQL_DATABASE="${MYSQL_DATABASE:-zabbix}"
+COLLECTOR_DIR="${COLLECTOR_DIR:-/opt/zabbix/l2collector}"
+INSTALL_CRON="${INSTALL_CRON:-1}"
 
 if [ ! -d "$SRC" ]; then
   echo "ERROR: WecomCenter folder not found next to install.sh" >&2
   exit 1
 fi
 
+DEST=""
+CONTAINER=""
 if [ $# -ge 1 ]; then
   DEST="$1"
   mkdir -p "$DEST"
@@ -27,19 +43,71 @@ if [ $# -ge 1 ]; then
   cp -r "$SRC" "$DEST/WecomCenter"
   echo "OK: copied to $DEST/WecomCenter"
 else
-  C="$(docker ps --format '{{.Names}}' 2>/dev/null | grep -iE 'zabbix.*(web|nginx|apache|frontend)' | head -n1 || true)"
-  if [ -z "$C" ]; then
+  CONTAINER="$(docker ps --format '{{.Names}}' 2>/dev/null | grep -iE 'zabbix.*(web|nginx|apache|frontend)' | head -n1 || true)"
+  if [ -z "$CONTAINER" ]; then
     echo "ERROR: no running Zabbix frontend container detected." >&2
     echo "       Pass the modules directory explicitly, e.g.:  ./install.sh /opt/zabbix/zabbix-server/web-modules" >&2
     exit 1
   fi
-  docker cp "$SRC" "$C:/usr/share/zabbix/modules/"
-  docker exec "$C" sh -c 'chown -R www-data:www-data /usr/share/zabbix/modules/WecomCenter 2>/dev/null || true'
-  echo "OK: copied into container '$C' -> /usr/share/zabbix/modules/WecomCenter"
+  docker cp "$SRC" "$CONTAINER:/usr/share/zabbix/modules/"
+  echo "OK: copied into container '$CONTAINER' -> /usr/share/zabbix/modules/WecomCenter"
+fi
+
+# ---- permissions: module readable everywhere, config dir writable by php ----
+if [ -n "$DEST" ]; then
+  chmod -R a+rX "$DEST/WecomCenter" 2>/dev/null || true
+  chmod -R 777 "$DEST/WecomCenter/assets/l2topology" 2>/dev/null || true
+else
+  docker exec "$CONTAINER" sh -c 'M=/usr/share/zabbix/modules/WecomCenter; chmod -R a+rX "$M"; chmod -R 777 "$M/assets/l2topology"' 2>/dev/null || true
+fi
+
+# ---- create L2 tables (idempotent) ----
+if [ -z "$MYSQL_CONTAINER" ]; then
+  MYSQL_CONTAINER="$(docker ps --format '{{.Names}}' 2>/dev/null | grep -iE 'zabbix.*(mysql|db|mariadb)' | head -n1 || true)"
+fi
+if [ -n "$MYSQL_CONTAINER" ]; then
+  echo "Ensuring L2 tables in container '$MYSQL_CONTAINER' ..."
+  docker exec -i "$MYSQL_CONTAINER" mysql -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE" <<'SQL' || echo "WARN: could not create tables automatically (page will retry on first load)."
+CREATE TABLE IF NOT EXISTS l2_topology_history (
+  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, ts BIGINT UNSIGNED NOT NULL,
+  captured_at DATETIME NOT NULL, stats TEXT NOT NULL, snapshot MEDIUMBLOB NOT NULL,
+  PRIMARY KEY (id), KEY idx_l2_ts (ts)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
+CREATE TABLE IF NOT EXISTS l2_topology_config (
+  id TINYINT UNSIGNED NOT NULL, config LONGTEXT NOT NULL, updated DATETIME NOT NULL,
+  PRIMARY KEY (id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
+SQL
+else
+  echo "WARN: no MySQL container detected; skip table creation (page auto-creates on load)."
+fi
+
+# ---- install collector on the host (needs SNMP access to switches) ----
+COLLECTOR_SRC="$HERE/../collector"
+if [ -d "$COLLECTOR_SRC" ] && [ -d /opt/zabbix ]; then
+  echo "Installing L2 collector to $COLLECTOR_DIR ..."
+  mkdir -p "$COLLECTOR_DIR"
+  cp -f "$COLLECTOR_SRC"/*.py "$COLLECTOR_DIR"/ 2>/dev/null || true
+  cp -f "$COLLECTOR_SRC"/*.sh "$COLLECTOR_DIR"/ 2>/dev/null || true
+  chmod +x "$COLLECTOR_DIR"/*.sh 2>/dev/null || true
+  # mirror config next to the module so collectors can read it
+  CFG="$(dirname "$SRC")/WecomCenter/assets/l2topology/config.json"
+  if [ -f "$CFG" ]; then cp -f "$CFG" "$COLLECTOR_DIR/config.json"; fi
+  if [ "$INSTALL_CRON" = "1" ] && command -v crontab >/dev/null 2>&1; then
+    { crontab -l 2>/dev/null | grep -v l2collector || true
+      echo "*/5 * * * * /bin/bash $COLLECTOR_DIR/run_full.sh >/dev/null 2>&1"
+      echo "* * * * * /bin/bash $COLLECTOR_DIR/refresh_alarms.sh >/dev/null 2>&1"
+    } | crontab - || echo "WARN: crontab install failed"
+    echo "OK: crontab entries installed (full every 5 min, alarm refresh every 1 min)."
+  fi
+else
+  echo "INFO: collector/ not found next to install.sh or /opt/zabbix missing; skip collector install."
 fi
 
 echo
-echo "Next steps:"
-echo "  1) Zabbix -> Administration -> Modules -> 'Scan directory' -> Enable 'WecomCenter'"
-echo "  2) Open 'WeCom Center' -> First-time setup -> paste your group-robot key -> Initialise"
-echo "     (it creates the media type, robot user, trigger action and {\$WECOM_KEY} macro)"
+echo "Done. Next steps:"
+echo "  1) Zabbix -> Administration -> ROMAN 定制中心  (module should already be enabled by compose)"
+echo "     If not listed: Administration -> Modules -> 'Scan directory' -> Enable 'WecomCenter'"
+echo "  2) Administration -> ROMAN 定制中心 -> 'L2 拓扑' tab: choose templates / colours / intervals -> Save"
+echo "  3) Monitoring -> L2 Topology  (or: /zabbix.php?action=l2.topology.view)"
+echo "  4) Run once: sudo bash $COLLECTOR_DIR/run_full.sh   (log: /var/log/l2collector.log)"

Some files were not shown because too many files changed in this diff