Преглед на файлове

feat(l2): configurable history snapshots and date-time picker

clm0081 преди 2 дни
родител
ревизия
96340166a0

+ 3 - 3
README.md

@@ -106,12 +106,12 @@ 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`。
+- 数据:按配置的 `history_interval_min`(默认 5 分钟)把快照 gzip 写入 MySQL `l2_topology_history`,
+  按 `history_days`(默认 7 天)覆盖清理旧数据;展示页可选实时或任意历史日期时间回看。
 - 表结构:`L2Store::ensureSchema()` 幂等建表;`wecom-module-init` 侧车在 `up` 时也会建表。
 
 ### docker-compose 一键启用

+ 11 - 4
collector/l2_snapshot.py

@@ -108,20 +108,27 @@ def mysql(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)))
+    interval = max(5, int(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:

+ 1 - 1
collector/refresh_alarms.sh

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

+ 9 - 6
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),不丢已有终端位置。端口颜色与上方链路速率颜色是两套语义,不要混用。
 - 右侧「离线交换机」面板可查看离线交换机并点击定位;终端面板点击任意终端可定位到其接入交换机及具体端口。
-- **时间轴**:右上「快照时间」下拉可选 `实时` 或历史某分钟快照(保存 **7 天**,5 分钟一个点)。
+- **时间轴**:右上「快照时间」下拉,或「选择日期时间」输入框,可选 `实时` / 历史某分钟快照(默认 5 分钟一个点,保存 7 天;`历史快照保存间隔`和`历史保留天数`都在配置页可调)。
 - **明暗主题**:自动跟随 Zabbix 当前主题(Zabbix 深色即深色、亮色即亮色),无手动开关。
 - **搜索**:输入 IP / MAC / 名称,下拉结果点击即居中定位。
 - **导出图片**:右上角「导出图片」把当前完整拓扑导出为 PNG;无论当前 Zabbix 是明色还是深色,导出图固定使用日间模式(白底),
@@ -252,8 +252,11 @@ zabbix-deploy/
 
 ### 10.3 数据与存储
 - 采集结果 → `assets/l2topology/snapshot.json`(展示页实时读取)。
-- 每次**全量采集**同时把快照 gzip 后写入 MySQL 表 `l2_topology_history`
-  (字段 ts、captured_at、stats、snapshot),并自动清理超过 `history_days`(默认7) 天的旧数据。
+- 按配置的 `history_interval_min`(默认 5 分钟)把快照 gzip 后写入 MySQL 表 `l2_topology_history`
+  (字段 ts、captured_at、stats、snapshot),并自动清理超过 `history_days`(默认 7 天)的旧数据;
+  全量采集和每分钟告警刷新都会触发写历史,但同一间隔内只保留一条,不会重复。
+  按当前约 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` 时建表,全新环境零手工。
@@ -266,10 +269,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     # 仅刷新告警(每分钟;到历史间隔时也补写历史)
 ```
 - **只采集配置页所选模板**(默认 `Huawei VRP by SNMP (walk)`,id 10962)的主机,避免误采其它设备。
 - 安装(一次性,幂等):
@@ -291,7 +294,7 @@ refresh_alarms.sh     # 仅刷新告警(每分钟,不写历史)
 
 ### 10.6 故障排查
 - 展示页空白:确认 `assets/l2topology/snapshot.json` 存在(跑一次 `run_full.sh`)。
-- 时间轴没有历史:只有**全量采集**写历史;确认 cron 在跑,`l2_topology_history` 行数在增长。
+- 时间轴没有历史:确认 cron 在跑、`l2_topology_history` 行数在增长;历史写入间隔最少 5 分钟,改配置页后需等下一个周期。
 - 交换机没被采集:检查配置页模板是否勾选正确、该主机是否挂了该模板、SNMP 可达。
 - 颜色/阈值不生效:改完配置页保存后,`config.json` 会同步;采集侧下个周期生效。
 

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

@@ -36,12 +36,20 @@ class L2TopologyView extends CController {
 		$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)));
+		$history_interval = max(5, (int) ($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']
 		]));

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

@@ -254,6 +254,8 @@ 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']));
 		$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_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']));

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

@@ -2205,6 +2205,25 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
       window.location = u;
     });
 
+    if (el('l2-ts-go')) {
+      var goTs = function () {
+        var pick = el('l2-ts-pick');
+        if (!pick || !pick.value) return;
+        var ms = new Date(pick.value).getTime();
+        if (isNaN(ms)) return;
+        var u = new URL(window.location.href);
+        u.searchParams.set('action', 'l2.topology.view');
+        u.searchParams.set('ts', String(Math.floor(ms / 1000)));
+        window.location.href = u.toString();
+      };
+      el('l2-ts-go').addEventListener('click', goTs);
+      if (el('l2-ts-pick')) {
+        el('l2-ts-pick').addEventListener('keydown', function (ev) {
+          if (ev.key === 'Enter') { ev.preventDefault(); goTs(); }
+        });
+      }
+    }
+
     el('l2-pause').addEventListener('click', function () {
       paused = !paused;
       el('l2-pause').classList.toggle('on', paused);

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

@@ -12,6 +12,7 @@
   "templates": ["Huawei VRP by SNMP (walk)"],
   "full_interval_min": 5,
   "alarm_interval_min": 1,
+  "history_interval_min": 5,
   "history_days": 7,
   "colors": {
     "100": "#F39C12",

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

@@ -60,10 +60,16 @@
 
 .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); }
+input.l2-ts-pick {
+  width: 190px; height: 32px; padding: 0 8px;
+  box-sizing: border-box; line-height: normal; font-family: inherit;
+  flex: 0 0 auto; margin: 0;
+}
 
 .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; }

+ 8 - 9
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;
@@ -46,7 +47,8 @@ class L2Store {
 			'templates'         => ['Huawei VRP by SNMP (walk)'],
 			'full_interval_min' => 5,            // full topology/port collection
 			'alarm_interval_min'=> 1,            // alarm-only refresh
-			'history_days'      => 7,
+			'history_interval_min' => 5,         // one DB snapshot per N minutes
+			'history_days'      => 7,             // retention window, hard-pruned
 			// Link colours (bits per second bucket) -----------------------------
 			'colors' => [
 				'100'     => '#F39C12', // 100M  橙色
@@ -189,9 +191,6 @@ class L2Store {
 					'SELECT ts, snapshot FROM '.self::HISTORY_TABLE.' WHERE ts<=? ORDER BY ts DESC LIMIT 1',
 					[$ts]
 				);
-				if (!$rows) {
-					$rows = DB::select('SELECT ts, snapshot FROM '.self::HISTORY_TABLE.' ORDER BY ts ASC LIMIT 1');
-				}
 			}
 			if ($rows) {
 				$blob = $rows[0]['snapshot'];
@@ -204,7 +203,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 +212,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 {

+ 6 - 1
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',
@@ -180,6 +180,9 @@ return [
 	'lbl.l2_refresh'     => 'Display auto-refresh (seconds)',
 	'lbl.l2_full'        => 'Full collection interval (minutes)',
 	'lbl.l2_alarm'       => 'Alarm-only refresh (minutes)',
+	'lbl.l2_history_interval' => 'History snapshot interval (minutes)',
+	'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_root'        => 'Outermost frame label',
 	'lbl.l2_other'       => 'Fallback group label',
 	'lbl.color_100'      => '100M',
@@ -223,6 +226,8 @@ return [
 	'l2.time_live'       => 'Live',
 	'l2.time_btn'        => 'Go',
 	'l2.time_label'      => 'Snapshot time',
+	'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.',

+ 5 - 1
web-modules/WecomCenter/locale/zh_CN.php

@@ -1,4 +1,4 @@
-<?php
+<?php
 // WeCom Center - 简体中文
 return [
 	'menu.title'      => 'ROMAN 定制中心',
@@ -167,6 +167,9 @@ return [
 	'lbl.l2_refresh'     => '展示页自动刷新(秒)',
 	'lbl.l2_full'        => '全量采集间隔(分钟)',
 	'lbl.l2_alarm'       => '仅告警刷新(分钟)',
+	'lbl.l2_history_interval' => '历史快照保存间隔(分钟)',
+	'lbl.l2_history_days' => '历史保留天数',
+	'lbl.l2_history_hint' => '按此间隔把快照写入 MySQL(最少 5 分钟),超过保留天数自动删除。默认 5 分钟 / 7 天约 70–100 MB(实测每张压缩后约 33 KB)。',
 	'lbl.l2_root'        => '最外层框名',
 	'lbl.l2_other'       => '兜底分组名',
 	'lbl.color_100'      => '100M',
@@ -210,6 +213,7 @@ return [
 	'l2.time_live'       => '实时',
 	'l2.time_btn'        => '查看',
 	'l2.time_label'      => '快照时间',
+	'l2.time_pick'       => '选择日期时间',
 	'l2.no_data'         => '暂无快照——采集尚未运行。',
 	'l2.offline_empty'   => '没有离线交换机。',
 	'l2.alarm_empty'     => '当前没有活动告警。',

+ 11 - 3
web-modules/WecomCenter/views/topology.view.php

@@ -6,7 +6,7 @@
 */
 use Modules\WecomCenter\Lib\I18n;
 
-$this->addCssFile('modules/WecomCenter/assets/l2topology/style.css?v=202611052032');
+$this->addCssFile('modules/WecomCenter/assets/l2topology/style.css?v=202611052200');
 $base = '/modules/WecomCenter/assets/l2topology/';
 
 $snapJson = (string) ($data['snap_json'] ?? 'null');
@@ -17,7 +17,7 @@ $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',
@@ -38,6 +38,9 @@ foreach ($hist as $h) {
 	$tsOpts .= '<option value="'.(int) $h['ts'].'"'.$sel.'>'.date('Y-m-d H:i:s', (int) $h['ts']).'</option>';
 }
 $refreshSec = (int) ($data['cfg_refresh'] ?? 300);
+$reqTs = (int) ($data['requested_ts'] ?? 0);
+$pickTs = $isLive ? 0 : ($reqTs ?: $usedTs);
+$pickValue = $isLive ? '' : date('Y-m-d\TH:i', $pickTs);
 
 if (!function_exists('l2e')) { function l2e($v) { return htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); } }
 ?>
@@ -56,6 +59,11 @@ if (!function_exists('l2e')) { function l2e($v) { return htmlspecialchars((strin
       <select id="l2-ts" class="l2-btn"><?php echo $tsOpts; ?></select>
     </label>
 
+    <label class="l2-ts-label"><?php echo l2e(I18n::t('l2.time_pick')); ?>
+      <input id="l2-ts-pick" class="l2-btn l2-ts-pick" type="datetime-local" step="300" value="<?php echo l2e($pickValue); ?>"/>
+    </label>
+    <button class="l2-btn" id="l2-ts-go"><?php echo l2e(I18n::t('l2.time_btn')); ?></button>
+
     <span class="l2-spacer"></span>
     <span id="l2-countdown" class="l2-muted"></span>
     <button class="l2-btn" id="l2-pause" title="pause">❚❚</button>
@@ -107,7 +115,7 @@ window.L2_META = {
 };
 </script>
 <script src="<?php echo $base; ?>cytoscape.min.js"></script>
-<script src="<?php echo $base; ?>app.js?v=202611052032"></script>
+<script src="<?php echo $base; ?>app.js?v=202611052200"></script>
 <script>
 (function(){
   var tabs = document.querySelectorAll('.side-tab');

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

@@ -341,7 +341,12 @@ $collectBody =
 	.'<div class="wc-inline">'
 	.'<div class="wc-field"><label>'.$t('lbl.l2_full').'</label><input class="wc-input" type="number" min="1" name="full_interval_min" value="'.$e($l2['full_interval_min']).'"></div>'
 	.'<div class="wc-field"><label>'.$t('lbl.l2_alarm').'</label><input class="wc-input" type="number" min="1" name="alarm_interval_min" value="'.$e($l2['alarm_interval_min']).'"></div>'
-	.'</div>';
+	.'</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>';
 $collectCard = $card('&#128225;', $t('sec.l2_collect'), $collectBody, ' wc-card--wide');
 
 $displayBody =