Przeglądaj źródła

feat(wecom): add L2 init and single-panel snapshot picker

clm0081 2 dni temu
rodzic
commit
ce6d849504

+ 5 - 2
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 变更)
 
@@ -113,10 +115,11 @@ docker compose -p zabbix ps                                     # 查看状态
 - 数据:每次全量采集成功后按 `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)。

+ 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)

+ 12 - 8
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. 页面使用详解
@@ -243,7 +245,7 @@ zabbix-deploy/
 - **告警/离线/延迟着色**:告警交换机整个节点填充红色,离线交换机同样整节点红色;延迟 **3–5ms 为橙色边框**,**大于 5ms 才为红色边框**。告警不会再只画一个红框。告警面板只显示拓扑内交换机,不混入服务器/测试机等“拓扑外”主机。
 - **显示终端(端口矩阵)**:勾选「显示终端」后,接入/汇聚交换机下方按实际端口清单绘制圆点;外圈颜色表示该端口**支持的最大速率**(100M 橙、1G 绿、2.5G 青、10G 紫、20G+ 深紫、未知灰),圆心实心颜色表示该端口**当前实际链路速率**。例如:10G 口插 1G 线 = 紫色外圈 + 绿色实心;10G 口未插线 = 空心紫色圈;2.5G 口 = 青色圈。端口较多时按实际面板自动分两行/多行,上联端口单独成行;历史快照没有端口清单时退回通用 24 口两行矩阵(12+12),不丢已有终端位置。端口颜色与上方链路速率颜色是两套语义,不要混用。
 - 右侧「离线交换机」面板可查看离线交换机并点击定位;终端面板点击任意终端可定位到其接入交换机及具体端口。
-- **时间轴**:右上「快照时间」下拉,或「选择日期时间」输入框,可选 `实时` / 历史某分钟快照(默认 5 分钟一个点,保存 7 天;`采集/快照间隔`和`历史保留天数`都在配置页可调;采集成功即保存,不再单独设置快照间隔)。
+- **时间轴**:右上「快照时间」是一个下拉面板(不是两个并排选择框):左侧只列实际保存过快照的日期(含「实时(最新)」),点日期后右侧只列该日期实际保存过的时间点,点时间即切换;默认 5 分钟一个点,保存 7 天;`采集/快照间隔`和`历史保留天数`都在配置页可调;采集成功即保存,不再单独设置快照间隔。
 - **明暗主题**:自动跟随 Zabbix 当前主题(Zabbix 深色即深色、亮色即亮色),无手动开关。
 - **搜索**:输入 IP / MAC / 名称,下拉结果点击即居中定位。
 - **导出图片**:右上角「导出图片」把当前完整拓扑导出为 PNG;无论当前 Zabbix 是明色还是深色,导出图固定使用日间模式(白底),
@@ -260,6 +262,7 @@ zabbix-deploy/
 - 配置存 `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/`:
@@ -272,7 +275,7 @@ latency_collect.py    # 并发 ping 交换机管理 IP -> /tmp/latency.tsv
 l2_snapshot.py        # 合成 snapshot.json + 按配置间隔写历史库
 l2cfg.py              # 读取 config.json,按模板过滤要采集的主机
 run_full.sh           # 全量:stp->fdb->analyze->port->latency->snapshot(写历史)
-refresh_alarms.sh     # 仅刷新告警(每分钟;到历史间隔时也补写历史)
+refresh_alarms.sh     # 仅刷新实时告警 JSON(每分钟;不写历史)
 ```
 - **只采集配置页所选模板**(默认 `Huawei VRP by SNMP (walk)`,id 10962)的主机,避免误采其它设备。
 - 安装(一次性,幂等):
@@ -287,8 +290,9 @@ 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 安装。
 

+ 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)。
+
 装完之后日常调参都在页面上点,不用再碰命令行。
 
 ## 目录结构

+ 15 - 0
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();
 

+ 135 - 60
web-modules/WecomCenter/assets/l2topology/app.js

@@ -1379,7 +1379,6 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
     buildAlarms(snap);
     buildOffline(snap);
     buildTerminals(snap);
-    buildHistory();
   }
 
   function renderFull(snap) {
@@ -1888,66 +1887,155 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
   }
 
   function buildHistory() {
+    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 dateSel = el('l2-ts-date');
-    var timeSel = el('l2-ts-time');
-    if (!dateSel || !timeSel) return;
-    var used = (window.L2_META && !window.L2_META.isLive && window.L2_META.usedTs) ? String(window.L2_META.usedTs) : '';
-    // Two-stage picker: the first select lists only days that really have
-    // snapshots; the second lists only times recorded on that day.  A user can
-    // therefore never submit an arbitrary timestamp or get a blank page.
+    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 usedDay = '';
+
+    var currentDay = '';
+    var currentTs = '';
     if (used) {
       list.forEach(function (it) {
-        if (String(it.ts) === used) usedDay = String(it.captured_at || fmtTs(it.ts)).slice(0, 10);
+        if (String(it.ts) === used) {
+          currentTs = used;
+          currentDay = String(it.captured_at || fmtTs(it.ts)).slice(0, 10);
+        }
+      });
+    }
+
+    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);
       });
     }
-    dateSel.innerHTML = '';
-    var live = document.createElement('option');
-    live.value = '';
-    live.textContent = t('l2.time_live', 'Live');
-    dateSel.appendChild(live);
-    days.forEach(function (day) {
-      var opt = document.createElement('option');
-      opt.value = day;
-      opt.textContent = day + ' (' + groups[day].length + ')';
-      dateSel.appendChild(opt);
-    });
-    dateSel.value = usedDay || '';
-
-    function fillTimes(preferTs) {
-      var day = dateSel.value;
-      timeSel.innerHTML = '';
-      if (!day) {
-        var dash = document.createElement('option');
-        dash.value = '';
-        dash.textContent = '--:--';
-        timeSel.appendChild(dash);
-        timeSel.disabled = true;
+
+    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;
       }
-      var items = groups[day] || [];
-      items.forEach(function (it) {
-        var opt = document.createElement('option');
-        opt.value = String(it.ts || '');
-        var ca = String(it.captured_at || '');
-        opt.textContent = ca.length >= 19 ? ca.slice(11, 19) : fmtTs(it.ts);
-        timeSel.appendChild(opt);
+      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);
       });
-      timeSel.disabled = false;
-      var chosen = preferTs && items.some(function (it) { return String(it.ts) === String(preferTs); }) ? String(preferTs) : '';
-      if (!chosen && items.length) chosen = String(items[0].ts || '');
-      timeSel.value = chosen;
     }
-    fillTimes(used);
-    dateSel.onchange = function () { fillTimes(''); };
-    timeSel.onchange = null;
+
+    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]; }); }
@@ -2375,20 +2463,6 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
       if (!ev.target.closest('.l2-search-wrap')) el('l2-sug').style.display = 'none';
     });
 
-    if (el('l2-ts-go')) {
-      var goTs = function () {
-        var dateSel = el('l2-ts-date');
-        var timeSel = el('l2-ts-time');
-        if (!dateSel || !timeSel) return;
-        var u = new URL(window.location.href);
-        u.searchParams.set('action', 'l2.topology.view');
-        if (dateSel.value && timeSel.value) u.searchParams.set('ts', timeSel.value);
-        else u.searchParams.delete('ts');
-        window.location.href = u.toString();
-      };
-      el('l2-ts-go').addEventListener('click', goTs);
-    }
-
     el('l2-pause').addEventListener('click', function () {
       paused = !paused;
       el('l2-pause').classList.toggle('on', paused);
@@ -2422,6 +2496,7 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
   /* ---------------- boot ---------------- */
   function boot() {
     setupControls();
+    buildHistory();
     fitViewport();
     try {
       var qs = new URLSearchParams(window.location.search);

+ 46 - 8
web-modules/WecomCenter/assets/l2topology/style.css

@@ -60,14 +60,52 @@
 
 .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); }
-select.l2-ts-select {
-  width: 156px; height: 32px; padding: 0 6px;
-  box-sizing: border-box; line-height: normal; font-family: inherit;
-  flex: 0 0 auto; margin: 0;
-}
-select.l2-ts-date { width: 150px; }
-select.l2-ts-time { width: 104px; }
-select.l2-ts-time:disabled { opacity: .55; }
+.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;

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

@@ -118,6 +118,49 @@ 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();

+ 12 - 0
web-modules/WecomCenter/locale/en.php

@@ -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',
@@ -232,6 +240,10 @@ return [
 	'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',

+ 12 - 0
web-modules/WecomCenter/locale/zh_CN.php

@@ -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 拓扑',
@@ -219,6 +227,10 @@ return [
 	'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'      => '总核心',

+ 30 - 29
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=202611052255');
+$this->addCssFile('modules/WecomCenter/assets/l2topology/style.css?v=202611060155');
 $base = '/modules/WecomCenter/assets/l2topology/';
 
 $snapJson = (string) ($data['snap_json'] ?? 'null');
@@ -24,7 +24,8 @@ $i18nKeys = [
 	'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.time_date','l2.time_time',
-	'l2.time_date_title','l2.time_time_title','l2.firewall','l2.behavior','l2.total_core','l2.upstream'
+	'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); }
@@ -33,21 +34,12 @@ $i18nJson = str_replace('</', '<\/', json_encode($i18n, JSON_UNESCAPED_UNICODE |
 $hist = $data['history'] ?? [];
 $isLive = !empty($data['is_live']);
 $usedTs = (int) ($data['used_ts'] ?? 0);
-$byDay = [];
-foreach ($hist as $h) {
-	$byDay[date('Y-m-d', (int) $h['ts'])][] = $h;
-}
-$usedDay = (!$isLive && $usedTs) ? date('Y-m-d', $usedTs) : '';
-$dateOpts = '<option value=""'.($isLive ? ' selected' : '').'>'.htmlspecialchars(I18n::t('l2.time_live')).'</option>';
-$timeOpts = '<option value="">--:--</option>';
-foreach ($byDay as $day => $items) {
-	$dateOpts .= '<option value="'.htmlspecialchars($day).'"'.($day === $usedDay ? ' selected' : '').'>'
-		.htmlspecialchars($day).' ('.count($items).')</option>';
-	if ($day === $usedDay) {
-		$timeOpts = '';
-		foreach ($items as $h) {
-			$sel = ($usedTs === (int) $h['ts']) ? ' selected' : '';
-			$timeOpts .= '<option value="'.(int) $h['ts'].'"'.$sel.'>'.date('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;
 		}
 	}
 }
@@ -66,20 +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_date')); ?>
-      <select id="l2-ts-date" class="l2-btn l2-ts-select l2-ts-date" title="<?php echo l2e(I18n::t('l2.time_date_title')); ?>"><?php echo $dateOpts; ?></select>
-    </label>
-    <label class="l2-ts-label"><?php echo l2e(I18n::t('l2.time_time')); ?>
-      <select id="l2-ts-time" class="l2-btn l2-ts-select l2-ts-time" title="<?php echo l2e(I18n::t('l2.time_time_title')); ?>"<?php echo $isLive ? ' disabled' : ''; ?>><?php echo $timeOpts; ?></select>
-    </label>
-    <button class="l2-btn" id="l2-ts-go"><?php echo l2e(I18n::t('l2.time_btn')); ?></button>
+    <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>
@@ -123,7 +124,7 @@ window.L2_META = {
 };
 </script>
 <script src="<?php echo $base; ?>cytoscape.min.js"></script>
-<script src="<?php echo $base; ?>app.js?v=202611052255"></script>
+<script src="<?php echo $base; ?>app.js?v=202611060155"></script>
 <script>
 (function(){
   var tabs = document.querySelectorAll('.side-tab');

+ 9 - 1
web-modules/WecomCenter/views/wecom.center.view.php

@@ -473,7 +473,15 @@ $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_hierarchy_present" value="1">'
 	.'<div class="wc-grid">'.$collectCard.$displayCard.$networkCard.$coreCard.$colorCard.$latCard.$groupCard.'</div>'

+ 18 - 1
web-modules/install.sh

@@ -28,7 +28,7 @@ MYSQL_CONTAINER="${MYSQL_CONTAINER:-}"
 MYSQL_USER="${MYSQL_USER:-}"
 MYSQL_PASSWORD="${MYSQL_PASSWORD:-}"
 MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-}"
-MYSQL_DATABASE="${MYSQL_DATABASE:-zabbix}"
+MYSQL_DATABASE="${MYSQL_DATABASE:-}"
 COLLECTOR_DIR="${COLLECTOR_DIR:-/opt/zabbix/l2collector}"
 INSTALL_CRON="${INSTALL_CRON:-1}"
 
@@ -75,8 +75,10 @@ if [ -n "$MYSQL_CONTAINER" ]; 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
@@ -97,6 +99,21 @@ 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