Parcourir la source

fix(l2): merge collection and history snapshot intervals

clm0081 il y a 2 jours
Parent
commit
017f8f55d4

+ 2 - 2
README.md

@@ -106,11 +106,11 @@ docker compose -p zabbix ps                                     # 查看状态
   右上角「导出图片」始终按日间模式导出 PNG(导出图为静态实线,不把动画虚线烘焙进图片)。
   PoE 交换机由采集器读取华为 POE MIB / RFC3621 判定,名称前自动加 ⚡️。
   主题自动跟随 Zabbix 明暗模式,无手动深色开关。
-- 配置页:`Administration → ROMAN 定制中心 → 「L2 拓扑」`(采集模板、刷新/采集间隔、**历史快照间隔/保留天数**、速率配色、
+- 配置页:`Administration → ROMAN 定制中心 → 「L2 拓扑」`(采集模板、刷新间隔、**采集/快照间隔、历史保留天数**、速率配色、
   延迟阈值、分组规则、根框名、**物理网络定义**、**核心交换机定义**等)。物理网络按每行
   `KEY | 显示名称 | 名称/IP关键字 | 颜色` 配置,交换机归入第一个名称或 IP 包含关键字的网络;
   核心交换机按行选择网络并填写名称/IP 匹配规则,同一网络可定义多个核心,全部固定在左侧核心列。
-- 数据:按配置的 `history_interval_min`(默认 5 分钟)把快照 gzip 写入 MySQL `l2_topology_history`,
+- 数据:每次全量采集成功后按 `full_interval_min`(默认 5 分钟)把快照 gzip 写入 MySQL `l2_topology_history`,
   按 `history_days`(默认 7 天)覆盖清理旧数据;展示页可选实时或任意历史日期时间回看。
 - 表结构:`L2Store::ensureSchema()` 幂等建表;`wecom-module-init` 侧车在 `up` 时也会建表。
 

+ 5 - 27
collector/history_tick.py

@@ -1,37 +1,15 @@
 #!/usr/bin/env python3
 # -*- coding: utf-8 -*-
-"""Write the periodic history row from the last complete snapshot.
+"""Deprecated compatibility stub.
 
-This runs every minute from cron, independently of the heavier full collection.
-The SQL guard in l2_snapshot.write_history() enforces the configured interval,
-so it only inserts when a new interval is due.  Keeping this out of the
-refresh_alarms.sh full-lock gate is what guarantees a five-minute history even
-while a full collection is running.
+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.
 """
-import json
-import os
-import sys
-import time
-
-HERE = os.path.dirname(os.path.abspath(__file__))
-sys.path.insert(0, HERE)
-
-import l2_snapshot  # noqa: E402
 
 
 def main():
-    try:
-        with open(l2_snapshot.OUT, encoding='utf-8') as f:
-            snap = json.load(f)
-        if not isinstance(snap, dict):
-            return
-        generated = int(snap.get('generated') or 0)
-        # Do not snapshot a stale file if the full collector has stopped.
-        if generated and time.time() - generated > 3600:
-            return
-        l2_snapshot.write_history(snap, int(time.time()))
-    except Exception as e:
-        print('history tick error:', e)
+    return
 
 
 if __name__ == '__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())

+ 3 - 1
collector/l2_snapshot.py

@@ -104,7 +104,9 @@ def mysql(sql):
 def write_history(snap, now):
     cfg = l2cfg.config()
     days = max(1, min(30, int(cfg.get('history_days') or 7)))
-    interval = max(5, int(cfg.get('history_interval_min') or 5))
+    # 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')

+ 4 - 1
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
-L2_WRITE_HISTORY=1 flock -n /tmp/l2_snap.lock /usr/bin/python3 $L/l2_snapshot.py >> /var/log/l2collector.log 2>&1 || true
+# 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

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

@@ -243,7 +243,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 是明色还是深色,导出图固定使用日间模式(白底),
@@ -252,9 +252,9 @@ zabbix-deploy/
 
 ### 10.3 数据与存储
 - 采集结果 → `assets/l2topology/snapshot.json`(展示页实时读取)。
-- 按配置的 `history_interval_min`(默认 5 分钟)把快照 gzip 后写入 MySQL 表 `l2_topology_history`
+- 每次全量采集成功后,按配置的 `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` 供采集脚本读取。

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

@@ -48,7 +48,8 @@ class L2TopologyView extends CController {
 		// 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)));
-		$history_interval = max(5, (int) ($cfg['history_interval_min'] ?? 5));
+		// 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));
 

+ 4 - 2
web-modules/WecomCenter/actions/WecomCenterSave.php

@@ -252,9 +252,11 @@ 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'] = max(5, min(1440, (int) ($_REQUEST['history_interval_min'] ?? $cfg['history_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'; }

+ 2 - 2
web-modules/WecomCenter/lib/L2Store.php

@@ -60,9 +60,9 @@ class L2Store {
 			],
 			// 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_interval_min' => 5,         // one DB snapshot per N minutes
+			'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' => [

+ 3 - 3
web-modules/WecomCenter/locale/en.php

@@ -179,11 +179,11 @@ 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 (minutes)',
+	'lbl.l2_history_interval' => 'History snapshot interval (legacy)',
 	'lbl.l2_history_days' => 'History retention (days)',
-	'lbl.l2_history_hint' => 'Snapshots are written to MySQL at this interval (minimum 5 minutes) and hard-pruned after the retention window. Default 5 min / 7 days is about 70-100 MB compressed (measured ~33 KB per snapshot).',
+	'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',

+ 3 - 3
web-modules/WecomCenter/locale/zh_CN.php

@@ -166,11 +166,11 @@ 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_interval' => '历史快照保存间隔(旧版兼容)',
 	'lbl.l2_history_days' => '历史保留天数',
-	'lbl.l2_history_hint' => '按此间隔把快照写入 MySQL(最少 5 分钟),超过保留天数自动删除。默认 5 分钟 / 7 天约 70–100 MB(实测每张压缩后约 33 KB)。',
+	'lbl.l2_history_hint' => '每次采集成功后立即保存一份快照,不再单独设置快照间隔;最少 5 分钟,超过保留天数自动删除。默认 5 分钟 / 7 天约 70–100 MB(实测每张压缩后约 33 KB)。',
 	'lbl.l2_root'        => '最外层框名',
 	'lbl.l2_other'       => '兜底分组名',
 	'lbl.color_100'      => '100M',

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

@@ -339,11 +339,10 @@ $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 class="wc-inline">'
-	.'<div class="wc-field"><label>'.$t('lbl.l2_history_interval').'</label><input class="wc-input" type="number" min="5" max="1440" step="5" name="history_interval_min" value="'.$e($l2['history_interval_min']).'"></div>'
 	.'<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>';