8 İşlemeler f31b257474 ... ce6d849504

Yazar SHA1 Mesaj Tarih
  clm0081 ce6d849504 feat(wecom): add L2 init and single-panel snapshot picker 2 gün önce
  clm0081 017f8f55d4 fix(l2): merge collection and history snapshot intervals 2 gün önce
  clm0081 468b208bf1 feat(l2): add core hierarchy config, DB-backed history and date-first snapshot picker 2 gün önce
  clm0081 25b1d5e0a8 chore(l2): align default latency legend with 1ms threshold 2 gün önce
  clm0081 0b9b05caf9 fix(l2): fall back to live view for stale snapshot timestamps 2 gün önce
  clm0081 7c0a63f79d fix(l2): recorded-snapshot picker, compact legend, export legend overlay 2 gün önce
  clm0081 96340166a0 feat(l2): configurable history snapshots and date-time picker 2 gün önce
  DeepSeek Harness bb1b120fa8 fix: detect Huawei PoE capability via entity model fallback 2 gün önce

+ 8 - 5
README.md

@@ -14,6 +14,8 @@
 - `compose-audit.md` —— 原 compose 的问题审计与缓存容量重估说明
 - `bootstrap/zbx-bootstrap.py` —— 「自监控-only」清理脚本(启动后摘除自身 agent 模板)
 - `bootstrap/docker-compose.bootstrap.yaml` —— 可选叠加编排:随 `up` 自动运行上述脚本
+- `docs/项目总结与验收.md` —— 功能、需求对照、安装初始化与验证记录
+- `docs/新会话功能测试与代码审查提示词.md` —— 可直接复制给新会话的验收/审查提示词
 
 ## 自监控-only:服务器自身主机不挂 agent(2026-11 变更)
 
@@ -106,17 +108,18 @@ docker compose -p zabbix ps                                     # 查看状态
   右上角「导出图片」始终按日间模式导出 PNG(导出图为静态实线,不把动画虚线烘焙进图片)。
   PoE 交换机由采集器读取华为 POE MIB / RFC3621 判定,名称前自动加 ⚡️。
   主题自动跟随 Zabbix 明暗模式,无手动深色开关。
-- 配置页:`Administration → ROMAN 定制中心 → 「L2 拓扑」`(采集模板、刷新/采集间隔、速率配色、
+- 配置页:`Administration → ROMAN 定制中心 → 「L2 拓扑」`(采集模板、刷新间隔、**采集/快照间隔、历史保留天数**、速率配色、
   延迟阈值、分组规则、根框名、**物理网络定义**、**核心交换机定义**等)。物理网络按每行
   `KEY | 显示名称 | 名称/IP关键字 | 颜色` 配置,交换机归入第一个名称或 IP 包含关键字的网络;
   核心交换机按行选择网络并填写名称/IP 匹配规则,同一网络可定义多个核心,全部固定在左侧核心列。
-- 数据:每次全量采集把快照 gzip 写入 MySQL `l2_topology_history`(保留 7 天,可回看任意时间);
-  配置存 `l2_topology_config`,并镜像 `assets/l2topology/config.json`。
+- 数据:每次全量采集成功后按 `full_interval_min`(默认 5 分钟)把快照 gzip 写入 MySQL `l2_topology_history`,
+  按 `history_days`(默认 7 天)覆盖清理旧数据;展示页可选实时或任意历史日期时间回看。
 - 表结构:`L2Store::ensureSchema()` 幂等建表;`wecom-module-init` 侧车在 `up` 时也会建表。
+- 初始化:配置页顶部有 **「初始化 / 自检 L2 存储」**(幂等建表 + 配置行缺失时写默认配置,不覆盖已有配置);告警页的「初始化 / 自检」也会顺带做这一步。采集器 cron 仍需在宿主机安装。
 
 ### docker-compose 一键启用
-`up -d` 时:`web-modules/` 挂入 web 容器 → `wecom-module-init` 自动登记并启用模块、创建 L2 表。
-全新环境无需进 UI 点启用。`WEB_MODULES_DIR` 可覆盖挂载源路径。
+`up -d` 时:`web-modules/` 挂入 web 容器 → `wecom-module-init` 等 Zabbix `module` 表就绪后自动登记并启用模块、创建 L2 表。
+全新环境无需进 UI 点启用;侧车会等待首次建库完成(最多 300 秒),避免时序失败。`WEB_MODULES_DIR` 可覆盖挂载源路径。
 
 ### 采集器(宿主机)
 采集器在 `collector/`,需装到 `/opt/zabbix/l2collector/` 并用 cron 调度(容器无法直接读交换机 SNMP)。

+ 16 - 0
collector/history_tick.py

@@ -0,0 +1,16 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""Deprecated compatibility stub.
+
+History rows are now written exactly once by ``run_full.sh`` together with the
+full collection.  Older installations may still have a crontab entry pointing
+here; keeping this stub prevents that entry from creating duplicate snapshots.
+"""
+
+
+def main():
+    return
+
+
+if __name__ == '__main__':
+    main()

+ 40 - 0
collector/l2_due.py

@@ -0,0 +1,40 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""Return exit 0 when the full collection interval is due.
+
+The shell scheduler wakes every five minutes.  This gate reads the same
+`full_interval_min` setting as the web config, so changing that single field
+changes both the full collection and the history-snapshot cadence.  A stamp
+file is updated only after a complete run, so a failed run is retried on the
+next scheduler tick.
+"""
+import os
+import sys
+import time
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, HERE)
+
+import l2cfg  # noqa: E402
+
+
+def main():
+    try:
+        cfg = l2cfg.config()
+        interval = max(5, int(cfg.get('full_interval_min') or 5))
+    except Exception as exc:
+        # Never let a temporary config/DB read failure block collection.
+        print('due check config fallback: %s' % exc)
+        interval = 5
+    stamp = os.environ.get('L2_FULL_STAMP') or '/tmp/l2_full_last'
+    try:
+        last = os.path.getmtime(stamp)
+    except OSError:
+        last = 0
+    if time.time() - last >= interval * 60:
+        return 0
+    return 1
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 82 - 17
collector/l2_snapshot.py

@@ -4,6 +4,7 @@
 import csv, json, os, re, collections, time, datetime, urllib.request, gzip, base64, subprocess
 
 import l2cfg
+import l2db
 
 TOK = 'd3abb125eb301a021ef80a2847a803a7cd2d0b1d3c8337a14d09db9d571369b5'
 API = 'http://192.168.87.20:8080/api_jsonrpc.php'
@@ -13,10 +14,6 @@ EP  = '/tmp/fdb_endpoints.tsv'
 PS  = '/tmp/port_status.tsv'
 LAT = '/tmp/latency.tsv'
 POE = '/tmp/poe_switches.tsv'
-MYSQL_CONTAINER = os.environ.get('L2_MYSQL_CONTAINER', 'zabbix-mysql')
-MYSQL_USER = os.environ.get('L2_MYSQL_USER', 'root')
-MYSQL_PASS = os.environ.get('L2_MYSQL_PASS', 'CobcoIT132')
-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'}
 
@@ -100,28 +97,35 @@ def load_poe():
     return out
 
 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)
+    # Credentials are read from the running Zabbix MySQL container itself;
+    # there is no separate L2 DB account or password file.
+    return l2db.run_sql(sql)
 
 def write_history(snap, now):
     cfg = l2cfg.config()
-    days = int(cfg.get('history_days') or 7)
+    days = max(1, min(30, int(cfg.get('history_days') or 7)))
+    # One snapshot per full collection.  history_interval_min is only a
+    # compatibility alias for configs saved by older module versions.
+    interval = max(5, int(cfg.get('full_interval_min') or cfg.get('history_interval_min') or 5))
+    interval_sec = interval * 60
     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')
+    # One row per configured interval.  The guard makes both run_full.sh and
+    # refresh_alarms.sh safe to call this function: whichever runs first at a
+    # boundary inserts, the other calls become no-ops.  Old rows are always
+    # pruned, so retention is a hard cap rather than a best effort.
     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"
+        "INSERT INTO l2_topology_history (ts,captured_at,stats,snapshot) "
+        "SELECT %d,'%s','%s',FROM_BASE64('%s') FROM DUAL "
+        "WHERE NOT EXISTS (SELECT 1 FROM l2_topology_history WHERE ts > %d);\n"
         "DELETE FROM l2_topology_history WHERE ts < %d;\n"
-        % (now, cap, st, b64, now - days * 86400)
+        % (now, cap, st, b64, now - interval_sec, now - days * 86400)
     )
     r = mysql(sql)
     if r.returncode != 0:
@@ -129,6 +133,7 @@ def write_history(snap, now):
 
 def main():
     now = int(time.time())
+    cfg = l2cfg.config()
     edges_raw = load(STP); eps = load(EP); ps = load(PS)
     lat = load_latency()
     poe = load_poe()
@@ -144,7 +149,7 @@ def main():
         pass
     prev_nodes = {str(n.get('id')): n for n in prev_snap.get('nodes', []) if n.get('id')}
     prev_edges = prev_snap.get('edges', []) or []
-    days = int(l2cfg.config().get('history_days') or 7)
+    days = int(cfg.get('history_days') or 7)
     cutoff = now - days * 86400
 
     # ---------- port status maps ----------
@@ -275,6 +280,55 @@ def main():
                     'error': i.get('error', '') or ''
                 }
 
+    # ---------- optional upstream hierarchy ----------
+    # Firewall -> behavior management -> total core are display-only roles
+    # above the sub-cores.  They are resolved from the configured name/IP
+    # patterns; an empty role contributes no node and no layout space.
+    def hier_hit(rule, name, ip):
+        pattern = str(rule.get('pattern') or '').strip()
+        if not pattern:
+            return False
+        hay = (name or '') + ' ' + (ip or '')
+        if str(rule.get('match') or 'contains') == 'regex':
+            try:
+                return re.search(pattern, hay, re.I) is not None
+            except re.error:
+                return False
+        return pattern.lower() in hay.lower()
+
+    upstream = []
+    upstream_seen = set()
+    hcfg = cfg.get('core_hierarchy') if isinstance(cfg.get('core_hierarchy'), dict) else {}
+    for role in ('firewall', 'behavior', 'total_core'):
+        rule = hcfg.get(role) if isinstance(hcfg.get(role), dict) else {}
+        if not rule.get('pattern'):
+            continue
+        for h in hs:
+            hn = norm(h.get('name') or '')
+            cand = [i for i in h.get('interfaces', []) if i.get('ip')]
+            pick = None
+            for i in cand:
+                if hier_hit(rule, hn, str(i.get('ip') or '')):
+                    pick = i
+                    if i.get('type') == '2':
+                        break
+            if not pick:
+                continue
+            ip = str(pick.get('ip') or '')
+            if not ip or ip in upstream_seen:
+                continue
+            upstream_seen.add(ip)
+            al = ip_alarms.get(ip, [])
+            upstream.append({
+                'id': ip, 'name': hn or ip_name.get(ip, ip), 'ip': ip,
+                'tier': 'upstream', 'hierarchy_role': role,
+                'hostid': h.get('hostid'), 'oper': 'down' if str(pick.get('available', '0')) == '2' else 'up',
+                'offline': str(pick.get('available', '0')) == '2',
+                'alarms': sorted(al, key=lambda a: -a['sev']),
+                'maxsev': max([a['sev'] for a in al], default=-1),
+                'latency_ms': lat.get(ip), 'children': 0, 'ports': [], 'last_online': now
+            })
+
     # ---------- terminals (current collection only; online only) ----------
     # An FDB entry on a port that is not up is stale and is not an "offline
     # terminal": the product only displays terminals that are present now.
@@ -369,6 +423,14 @@ def main():
             'ports': ports_by_ip.get(ip, []),
             'last_online': last_online
         })
+    # A configured upstream host that is also a protocol switch gets a role
+    # marker instead of a duplicate node.
+    upstream_by_ip = {u['id']: u for u in upstream}
+    for n in nodes:
+        u = upstream_by_ip.get(n['id'])
+        if u:
+            n['hierarchy_role'] = u['hierarchy_role']
+    upstream = [u for u in upstream if u['id'] not in known_ips]
 
     # ---------- edges ----------
     edges = []
@@ -416,9 +478,10 @@ def main():
         'ports': sum(len(n.get('ports') or []) for n in nodes),
         'edges': len(edges),
         'term_total': len(terminals), 'term_online': len(terminals), 'term_offline': 0,
-        'alarms': sum(len(n['alarms']) for n in nodes),
-        'devices_alarm': sum(1 for n in nodes if n['alarms']),
-        'maxsev': max([n['maxsev'] for n in nodes], default=-1)
+        'alarms': sum(len(n['alarms']) for n in nodes) + sum(len(n['alarms']) for n in upstream),
+        'devices_alarm': sum(1 for n in nodes if n['alarms']) + sum(1 for n in upstream if n['alarms']),
+        'upstream': len(upstream),
+        'maxsev': max([n['maxsev'] for n in nodes] + [n['maxsev'] for n in upstream], default=-1)
     }
     in_nodes = set(known_ips)
     other = []
@@ -433,6 +496,8 @@ def main():
             'refresh_default': 300, 'roots': roots, 'stats': stats,
             'other_alarms': other,
             'nodes': nodes, 'edges': edges, 'terminals': terminals}
+    if upstream:
+        snap['upstream'] = upstream
     os.makedirs(os.path.dirname(OUT), exist_ok=True)
     tmp = OUT + '.tmp'
     with open(tmp, 'w', encoding='utf-8') as f:

+ 42 - 2
collector/l2cfg.py

@@ -4,6 +4,12 @@ selected templates to template ids, and list the hosts that should be collected.
 """
 import os
 import json
+import time
+
+try:
+    import l2db
+except Exception:  # pragma: no cover - only used on an installed host
+    l2db = None
 
 DEFAULT_PATHS = [
     os.environ.get('L2_CONFIG') or '',
@@ -13,7 +19,25 @@ DEFAULT_PATHS = [
 ]
 
 
-def config():
+_CACHE = None
+_CACHE_AT = 0
+
+
+def _from_db():
+    """Read the module config from Zabbix's own DB (primary source)."""
+    if l2db is None:
+        return None
+    try:
+        raw = l2db.scalar('SELECT HEX(config) FROM l2_topology_config WHERE id=1')
+        if raw:
+            return json.loads(bytes.fromhex(raw).decode('utf-8'))
+    except Exception:
+        pass
+    return None
+
+
+def _from_mirror():
+    """Fallback for a fresh install before the first DB save."""
     for p in DEFAULT_PATHS:
         if p and os.path.isfile(p):
             try:
@@ -21,7 +45,23 @@ def config():
                     return json.load(f)
             except Exception:
                 pass
-    return {}
+    return None
+
+
+def config():
+    """Return the L2 configuration, DB first and JSON mirror as fallback."""
+    global _CACHE, _CACHE_AT
+    now = time.time()
+    if _CACHE is not None and now - _CACHE_AT < 30:
+        return _CACHE
+    data = _from_db()
+    if data is None:
+        data = _from_mirror()
+    if data is None:
+        data = {}
+    _CACHE = data
+    _CACHE_AT = now
+    return data
 
 
 def template_ids(call):

+ 72 - 0
collector/l2db.py

@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+"""Small DB bridge for the out-of-process L2 collectors.
+
+The collectors run on the Docker host, outside the Zabbix PHP container.  We
+deliberately do not keep a second set of MySQL credentials: the credentials
+are read from the already-running Zabbix MySQL container's environment (the
+same MYSQL_* values that the official Zabbix compose file passed to MySQL and
+Zabbix).  Queries are executed inside that container, so the DB host never
+needs to be resolvable from the host shell.
+"""
+import json
+import os
+import subprocess
+
+
+def _container_name():
+    return os.environ.get('L2_DB_CONTAINER') or os.environ.get('L2_MYSQL_CONTAINER') or 'zabbix-mysql'
+
+
+def _container_env():
+    """Return the real environment of the Zabbix MySQL container as a dict."""
+    name = _container_name()
+    out = subprocess.check_output(
+        ['docker', 'inspect', name, '--format', '{{json .Config.Env}}'],
+        text=True, timeout=30
+    )
+    env = {}
+    for item in json.loads(out or '[]'):
+        key, sep, value = str(item).partition('=')
+        if sep:
+            env[key] = value
+    return env
+
+
+def credentials():
+    """Return (container, user, password, database) without hard-coded secrets."""
+    env = _container_env()
+    user = env.get('MYSQL_USER') or env.get('DB_SERVER_USER') or ''
+    password = env.get('MYSQL_PASSWORD') or env.get('DB_SERVER_PASS') or ''
+    database = env.get('MYSQL_DATABASE') or env.get('DB_SERVER_DBNAME') or 'zabbix'
+    if not user or not password:
+        raise RuntimeError('Zabbix MySQL container has no MYSQL_USER/MYSQL_PASSWORD')
+    return _container_name(), user, password, database
+
+
+def run_sql(sql, timeout=120):
+    """Execute SQL inside the Zabbix MySQL container.
+
+    Returns the completed process.  stdin is used instead of argv so long
+    compressed history blobs never hit the command-line length limit.
+    """
+    container, user, password, database = credentials()
+    cmd = [
+        'docker', 'exec', '-i', container, 'mysql',
+        '-N', '-B', '--default-character-set=utf8mb4',
+        '-u' + user, '-p' + password, database
+    ]
+    return subprocess.run(cmd, input=sql.encode('utf-8'), capture_output=True, timeout=timeout)
+
+
+def query(sql, timeout=120):
+    """Run a SELECT and return rows as lists of strings (TSV, no header)."""
+    r = run_sql(sql, timeout=timeout)
+    if r.returncode != 0:
+        raise RuntimeError((r.stderr or b'').decode('utf-8', 'ignore')[:500])
+    text = (r.stdout or b'').decode('utf-8', 'ignore')
+    return [line.split('\t') for line in text.splitlines() if line != '']
+
+
+def scalar(sql, timeout=120):
+    rows = query(sql, timeout=timeout)
+    return rows[0][0] if rows and rows[0] else None

+ 20 - 5
collector/port_status.py

@@ -44,6 +44,7 @@ O = {
 }
 POE_STD = '1.3.6.1.2.1.105.1.1.1.1.1'                   # RFC 3621 pethPsePortAdminEnable
 POE_HW_JUDGE = '1.3.6.1.4.1.2011.5.25.195.4.1.2'        # HUAWEI-POE-MIB hwPoePortJudgeIsSupport (TruthValue)
+POE_ENTITY = '1.3.6.1.2.1.47.1.1.1.1.2'                 # ENTITY-MIB entPhysicalDescr (model description)
 def parse(lines, base):
     out = []
     for ln in lines:
@@ -66,13 +67,26 @@ def collect(t):
     iftype = n1('ifType'); ifadmin = n1('ifAdmin'); ifoper = n1('ifOper')
     ifspeed = n1('ifSpeed'); ifhigh = n1('ifHighSpeed'); lastchg = n1('ifLastChange')
     conn = n1('ifConnector'); dup = n1('duplex')
-    # PoE capability: standard RFC 3621 PSE table, plus Huawei's TruthValue
-    # capability table.  A row in the standard table means a PSE port exists;
-    # Huawei reports true(1)/false(2) for each interface.  This is capability,
-    # not current PD power, so it stays stable when no device is attached.
+    # PoE capability: standard RFC 3621 PSE table, Huawei's TruthValue
+    # capability table, and finally the ENTITY-MIB model description.  Some
+    # Huawei firmware hides the HUAWEI-POE-MIB subtree from SNMP views even
+    # though the switch is a PoE+ model; the entity description still contains
+    # the exact model (for example "S5735-L24P4XE-A-V2 ... PoE+"), so it is a
+    # reliable capability fallback.  This is capability, not current PD power.
     std_poe = parse(walk(args, ip, POE_STD), POE_STD)
     hw_poe = parse(walk(args, ip, POE_HW_JUDGE), POE_HW_JUDGE)
     poe = bool(std_poe) or any(num(v) == 1 for _, v in hw_poe)
+    poe_source = 'std' if std_poe else ('huawei' if poe else '')
+    poe_model = ''
+    if not poe:
+        ent_poe = parse(walk(args, ip, POE_ENTITY), POE_ENTITY)
+        for _, v in ent_poe:
+            txt = strval(v)
+            if 'POE' in txt.upper() or 'PSE' in txt.upper():
+                poe = True
+                poe_source = 'entity'
+                poe_model = txt
+                break
     # 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'])}
@@ -90,7 +104,8 @@ def collect(t):
     d.update({'ok': True, 'ifname': ifname, 'ifdescr': ifdescr, 'ifalias': ifalias, 'iftype': iftype,
               'ifadmin': ifadmin, 'ifoper': ifoper, 'ifspeed': ifspeed, 'ifhigh': ifhigh,
               'lastchg': lastchg, 'conn': conn, 'dup': dup, 'mac_cnt': dict(mac_cnt),
-              'mac_by_if': {k: v for k, v in mac_by_if.items()}, 'poe': poe})
+              'mac_by_if': {k: v for k, v in mac_by_if.items()}, 'poe': poe,
+              'poe_source': poe_source, 'poe_model': poe_model})
     return d
 
 def main():

+ 3 - 0
collector/refresh_alarms.sh

@@ -2,4 +2,7 @@
 L=/opt/zabbix/l2collector
 exec 7>/tmp/l2_full.lock
 flock -n 7 || exit 0            # skip if a full collection is running
+# Refresh the live JSON only. History is written exactly once by run_full.sh
+# after a successful full collection; this minute-level refresh must not add
+# duplicate snapshots.
 flock -n /tmp/l2_snap.lock /usr/bin/python3 $L/l2_snapshot.py >> /var/log/l2collector.log 2>&1 || true

+ 11 - 1
collector/run_full.sh

@@ -3,11 +3,21 @@ 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; }
+# The page exposes one interval: full collection and its history snapshot.
+# This gate makes the configured value effective even though cron wakes every
+# five minutes; L2_FORCE_FULL=1 bypasses it for a manual run.
+if [ "${L2_FORCE_FULL:-0}" != "1" ] && ! /usr/bin/python3 "$L/l2_due.py" >> "$LOG" 2>&1; then
+  exit 0
+fi
 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
+if L2_WRITE_HISTORY=1 flock /tmp/l2_snap.lock /usr/bin/python3 $L/l2_snapshot.py >> $LOG 2>&1; then
+  date +%s > /tmp/l2_full_last
+else
+  echo "snapshot failed" >> $LOG
+fi
 echo "[$(date -u +%FT%TZ)] FULL done" >> $LOG

+ 15 - 0
docker-compose.yaml

@@ -146,6 +146,8 @@ services:
     depends_on:
       zabbix-mysql:
         condition: service_healthy
+      zabbix-server:
+        condition: service_started
     environment:
       MYSQL_PWD: ${MYSQL_ROOT_PASSWORD:-CobcoIT132}
       MYSQL_DATABASE: ${MYSQL_DATABASE:-zabbix}
@@ -157,6 +159,19 @@ services:
         DB="$${MYSQL_DATABASE}"
         echo "[wecom-init] waiting for MySQL at $$HOST ..."
         until mysql -h "$$HOST" -uroot -e "SELECT 1" >/dev/null 2>&1; do sleep 2; done
+        echo "[wecom-init] waiting for Zabbix schema (module table) ..."
+        i=0
+        while [ "$$i" -lt 150 ]; do
+          if mysql -h "$$HOST" -uroot "$$DB" -e "SELECT 1 FROM module LIMIT 1" >/dev/null 2>&1; then
+            break
+          fi
+          sleep 2
+          i=$$((i + 1))
+        done
+        if ! mysql -h "$$HOST" -uroot "$$DB" -e "SELECT 1 FROM module LIMIT 1" >/dev/null 2>&1; then
+          echo "[wecom-init] ERROR: Zabbix schema/module table not ready after 300s" >&2
+          exit 1
+        fi
         mysql -h "$$HOST" -uroot "$$DB" <<'SQL'
         SET @nid := (SELECT COALESCE(MAX(moduleid), 0) + 1 FROM module);
         INSERT INTO module (moduleid, id, relative_path, status, config)

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

@@ -2,7 +2,7 @@
 
 > 适用版本:Zabbix **7.4.x**(已在 7.4.16 / MySQL 8.0.43 验证)。
 > 一个自包含、多语言的 Zabbix 前端模块(custom module / plugin)。加载后会在
-> **Administration → WeCom Center** 增加一个定制页面,分 **告警 / 优化 / 日常维护** 三个标签页。
+> **Administration → WeCom Center** 增加一个定制页面,分 **告警 / 优化 / 日常维护 / L2 拓扑** 四个标签页。
 
 ---
 
@@ -13,6 +13,7 @@
 | **告警 Alert** | 企业微信群机器人 key 设置与测试;5 套告警策略预设(**先预览 → 再应用**);手动勾选会通知的严重度;一键刷新(写入模板触发器 / 触发器原型 / 已发现触发器,全部节点生效)。 |
 | **优化 Optimization** | 服务端调优参数生成(只生成 `.env`/compose 片段与重建命令,**不落地**,运维自行重建);真实华为模板 →「导入」→「切换」迁移设备;Geomap 单点标记。 |
 | **日常维护 Maintenance** | **按类别批量改告警等级**;**按单个触发器精确调整**:勾选开关告警 + 行尾下拉改严重度;**保存后页面自动刷新**。 |
+| **L2 拓扑 Topology** | 一张只读全局物理拓扑;STP/MAC 协议关系、7 天快照历史、单面板时间选择器;PNG 导出、终端端口矩阵、告警/离线/延迟/速率着色。 |
 
 所有改动都走 **Zabbix 官方 API / 受控 SQL** 写进数据库,所以**换机器、重建容器、升级镜像,配置都在**——只要「加载插件」即可复用。
 
@@ -37,7 +38,7 @@ docker compose up -d
 启动后:
 
 - 文件层面:`./web-modules/WecomCenter` 被挂到容器 `/usr/share/zabbix/modules/WecomCenter`。
-- 数据层面:一次性服务 **`wecom-module-init`** 会等 MySQL 就绪后,把 `module` 表里的 `wecom_center` 登记为 **已启用(status=1)**。脚本是**幂等**的:已存在就不动,重复 `up` 也安全。
+- 数据层面:一次性服务 **`wecom-module-init`** 会等 MySQL 和 Zabbix 的 `module` 表就绪后,把 `wecom_center` 登记为 **已启用(status=1)**,并创建 L2 两张表。脚本是**幂等**的:已存在就不动,重复 `up` 也安全;不会因首次建库时序而失败。
 
 > 也就是说:**全新环境 `up -d` 完,什么都不用点,插件已经在了。**
 
@@ -50,16 +51,15 @@ docker compose up -d
        - /opt/zabbix/zabbix-server/web-modules:/usr/share/zabbix/modules
    ```
 2. 把 `web-modules/WecomCenter` 整个目录拷到宿主那台目录下(即 `.../web-modules/WecomCenter/`)。
-   - 也可用自带脚本:`cd web-modules && ./install.sh`(自动探测运行中的 `zabbix-*` web 容器并复制进去;或 `./install.sh /usr/share/zabbix/modules`)。
+   - 也可用自带脚本:`cd web-modules && ./install.sh`(自动探测运行中的 `zabbix-*` web 容器并复制进去;或 `./install.sh /usr/share/zabbix/modules`)。脚本会顺带建 L2 表,并在 `module` 表已就绪时**自动登记/启用** `wecom_center`。
 3. `docker compose up -d` 重建 web 容器使挂载生效(只重启 web,数据不动)。
-4. **启用模块**(写 `module` 表,`status=1` 为启用;`moduleid` 不是自增列,需显式给值):
+4. 若脚本提示 `module` 表尚未就绪,等 Zabbix 首次启动完成后,进 **Administration → General → Modules → Scan directory → 启用 `WecomCenter`**;也可以手工执行:
    ```sql
    SET @nid := (SELECT COALESCE(MAX(moduleid),0)+1 FROM module);
    INSERT INTO module(moduleid,id,relative_path,status,config)
    SELECT @nid,'wecom_center','modules/WecomCenter',1,'[]'
    WHERE NOT EXISTS (SELECT 1 FROM module WHERE id='wecom_center');
    ```
-   也可以进 **Administration → General → Modules → Scan directory → 启用 `WecomCenter`**。
 
 ### 2.3 换环境 / 升级镜像
 
@@ -90,6 +90,8 @@ docker compose up -d
 
 > 群机器人就是「往群里发一条消息」,**没有收件人账号/接收目标**的概念,所以这里不设真人接收者,只维护一个机器人账号承载媒介。
 
+**拓扑初始化**:同一次「初始化 / 自检」也会幂等创建 L2 拓扑的 `l2_topology_history` / `l2_topology_config`,并在配置行缺失时写入默认配置(已有配置绝不覆盖)。如果只想初始化拓扑、不碰告警链路,到 **ROMAN 定制中心 → L2 拓扑** 顶部点 **「初始化 / 自检 L2 存储」** 即可。采集器 cron 仍需按 10.4 在宿主机安装,容器不会替你做 SNMP 采集。
+
 ---
 
 ## 4. 页面使用详解
@@ -238,13 +240,12 @@ zabbix-deploy/
     但任何目标标签和其他线路不被遮挡;同一车间内部线路互不交叉,跨区直连核心的链路除外。点任意交换机高亮完整上联链路,点空白恢复。
   - **数据流动效果**:正常在线链路叠加从核心向下游流动的细虚线(下行/离线链路不流动),
     100M/1G/10G/20G+ 的流动速度依次加快,仅作视觉提示,不改变任何连接关系。
-  - **PoE 交换机**:采集器通过华为 `hwPoePortJudgeIsSupport` 或 RFC3621 `pethPsePortAdminEnable` 判断 PoE 能力,
-    交换机名称最前面显示 `⚡️`;当前网络若没有 PoE 设备则不显示。
+  - **PoE 交换机**:采集器优先通过 RFC3621 `pethPsePortAdminEnable` 和华为 `hwPoePortJudgeIsSupport` 判断;部分华为固件不开放 HUAWEI-POE-MIB 时,再用 ENTITY-MIB `entPhysicalDescr` 的型号描述(如 `S5735-L24P4XE-A-V2 ... PoE+`)兜底。支持 PoE 的交换机名称最前面显示 `⚡️`;当前离线或 SNMP 无响应的设备不显示。
 - **节点标签**:`车间 · 楼层机柜U · 角色 · IP`;双击节点跳转该主机的问题页。
 - **告警/离线/延迟着色**:告警交换机整个节点填充红色,离线交换机同样整节点红色;延迟 **3–5ms 为橙色边框**,**大于 5ms 才为红色边框**。告警不会再只画一个红框。告警面板只显示拓扑内交换机,不混入服务器/测试机等“拓扑外”主机。
 - **显示终端(端口矩阵)**:勾选「显示终端」后,接入/汇聚交换机下方按实际端口清单绘制圆点;外圈颜色表示该端口**支持的最大速率**(100M 橙、1G 绿、2.5G 青、10G 紫、20G+ 深紫、未知灰),圆心实心颜色表示该端口**当前实际链路速率**。例如:10G 口插 1G 线 = 紫色外圈 + 绿色实心;10G 口未插线 = 空心紫色圈;2.5G 口 = 青色圈。端口较多时按实际面板自动分两行/多行,上联端口单独成行;历史快照没有端口清单时退回通用 24 口两行矩阵(12+12),不丢已有终端位置。端口颜色与上方链路速率颜色是两套语义,不要混用。
 - 右侧「离线交换机」面板可查看离线交换机并点击定位;终端面板点击任意终端可定位到其接入交换机及具体端口。
-- **时间轴**:右上「快照时间」下拉可选 `实时` 或历史某分钟快照(保存 **7 天**,5 分钟一个点)。
+- **时间轴**:右上「快照时间」是一个下拉面板(不是两个并排选择框):左侧只列实际保存过快照的日期(含「实时(最新)」),点日期后右侧只列该日期实际保存过的时间点,点时间即切换;默认 5 分钟一个点,保存 7 天;`采集/快照间隔`和`历史保留天数`都在配置页可调;采集成功即保存,不再单独设置快照间隔。
 - **明暗主题**:自动跟随 Zabbix 当前主题(Zabbix 深色即深色、亮色即亮色),无手动开关。
 - **搜索**:输入 IP / MAC / 名称,下拉结果点击即居中定位。
 - **导出图片**:右上角「导出图片」把当前完整拓扑导出为 PNG;无论当前 Zabbix 是明色还是深色,导出图固定使用日间模式(白底),
@@ -253,11 +254,15 @@ zabbix-deploy/
 
 ### 10.3 数据与存储
 - 采集结果 → `assets/l2topology/snapshot.json`(展示页实时读取)。
-- 每次**全量采集**同时把快照 gzip 后写入 MySQL 表 `l2_topology_history`
-  (字段 ts、captured_at、stats、snapshot),并自动清理超过 `history_days`(默认7) 天的旧数据。
+- 每次全量采集成功后,按配置的 `full_interval_min`(默认 5 分钟)把快照 gzip 后写入 MySQL 表 `l2_topology_history`
+  (字段 ts、captured_at、stats、snapshot),并自动清理超过 `history_days`(默认 7 天)的旧数据;
+  每分钟告警刷新只更新实时 JSON,不重复写历史。
+  按当前约 822 KB 原始 JSON / 约 33 KB gzip 压缩后估算:5 分钟 × 7 天 = **2,016 条**,约 **65–75 MB**;
+  10 分钟约 33 MB,1 分钟约 330 MB。默认 5 分钟/7 天不夸张。
 - 配置存 `l2_topology_config`(单行 id=1),并镜像到 `assets/l2topology/config.json` 供采集脚本读取。
 - 两张表由 `L2Store::ensureSchema()` 在页面加载时自动 `CREATE TABLE IF NOT EXISTS`;
   `docker-compose.yaml` 的 `wecom-module-init` 一次性容器也会在 `up` 时建表,全新环境零手工。
+- 手动安装/升级后可在 **L2 拓扑配置页顶部**点 **「初始化 / 自检 L2 存储」**:幂等建表、配置行缺失时写默认配置,不覆盖已有配置。它与告警「初始化 / 自检」是同一套幂等逻辑,但不会创建/修改任何 Zabbix 用户或告警对象。
 
 ### 10.4 采集器(宿主机运行)
 采集器代码位于仓库 `collector/`,安装到 `/opt/zabbix/l2collector/`:
@@ -267,10 +272,10 @@ 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 + 写历史库(仅全量时)
+l2_snapshot.py        # 合成 snapshot.json + 按配置间隔写历史库
 l2cfg.py              # 读取 config.json,按模板过滤要采集的主机
 run_full.sh           # 全量:stp->fdb->analyze->port->latency->snapshot(写历史)
-refresh_alarms.sh     # 仅刷新告警(每分钟,不写历史)
+refresh_alarms.sh     # 仅刷新实时告警 JSON(每分钟;不写历史)
 ```
 - **只采集配置页所选模板**(默认 `Huawei VRP by SNMP (walk)`,id 10962)的主机,避免误采其它设备。
 - 安装(一次性,幂等):
@@ -285,14 +290,15 @@ refresh_alarms.sh     # 仅刷新告警(每分钟,不写历史)
 
 ### 10.5 docker-compose 一键启用
 - `docker-compose.yaml` 已把 `web-modules/` 挂进 `zabbix-web` 容器,
-  并由 `wecom-module-init` 一次性容器自动登记(启用)模块、创建 L2 两张表。
+  并由 `wecom-module-init` 一次性容器等待 Zabbix 的 `module` 表就绪后自动登记(启用)模块、创建 L2 两张表。
   全新机器 `docker compose up -d` 后插件即已启用,无需手工去「Administration → Modules」点启用。
+  全新库首次创建 `module` 表需要一点时间,侧车最多等 300 秒,避免首次 `up` 时序失败。
 - 若 `web-modules` 目录不在 compose 同级,用 `.env` 的 `WEB_MODULES_DIR` 指向绝对路径。
 - 采集器为宿主机进程(需 SNMP 工具、且要读交换机网络),不在容器内运行,按 10.4 安装。
 
 ### 10.6 故障排查
 - 展示页空白:确认 `assets/l2topology/snapshot.json` 存在(跑一次 `run_full.sh`)。
-- 时间轴没有历史:只有**全量采集**写历史;确认 cron 在跑,`l2_topology_history` 行数在增长。
+- 时间轴没有历史:确认 cron 在跑、`l2_topology_history` 行数在增长;历史写入间隔最少 5 分钟,改配置页后需等下一个周期。
 - 交换机没被采集:检查配置页模板是否勾选正确、该主机是否挂了该模板、SNMP 可达。
 - 颜色/阈值不生效:改完配置页保存后,`config.json` 会同步;采集侧下个周期生效。
 

+ 148 - 0
docs/新会话功能测试与代码审查提示词.md

@@ -0,0 +1,148 @@
+# 新会话:WecomCenter / ROMAN 定制中心 功能测试与代码审查提示词
+
+> 把下面整段提示词复制给新会话即可。它假定新会话能访问同一仓库与测试环境;如果环境不同,请先替换“环境事实”中的地址/路径。
+
+---
+
+## 角色与目标
+
+你是一名资深 Zabbix 7.4 模块开发/测试工程师,同时具备 PHP、JavaScript、MySQL、Docker 与 SNMP 运维经验。请对 `zabbix-deploy` 仓库中的 `WecomCenter` 自定义模块做一次**只读优先**的功能验收 + 代码审查:
+
+1. **不要先改代码**。先复现、取证、定位;输出按严重级别排序的问题清单和最小修复建议。
+2. 只有我明确说“修复”后,才允许改代码;任何生产环境变更必须先备份并说明回滚方法。
+3. 不要把测试环境的密码、Cookie、Token 写进报告或提交到仓库。
+
+## 环境事实
+
+- 仓库/工作目录:`C:\Users\COBCO\Documents\deepseek-harness\default-workspace\diag\zabbix-deploy`
+- 分支:`master`;远端:`origin/master`
+- 生产 Zabbix:`https://zbx.cobco.ma`(Zabbix 7.4.16,MySQL 8.0.43)
+- 主机:`root@10.160.10.213`;部署目录:`/opt/zabbix`
+- 容器:`zabbix-webservice`(web,PHP 8.5.4)、`zabbix-server`、`zabbix-mysql`
+- 模块容器路径:`/usr/share/zabbix/modules/WecomCenter`
+- 宿主机采集器路径:`/opt/zabbix/l2collector/`;cron:
+  - `*/5 * * * * /bin/bash /opt/zabbix/l2collector/run_full.sh`
+  - `* * * * * /bin/bash /opt/zabbix/l2collector/refresh_alarms.sh`
+- 现有测试脚本:`diag/l2test/*.mjs`(Playwright),`diag/scprun.ps1` / `diag/sshrun.ps1`(上传/远程执行),`diag/lint_l2.sh`(PHP lint)
+- 凭据:从运行中的容器环境读取(`MYSQL_USER` / `MYSQL_PASSWORD` / `MYSQL_DATABASE`),不要在报告中回显。
+
+## 必须遵守的业务约束
+
+- **绝不破坏 Zabbix**:不改 Zabbix 核心文件,不创建用户,不做破坏性 DROP/TRUNCATE。
+- **拓扑只读**:L2 拓扑是展示层,不写交换机配置,不改 VLAN/端口/STP。
+- **协议真相**:交换机级联关系只能来自 STP 指定桥 + MAC 学习;绝不使用 `ifAlias` 猜关系。
+- 层级:`防火墙 → 行为管理 → 总核心 → 分核心(监控核心/办公核心/未来生产核心)`;空角色不显示且不占位。
+- 时间选择器:只列 `l2_topology_history` 中真实存在的快照;非法 `?ts=` 必须回退实时最新,不能白屏/报错。
+- 导出 PNG 必须把图例烘焙进图片(DOM overlay 不会被 `cy.png()` 导出)。
+- 告警语义:告警交换机整节点红色填充;延迟 3–5ms 橙色边框、>5ms 红色边框;不要把告警降级成只画边框。
+- 改动要可逆、最小化;不要“乱改交换机关系”。
+
+## 第一部分:代码审查清单
+
+请逐个文件审查并给出 `文件:行号`:
+
+### A. 数据库与 PHP
+- `web-modules/WecomCenter/lib/L2Store.php`
+  - 自定义表读写是否使用 `\DBselect()` / `\DBfetch()` / `\DBexecute()`;是否存在误用 `DB::select()` / `DB::execute()`。
+  - `ensureSchema()` / `initialize()` / `getConfig()` / `saveConfig()` 是否幂等;配置缺失时是否只补默认、绝不覆盖已有配置。
+  - 是否存在 SQL 注入、表名拼接风险、异常吞掉导致静默失败。
+- `web-modules/WecomCenter/actions/WecomCenterSave.php`
+  - `init_l2` / `init_env` 的权限、CSRF、输入校验、消息 i18n。
+  - 是否可能创建/修改用户;是否可能改动交换机配置。
+- `web-modules/WecomCenter/actions/L2TopologyView.php`
+  - 历史查询限制、`usedTs` 回退逻辑、排序/分页、对超大 history 表的性能。
+- `web-modules/WecomCenter/views/topology.view.php` / `wecom.center.view.php`
+  - 输出转义(XSS)、表单嵌套是否合法、asset 版本号、空数据状态。
+
+### B. 前端
+- `assets/l2topology/app.js`
+  - 单面板选择器:日期→时间联动、`stopPropagation`、outside click / Escape、无历史时的空态、非法 `?ts=` 回退。
+  - `render()` 与 `buildHistory()` 的初始化顺序;自动刷新是否破坏已打开面板。
+  - Cytoscape 布局:空角色不占位、核心/分核心/防火墙/行为管理顺序、链路标签遮挡、导出 canvas 图例。
+  - 事件监听是否重复绑定/内存泄漏;`innerHTML` 插入配置文本是否转义。
+- `assets/l2topology/style.css`
+  - 明暗主题变量、popover 在窄屏/移动端的溢出、按钮可点击区域、颜色对比度。
+
+### C. 部署与初始化
+- `docker-compose.yaml`:`wecom-module-init` 等待 `module` 表的时序、幂等、失败重试、`moduleid` 处理、L2 表 DDL。
+- `web-modules/install.sh`:容器/目录两种模式、MySQL 凭据来源、模块登记、collector/cron 安装、权限、失败提示。
+- `Module.php` / `manifest.json`:action 注册、menu 注入、locale、schema 初始化是否一致。
+- 文档:`README.md`、`docs/使用说明.md`、`web-modules/README.md` 与实际行为是否一致(尤其“全新 compose 默认启用”“L2 初始化”“时间选择器”)。
+
+### D. 安全与兼容性
+- Zabbix 7.4 / PHP 8.5 兼容性;是否使用已废弃 API。
+- 所有用户输入(模板名、网络名、颜色、匹配规则、label)的输出编码。
+- 最小权限:web 进程对 `assets/l2topology` 的写权限是否过大;配置文件是否可能被未授权读取。
+- 采集器 gzip / MySQL MEDIUMBLOB / 7 天清理是否有边界错误(时区、0 行、重复 ts)。
+
+## 第二部分:功能测试用例
+
+建议用 Playwright(参考 `diag/l2test/picker-popover.mjs`)或手工浏览器执行;每个用例记录 URL、步骤、实际结果、截图路径。测试前先确认当前 `l2_topology_history` 至少有 2 个日期、多个时间点。
+
+### T1. 配置页与初始化
+1. 打开 `https://zbx.cobco.ma/zabbix.php?action=wecom.center.view&tab=l2`。
+2. 确认标题为「ROMAN 定制中心」,顶部有「L2 初始化 / 自检」卡片和按钮。
+3. 点「初始化 / 自检 L2 存储」;预期成功消息包含「配置已存在」或「配置已创建」,历史快照条数;页面无 JS 错误。
+4. 刷新页面,确认配置值未被重置;数据库 `l2_topology_config` 仍只有 1 行 id=1。
+5. 检查空角色:把「生产核心」规则留空保存;拓扑中不应出现空节点/空白列,但配置页空行仍可保留。
+
+### T2. 时间选择器(重点回归)
+1. 打开 `https://zbx.cobco.ma/zabbix.php?action=l2.topology.view`。
+2. 点右上「快照时间」按钮;预期只出现一个面板(不是两个并排 select),面板含「实时(最新)」+ 实际有快照的日期。
+3. 点某个日期;预期右侧时间列表立即出现该日期真实时间点,label 变成 `日期 · 选择时间`(不能灰色不可选)。
+4. 点某个时间;预期 URL 带 `&ts=...`,拓扑切换到该快照,label 显示 `日期 时间`。
+5. 点「实时(最新)」;预期回到 live,URL 无 `ts`,label 为「实时(最新)」。
+6. 打开面板后点页面空白 / 按 Escape;预期面板关闭。
+7. 访问 `...&ts=1`(或已过期 ts);预期回退实时最新,不白屏、不报错、不显示不存在的快照。
+8. 在 1366×768 和手机宽度 390px 各看一次;预期面板不溢出、按钮可点。
+
+### T3. 拓扑展示与导出
+1. 确认节点顺序:`防火墙 → 行为管理 → 总核心 → 分核心`;核心/分核心按配置匹配。
+2. 找一台告警交换机:整节点应为红色填充;找延迟 3–5ms:橙色边框;>5ms:红色边框。
+3. 检查链路颜色:100M 橙、1G 绿、2.5G 青、10G 蓝、20G+ 紫、未知红、down 红虚线。
+4. 检查车间框/楼层框:GRD→1F→3F→4F→7F;跨网络车间拆成两个框;链路不穿越目标标签。
+5. 勾选「显示终端」:端口矩阵外圈=最大速率、实心=当前速率;无端口清单时显示 24 口兜底。
+6. 点「导出图片」:下载 PNG,打开确认图例已烘焙进去、白底日间模式、无动画虚线。
+7. 点空白、点节点、搜索 IP/MAC/名称、缩放/适应窗口;确认无异常。
+
+### T4. 历史与数据
+1. 触发一次 `L2_FORCE_FULL=1 bash /opt/zabbix/l2collector/run_full.sh`(或等下一个周期)。
+2. 查 `l2_topology_history`:应有新行,`captured_at` 合理,`snapshot` gzip 可解压。
+3. 确认 `refresh_alarms.sh` 只更新 `snapshot.json`,不新增历史行。
+4. 将 `full_interval_min` 改为 10,等下一周期;确认没有早于 10 分钟的重复全量写历史(`l2_due.py` 门控)。
+5. 检查超过 `history_days` 的旧行会被清理;至少确认清理 SQL 不会误删当天数据。
+
+### T5. 安装/升级
+1. 在测试目录跑 `bash install.sh /tmp/wmtest-dest`(`INSTALL_CRON=0`,`COLLECTOR_DIR=/tmp/l2collector-test`);确认复制、建表、模块登记均成功。
+2. `docker compose -f docker-compose.yaml config -q` 通过。
+3. 在全新/隔离 compose 项目执行 `up -d`;确认 `wecom-module-init` 等待 `module` 表后成功,模块 status=1,L2 表存在。
+4. 重复 `up -d`;确认幂等,不产生重复 module 行、不覆盖配置。
+
+## 第三部分:输出格式
+
+请按以下结构输出:
+
+1. **结论**:可上线 / 有阻塞问题 / 需修复后复测(一句话)。
+2. **问题清单**(表格):`ID | 严重级别 P0/P1/P2/P3 | 文件:行号 | 现象 | 复现步骤 | 期望 | 建议修复`。
+3. **测试结果表**:`用例 | 通过/失败/阻塞 | 证据(截图/URL/命令输出)`。
+4. **代码审查发现**:按 A/B/C/D 分类,区分“确定 bug / 风险 / 建议”。
+5. **回归风险**:列出修复可能影响的功能。
+6. **未覆盖项**:明确哪些没测、为什么。
+
+严重级别定义:
+- P0:会导致 Zabbix 不可用、数据丢失、创建用户/改交换机、拓扑关系错误。
+- P1:主要功能不可用(时间选择器、导出、初始化、模块安装失败)。
+- P2:局部显示/交互/兼容性问题。
+- P3:代码质量、文档、可维护性建议。
+
+## 第四部分:禁止事项
+
+- 不要执行 `DROP DATABASE`、`TRUNCATE`、`DELETE FROM module`、批量删除历史等破坏性 SQL。
+- 不要在生产环境创建/删除 Zabbix 用户,不要改交换机配置。
+- 不要为了“让测试通过”而修改测试脚本断言或隐藏错误。
+- 不要提交任何凭证、Cookie、Token、生产数据库导出到 git。
+- 任何生产修复必须先给出备份路径和回滚命令,等我确认。
+
+---
+
+**开始前请先**:`git status`、`git log --oneline -10`、确认当前部署版本;然后按 T1→T5 顺序执行,先取证再给结论。

+ 121 - 0
docs/项目总结与验收.md

@@ -0,0 +1,121 @@
+# WecomCenter / ROMAN 定制中心 — 项目总结与验收
+
+> 更新时间:2026-11-06
+> 适用:Zabbix 7.4.x(已在 7.4.16 + MySQL 8.0.43 验证)
+> 模块目录:`web-modules/WecomCenter`
+
+## 1. 一句话定位
+
+一个不修改 Zabbix 源码、以 custom module 方式挂载的运维定制中心:把企业微信告警、模板调优、日常告警等级维护,以及 L2 物理拓扑集中在一个页面里。所有配置写 Zabbix 数据库,重建容器/升级镜像后仍在。
+
+## 2. 功能清单
+
+### 2.1 告警 Alert
+- 企业微信群机器人 Webhook Key 的单字段读写、测试发送。
+- 5 套告警策略预设:先预览(dry-run,不写库)再应用;支持回滚。
+- 按类别批量调整模板触发器 / 触发器原型 / 已发现触发器严重度;必告警类别固定 Disaster。
+- 手动严重度勾选、媒介总开关。
+- 一键初始化/自检:媒体类型、机器人用户、触发器动作、`{$WECOM_KEY}` 宏;已存在则复用。
+
+### 2.2 优化 Optimization
+- 按 小/中/大 规模生成 Zabbix server 调优参数(只生成 `.env` / compose 片段,不落地)。
+- 导入真实华为模板 `Huawei VRP by SNMP walk`,并按旧模板→新模板批量切换设备(保留其它模板链接)。
+- Geomap 单点标记。
+
+### 2.3 日常维护 Maintenance
+- 按类别批量改告警等级。
+- 按单个触发器勾选开关告警、行尾下拉改严重度;保存后页面自动刷新。
+- 所有探测节点(模板→主机)同步。
+
+### 2.4 L2 拓扑 Topology(本次重点)
+- **只读展示**:不做任何交换机配置写入,不创建用户,不改 VLAN/端口/STP。
+- **协议真相**:交换机级联关系只来自 STP 指定桥 + MAC 学习;绝不使用 `ifAlias` 猜关系。
+- **一张全局大图**:核心 → 网络主交换 → 车间汇聚/分布 → 接入,一级一列;车间框内按 GRD→1F→3F→4F→7F 楼层排列。
+- **层级**:`防火墙 → 行为管理 → 总核心 → 分核心(监控核心/办公核心/未来生产核心)`;角色留空则完全不显示、不占位;空的未来生产核心保留一行但不激活。
+- **7 天历史**:每次全量采集成功按 `full_interval_min`(默认 5 分钟)gzip 写 `l2_topology_history`,按 `history_days`(默认 7 天)清理;`refresh_alarms.sh` 只刷新实时 JSON,不写历史。
+- **单面板时间选择器**:右上「快照时间」一个按钮打开一个面板,左侧只列实际有快照的日期(含“实时(最新)”),右侧列该日期实际时间点;不再使用两个并排 select,也不会出现选了日期时间仍灰色不可选。
+- **展示能力**:告警整节点红填充;离线红填充;延迟 3–5ms 橙框、>5ms 红框;链路 100M 橙 / 1G 绿 / 2.5G 青 / 10G 蓝 / 20G+ 紫;终端端口矩阵区分“最大速率外圈”和“当前速率实心”。
+- **导出**:PNG 固定日间模式,图例直接烘焙进 PNG(DOM 覆盖层不会被 `cy.png()` 导出)。
+- **交互**:搜索、缩放、适应窗口、离线面板、终端面板、实时自动刷新/暂停。
+- **PoE**:优先 RFC3621 / 华为 PoE MIB,固件不开放时用 ENTITY-MIB 型号描述兜底;离线/无响应不显示 ⚡️。
+
+## 3. 需求与实现对照
+
+| 需求 | 状态 | 落点 |
+|---|---|---|
+| 增加 L2 物理拓扑,只读展示 | 完成 | `actions/L2TopologyView.php`、`views/topology.view.php`、`assets/l2topology/` |
+| 7 天历史、按天/时间回看 | 完成 | `l2_topology_history`、`L2Store`、`app.js` |
+| 配置页命名 ROMAN 定制中心 / ROMAN Custom Center | 完成 | 菜单、`page.title`、locale |
+| 拓扑层级:总核心/分核心/防火墙/行为管理 | 完成 | `L2Store::defaults()` 的 `core_hierarchy`、配置页 |
+| 留空角色不显示、不占空间 | 完成 | 拓扑布局按实际匹配节点生成 |
+| 时间选择器:选日期后时间不能灰 | 完成 | 单面板 popover,日期与时间联动 |
+| 不要两个选择框,要美观 | 完成 | `.l2-ts-picker` 单按钮 + 单面板 |
+| 图例进 PNG 导出 | 完成 | `app.js` 导出逻辑追加 canvas 图例 |
+| 移除“鼠标移到交换机上…”提示 | 完成 | 已删除 |
+| 告警块上移 | 完成 | 布局调整 |
+| 不破坏 Zabbix、不创建用户、协议真相只来自 STP/MAC | 遵守 | L2 无写交换机、无新用户;关系仅 STP/MAC |
+| 检查 git 是否上传、新 Zabbix 能否安装、compose 是否默认有功能、拓扑是否有初始化 | 完成/已修 | 见第 5、6 节 |
+
+## 4. 关键实现说明
+
+- 自定义表:
+  - `l2_topology_config`:单行 `id=1`,`config` LONGTEXT,`updated` DATETIME。
+  - `l2_topology_history`:`ts`、`captured_at`、`stats`、gzip `snapshot` MEDIUMBLOB。
+- Zabbix 模块内读自定义表必须用 `\DBselect()` + `\DBfetch()`,写入/DDL 用 `\DBexecute()`;不能用 `DB::select()`(它不是裸 SQL,且会查 Zabbix schema 缓存)。
+- `L2Store::ensureSchema()` 在页面加载时幂等建表;`L2Store::initialize()` 额外在配置行缺失时写入默认配置(绝不覆盖已有配置)。
+- 采集器:宿主机 `/opt/zabbix/l2collector/`,cron:
+  - `*/5 * * * * /bin/bash /opt/zabbix/l2collector/run_full.sh`
+  - `* * * * * /bin/bash /opt/zabbix/l2collector/refresh_alarms.sh`
+  - `l2_due.py` 按 `full_interval_min` 控制全量采集频率;`L2_FORCE_FULL=1` 可强制一次。
+- 单次全量采集同时写实时 `snapshot.json` 和一份历史;历史行实测约 33–36 KB gzip,7 天 @5 分钟约 65–75 MB。
+
+## 5. 安装与初始化
+
+### 5.1 全新 Zabbix(仓库 compose)
+仓库 `docker-compose.yaml` 已包含:
+- `web-modules/` 挂载到 `/usr/share/zabbix/modules`;
+- `wecom-module-init` 一次性侧车:等待 MySQL 和 Zabbix `module` 表就绪(最多 300 秒)→ 幂等登记 `wecom_center`(status=1)→ 创建 L2 两张表。
+- 全新环境 `docker compose up -d` 后模块已启用,无需手工点启用;打开页面后默认配置自动生效。
+
+### 5.2 已有 Zabbix(手动)
+```bash
+cd web-modules
+./install.sh                 # 自动探测 web/mysql 容器
+# 或:./install.sh /opt/zabbix/zabbix-server/web-modules
+```
+`install.sh` 会复制模块、设置权限、建 L2 表、在 `module` 表已就绪时自动登记/启用模块、把 collector 安装到 `/opt/zabbix/l2collector` 并安装 cron(`INSTALL_CRON=0` 可跳过)。若 MySQL/`module` 表尚未就绪,会提示到 UI 启用。
+
+### 5.3 初始化入口
+- **告警页「初始化 / 自检」**:原有告警链路初始化 + 顺带 `L2Store::initialize()`(拓扑表/配置)。
+- **L2 拓扑配置页顶部「初始化 / 自检 L2 存储」**:只初始化 L2(建表 + 缺配置时写默认),不创建/修改用户或告警对象。
+- 采集器 cron 不在容器里,必须按 `docs/使用说明.md` 10.4 在宿主机安装;这是“页面初始化”不会替用户做的部分。
+
+## 6. 本次验证记录
+
+- PHP lint:`L2Store.php`、`WecomCenterSave.php`、`topology.view.php`、`wecom.center.view.php`、`locale/en.php`、`locale/zh_CN.php` 全部通过。
+- L2 初始化按钮:真实页面点击返回「L2 拓扑初始化完成:配置已存在,历史快照 71 条。」,无 JS 错误。
+- 单面板时间选择器(Playwright):
+  - 日期 `["实时(最新)","2026-11-06 (11)","2026-11-05 (60)"]`;
+  - 选日期后 label `2026-11-06 · 选择时间`,时间列表 11 项;
+  - 点 `01:32:03` 后 URL 带 `ts=...`,label 正确显示;
+  - `pageerror` / `console.error` 为空。
+- `docker compose config -q`:仓库 compose 与生产 `/opt/zabbix/docker-compose.yaml` 均通过。
+- 生产侧车实测日志:
+  `[wecom-init] waiting for MySQL...` → `waiting for Zabbix schema (module table)...` → `module 'wecom_center' ensured (enabled) + L2 tables ensured.`
+- `install.sh` 在临时目录实测:复制模块、建表、幂等登记模块、安装 collector 均成功。
+- 数据库:`wecom_center` moduleid=1001,status=1;`l2_topology_history` 行数持续增长;`l2_topology_config` 行存在。
+
+## 7. 已知限制 / 注意事项
+
+- 采集器必须能访问交换机 SNMP(UDP 161)且安装 `snmpwalk` 等工具;容器默认不跑采集器。
+- 历史体积随 `full_interval_min` 缩短迅速增长;1 分钟 @7 天约 300 MB 级,默认 5 分钟更稳妥。
+- 拓扑是展示层,不改交换机;STP/MAC 采集不到时不会凭空画关系。
+- 生产 `/opt/zabbix/docker-compose.yaml` 已加入侧车并留备份 `docker-compose.yaml.bak_wecom-init_*`;下次 `docker compose up -d` 会自动保持模块启用。
+- 告警初始化会创建/复用 `wecomrobot` 用户(原有行为);L2 单独初始化不会创建任何用户。
+
+## 8. 代码与提交
+
+- 分支:`master`
+- 远端:`origin/master`
+- 本次涉及:`web-modules/WecomCenter/{actions,lib,views,locale,assets/l2topology}`、`web-modules/install.sh`、`docker-compose.yaml`、`README.md`、`docs/使用说明.md`
+- 具体提交见 `git log --oneline`;最新提交包含:单面板时间选择器、L2 初始化、compose 首装时序修复、安装器自动启用模块。

+ 3 - 1
web-modules/README.md

@@ -30,7 +30,7 @@
 ./install.sh /usr/share/zabbix/modules
 ```
 
-然后到 **Administration → Modules → Scan directory → 启用 `WecomCenter`**(或执行 README 末尾的 SQL)。
+`install.sh` 会在 MySQL 容器可访问时顺手建 L2 表,并在 Zabbix `module` 表已就绪时自动登记/启用 `wecom_center`;若提示表未就绪,再到 **Administration → Modules → Scan directory → 启用 `WecomCenter`**(或执行 README 末尾的 SQL)。
 
 **第二步 —— 点“初始化”**:打开 **WeCom Center** 页面,首次进入会自动显示 **First-time setup**:
 粘贴群机器人 Key → 点 **Initialise / self-check**。它会自动补齐整条链路(缺啥建啥,已存在则复用、可反复点当自检):
@@ -42,6 +42,8 @@
 | 机器人用户 `wecomrobot` | 挂上面这个媒体类型,severity = High+Disaster |
 | 触发器动作 `Send alarm by Wecom Robot` | 告警/恢复各发一条 markdown 到群里(含 `{$ZBX_URL}` 详情链接) |
 
+同一次初始化也会幂等创建 L2 拓扑的 `l2_topology_history` / `l2_topology_config`,配置行缺失时写默认配置(不覆盖已有配置)。如果只想初始化拓扑,到 **ROMAN 定制中心 → L2 拓扑 → 初始化 / 自检 L2 存储** 单独点一次即可;它不会创建用户或改动告警对象。采集器 cron 仍需在宿主机安装(见使用说明 10.4)。
+
 装完之后日常调参都在页面上点,不用再碰命令行。
 
 ## 目录结构

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

@@ -32,16 +32,34 @@ class L2TopologyView extends CController {
 		$ts_raw = $_REQUEST['ts'] ?? '';
 		$ts = ($ts_raw === '' || $ts_raw === null) ? null : (int) $ts_raw;
 
+		$requested_ts = $ts;
 		$snap = L2Store::snapshotAt($ts);
 		$json = $snap['json'] ?? null;
 		$used_ts = $snap['ts'];
+		// A stale bookmark / hand-edited ?ts= must not leave an empty page.
+		// If that exact snapshot is gone, fall back to the live latest view.
+		if ($ts !== null && ($json === null || $json === '')) {
+			$ts = null;
+			$snap = L2Store::snapshotAt(null);
+			$json = $snap['json'] ?? null;
+			$used_ts = $snap['ts'];
+		}
+
+		// The selector must cover the full retention window, not only the
+		// latest 500 rows.  At 5 minutes / 7 days this is ~2,016 points.
+		$history_days = max(1, min(30, (int) ($cfg['history_days'] ?? 7)));
+		// The single collection interval is also the history snapshot interval.
+		$history_interval = max(5, (int) ($cfg['full_interval_min'] ?? $cfg['history_interval_min'] ?? 5));
+		$history_limit = (int) ceil($history_days * 1440 / $history_interval) + 30;
+		$history_limit = max(500, min(10000, $history_limit));
 
 		$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),
+			'history'    => L2Store::listHistory($history_limit),
 			'used_ts'    => $used_ts,
+			'requested_ts' => $ts,
 			'is_live'    => ($ts === null),
 			'cfg_refresh'=> (int) $cfg['refresh_seconds']
 		]));

+ 70 - 5
web-modules/WecomCenter/actions/WecomCenterSave.php

@@ -48,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 'init_l2':         $this->initL2();                break;
 					case 'save_l2':         $this->saveL2();                break;
 					default: error(I18n::t('msg.unknown_op'));
 				}
@@ -238,9 +239,23 @@ class WecomCenterSave extends CController {
 		if ($r['key_set']) {
 			$parts[] = I18n::t('msg.init_key_set');
 		}
+
+		// A fresh install should also get the L2 storage/config ready. This is
+		// storage-only and never touches switch relationships or existing config.
+		$l2 = L2Store::initialize();
+		$parts[] = $l2['config_created'] ? I18n::t('msg.init_l2_new') : I18n::t('msg.init_l2_ok');
+
 		info(I18n::t('msg.init_done', [implode(' · ', $parts)]));
 	}
 
+	private function initL2(): void {
+		$r = L2Store::initialize();
+		info(I18n::t('msg.l2_init_done', [
+			$r['config_created'] ? I18n::t('msg.l2_init_config_new') : I18n::t('msg.l2_init_config_ok'),
+			$r['history_rows']
+		]));
+	}
+
 	private function saveL2(): void {
 		$cfg = L2Store::getConfig();
 
@@ -252,8 +267,12 @@ class WecomCenterSave extends CController {
 		}
 
 		$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']));
+		// One interval drives both the full collection and the history snapshot:
+		// every successful full collection writes exactly one historical row.
+		$cfg['full_interval_min']  = max(5, min(1440, (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['history_interval_min'] = $cfg['full_interval_min']; // legacy compatibility
+		$cfg['history_days']       = max(1, min(30, (int) ($_REQUEST['history_days'] ?? $cfg['history_days'])));
 		$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']));
@@ -279,9 +298,55 @@ class WecomCenterSave extends CController {
 			if ($networks) { $cfg['networks'] = $networks; }
 		}
 
-		// Core/root switches: one row per switch, arrays are parallel:
-		//   core_net[] | core_match[] | core_pattern[]
-		if (isset($_REQUEST['core_rules_present'])) {
+		// Core hierarchy: optional upstream roles (empty pattern = hidden) and
+		// the sub-core definitions (monitoring / office / future production).
+		if (isset($_REQUEST['core_hierarchy_present'])) {
+			$netKeys = array_map(function ($n) { return (string) ($n['key'] ?? ''); }, (array) $cfg['networks']);
+			$hier = [];
+			foreach (['firewall', 'behavior', 'total_core'] as $role) {
+				$match = (string) ($_REQUEST['hier_'.$role.'_match'] ?? 'contains');
+				if ($match !== 'regex') { $match = 'contains'; }
+				$hier[$role] = [
+					'match' => $match,
+					'pattern' => trim((string) ($_REQUEST['hier_'.$role.'_pattern'] ?? ''))
+				];
+			}
+			$subNet = (array) ($_REQUEST['sub_net'] ?? []);
+			$subKey = (array) ($_REQUEST['sub_key'] ?? []);
+			$subLabel = (array) ($_REQUEST['sub_label'] ?? []);
+			$subMatch = (array) ($_REQUEST['sub_match'] ?? []);
+			$subPattern = (array) ($_REQUEST['sub_pattern'] ?? []);
+			$subs = [];
+			$coreRules = [];
+			foreach ($subKey as $i => $key) {
+				$pattern = trim((string) ($subPattern[$i] ?? ''));
+				$net = strtoupper(trim((string) ($subNet[$i] ?? '')));
+				if ($net !== '' && !in_array($net, $netKeys, true)) { $net = ''; }
+				$match = (string) ($subMatch[$i] ?? 'contains');
+				if ($match !== 'regex') { $match = 'contains'; }
+				$key = preg_replace('/[^a-z0-9_]/i', '', (string) $key);
+				if ($key === '') { $key = 'sub'.($i + 1); }
+				$label = trim((string) ($subLabel[$i] ?? ''));
+				$subs[] = [
+					'key' => $key,
+					'label' => $label !== '' ? $label : $key,
+					'net' => $net,
+					'match' => $match,
+					'pattern' => $pattern
+				];
+				if ($pattern !== '') {
+					$coreRules[] = ['net' => $net, 'match' => $match, 'pattern' => $pattern];
+				}
+			}
+			$hier['sub_cores'] = $subs;
+			$cfg['core_hierarchy'] = $hier;
+			// Keep the old flat key in sync for collectors/frontends that
+			// still read core_rules.  Empty future-core rows are retained in
+			// core_hierarchy as visible placeholder configuration but are not
+			// emitted as active matching rules.
+			$cfg['core_rules'] = $coreRules;
+		}
+		elseif (isset($_REQUEST['core_rules_present'])) {
 			$coreNets = (array) ($_REQUEST['core_net'] ?? []);
 			$coreMatch = (array) ($_REQUEST['core_match'] ?? []);
 			$corePattern = (array) ($_REQUEST['core_pattern'] ?? []);
@@ -302,7 +367,7 @@ class WecomCenterSave extends CController {
 		$cfg['latency_bad']        = (float) ($_REQUEST['latency_bad'] ?? $cfg['latency_bad']);
 
 		$colors = $cfg['colors'];
-		foreach (['100', '1000', '10000', '20000', 'unknown', 'down'] as $k) {
+		foreach (['100', '1000', '2500', '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;

+ 334 - 75
web-modules/WecomCenter/assets/l2topology/app.js

@@ -17,8 +17,8 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
   var LATW = (CFG.latency_warn != null) ? +CFG.latency_warn : 3;
   var LATB = (CFG.latency_bad != null) ? +CFG.latency_bad : 5;
 
-  var TIER_BG = {core:'#1F4E78', dist:'#2E75B6', access:'#548235'};
-  var TIER_BG_DARK = {core:'#1B4A72', dist:'#2A5E93', access:'#3E6B2E'};
+  var TIER_BG = {core:'#1F4E78', dist:'#2E75B6', access:'#548235', upstream:'#34495E'};
+  var TIER_BG_DARK = {core:'#1B4A72', dist:'#2A5E93', access:'#3E6B2E', upstream:'#2C3E50'};
   var SEV_COLORS = ['#97AAB3','#7499FF','#FFC859','#FFA059','#E97659','#E45959'];
 
   var NODE_W = 156, NODE_H = 50, GX = 26, GY = 18, PADX = 20, PADY = 42;
@@ -574,6 +574,8 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
   // Multiple cores in the same network are supported and all of them are
   // placed in the left-most core column.
   function coreRules() {
+    var h = CFG.core_hierarchy;
+    if (h && h.sub_cores instanceof Array) return h.sub_cores;
     var r = CFG.core_rules;
     return (r instanceof Array) ? r : [];
   }
@@ -599,6 +601,13 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
     for (var i = 0; i < rules.length; i++) if (coreRuleHit(rules[i], n)) return true;
     return false;
   }
+  function coreLabelOf(n) {
+    var rules = coreRules();
+    for (var i = 0; i < rules.length; i++) {
+      if (coreRuleHit(rules[i], n)) return String(rules[i].label || '');
+    }
+    return '';
+  }
   function configuredCoreNet(n) {
     var rules = coreRules();
     for (var i = 0; i < rules.length; i++) {
@@ -645,6 +654,9 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
     return net;
   }
 
+  function isUpstreamRole(n) {
+    return ['firewall', 'behavior', 'total_core'].indexOf(String((n && n.hierarchy_role) || '')) >= 0;
+  }
   function roleOf(n) {
     var p = parseName(n.name || '');
     var r = String(p.role || '').toLowerCase();
@@ -652,10 +664,12 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
     var childCount = parseInt(n.children, 10);
     if (isNaN(childCount)) childCount = -1;
     if (configuredCore(n)) return 'core';
-    // Backward-compatible fallback only when the core list is absent (old
-    // config).  An explicitly empty list means the administrator wants no
-    // configured core column.
-    if (!(CFG.core_rules instanceof Array) && (/^core(\s|-)/i.test(String(n.name || '')) || groupOf(n).label === 'Core')) return 'core';
+    // Backward-compatible fallback only when no core configuration exists
+    // (old config).  An explicitly empty list means the administrator wants
+    // no configured core column.
+    var hasCoreCfg = (CFG.core_hierarchy && CFG.core_hierarchy.sub_cores instanceof Array)
+      || (CFG.core_rules instanceof Array);
+    if (!hasCoreCfg && (/^core(\s|-)/i.test(String(n.name || '')) || groupOf(n).label === 'Core')) return 'core';
     // Hostname role is the display truth for Access switches.  An access
     // switch may itself have access children (cascade/stack); it still stays
     // an access node and the children simply take a further-right column.
@@ -693,7 +707,16 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
   function switchLabel(n, role, netKey) {
     var g = groupOf(n).label;
     var s;
-    if (role === 'core') s = roleName('core') + ' · ' + (netKey || rawNetOf(n)) + ' · ' + (n.ip || '');
+    if (role === 'upstream') {
+      var rl = n.hierarchy_role === 'firewall' ? t('l2.firewall', 'Firewall')
+        : (n.hierarchy_role === 'behavior' ? t('l2.behavior', 'Behavior management')
+          : t('l2.total_core', 'Total core'));
+      s = rl + ' · ' + (n.ip || '');
+    }
+    else if (role === 'core') {
+      var cl = coreLabelOf(n) || roleName('core');
+      s = cl + ' · ' + (netKey || rawNetOf(n)) + ' · ' + (n.ip || '');
+    }
     else if (role === 'main') s = roleName('main') + ' · ' + g + ' · ' + (n.ip || '');
     else {
       var loc = shortLoc(n);
@@ -739,6 +762,7 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
       return cur || n;
     }
     (snap.nodes || []).forEach(function (n) {
+      if (isUpstreamRole(n)) return;
       var r = roleOf(n);
       if (r === 'core' || r === 'main') return;
       var root = rootOf(n);
@@ -750,6 +774,7 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
   function workshopList(snap, netMap, netKey, wsKeys) {
     var map = {}, order = [];
     (snap.nodes || []).forEach(function (n) {
+      if (isUpstreamRole(n)) return;
       if (netMap[n.id] !== netKey) return;
       var r = roleOf(n);
       if (r === 'core' || r === 'main') return;
@@ -898,11 +923,54 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
   }
 
   function computeFull(snap) {
-    var nodes = snap.nodes || [];
+    var nodes = (snap.nodes || []).slice();
     var netMap = assignNetworks(snap);
     var sections = [], placements = [], frames = [], roleMap = {};
     var wsKeys = workshopKeys(snap);
     var y = 100, maxRight = CHART_W;
+    // Optional upstream band: firewall -> behavior management -> total core.
+    // If a role is empty or has no matching host in this snapshot, it is not
+    // placed at all, so the topology reserves no blank space for it.
+    var upstream = (snap.upstream || []).filter(function (n) { return n && n.id; });
+    var upstreamIds = {};
+    upstream.forEach(function (n) { upstreamIds[n.id] = true; });
+    // A configured upstream host can also be part of the STP graph.  The
+    // collector then keeps one node and marks it with hierarchy_role; move
+    // that node into the upstream band instead of drawing it twice.
+    nodes = nodes.filter(function (n) {
+      if (!isUpstreamRole(n)) return true;
+      if (!upstreamIds[n.id]) {
+        upstream.push(n);
+        upstreamIds[n.id] = true;
+      }
+      return false;
+    });
+    var UP_ROLE_ORDER = ['firewall', 'behavior', 'total_core'];
+    var UP_ROLE_LABEL = {
+      firewall: t('l2.firewall', '防火墙'),
+      behavior: t('l2.behavior', '行为管理'),
+      total_core: t('l2.total_core', '总核心')
+    };
+    if (upstream.length) {
+      upstream.sort(function (a, b) {
+        var ai = UP_ROLE_ORDER.indexOf(a.hierarchy_role), bi = UP_ROLE_ORDER.indexOf(b.hierarchy_role);
+        return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);
+      });
+      // x is the node centre in Cytoscape, so start at half the node width
+      // plus the frame's left padding; otherwise the first node is clipped by
+      // the sidebar/canvas edge.  Keep enough width for “role · IP” labels.
+      var ux = 170;
+      upstream.forEach(function (n) {
+        var rl = UP_ROLE_LABEL[n.hierarchy_role] || t('l2.upstream', '上游');
+        placements.push({node: n, role: 'upstream', x: ux, y: 68, w: 280, h: 46, net: '', group: rl});
+        roleMap[n.id] = 'upstream';
+        ux += 296;
+      });
+      frames.push({id: 'upframe', type: 'wsframe', x: 10, y: 34, w: CHART_W - 20, h: 84,
+        label: t('l2.upstream', '上游层级'), net: '', color: '#8E44AD'});
+      y = 146;
+      maxRight = Math.max(maxRight, ux + 20);
+    }
     NETWORKS.forEach(function (net) {
       var secNodes = nodes.filter(function (n) { return netMap[n.id] === net.key; });
       if (!secNodes.length) return;
@@ -959,6 +1027,7 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
     });
     var chartW = Math.max(CHART_W, maxRight + 40);
     sections.forEach(function (sec) { sec.w = chartW - 20; });
+    frames.forEach(function (f) { if (f.id === 'upframe') f.w = chartW - 20; });
     var root = {x: 0, y: 0, w: chartW, h: y + 6};
     return {sections: sections, placements: placements, frames: frames, netMap: netMap, roleMap: roleMap, root: root};
   }
@@ -1310,7 +1379,6 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
     buildAlarms(snap);
     buildOffline(snap);
     buildTerminals(snap);
-    buildHistory();
   }
 
   function renderFull(snap) {
@@ -1343,11 +1411,12 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
 
     L.placements.forEach(function (p) {
       var n = p.node, cls = [];
-      var bg = p.role === 'core' ? TIER_BG.core : (p.role === 'main' ? '#2E75B6' : (p.role === 'dist' ? TIER_BG.dist : TIER_BG.access));
+      var bg = p.role === 'upstream' ? TIER_BG.upstream
+        : (p.role === 'core' ? TIER_BG.core : (p.role === 'main' ? '#2E75B6' : (p.role === 'dist' ? TIER_BG.dist : TIER_BG.access)));
       var bd = bg;
       // Alarm and offline are node-level states: fill the whole node red.
-      // Only latency > LATB is allowed to draw a red frame; 3-5 ms uses an
-      // orange frame.  Alarm must never be represented by a red border alone.
+      // Only latency > LATB is allowed to draw a red frame; LATW..LATB uses
+      // an orange frame.  Alarm must never be represented by a red border alone.
       if (n.alarms && n.alarms.length) { cls.push('alarm'); bg = '#E45959'; bd = '#E45959'; }
       if (n.offline || n.oper === 'down') { cls.push('offline'); bg = '#C0392B'; bd = '#C0392B'; }
       // Alarm/offline already owns the whole fill; do not put a latency frame
@@ -1363,7 +1432,8 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
         maxsev: n.maxsev, alarms: n.alarms || [], term_online: n.term_online, term_offline: 0, latency_ms: n.latency_ms,
         poe: !!n.poe,
         label: switchLabel(n, p.role, p.net), tw: p.w - 12, bg: bg, bd: bd, w: p.w, h: p.h,
-        group: p.group + ' · ' + netLabel(p.net), blockGroup: p.group, net: p.net, role: p.role
+        group: p.role === 'upstream' ? p.group : (p.group + ' · ' + netLabel(p.net)),
+        blockGroup: p.group, net: p.net, role: p.role
       }, position: {x: p.x, y: p.y}, classes: cls.join(' ')});
     });
 
@@ -1683,7 +1753,7 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
   /* ---------------- side panels & stats ---------------- */
   function updateStats(snap) {
     var s = snap.stats || {};
-    var txt = '● ' + (s.switches || 0) + '  |  ⇄ ' + (s.edges || 0) + '  |  ' +
+    var txt = '● ' + (s.switches || 0) + '  |  ⇄ ' + (s.edges || 0) + '  |  ⚡ ' + (s.poe_switches || 0) + ' ' + t('l2.poe', 'PoE') + '  |  ' +
       (s.term_total || 0) + ' ' + t('l2.terminals', 'terminals') + '  |  ' +
       (s.switches_offline || 0) + ' ' + t('l2.offline_short', 'offline SW') + '  |  ⚠ ' + (s.alarms || 0);
     var up = snap.generated_iso ? ('  ·  ' + t('l2.updated', 'Updated') + ' ' + snap.generated_iso) : '';
@@ -1703,18 +1773,22 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
          '<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:transparent;border:2px solid #F39C12;border-radius:2px"></span>&gt;' + LATW + 'ms' +
-         '<span class="lg-box" style="background:transparent;border:2px solid #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:#C0392B;border-radius:2px;margin-left:8px"></span>' + t('l2.offline', 'Offline switch') + '</div>';
-    h += '<div class="lg-row" style="opacity:.72;margin-top:4px">' + t('l2.click_hint', 'Hover a switch to highlight its full upstream path') + '</div>';
+    // Latency + alarm state share one compact row.  This keeps the legend
+    // short enough to leave more room for the real topology on small screens.
+    h += '<div class="lg-row lg-compact">' +
+         '<span class="lg-box" style="background:transparent;border:2px solid #F39C12;border-radius:2px"></span>&gt;' + LATW + 'ms' +
+         '<span class="lg-box" style="background:transparent;border:2px solid #E74C3C;border-radius:2px"></span>&gt;' + LATB + 'ms' +
+         '<span class="lg-sep"></span>' +
+         '<span class="lg-box" style="background:#E45959;border-radius:2px"></span>' + t('l2.alarms', 'Alarms') +
+         '<span class="lg-box" style="background:#2ECC71;border-radius:50%"></span>' + t('l2.term_on', 'Online') +
+         '<span class="lg-box" style="background:#C0392B;border-radius:2px"></span>' + t('l2.offline', 'Offline switch') +
+         '</div>';
     el('l2-legend').innerHTML = h;
   }
 
   function buildAlarms(snap) {
     var items = [];
-    (snap.nodes || []).forEach(function (n) {
+    (snap.nodes || []).concat(snap.upstream || []).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});
@@ -1813,57 +1887,155 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
   }
 
   function buildHistory() {
-    var box = el('l2-history-list');
-    if (!box) return;
+    var picker = el('l2-ts-picker');
+    if (!picker || picker.getAttribute('data-built') === '1') return;
+    picker.setAttribute('data-built', '1');
+
+    var trigger = el('l2-ts-trigger');
+    var pop = el('l2-ts-pop');
+    var daysEl = el('l2-ts-days');
+    var timesEl = el('l2-ts-times');
+    var timesTitle = el('l2-ts-times-title');
+    var label = el('l2-ts-current');
     var list = window.L2_HISTORY || [];
-    var used = window.L2_META && window.L2_META.usedTs ? String(window.L2_META.usedTs) : '';
-    var h = '<div class="off-row hist-row' + (used ? '' : ' active') + '" data-ts=""><div class="off-main">' +
-      '<div class="off-mac">' + t('l2.history_live', 'Live / latest') + '</div>' +
-      '<div class="off-sub">' + t('l2.history_live_hint', 'Newest snapshot, auto refresh') + '</div></div></div>';
-    if (!list.length) {
-      h += '<div class="panel-empty">' + t('l2.history_empty', 'No history snapshots yet.') + '</div>';
-    } else {
+    var meta = window.L2_META || {};
+    var used = (!meta.isLive && meta.usedTs) ? String(meta.usedTs) : '';
+    var groups = {}, days = [];
+
+    // Group only timestamps that actually exist in l2_topology_history.
+    // The popover therefore never offers an arbitrary date/time and cannot
+    // lead to a blank page.
+    list.forEach(function (it) {
+      var day = String(it.captured_at || fmtTs(it.ts)).slice(0, 10);
+      if (!groups[day]) { groups[day] = []; days.push(day); }
+      groups[day].push(it);
+    });
+
+    var currentDay = '';
+    var currentTs = '';
+    if (used) {
       list.forEach(function (it) {
-        var st = it.stats || {};
-        var ts = String(it.ts || '');
-        var sub = (st.switches != null ? st.switches + ' ' + t('l2.unit_sw', 'sw') : '') +
-          (st.term_total != null ? ' · ' + st.term_total + ' ' + t('l2.terminals', 'terminals') : '') +
-          (st.switches_offline ? ' · ' + st.switches_offline + ' ' + t('l2.offline_short', 'offline SW') : '');
-        h += '<div class="off-row hist-row' + (ts === used ? ' active' : '') + '" data-ts="' + esc(ts) + '">' +
-          '<div class="off-main"><div class="off-mac">' + esc(it.captured_at || fmtTs(it.ts)) + '</div>' +
-          '<div class="off-sub">' + esc(sub) + '</div></div></div>';
+        if (String(it.ts) === used) {
+          currentTs = used;
+          currentDay = String(it.captured_at || fmtTs(it.ts)).slice(0, 10);
+        }
       });
     }
-    box.innerHTML = h;
-    box.querySelectorAll('.hist-row').forEach(function (r) {
-      r.addEventListener('click', function () {
-        var ts = r.getAttribute('data-ts');
-        var u = new URL(window.location.href);
-        u.searchParams.set('action', 'l2.topology.view');
-        if (ts) u.searchParams.set('ts', ts);
-        else u.searchParams.delete('ts');
-        window.location.href = u.toString();
+
+    function timeText(it) {
+      var ca = String(it.captured_at || '');
+      return ca.length >= 19 ? ca.slice(11, 19) : fmtTs(it.ts);
+    }
+
+    function go(ts) {
+      var u = new URL(window.location.href);
+      u.searchParams.set('action', 'l2.topology.view');
+      if (ts) u.searchParams.set('ts', ts);
+      else u.searchParams.delete('ts');
+      window.location.href = u.toString();
+    }
+
+    function positionPop() {
+      if (!pop) return;
+      if (window.matchMedia('(max-width: 720px)').matches) {
+        var rect = trigger.getBoundingClientRect();
+        var width = Math.min(430, window.innerWidth - 20);
+        var left = Math.max(10, Math.min(rect.left, window.innerWidth - 10 - width));
+        pop.style.position = 'fixed';
+        pop.style.left = left + 'px';
+        pop.style.right = 'auto';
+        pop.style.top = Math.max(10, rect.bottom + 6) + 'px';
+        pop.style.width = width + 'px';
+      }
+      else {
+        pop.style.position = '';
+        pop.style.left = '';
+        pop.style.right = '';
+        pop.style.top = '';
+        pop.style.width = '';
+      }
+    }
+
+    function setExpanded(open) {
+      picker.classList.toggle('open', open);
+      trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
+      if (open) positionPop();
+    }
+
+    function updateLabel() {
+      if (!currentDay) { label.textContent = t('l2.time_live_full', '实时(最新)'); return; }
+      if (!currentTs) { label.textContent = currentDay + ' · ' + t('l2.time_choose', '选择时间'); return; }
+      var found = null;
+      (groups[currentDay] || []).forEach(function (it) { if (String(it.ts) === currentTs) found = it; });
+      label.textContent = found ? currentDay + ' ' + timeText(found) : currentDay;
+    }
+
+    function renderDays() {
+      daysEl.innerHTML = '';
+      var live = document.createElement('button');
+      live.type = 'button';
+      live.className = 'l2-ts-day' + (currentDay ? '' : ' active');
+      live.textContent = t('l2.time_live_full', '实时(最新)');
+      live.addEventListener('click', function () { go(''); });
+      daysEl.appendChild(live);
+      days.forEach(function (day) {
+        var b = document.createElement('button');
+        b.type = 'button';
+        b.className = 'l2-ts-day' + (day === currentDay ? ' active' : '');
+        b.textContent = day + ' (' + groups[day].length + ')';
+        b.addEventListener('click', function (ev) {
+          ev.stopPropagation();
+          currentDay = day;
+          currentTs = '';
+          renderDays();
+          renderTimes();
+          updateLabel();
+        });
+        daysEl.appendChild(b);
       });
-    });
-    if (el('l2-history-count')) el('l2-history-count').textContent = list.length;
-    // Keep the toolbar selector in sync with the same history list.  The
-    // side panel is the primary entry point; the selector is a shortcut.
-    var sel = el('l2-ts');
-    if (sel) {
-      var old = sel.value;
-      sel.innerHTML = '';
-      var live = document.createElement('option');
-      live.value = '';
-      live.textContent = t('l2.time_live', 'Live');
-      sel.appendChild(live);
-      list.forEach(function (it) {
-        var opt = document.createElement('option');
-        opt.value = String(it.ts || '');
-        opt.textContent = it.captured_at || fmtTs(it.ts);
-        sel.appendChild(opt);
+    }
+
+    function renderTimes() {
+      timesEl.innerHTML = '';
+      if (!currentDay) {
+        timesTitle.textContent = t('l2.time_live', '实时');
+        var empty = document.createElement('div');
+        empty.className = 'l2-ts-empty';
+        empty.textContent = t('l2.time_live_hint', '当前显示最新采集数据');
+        timesEl.appendChild(empty);
+        return;
+      }
+      timesTitle.textContent = currentDay;
+      (groups[currentDay] || []).forEach(function (it) {
+        var b = document.createElement('button');
+        b.type = 'button';
+        b.className = 'l2-ts-time' + (String(it.ts) === currentTs ? ' active' : '');
+        b.textContent = timeText(it);
+        b.addEventListener('click', function () { go(String(it.ts)); });
+        timesEl.appendChild(b);
       });
-      sel.value = used || old || '';
     }
+
+    trigger.addEventListener('click', function (ev) {
+      ev.stopPropagation();
+      setExpanded(!picker.classList.contains('open'));
+    });
+    window.addEventListener('resize', function () {
+      if (picker.classList.contains('open')) positionPop();
+    });
+    window.addEventListener('scroll', function () {
+      if (picker.classList.contains('open')) positionPop();
+    }, {passive: true});
+    document.addEventListener('click', function (ev) {
+      if (!picker.contains(ev.target)) setExpanded(false);
+    });
+    document.addEventListener('keydown', function (ev) {
+      if (ev.key === 'Escape') setExpanded(false);
+    });
+
+    renderDays();
+    renderTimes();
+    updateLabel();
   }
 
   function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]; }); }
@@ -1879,7 +2051,99 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
     setTimeout(function () { n.removeClass('hl'); }, 2600);
   }
 
-  function exportPng() {
+  // cy.png() only contains the Cytoscape canvas, not the HTML legend.  Append
+  // a dedicated legend strip to the exported PNG (same symbols as the page,
+  // alarm block on the latency row, and no hover hint).
+  function withExportLegend(dataUrl) {
+    return new Promise(function (resolve, reject) {
+      var img = new Image();
+      img.onload = function () {
+        try {
+          var s = Math.max(1.0, Math.min(1.6, img.width / 1300));
+          var pad = Math.round(16 * s);
+          var legendH = Math.round(154 * s);
+          var cv = document.createElement('canvas');
+          cv.width = img.width;
+          cv.height = img.height + legendH;
+          var ctx = cv.getContext('2d');
+          ctx.fillStyle = '#ffffff';
+          ctx.fillRect(0, 0, cv.width, cv.height);
+          ctx.drawImage(img, 0, 0);
+          var y0 = img.height;
+          ctx.strokeStyle = '#cbd5e1';
+          ctx.lineWidth = Math.max(1, Math.round(s));
+          ctx.beginPath(); ctx.moveTo(0, y0); ctx.lineTo(cv.width, y0); ctx.stroke();
+          ctx.textBaseline = 'middle';
+          var family = '"Microsoft YaHei","PingFang SC",Arial,sans-serif';
+          function title(text, y) {
+            ctx.fillStyle = '#1f2937';
+            ctx.font = '600 ' + Math.round(12.5 * s) + 'px ' + family;
+            ctx.fillText(text, pad, y);
+          }
+          function lineItem(x, y, color, text, dashed) {
+            ctx.strokeStyle = color;
+            ctx.lineWidth = Math.max(2, Math.round(3 * s));
+            ctx.setLineDash(dashed ? [Math.round(5 * s), Math.round(3 * s)] : []);
+            ctx.beginPath();
+            ctx.moveTo(x, y); ctx.lineTo(x + Math.round(24 * s), y); ctx.stroke();
+            ctx.setLineDash([]);
+            ctx.fillStyle = '#263445';
+            ctx.font = Math.round(11.5 * s) + 'px ' + family;
+            ctx.fillText(text, x + Math.round(30 * s), y + 1);
+            return x + Math.round(30 * s) + ctx.measureText(text).width + Math.round(18 * s);
+          }
+          function boxItem(x, y, color, text, circle, outline) {
+            var bw = Math.round(12 * s), by = y - bw / 2;
+            ctx.fillStyle = outline ? 'transparent' : color;
+            ctx.strokeStyle = color;
+            ctx.lineWidth = Math.max(2, Math.round(2 * s));
+            ctx.beginPath();
+            if (circle) ctx.arc(x + bw / 2, y, bw / 2, 0, Math.PI * 2);
+            else ctx.rect(x, by, bw, bw);
+            if (outline) ctx.stroke(); else ctx.fill();
+            ctx.fillStyle = '#263445';
+            ctx.font = Math.round(11.5 * s) + 'px ' + family;
+            ctx.fillText(text, x + bw + Math.round(5 * s), y + 1);
+            return x + bw + Math.round(5 * s) + ctx.measureText(text).width + Math.round(16 * s);
+          }
+          var y = y0 + Math.round(18 * s);
+          title(t('l2.legend_speed', '链路速率'), y);
+          y += Math.round(22 * s);
+          var x = pad;
+          x = lineItem(x, y, colors['100'], '100M');
+          x = lineItem(x, y, colors['1000'], '1G');
+          x = lineItem(x, y, colors['2500'], '2.5G');
+          x = lineItem(x, y, colors['10000'], '10G');
+          x = lineItem(x, y, colors['20000'], '20G+');
+          x = lineItem(x, y, colors.unknown, t('lbl.color_unknown', 'Unknown'));
+          lineItem(x, y, colors.down, t('lbl.color_down', 'Down'), true);
+          y += Math.round(24 * s);
+          title(t('l2.legend_tier', '层级'), y);
+          y += Math.round(22 * s);
+          x = pad;
+          x = boxItem(x, y, TIER_BG.core, t('l2.core', '核心'));
+          x = boxItem(x, y, '#2E75B6', t('l2.main', '主交换'));
+          x = boxItem(x, y, TIER_BG.dist, t('l2.dist', '汇聚'));
+          boxItem(x, y, TIER_BG.access, t('l2.access', '接入'));
+          y += Math.round(24 * s);
+          title(t('l2.legend_lat', '延迟'), y);
+          y += Math.round(22 * s);
+          x = pad;
+          x = boxItem(x, y, '#F39C12', '>' + LATW + 'ms', false, true);
+          x = boxItem(x, y, '#E74C3C', '>' + LATB + 'ms', false, true);
+          x += Math.round(16 * s);
+          x = boxItem(x, y, '#E45959', t('l2.alarms', '告警'));
+          x = boxItem(x, y, '#2ECC71', t('l2.term_on', '在线'), true);
+          boxItem(x, y, '#C0392B', t('l2.offline', '离线交换机'));
+          resolve(cv.toDataURL('image/png'));
+        } catch (e) { reject(e); }
+      };
+      img.onerror = function () { reject(new Error('legend image load failed')); };
+      img.src = dataUrl;
+    });
+  }
+
+  async function exportPng() {
     if (!cy) return;
     var btn = el('l2-export'), oldText = btn ? btn.textContent : '';
     if (btn) { btn.disabled = true; btn.textContent = t('l2.exporting', '正在导出…'); }
@@ -1894,10 +2158,11 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
       flowEdges.style('display', 'none');
       cy.style(styleFor(false));
       if (typeof cy.forceRender === 'function') cy.forceRender();
-      var data = cy.png({full: true, scale: 1, maxWidth: 2600, maxHeight: 18000, bg: '#ffffff'});
+      var raw = cy.png({full: true, scale: 1, maxWidth: 2600, maxHeight: 18000, bg: '#ffffff'});
+      var data = await withExportLegend(raw);
       var now = new Date();
       function p2(v) { return (v < 10 ? '0' : '') + v; }
-      var suffix = META.usedTs ? ('_' + META.usedTs) : '_live';
+      var suffix = (!META.isLive && META.usedTs) ? ('_' + META.usedTs) : '_live';
       var name = 'L2_Topology_' + now.getFullYear() + p2(now.getMonth() + 1) + p2(now.getDate()) +
         '_' + p2(now.getHours()) + p2(now.getMinutes()) + suffix + '.png';
       var a = document.createElement('a');
@@ -2198,13 +2463,6 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
       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);
@@ -2238,6 +2496,7 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
   /* ---------------- boot ---------------- */
   function boot() {
     setupControls();
+    buildHistory();
     fitViewport();
     try {
       var qs = new URLSearchParams(window.location.search);

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

@@ -9,19 +9,31 @@
     {"net": "SURVE", "match": "contains", "pattern": "core surve"},
     {"net": "OFFICE", "match": "contains", "pattern": "core office"}
   ],
+  "core_hierarchy": {
+    "firewall": {"match": "contains", "pattern": ""},
+    "behavior": {"match": "contains", "pattern": ""},
+    "total_core": {"match": "contains", "pattern": ""},
+    "sub_cores": [
+      {"key": "monitor", "label": "监控核心", "net": "SURVE", "match": "contains", "pattern": "core surve"},
+      {"key": "office", "label": "办公核心", "net": "OFFICE", "match": "contains", "pattern": "core office"},
+      {"key": "production", "label": "生产核心", "net": "", "match": "contains", "pattern": ""}
+    ]
+  },
   "templates": ["Huawei VRP by SNMP (walk)"],
   "full_interval_min": 5,
   "alarm_interval_min": 1,
+  "history_interval_min": 5,
   "history_days": 7,
   "colors": {
     "100": "#F39C12",
     "1000": "#2ECC71",
+    "2500": "#17C3B2",
     "10000": "#3498DB",
     "20000": "#9B59B6",
     "unknown": "#E74C3C",
     "down": "#C0392B"
   },
-  "latency_warn": 3.0,
+  "latency_warn": 1.0,
   "latency_bad": 5.0,
   "group_rules": [
     {"match": "contains", "pattern": "DataCenter", "label": "DataCenter", "big": true},

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

@@ -60,10 +60,57 @@
 
 .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-ts-picker { position: relative; display: inline-block; flex: 0 0 auto; }
+.l2-ts-trigger {
+  display: inline-flex; align-items: center; gap: 7px;
+  min-width: 226px; max-width: 280px; height: 32px; box-sizing: border-box;
+  padding: 0 10px; border: 1px solid var(--border); border-radius: 8px;
+  background: var(--panel-2); color: var(--text); font: inherit; font-size: 12.5px;
+  cursor: pointer; text-align: left; transition: border-color .15s, box-shadow .15s;
+}
+.l2-ts-trigger:hover { border-color: var(--brand); }
+.l2-ts-trigger[aria-expanded="true"] { border-color: var(--brand); box-shadow: 0 0 0 2px color-mix(in srgb, var(--brand) 22%, transparent); }
+.l2-ts-clock { opacity: .8; font-size: 14px; line-height: 1; }
+.l2-ts-current { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.l2-ts-caret { flex: 0 0 auto; opacity: .65; font-size: 11px; transition: transform .15s; }
+.l2-ts-trigger[aria-expanded="true"] .l2-ts-caret { transform: rotate(180deg); }
+.l2-ts-pop {
+  display: none; position: absolute; z-index: 1200; top: 38px; left: 0;
+  width: 430px; max-width: calc(100vw - 24px); box-sizing: border-box;
+  padding: 10px; grid-template-columns: 158px minmax(0, 1fr); gap: 10px;
+  background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
+  box-shadow: 0 14px 34px rgba(0, 0, 0, .28);
+}
+.l2-ts-picker.open .l2-ts-pop { display: grid; }
+@media (max-width: 720px) {
+  .l2-ts-label { flex: 1 1 100%; min-width: 0; }
+  .l2-ts-picker { flex: 1 1 auto; min-width: 0; }
+  .l2-ts-trigger { min-width: 0; max-width: none; width: 100%; }
+  .l2-ts-pop { left: auto; right: 0; }
+}
+.l2-ts-days { max-height: 252px; overflow: auto; padding-right: 8px; border-right: 1px solid var(--border); }
+.l2-ts-day {
+  display: block; width: 100%; box-sizing: border-box; margin: 0 0 3px;
+  padding: 7px 8px; border: 0; border-radius: 7px; background: transparent;
+  color: var(--text); font: inherit; font-size: 12.5px; text-align: left; cursor: pointer;
+}
+.l2-ts-day:hover { background: color-mix(in srgb, var(--brand) 12%, transparent); }
+.l2-ts-day.active { background: var(--brand); color: #fff; font-weight: 600; }
+.l2-ts-times-wrap { min-width: 0; }
+.l2-ts-times-title { padding: 1px 2px 8px; color: var(--muted); font-size: 12px; }
+.l2-ts-times { display: flex; flex-wrap: wrap; gap: 6px; max-height: 224px; overflow: auto; align-content: flex-start; }
+.l2-ts-time {
+  padding: 6px 10px; border: 1px solid var(--border); border-radius: 7px;
+  background: var(--panel-2); color: var(--text); font: inherit; font-size: 12.5px; cursor: pointer;
+}
+.l2-ts-time:hover { border-color: var(--brand); color: var(--brand); }
+.l2-ts-time.active { border-color: var(--brand); background: var(--brand); color: #fff; }
+.l2-ts-empty { padding: 10px 2px; color: var(--muted); font-size: 12.5px; line-height: 1.6; }
 
 .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;
+  box-sizing: border-box;
 }
 .l2-btn:hover { border-color: var(--brand); color: var(--brand); }
 .l2-btn.primary { background: var(--brand); border-color: var(--brand); color: #fff; }
@@ -113,6 +160,9 @@
 .lg-title { font-weight: 700; color: var(--muted); font-size: 10.5px; letter-spacing: .6px; text-transform: uppercase; margin: 8px 0 4px; }
 .lg-title:first-child { margin-top: 0; }
 .lg-row { display: flex; align-items: center; gap: 6px; margin: 3px 0; }
+.lg-row.lg-compact { flex-wrap: wrap; gap: 3px 6px; font-size: 11px; }
+.lg-row.lg-compact .lg-box { width: 11px; height: 11px; }
+.lg-sep { width: 1px; height: 12px; background: var(--border); display: inline-block; margin: 0 1px; }
 .lg-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 3px 8px; margin: 3px 0; }
 .lg-item { display: flex; align-items: center; gap: 4px; white-space: nowrap; font-size: 11px; min-width: 0; }
 .lg-line { width: 20px; height: 0; border-top: 3px solid #999; display: inline-block; flex: 0 0 auto; }

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

@@ -204,6 +204,8 @@ select.wc-input option { color: #1f2c33; }
 	padding: 8px; border: 1px solid #e3e6ea; border-radius: 8px; background: #fafbfd;
 }
 .wc-rule .wc-input { min-width: 0; }
+.wc-rule-label { flex: 0 0 82px; font-size: 12.5px; font-weight: 600; color: #34495e; }
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-rule-label { color: #cfe0f5; }
 .wc-rule input[name="rule_pattern[]"],
 .wc-rule input[name="rule_label[]"],
 .wc-rule input[name="core_pattern[]"] { flex: 1 1 0; }

+ 110 - 28
web-modules/WecomCenter/lib/L2Store.php

@@ -5,11 +5,12 @@
 ** 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_history : one compressed snapshot per configured interval
 **   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.
+** Snapshots are gzip-compressed MEDIUMBLOBs.  At the default 5-minute
+** interval a full week is ~70-100 MB (measured ~33 KB per compressed
+** snapshot), instead of >1 GB of raw JSON.
 */
 
 namespace Modules\WecomCenter\Lib;
@@ -42,22 +43,39 @@ class L2Store {
 				['net' => 'SURVE',  'match' => 'contains', 'pattern' => 'core surve'],
 				['net' => 'OFFICE', 'match' => 'contains', 'pattern' => 'core office']
 			],
+			// Optional physical hierarchy above the sub-cores.  The display
+			// order is firewall -> behavior -> total core -> sub-core.  Every
+			// pattern may be empty; an empty role is not drawn and reserves no
+			// space in the topology.  sub_cores feed core_rules for backwards
+			// compatibility with older collectors/frontends.
+			'core_hierarchy' => [
+				'firewall'   => ['match' => 'contains', 'pattern' => ''],
+				'behavior'   => ['match' => 'contains', 'pattern' => ''],
+				'total_core' => ['match' => 'contains', 'pattern' => ''],
+				'sub_cores'  => [
+					['key' => 'monitor',    'label' => '监控核心', 'net' => 'SURVE',  'match' => 'contains', 'pattern' => 'core surve'],
+					['key' => 'office',     'label' => '办公核心', 'net' => 'OFFICE', 'match' => 'contains', 'pattern' => 'core office'],
+					['key' => 'production', 'label' => '生产核心', 'net' => '',       'match' => 'contains', 'pattern' => '']
+				]
+			],
 			// Collection --------------------------------------------------------
 			'templates'         => ['Huawei VRP by SNMP (walk)'],
-			'full_interval_min' => 5,            // full topology/port collection
+			'full_interval_min' => 5,            // full collection + one history snapshot
 			'alarm_interval_min'=> 1,            // alarm-only refresh
-			'history_days'      => 7,
+			'history_interval_min' => 5,         // legacy alias, kept equal to full_interval_min
+			'history_days'      => 7,             // retention window, hard-pruned
 			// Link colours (bits per second bucket) -----------------------------
 			'colors' => [
 				'100'     => '#F39C12', // 100M  橙色
 				'1000'    => '#2ECC71', // 1G    绿色
+				'2500'    => '#17C3B2', // 2.5G  青色
 				'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_warn'      => 1.0,
 			'latency_bad'       => 5.0,
 			// Grouping rules: first matching rule wins, else "Other".
 			// match = contains | regex ; label may contain $1..$9 backrefs.
@@ -74,7 +92,7 @@ class L2Store {
 	/** Create tables if absent. Safe to call on every request (idempotent). */
 	public static function ensureSchema(): void {
 		try {
-			DB::execute(
+			\DBexecute(
 				'CREATE TABLE IF NOT EXISTS '.self::HISTORY_TABLE.' ('.
 				' id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,'.
 				' ts BIGINT UNSIGNED NOT NULL,'.
@@ -85,7 +103,7 @@ class L2Store {
 				' KEY idx_l2_ts (ts)'.
 				') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin'
 			);
-			DB::execute(
+			\DBexecute(
 				'CREATE TABLE IF NOT EXISTS '.self::CONFIG_TABLE.' ('.
 				' id TINYINT UNSIGNED NOT NULL,'.
 				' config LONGTEXT NOT NULL,'.
@@ -100,24 +118,79 @@ class L2Store {
 		}
 	}
 
+	/**
+	 * Explicit, idempotent first-run initialisation used by the “初始化/自检”
+	 * button and by the alert-environment initialiser. It only creates missing
+	 * storage and seeds the config row when it is absent; an existing config is
+	 * never overwritten.
+	 */
+	public static function initialize(): array {
+		self::ensureSchema();
+		$out = [
+			'config_created' => false,
+			'config_rows'    => 0,
+			'history_rows'   => 0,
+			'oldest_ts'      => 0,
+			'newest_ts'      => 0
+		];
+		try {
+			$cursor = \DBselect('SELECT id FROM '.self::CONFIG_TABLE.' WHERE id='.self::CONFIG_ID);
+			$out['config_rows'] = \DBfetch($cursor) ? 1 : 0;
+			if ($out['config_rows'] === 0) {
+				self::saveConfig(self::getConfig());
+				$out['config_created'] = true;
+				$out['config_rows'] = 1;
+			}
+		}
+		catch (Throwable $e) {
+			// Keep the page usable even when DDL/INSERT rights are unavailable.
+		}
+		try {
+			$cursor = \DBselect(
+				'SELECT COUNT(*) AS cnt, MIN(ts) AS oldest_ts, MAX(ts) AS newest_ts FROM '.self::HISTORY_TABLE
+			);
+			$row = \DBfetch($cursor);
+			if ($row) {
+				$out['history_rows'] = (int) ($row['cnt'] ?? 0);
+				$out['oldest_ts'] = (int) ($row['oldest_ts'] ?? 0);
+				$out['newest_ts'] = (int) ($row['newest_ts'] ?? 0);
+			}
+		}
+		catch (Throwable $e) {
+		}
+		return $out;
+	}
+
 	public static function getConfig(): array {
 		self::ensureSchema();
 		$cfg = self::defaults();
+		$mirror = dirname(__DIR__).'/assets/l2topology/config.json';
 		try {
-			$rows = DB::select('SELECT config FROM '.self::CONFIG_TABLE.' WHERE id='.self::CONFIG_ID);
+			// Module tables are not in Zabbix's table-schema cache, so use the
+			// raw DBselect()/DBfetch() helpers rather than DB::select().
+			$cursor = \DBselect('SELECT config FROM '.self::CONFIG_TABLE.' WHERE id='.self::CONFIG_ID);
+			$row = \DBfetch($cursor);
+			$rows = $row ? [$row] : [];
 			if ($rows && isset($rows[0]['config'])) {
 				$saved = json_decode((string) $rows[0]['config'], true);
 				if (is_array($saved)) {
 					$cfg = self::mergeDeep($cfg, $saved);
 				}
 			}
+			else if (is_readable($mirror)) {
+				// Keep working when the table could not be created yet but the
+				// collector mirror was written by an earlier save.
+				$saved = json_decode((string) @file_get_contents($mirror), 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);
 		}
@@ -129,13 +202,14 @@ class L2Store {
 		$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]
+			\DBexecute(
+				'INSERT INTO '.self::CONFIG_TABLE.' (id, config, updated) VALUES ('.
+				self::CONFIG_ID.', '.\zbx_dbstr($json).', UTC_TIMESTAMP())'.
+				' ON DUPLICATE KEY UPDATE config=VALUES(config), updated=VALUES(updated)'
 			);
 		}
 		catch (Throwable $e) {
+			error_log('L2Store::saveConfig failed: '.$e->getMessage());
 		}
 		self::writeMirror($cfg);
 	}
@@ -157,10 +231,11 @@ class L2Store {
 	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)
+			$cursor = \DBselect(
+				'SELECT ts, captured_at, stats FROM '.self::HISTORY_TABLE.
+				' ORDER BY ts DESC LIMIT '.max(1, $limit)
 			);
-			foreach ($rows as $r) {
+			while ($r = \DBfetch($cursor)) {
 				$out[] = [
 					'ts'          => (int) $r['ts'],
 					'captured_at' => (string) $r['captured_at'],
@@ -169,29 +244,36 @@ class L2Store {
 			}
 		}
 		catch (Throwable $e) {
+			// No history yet / table not ready: the selector simply stays empty.
 		}
 		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.
+	 * Snapshot JSON at the exact recorded $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');
+				$cursor = \DBselect(
+					'SELECT ts, snapshot FROM '.self::HISTORY_TABLE.' ORDER BY ts DESC LIMIT 1'
+				);
+				$row = \DBfetch($cursor);
+				$rows = $row ? [$row] : [];
 			}
 			else {
-				$rows = DB::select(
-					'SELECT ts, snapshot FROM '.self::HISTORY_TABLE.' WHERE ts<=? ORDER BY ts DESC LIMIT 1',
-					[$ts]
+				// The UI only offers exact recorded timestamps.  Querying by
+				// exact ts prevents an arbitrary URL from silently showing
+				// the nearest (wrong) snapshot.
+				$cursor = \DBselect(
+					'SELECT ts, snapshot FROM '.self::HISTORY_TABLE.
+					' WHERE ts='.(int) $ts.' LIMIT 1'
 				);
-				if (!$rows) {
-					$rows = DB::select('SELECT ts, snapshot FROM '.self::HISTORY_TABLE.' ORDER BY ts ASC LIMIT 1');
-				}
+				$row = \DBfetch($cursor);
+				$rows = $row ? [$row] : [];
 			}
 			if ($rows) {
 				$blob = $rows[0]['snapshot'];
@@ -204,7 +286,7 @@ class L2Store {
 		}
 		catch (Throwable $e) {
 		}
-		if ($json === null) {
+		if ($json === null && $ts === null) {
 			$file = dirname(__DIR__).'/assets/l2topology/snapshot.json';
 			if (is_readable($file)) {
 				$raw = file_get_contents($file);
@@ -213,7 +295,7 @@ class L2Store {
 				}
 			}
 		}
-		return ['json' => $json, 'ts' => $used];
+		return ['json' => $json, 'ts' => ($json !== null ? $used : null)];
 	}
 
 	private static function mergeDeep(array $base, array $over): array {

+ 31 - 4
web-modules/WecomCenter/locale/en.php

@@ -1,4 +1,4 @@
-<?php
+<?php
 // WeCom Center - English (base language). Copy this file to <lang>.php to add a language.
 return [
 	'menu.title'      => 'ROMAN Custom Center',
@@ -158,6 +158,14 @@ return [
 	'msg.init_action_new'    => 'trigger action created',
 	'msg.init_action_ok'     => 'trigger action exists',
 	'msg.init_key_set'       => 'key saved',
+	'msg.init_l2_new'        => 'L2 topology storage/config initialised',
+	'msg.init_l2_ok'         => 'L2 topology storage ready',
+	'msg.l2_init_done'       => 'L2 topology initialisation complete: {1}, {2} historical snapshots.',
+	'msg.l2_init_config_new' => 'config created',
+	'msg.l2_init_config_ok'  => 'config already exists',
+	'sec.l2_init'            => 'L2 initialisation / self-check',
+	'lbl.l2_init_hint'       => 'Run once after a manual install or upgrade: idempotently creates l2_topology_history / l2_topology_config and writes the default config only when the row is missing; an existing config is never overwritten. The collector cron still has to be installed on the host.',
+	'btn.l2_init'            => 'Initialise / self-check L2 storage',
 
 	// ============================ L2 topology ============================
 	'menu.l2'            => 'L2 Topology',
@@ -167,8 +175,9 @@ return [
 	'sec.l2_latency'     => 'Latency thresholds',
 	'sec.l2_group'       => 'Grouping rules',
 	'sec.l2_networks'     => 'Physical networks',
-	'sec.l2_cores' => 'Core switches',
-	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'sec.l2_cores' => 'Core hierarchy',
+	'lbl.l2_cores_hint' => 'Upstream order: firewall → behavior management → total core. Leave a field empty when the device is not connected yet; the topology will not reserve space for it.',
+	'lbl.l2_subcores_hint' => 'Sub-cores: monitoring core, office core, and the future production core. Each row takes a network, match mode and name/IP pattern; empty rows are hidden.',
 	'lbl.core_net' => 'Network',
 	'lbl.core_any' => 'All networks',
 	'lbl.core_pattern' => 'Name or IP pattern',
@@ -178,12 +187,16 @@ return [
 	'lbl.l2_networks' => 'Physical networks (one per line: KEY | label | name/IP keywords | colour)',
 	'lbl.l2_networks_hint' => 'A switch belongs to the first network whose keyword appears in its name or IP. Example: SURVE | SURVE monitoring | SURVE,10.169. | #2E75B6.',
 	'lbl.l2_refresh'     => 'Display auto-refresh (seconds)',
-	'lbl.l2_full'        => 'Full collection interval (minutes)',
+	'lbl.l2_full'        => 'Collection / snapshot interval (minutes)',
 	'lbl.l2_alarm'       => 'Alarm-only refresh (minutes)',
+	'lbl.l2_history_interval' => 'History snapshot interval (legacy)',
+	'lbl.l2_history_days' => 'History retention (days)',
+	'lbl.l2_history_hint' => 'One snapshot is saved immediately after each successful collection; there is no separate snapshot interval. Minimum 5 minutes, hard-pruned after the retention window. Default 5 min / 7 days is about 70-100 MB compressed (measured ~33 KB per snapshot).',
 	'lbl.l2_root'        => 'Outermost frame label',
 	'lbl.l2_other'       => 'Fallback group label',
 	'lbl.color_100'      => '100M',
 	'lbl.color_1000'     => '1G',
+	'lbl.color_2500'     => '2.5G',
 	'lbl.color_10000'    => '10G',
 	'lbl.color_20000'    => '20G+',
 	'lbl.color_unknown'  => 'Unknown',
@@ -223,6 +236,20 @@ return [
 	'l2.time_live'       => 'Live',
 	'l2.time_btn'        => 'Go',
 	'l2.time_label'      => 'Snapshot time',
+	'l2.time_date'       => 'Date',
+	'l2.time_time'       => 'Time',
+	'l2.time_date_title' => 'Only dates with saved snapshots are listed',
+	'l2.time_time_title' => 'Only times saved on the selected date are listed',
+	'l2.time_pick_title' => 'Snapshot time',
+	'l2.time_choose'     => 'Choose a time',
+	'l2.time_live_full'  => 'Live (latest)',
+	'l2.time_live_hint'  => 'Showing the latest collected data',
+	'l2.firewall'        => 'Firewall',
+	'l2.behavior'        => 'Behavior management',
+	'l2.total_core'      => 'Total core',
+	'l2.upstream'        => 'Upstream hierarchy',
+	'l2.time_pick'       => 'Pick date/time',
+	'l2.view'            => 'View',
 	'l2.no_data'         => 'No snapshot yet — the collector has not run.',
 	'l2.offline_empty'   => 'No offline switches.',
 	'l2.alarm_empty'     => 'No active alarms.',

+ 30 - 4
web-modules/WecomCenter/locale/zh_CN.php

@@ -1,4 +1,4 @@
-<?php
+<?php
 // WeCom Center - 简体中文
 return [
 	'menu.title'      => 'ROMAN 定制中心',
@@ -145,6 +145,14 @@ return [
 	'msg.init_action_new'    => '触发器动作已创建',
 	'msg.init_action_ok'     => '触发器动作已存在',
 	'msg.init_key_set'       => 'Key 已保存',
+	'msg.init_l2_new'        => 'L2 拓扑存储/配置已初始化',
+	'msg.init_l2_ok'         => 'L2 拓扑存储已就绪',
+	'msg.l2_init_done'       => 'L2 拓扑初始化完成:{1},历史快照 {2} 条。',
+	'msg.l2_init_config_new' => '配置已创建',
+	'msg.l2_init_config_ok'  => '配置已存在',
+	'sec.l2_init'            => 'L2 初始化 / 自检',
+	'lbl.l2_init_hint'       => '手动安装或升级后点一次:幂等创建 l2_topology_history / l2_topology_config,并在配置行缺失时写入默认配置,绝不覆盖已有配置。采集器仍需在宿主机安装 cron。',
+	'btn.l2_init'            => '初始化 / 自检 L2 存储',
 
 	// ============================ L2 拓扑 ============================
 	'menu.l2'            => 'L2 拓扑',
@@ -154,8 +162,9 @@ return [
 	'sec.l2_latency'     => '延迟阈值',
 	'sec.l2_group'       => '分组规则',
 	'sec.l2_networks'     => '物理网络定义',
-	'sec.l2_cores' => '核心交换机',
-	'lbl.l2_cores_hint' => '定义根交换机(最左侧一列)。每行一个核心:网络 | 匹配方式 | 名称或IP规则;同一网络可配置多个核心,顺序不限。',
+	'sec.l2_cores' => '核心层级',
+	'lbl.l2_cores_hint' => '上游层级顺序:防火墙 → 行为管理 → 总核心。留空表示该设备尚未接入,拓扑图不会为它预留位置。',
+	'lbl.l2_subcores_hint' => '分核心:监控核心、办公核心、未来的生产核心。每行填写网络、匹配方式和名称或IP规则;留空同样不显示。',
 	'lbl.core_net' => '网络',
 	'lbl.core_any' => '全部网络',
 	'lbl.core_pattern' => '名称或IP规则',
@@ -165,12 +174,16 @@ return [
 	'lbl.l2_networks' => '物理网络(每行一个:KEY | 显示名称 | 名称或IP关键字 | 颜色)',
 	'lbl.l2_networks_hint' => '交换机归入第一个名称或IP中包含关键字的网络;例如:SURVE | SURVE 监控网 | SURVE,10.169. | #2E75B6。',
 	'lbl.l2_refresh'     => '展示页自动刷新(秒)',
-	'lbl.l2_full'        => '全量采集间隔(分钟)',
+	'lbl.l2_full'        => '采集/快照间隔(分钟)',
 	'lbl.l2_alarm'       => '仅告警刷新(分钟)',
+	'lbl.l2_history_interval' => '历史快照保存间隔(旧版兼容)',
+	'lbl.l2_history_days' => '历史保留天数',
+	'lbl.l2_history_hint' => '每次采集成功后立即保存一份快照,不再单独设置快照间隔;最少 5 分钟,超过保留天数自动删除。默认 5 分钟 / 7 天约 70–100 MB(实测每张压缩后约 33 KB)。',
 	'lbl.l2_root'        => '最外层框名',
 	'lbl.l2_other'       => '兜底分组名',
 	'lbl.color_100'      => '100M',
 	'lbl.color_1000'     => '1G',
+	'lbl.color_2500'     => '2.5G',
 	'lbl.color_10000'    => '10G',
 	'lbl.color_20000'    => '20G+',
 	'lbl.color_unknown'  => '未知',
@@ -210,6 +223,19 @@ return [
 	'l2.time_live'       => '实时',
 	'l2.time_btn'        => '查看',
 	'l2.time_label'      => '快照时间',
+	'l2.time_date'       => '日期',
+	'l2.time_time'       => '时间',
+	'l2.time_date_title' => '只列出实际保存过快照的日期',
+	'l2.time_time_title' => '只列出所选日期实际保存过快照的时间',
+	'l2.time_pick_title' => '快照时间',
+	'l2.time_choose'     => '选择时间',
+	'l2.time_live_full'  => '实时(最新)',
+	'l2.time_live_hint'  => '当前显示最新采集数据',
+	'l2.firewall'        => '防火墙',
+	'l2.behavior'        => '行为管理',
+	'l2.total_core'      => '总核心',
+	'l2.upstream'        => '上游层级',
+	'l2.time_pick'       => '选择日期时间',
 	'l2.no_data'         => '暂无快照——采集尚未运行。',
 	'l2.offline_empty'   => '没有离线交换机。',
 	'l2.alarm_empty'     => '当前没有活动告警。',

+ 34 - 17
web-modules/WecomCenter/views/topology.view.php

@@ -1,12 +1,12 @@
 <?php declare(strict_types = 0);
 /*
-** L2 Topology page — display-only map (all settings live in the config center).
+** 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?v=202611052015');
+$this->addCssFile('modules/WecomCenter/assets/l2topology/style.css?v=202611060155');
 $base = '/modules/WecomCenter/assets/l2topology/';
 
 $snapJson = (string) ($data['snap_json'] ?? 'null');
@@ -17,13 +17,15 @@ $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.no_data','l2.offline_empty','l2.alarm_empty','l2.updated',
+	'l2.time_label','l2.time_pick','l2.no_data','l2.offline_empty','l2.alarm_empty','l2.updated',
 	'l2.pause','l2.resume','l2.legend_speed','l2.legend_tier','l2.legend_lat','l2.core','l2.main','l2.dist','l2.access',
 	'l2.lat_norm','l2.term_on','l2.term_off','l2.term_title','l2.term_mac','l2.term_rate','l2.term_switch','l2.term_port','l2.term_ip','l2.zoom_in','l2.zoom_out','l2.snapshot','l2.showing','l2.live_tag',
 	'l2.unit_sw','l2.tile_hint','l2.not_in_topo','l2.click_hint','l2.floor_na',
 	'l2.net_surve','l2.net_office','l2.net_other',
 	'l2.terminals','l2.offline_short','l2.last_online','l2.history','l2.history_live',
-	'l2.history_live_hint','l2.history_empty'
+	'l2.history_live_hint','l2.history_empty','l2.time_date','l2.time_time',
+	'l2.time_date_title','l2.time_time_title','l2.time_pick_title','l2.time_choose',
+	'l2.time_live_full','l2.time_live_hint','l2.firewall','l2.behavior','l2.total_core','l2.upstream'
 ];
 $i18n = [];
 foreach ($i18nKeys as $k) { $i18n[$k] = I18n::t($k); }
@@ -32,10 +34,14 @@ $i18nJson = str_replace('</', '<\/', json_encode($i18n, JSON_UNESCAPED_UNICODE |
 $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>';
+$pickerLabel = I18n::t('l2.time_live_full');
+if (!$isLive && $usedTs) {
+	foreach ($hist as $h) {
+		if ((int) $h['ts'] === $usedTs) {
+			$pickerLabel = date('Y-m-d H:i:s', (int) $h['ts']);
+			break;
+		}
+	}
 }
 $refreshSec = (int) ($data['cfg_refresh'] ?? 300);
 
@@ -52,16 +58,29 @@ if (!function_exists('l2e')) { function l2e($v) { return htmlspecialchars((strin
 
     <label class="l2-chk"><input type="checkbox" id="l2-showterm"/> <?php echo l2e(I18n::t('l2.show_term')); ?></label>
 
-    <label class="l2-ts-label"><?php echo l2e(I18n::t('l2.time_label')); ?>
-      <select id="l2-ts" class="l2-btn"><?php echo $tsOpts; ?></select>
-    </label>
+    <div class="l2-ts-label"><?php echo l2e(I18n::t('l2.time_pick_title')); ?>
+      <span class="l2-ts-picker" id="l2-ts-picker">
+        <button type="button" class="l2-ts-trigger" id="l2-ts-trigger" aria-haspopup="dialog" aria-expanded="false" title="<?php echo l2e(I18n::t('l2.time_pick_title')); ?>">
+          <span class="l2-ts-clock" aria-hidden="true">鈼?/span>
+          <span class="l2-ts-current" id="l2-ts-current"><?php echo l2e($pickerLabel); ?></span>
+          <span class="l2-ts-caret" aria-hidden="true">鈱?/span>
+        </button>
+        <div class="l2-ts-pop" id="l2-ts-pop" role="dialog" aria-label="<?php echo l2e(I18n::t('l2.time_pick_title')); ?>">
+          <div class="l2-ts-days" id="l2-ts-days"></div>
+          <div class="l2-ts-times-wrap">
+            <div class="l2-ts-times-title" id="l2-ts-times-title"></div>
+            <div class="l2-ts-times" id="l2-ts-times"></div>
+          </div>
+        </div>
+      </span>
+    </div>
 
     <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" 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-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>
     <button class="l2-btn" id="l2-export"><?php echo l2e(I18n::t('l2.export_img')); ?></button>
   </div>
@@ -77,13 +96,11 @@ if (!function_exists('l2e')) { function l2e($v) { return htmlspecialchars((strin
         <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 class="side-tab" data-sec="terms"><?php echo l2e(I18n::t('l2.show_term')); ?> <span class="badge" id="l2-term-count" style="background:#2E75B6">0</span></div>
-        <div class="side-tab" data-sec="history"><?php echo l2e(I18n::t('l2.history')); ?> <span class="badge" id="l2-history-count" style="background:#8e6fb3">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 class="panel-sec" id="sec-terms"><div id="l2-term-list"></div></div>
-        <div class="panel-sec" id="sec-history"><div id="l2-history-list"></div></div>
       </div>
       <div class="l2-legend" id="l2-legend"></div>
     </div>
@@ -107,7 +124,7 @@ window.L2_META = {
 };
 </script>
 <script src="<?php echo $base; ?>cytoscape.min.js"></script>
-<script src="<?php echo $base; ?>app.js?v=202611052015"></script>
+<script src="<?php echo $base; ?>app.js?v=202611060155"></script>
 <script>
 (function(){
   var tabs = document.querySelectorAll('.side-tab');

+ 72 - 21
web-modules/WecomCenter/views/wecom.center.view.php

@@ -339,9 +339,13 @@ $collectBody =
 	.'<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_full').'</label><input class="wc-input" type="number" min="5" max="1440" step="5" 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>';
+	.'</div>'
+	.'<div class="wc-inline">'
+	.'<div class="wc-field"><label>'.$t('lbl.l2_history_days').'</label><input class="wc-input" type="number" min="1" max="30" name="history_days" value="'.$e($l2['history_days']).'"></div>'
+	.'</div>'
+	.'<div class="wc-hint">'.$t('lbl.l2_history_hint').'</div>';
 $collectCard = $card('&#128225;', $t('sec.l2_collect'), $collectBody, ' wc-card--wide');
 
 $displayBody =
@@ -362,35 +366,74 @@ $networkBody =
 	.'<textarea class="wc-input wc-input--mono" name="networks_text" rows="5" style="min-height:120px">'.$e(trim($netLines)).'</textarea></div>';
 $networkCard = $card('&#127760;', $t('sec.l2_networks'), $networkBody, ' wc-card--wide');
 
-$coreRowHtml = function (array $r) use ($e, $t, $l2) {
-	$netSel = '<select class="wc-input" name="core_net[]" style="max-width:230px">'
+$hier = is_array($l2['core_hierarchy'] ?? null) ? $l2['core_hierarchy'] : [];
+$matchSel = function (string $name, $value) use ($e) {
+	$isRegex = ((string) $value === 'regex');
+	return '<select class="wc-input" name="'.$e($name).'" style="max-width:120px">'
+		.'<option value="contains"'.($isRegex ? '' : ' selected').'>contains</option>'
+		.'<option value="regex"'.($isRegex ? ' selected' : '').'>regex</option></select>';
+};
+$hierRow = function (string $role, string $label, array $r) use ($e, $t, $matchSel) {
+	return '<div class="wc-rule"><span class="wc-rule-label">'.$e($label).'</span>'
+		.$matchSel('hier_'.$role.'_match', $r['match'] ?? 'contains')
+		.'<input class="wc-input wc-input--mono" name="hier_'.$role.'_pattern" value="'.$e($r['pattern'] ?? '').'" placeholder="'.$t('lbl.core_pattern').'(留空 = 不显示)">'
+		.'</div>';
+};
+$hierRows = '';
+foreach ([
+	'firewall' => '防火墙',
+	'behavior' => '行为管理',
+	'total_core' => '总核心'
+] as $role => $label) {
+	$hierRows .= $hierRow($role, $label, (array) ($hier[$role] ?? []));
+}
+$subRows = '';
+$defaultSubs = [
+	['key' => 'monitor', 'label' => '监控核心', 'net' => 'SURVE', 'match' => 'contains', 'pattern' => 'core surve'],
+	['key' => 'office', 'label' => '办公核心', 'net' => 'OFFICE', 'match' => 'contains', 'pattern' => 'core office'],
+	['key' => 'production', 'label' => '生产核心', 'net' => '', 'match' => 'contains', 'pattern' => '']
+];
+$savedSubs = (array) ($hier['sub_cores'] ?? []);
+$savedByKey = [];
+foreach ($savedSubs as $sr) {
+	$savedByKey[(string) ($sr['key'] ?? '')] = $sr;
+}
+$subDefs = [];
+foreach ($defaultSubs as $d) {
+	$r = $savedByKey[$d['key']] ?? $d;
+	$r['label'] = $d['label'];
+	$subDefs[] = $r;
+	unset($savedByKey[$d['key']]);
+}
+foreach ($savedByKey as $extra) {
+	$subDefs[] = $extra;
+}
+foreach ($subDefs as $i => $r) {
+	$netSel = '<select class="wc-input" name="sub_net[]" style="max-width:190px">'
 		.'<option value="">'.$t('lbl.core_any').'</option>';
 	foreach ((array) $l2['networks'] as $nw) {
 		$k = (string) ($nw['key'] ?? '');
 		$netSel .= '<option value="'.$e($k).'"'.((string) ($r['net'] ?? '') === $k ? ' selected' : '').'>'.$e($k).' · '.$e($nw['label'] ?? $k).'</option>';
 	}
 	$netSel .= '</select>';
-	$isRegex = (($r['match'] ?? 'contains') === 'regex');
-	return '<div class="wc-rule">'.$netSel
-		.'<select class="wc-input" name="core_match[]" style="max-width:120px">'
-		.'<option value="contains"'.($isRegex ? '' : ' selected').'>contains</option>'
-		.'<option value="regex"'.($isRegex ? ' selected' : '').'>regex</option></select>'
-		.'<input class="wc-input wc-input--mono" name="core_pattern[]" value="'.$e($r['pattern'] ?? '').'" placeholder="'.$t('lbl.core_pattern').'">'
-		.'<button type="button" class="wc-btn wc-btn--ghost wc-rule-del" title="remove">&times;</button></div>';
-};
-$coreRows = '';
-foreach ((array) ($l2['core_rules'] ?? []) as $r) {
-	$coreRows .= $coreRowHtml($r);
+	$subRows .= '<div class="wc-rule"><span class="wc-rule-label">'.$e($r['label'] ?? ('核心 '.($i + 1))).'</span>'
+		.'<input type="hidden" name="sub_key[]" value="'.$e($r['key'] ?? ('sub'.($i + 1))).'">'
+		.'<input type="hidden" name="sub_label[]" value="'.$e($r['label'] ?? ('核心 '.($i + 1))).'">'
+		.$netSel
+		.$matchSel('sub_match[]', $r['match'] ?? 'contains')
+		.'<input class="wc-input wc-input--mono" name="sub_pattern[]" value="'.$e($r['pattern'] ?? '').'" placeholder="'.$t('lbl.core_pattern').'(留空 = 不显示)">'
+		.'</div>';
 }
 $coreBody =
 	'<div class="wc-hint">'.$t('lbl.l2_cores_hint').'</div>'
-	.'<div class="wc-rules" id="wc-cores">'.$coreRows.'</div>'
-	.'<div class="wc-actions"><button type="button" class="wc-btn wc-btn--ghost" id="wc-core-add">'.$t('btn.add_core').'</button></div>'
-	.'<script type="text/template" id="wc-core-tpl">'.$coreRowHtml(['net' => '', 'match' => 'contains', 'pattern' => '']).'</script>';
+	.'<div class="wc-rules">'.$hierRows.'</div>'
+	.'<div class="wc-divider"></div>'
+	.'<div class="wc-hint">'.$t('lbl.l2_subcores_hint').'</div>'
+	.'<div class="wc-rules">'.$subRows.'</div>';
 $coreCard = $card('&#128273;', $t('sec.l2_cores'), $coreBody, ' wc-card--wide');
 
 $colorFields = '';
-foreach (['100' => 'lbl.color_100', '1000' => 'lbl.color_1000', '10000' => 'lbl.color_10000',
+foreach (['100' => 'lbl.color_100', '1000' => 'lbl.color_1000', '2500' => 'lbl.color_2500', '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>';
@@ -430,9 +473,17 @@ $groupBody =
 	.'<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">'
+$l2InitBody = '<div class="wc-hint">'.$t('lbl.l2_init_hint').'</div>'
+	.'<form method="post" action="'.$save.'">'
+	.'<input type="hidden" name="op" value="init_l2"><input type="hidden" name="tab" value="l2">'
+	.'<div class="wc-actions"><button class="wc-btn wc-btn--ghost" type="submit">'.$t('btn.l2_init').'</button></div>'
+	.'</form>';
+$l2InitCard = $card('&#9881;&#65039;', $t('sec.l2_init'), $l2InitBody, ' wc-card--wide');
+
+$tab_l2 = $l2InitCard
+	.'<form method="post" action="'.$save.'" id="wc-l2-form">'
 	.'<input type="hidden" name="op" value="save_l2"><input type="hidden" name="tab" value="l2">'
-	.'<input type="hidden" name="core_rules_present" value="1">'
+	.'<input type="hidden" name="core_hierarchy_present" value="1">'
 	.'<div class="wc-grid">'.$collectCard.$displayCard.$networkCard.$coreCard.$colorCard.$latCard.$groupCard.'</div>'
 	.'<div class="wc-actions" style="margin-top:18px"><button class="wc-btn wc-btn--primary" type="submit">'.$t('btn.save').'</button></div>'
 	.'</form>'

+ 44 - 5
web-modules/install.sh

@@ -14,7 +14,8 @@
 #
 # Env knobs:
 #   MYSQL_CONTAINER (default: auto / zabbix-mysql)
-#   MYSQL_ROOT_PASSWORD (default: CobcoIT132)
+#   MYSQL_USER / MYSQL_PASSWORD (default: read from the Zabbix MySQL container)
+#   MYSQL_ROOT_PASSWORD (optional fallback only; leave unset to use MYSQL_USER)
 #   MYSQL_DATABASE (default: zabbix)
 #   COLLECTOR_DIR (default: /opt/zabbix/l2collector)
 #   INSTALL_CRON=0 to skip installing crontab entries.
@@ -24,8 +25,10 @@ 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}"
+MYSQL_USER="${MYSQL_USER:-}"
+MYSQL_PASSWORD="${MYSQL_PASSWORD:-}"
+MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-}"
+MYSQL_DATABASE="${MYSQL_DATABASE:-}"
 COLLECTOR_DIR="${COLLECTOR_DIR:-/opt/zabbix/l2collector}"
 INSTALL_CRON="${INSTALL_CRON:-1}"
 
@@ -66,8 +69,26 @@ 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)."
+  # Reuse the credentials Zabbix already injected into its MySQL container;
+  # never keep a second, hard-coded database login in this installer.
+  if [ -z "$MYSQL_USER" ] || [ -z "$MYSQL_PASSWORD" ]; then
+    MYSQL_ENV="$(docker inspect "$MYSQL_CONTAINER" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null || true)"
+    MYSQL_USER="${MYSQL_USER:-$(printf '%s\n' "$MYSQL_ENV" | sed -n 's/^MYSQL_USER=//p' | head -n1)}"
+    MYSQL_PASSWORD="${MYSQL_PASSWORD:-$(printf '%s\n' "$MYSQL_ENV" | sed -n 's/^MYSQL_PASSWORD=//p' | head -n1)}"
+    MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-$(printf '%s\n' "$MYSQL_ENV" | sed -n 's/^MYSQL_ROOT_PASSWORD=//p' | head -n1)}"
+    MYSQL_DATABASE="${MYSQL_DATABASE:-$(printf '%s\n' "$MYSQL_ENV" | sed -n 's/^MYSQL_DATABASE=//p' | head -n1)}"
+  fi
+  MYSQL_DATABASE="${MYSQL_DATABASE:-zabbix}"
+  if [ -n "$MYSQL_USER" ] && [ -n "$MYSQL_PASSWORD" ]; then
+    MYSQL_AUTH=(-u"$MYSQL_USER" -p"$MYSQL_PASSWORD")
+  elif [ -n "$MYSQL_ROOT_PASSWORD" ]; then
+    MYSQL_AUTH=(-uroot -p"$MYSQL_ROOT_PASSWORD")
+  else
+    MYSQL_AUTH=()
+  fi
+  if [ ${#MYSQL_AUTH[@]} -gt 0 ]; then
+    echo "Ensuring L2 tables in container '$MYSQL_CONTAINER' ..."
+    docker exec -i "$MYSQL_CONTAINER" mysql "${MYSQL_AUTH[@]}" "$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,
@@ -78,6 +99,24 @@ CREATE TABLE IF NOT EXISTS l2_topology_config (
   PRIMARY KEY (id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
 SQL
+    # Register/enable the module as well. This is exactly what the compose
+    # sidecar does; doing it here keeps a manual install from requiring a
+    # manual "Scan directory + Enable" step. Never touch an existing row.
+    if docker exec "$MYSQL_CONTAINER" mysql "${MYSQL_AUTH[@]}" "$MYSQL_DATABASE" -N -e "SHOW TABLES LIKE 'module'" 2>/dev/null | grep -qx 'module'; then
+      docker exec -i "$MYSQL_CONTAINER" mysql "${MYSQL_AUTH[@]}" "$MYSQL_DATABASE" <<'SQL' || echo "WARN: could not register module automatically; enable it in Administration -> Modules."
+SET @nid := (SELECT COALESCE(MAX(moduleid), 0) + 1 FROM module);
+INSERT INTO module (moduleid, id, relative_path, status, config)
+SELECT @nid, 'wecom_center', 'modules/WecomCenter', 1, '[]'
+FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM module WHERE id = 'wecom_center');
+SQL
+      echo "OK: module 'wecom_center' registered/enabled (idempotent)."
+    else
+      echo "WARN: Zabbix 'module' table not found yet; enable WecomCenter in Administration -> Modules after Zabbix finishes its first start."
+    fi
+  else
+    echo "WARN: no MYSQL_USER/MYSQL_PASSWORD found in '$MYSQL_CONTAINER'; page auto-creates tables on load."
+  fi
 else
   echo "WARN: no MySQL container detected; skip table creation (page auto-creates on load)."
 fi