Procházet zdrojové kódy

fix(l2): recorded-snapshot picker, compact legend, export legend overlay

clm0081 před 2 dny
rodič
revize
7c0a63f79d

+ 38 - 0
collector/history_tick.py

@@ -0,0 +1,38 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""Write the periodic history row from the last complete snapshot.
+
+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.
+"""
+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)
+
+
+if __name__ == '__main__':
+    main()

+ 143 - 46
web-modules/WecomCenter/assets/l2topology/app.js

@@ -1703,12 +1703,16 @@ 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;
   }
 
@@ -1813,10 +1817,41 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
   }
 
   function buildHistory() {
+    var list = window.L2_HISTORY || [];
+    var used = (window.L2_META && !window.L2_META.isLive && window.L2_META.usedTs) ? String(window.L2_META.usedTs) : '';
+    // The toolbar selector is the only time control.  It is built strictly
+    // from snapshots that really exist in l2_topology_history, so the user
+    // can never submit an arbitrary/empty timestamp.
+    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);
+      var groups = {}, order = [];
+      list.forEach(function (it) {
+        var day = String(it.captured_at || fmtTs(it.ts)).slice(0, 10);
+        if (!groups[day]) { groups[day] = []; order.push(day); }
+        groups[day].push(it);
+      });
+      order.forEach(function (day) {
+        var og = document.createElement('optgroup');
+        og.label = day;
+        groups[day].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);
+          og.appendChild(opt);
+        });
+        sel.appendChild(og);
+      });
+      sel.value = used || old || '';
+    }
     var box = el('l2-history-list');
     if (!box) return;
-    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>';
@@ -1846,24 +1881,6 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
       });
     });
     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);
-      });
-      sel.value = used || old || '';
-    }
   }
 
   function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]; }); }
@@ -1879,7 +1896,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 +2003,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,30 +2308,17 @@ 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;
-    });
-
     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 sel = el('l2-ts');
+        if (!sel) return;
         var u = new URL(window.location.href);
         u.searchParams.set('action', 'l2.topology.view');
-        u.searchParams.set('ts', String(Math.floor(ms / 1000)));
+        if (sel.value) u.searchParams.set('ts', sel.value);
+        else u.searchParams.delete('ts');
         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 () {

+ 5 - 2
web-modules/WecomCenter/assets/l2topology/style.css

@@ -60,8 +60,8 @@
 
 .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;
+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;
 }
@@ -119,6 +119,9 @@ input.l2-ts-pick {
 .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; }

+ 41 - 17
web-modules/WecomCenter/lib/L2Store.php

@@ -76,7 +76,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,'.
@@ -87,7 +87,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,'.
@@ -105,21 +105,33 @@ class L2Store {
 	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);
 		}
@@ -131,13 +143,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);
 	}
@@ -159,10 +172,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'],
@@ -171,26 +185,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'
 				);
+				$row = \DBfetch($cursor);
+				$rows = $row ? [$row] : [];
 			}
 			if ($rows) {
 				$blob = $rows[0]['snapshot'];

+ 13 - 14
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=202611052200');
+$this->addCssFile('modules/WecomCenter/assets/l2topology/style.css?v=202611052255');
 $base = '/modules/WecomCenter/assets/l2topology/';
 
 $snapJson = (string) ($data['snap_json'] ?? 'null');
@@ -33,14 +33,19 @@ $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>';
+$byDay = [];
 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>';
+	$byDay[date('Y-m-d', (int) $h['ts'])][] = $h;
+}
+foreach ($byDay as $day => $items) {
+	$tsOpts .= '<optgroup label="'.htmlspecialchars($day).'">';
+	foreach ($items as $h) {
+		$sel = (!$isLive && $usedTs === (int) $h['ts']) ? ' selected' : '';
+		$tsOpts .= '<option value="'.(int) $h['ts'].'"'.$sel.'>'.date('H:i:s', (int) $h['ts']).'</option>';
+	}
+	$tsOpts .= '</optgroup>';
 }
 $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,11 +61,7 @@ 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>
-
-    <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); ?>"/>
+      <select id="l2-ts" class="l2-btn l2-ts-select" title="只列出实际保存过快照的时间"><?php echo $tsOpts; ?></select>
     </label>
     <button class="l2-btn" id="l2-ts-go"><?php echo l2e(I18n::t('l2.time_btn')); ?></button>
 
@@ -85,13 +86,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>
@@ -115,7 +114,7 @@ window.L2_META = {
 };
 </script>
 <script src="<?php echo $base; ?>cytoscape.min.js"></script>
-<script src="<?php echo $base; ?>app.js?v=202611052200"></script>
+<script src="<?php echo $base; ?>app.js?v=202611052255"></script>
 <script>
 (function(){
   var tabs = document.querySelectorAll('.side-tab');