Fix field name mismatches, add events filter, in-place suppression refresh
Lint / Python (flake8) (push) Failing after 50s
Lint / JS (eslint) (push) Successful in 7s
Test / Python Tests (pytest) (push) Successful in 51s
Lint / Notify on failure (push) Successful in 2s
Lint / Deploy (push) Has been skipped
Security / Python Security (bandit) (push) Failing after 59s

- links.html: fix all field name bugs (auto_negotiation→autoneg, full_duplex,
  tx/rx_errors/drops_per_sec→_rate, tx/rx_bytes_per_sec→_rate, poe_total_w/poe_max_w
  computed from ports, renderUnifiSwitches uses top-level updated timestamp)
- suppressions.html: in-place DOM refresh after create/remove (no page reload),
  datalist autocomplete for target names, form reset after submit
- inspector.html: ESC key closes detail panel via lt.keys.on
- index.html: events filter bar with search input + severity pills (All/Critical/Warning),
  MutationObserver re-applies filter after dynamic updates
- style.css: g-section-actions, events-filter-bar, sev-pills layout
- app.js/db.py/monitor.py: carry forward prior session fixes (Promise.allSettled,
  daemon_ok, stale connection handling, double Prometheus call, self.cfg fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 23:35:02 -04:00
parent b6cd168542
commit c45dd007d1
9 changed files with 274 additions and 90 deletions
+23 -16
View File
@@ -4,6 +4,7 @@ Flask web application serving the monitoring dashboard and suppression
management UI. Authentication via Authelia forward-auth headers. management UI. Authentication via Authelia forward-auth headers.
All monitoring and alerting is handled by the separate monitor.py daemon. All monitoring and alerting is handled by the separate monitor.py daemon.
""" """
import hashlib
import ipaddress import ipaddress
import json import json
import logging import logging
@@ -11,6 +12,7 @@ import re
import threading import threading
import time import time
import uuid import uuid
from datetime import datetime, timezone
from functools import wraps from functools import wraps
from flask import Flask, jsonify, render_template, request from flask import Flask, jsonify, render_template, request
@@ -31,9 +33,10 @@ _AVATAR_COLORS = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple',
@app.template_filter('avatar_color') @app.template_filter('avatar_color')
def avatar_color_filter(name: str) -> str: def avatar_color_filter(name: str) -> str:
return _AVATAR_COLORS[abs(hash(name)) % len(_AVATAR_COLORS)] return _AVATAR_COLORS[int(hashlib.md5(name.encode()).hexdigest(), 16) % len(_AVATAR_COLORS)]
_cfg = None _cfg = None
_cfg_lock = threading.Lock()
@app.context_processor @app.context_processor
@@ -54,7 +57,6 @@ _diag_jobs: dict = {}
_diag_lock = threading.Lock() _diag_lock = threading.Lock()
_last_event_purge = [0.0] # mutable container so the thread can update it
def _purge_old_jobs_loop(): def _purge_old_jobs_loop():
@@ -67,21 +69,12 @@ def _purge_old_jobs_loop():
stale = [jid for jid, j in _diag_jobs.items() if j.get('created_at', 0) < cutoff] stale = [jid for jid, j in _diag_jobs.items() if j.get('created_at', 0) < cutoff]
for jid in stale: for jid in stale:
del _diag_jobs[jid] del _diag_jobs[jid]
for jid, j in _diag_jobs.items(): for jid, j in list(_diag_jobs.items()):
if j['status'] == 'running' and j.get('created_at', 0) < stuck_cutoff: if j['status'] == 'running' and j.get('created_at', 0) < stuck_cutoff:
j['status'] = 'done' j['status'] = 'done'
j['result'] = {'status': 'error', 'error': 'Diagnostic timed out (thread crash)'} j['result'] = {'status': 'error', 'error': 'Diagnostic timed out (thread crash)'}
logger.error(f'Diagnostic job {jid} appeared stuck; marked as errored') logger.error(f'Diagnostic job {jid} appeared stuck; marked as errored')
# Purge old resolved events once per day
now = time.time()
if now - _last_event_purge[0] > 86400:
try:
db.purge_old_resolved_events(days=90)
except Exception as e:
logger.error(f'Daily event purge failed: {e}')
_last_event_purge[0] = now
_purge_thread = threading.Thread(target=_purge_old_jobs_loop, daemon=True) _purge_thread = threading.Thread(target=_purge_old_jobs_loop, daemon=True)
_purge_thread.start() _purge_thread.start()
@@ -90,11 +83,24 @@ _purge_thread.start()
def _config() -> dict: def _config() -> dict:
global _cfg global _cfg
if _cfg is None: if _cfg is None:
with open('config.json') as f: with _cfg_lock:
_cfg = json.load(f) if _cfg is None:
with open('config.json') as f:
_cfg = json.load(f)
return _cfg return _cfg
def _daemon_ok(last_check: str) -> bool:
"""Return True if monitor last checked within 20 minutes."""
if not last_check or last_check == 'Never':
return False
try:
ts = datetime.strptime(last_check, '%Y-%m-%d %H:%M:%S UTC').replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - ts).total_seconds() < 1200
except Exception:
return False
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Auth helpers # Auth helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -206,11 +212,13 @@ def suppressions_page():
@require_auth @require_auth
def api_status(): def api_status():
active = db.get_active_events(limit=_PAGE_LIMIT) active = db.get_active_events(limit=_PAGE_LIMIT)
last_check = db.get_state('last_check', 'Never')
return jsonify({ return jsonify({
'summary': db.get_status_summary(), 'summary': db.get_status_summary(),
'last_check': db.get_state('last_check', 'Never'), 'last_check': last_check,
'events': active, 'events': active,
'total_active': db.count_active_events(), 'total_active': db.count_active_events(),
'daemon_ok': _daemon_ok(last_check),
}) })
@@ -453,7 +461,6 @@ def health():
try: try:
last_check = db.get_state('last_check', '') last_check = db.get_state('last_check', '')
if last_check: if last_check:
from datetime import datetime, timezone
ts = datetime.strptime(last_check, '%Y-%m-%d %H:%M:%S UTC').replace(tzinfo=timezone.utc) ts = datetime.strptime(last_check, '%Y-%m-%d %H:%M:%S UTC').replace(tzinfo=timezone.utc)
age_s = (datetime.now(timezone.utc) - ts).total_seconds() age_s = (datetime.now(timezone.utc) - ts).total_seconds()
if age_s > 1200: if age_s > 1200:
+25 -12
View File
@@ -23,26 +23,39 @@ def _config() -> dict:
return _config_cache return _config_cache
def _new_conn(cfg: dict):
return pymysql.connect(
host=cfg['host'],
port=cfg.get('port', 3306),
user=cfg['user'],
password=cfg['password'],
database=cfg['name'],
autocommit=True,
cursorclass=pymysql.cursors.DictCursor,
connect_timeout=10,
charset='utf8mb4',
)
@contextmanager @contextmanager
def get_conn(): def get_conn():
"""Yield a per-thread cached database connection, reconnecting as needed.""" """Yield a per-thread cached database connection, reconnecting as needed."""
cfg = _config() cfg = _config()
conn = getattr(_local, 'conn', None) conn = getattr(_local, 'conn', None)
if conn is None: if conn is None:
conn = pymysql.connect( conn = _new_conn(cfg)
host=cfg['host'],
port=cfg.get('port', 3306),
user=cfg['user'],
password=cfg['password'],
database=cfg['name'],
autocommit=True,
cursorclass=pymysql.cursors.DictCursor,
connect_timeout=10,
charset='utf8mb4',
)
_local.conn = conn _local.conn = conn
else: else:
conn.ping(reconnect=True) try:
conn.ping(reconnect=True)
except Exception:
try:
conn.close()
except Exception:
pass
_local.conn = None
conn = _new_conn(cfg)
_local.conn = conn
yield conn yield conn
+9 -9
View File
@@ -325,6 +325,7 @@ class LinkStatsCollector:
def __init__(self, cfg: dict, prom: 'PrometheusClient', def __init__(self, cfg: dict, prom: 'PrometheusClient',
unifi: Optional['UnifiClient'] = None): unifi: Optional['UnifiClient'] = None):
self.cfg = cfg
self.prom = prom self.prom = prom
self.pulse = PulseClient(cfg) self.pulse = PulseClient(cfg)
self.unifi = unifi self.unifi = unifi
@@ -876,8 +877,7 @@ class NetworkMonitor:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Snapshot collection (for dashboard) # Snapshot collection (for dashboard)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _collect_snapshot(self) -> dict: def _collect_snapshot(self, iface_states: Dict[str, Dict[str, bool]]) -> dict:
iface_states = self.prom.get_interface_states()
unifi_devices = self.unifi.get_devices() or [] unifi_devices = self.unifi.get_devices() or []
hosts = {} hosts = {}
@@ -930,23 +930,23 @@ class NetworkMonitor:
try: try:
logger.info('Starting network check cycle') logger.info('Starting network check cycle')
# 1. Collect and store snapshot for dashboard # 1. Fetch interface states once — shared by snapshot and alert processing
snapshot = self._collect_snapshot() iface_states = self.prom.get_interface_states()
# 2. Collect and store snapshot for dashboard
snapshot = self._collect_snapshot(iface_states)
db.set_state('network_snapshot', snapshot) db.set_state('network_snapshot', snapshot)
db.set_state('last_check', _now_utc()) db.set_state('last_check', _now_utc())
# 2. Collect link stats (ethtool + traffic metrics) # 3. Collect link stats (ethtool + traffic metrics)
try: try:
link_data = self.link_stats.collect(self._instance_map) link_data = self.link_stats.collect(self._instance_map)
db.set_state('link_stats', link_data) db.set_state('link_stats', link_data)
except Exception as e: except Exception as e:
logger.error(f'Link stats collection failed: {e}', exc_info=True) logger.error(f'Link stats collection failed: {e}', exc_info=True)
# 3. Process alerts (separate Prometheus call for fresh data) # 4. Process alerts using already-fetched interface states
# Load suppressions once per cycle to avoid N*M DB queries
suppressions = db.get_active_suppressions() suppressions = db.get_active_suppressions()
iface_states = self.prom.get_interface_states()
self._process_interfaces(iface_states, suppressions) self._process_interfaces(iface_states, suppressions)
unifi_devices = self.unifi.get_devices() unifi_devices = self.unifi.get_devices()
+27 -11
View File
@@ -7,7 +7,10 @@
const _fetch = window.fetch; const _fetch = window.fetch;
window.fetch = async function (...args) { window.fetch = async function (...args) {
const resp = await _fetch(...args); const resp = await _fetch(...args);
if (resp.status === 401) window.location.reload(); if (resp.status === 401) {
window.location.reload();
throw new Error('Session expired — reloading');
}
return resp; return resp;
}; };
})(); })();
@@ -29,28 +32,41 @@ function _toIso(s) {
// ── Dashboard auto-refresh ──────────────────────────────────────────── // ── Dashboard auto-refresh ────────────────────────────────────────────
async function refreshAll() { async function refreshAll() {
const refreshBtn = document.querySelector('[data-action="refresh"]');
if (refreshBtn) refreshBtn.classList.add('is-loading');
try { try {
const [net, status] = await Promise.all([ const [netResult, statusResult] = await Promise.allSettled([
lt.api.get('/api/network'), lt.api.get('/api/network'),
lt.api.get('/api/status'), lt.api.get('/api/status'),
]); ]);
updateHostGrid(net.hosts || {}); if (netResult.status === 'fulfilled') {
updateUnifiTable(net.unifi || []); const net = netResult.value;
updateEventsTable(status.events || [], status.total_active); updateHostGrid(net.hosts || {});
updateStatusBar(status.summary || {}, status.last_check || ''); updateUnifiTable(net.unifi || []);
updateTopology(net.hosts || {}); updateTopology(net.hosts || {});
} catch (e) { } else {
console.warn('Refresh failed:', e); console.warn('Network API failed:', netResult.reason);
}
if (statusResult.status === 'fulfilled') {
const status = statusResult.value;
updateEventsTable(status.events || [], status.total_active);
updateStatusBar(status.summary || {}, status.last_check || '', status.daemon_ok);
} else {
console.warn('Status API failed:', statusResult.reason);
}
} finally {
if (refreshBtn) refreshBtn.classList.remove('is-loading');
} }
} }
function updateStatusBar(summary, lastCheck) { function updateStatusBar(summary, lastCheck, daemonOk) {
const bar = document.querySelector('.status-chips'); const bar = document.querySelector('.status-chips');
if (!bar) return; if (!bar) return;
const chips = []; const chips = [];
if (daemonOk === false) chips.push('<span class="chip chip-critical">⚠ MONITOR OFFLINE</span>');
if (summary.critical) chips.push(`<span class="chip chip-critical">● ${summary.critical} CRITICAL</span>`); if (summary.critical) chips.push(`<span class="chip chip-critical">● ${summary.critical} CRITICAL</span>`);
if (summary.warning) chips.push(`<span class="chip chip-warning">● ${summary.warning} WARNING</span>`); if (summary.warning) chips.push(`<span class="chip chip-warning">● ${summary.warning} WARNING</span>`);
if (!summary.critical && !summary.warning) chips.push('<span class="chip chip-ok">✔ ALL SYSTEMS NOMINAL</span>'); if (!summary.critical && !summary.warning && daemonOk !== false) chips.push('<span class="chip chip-ok">✔ ALL SYSTEMS NOMINAL</span>');
bar.innerHTML = chips.join(''); bar.innerHTML = chips.join('');
const lc = document.getElementById('last-check'); const lc = document.getElementById('last-check');
+29
View File
@@ -40,6 +40,31 @@
--glow-xl: 0 0 8px var(--accent-green), 0 0 20px rgba(0,255,136,.5); --glow-xl: 0 0 8px var(--accent-green), 0 0 20px rgba(0,255,136,.5);
} }
/* ── Light theme overrides for dim/glow variables ────────────────── */
[data-theme="light"] {
--green-dim: rgba(0,160,80,.08);
--green-muted: rgba(0,160,80,.45);
--amber-dim: rgba(180,120,0,.07);
--cyan-dim: rgba(0,140,180,.08);
--red-dim: rgba(200,30,60,.06);
--orange-dim: rgba(180,80,0,.06);
--glow: none;
--glow-amber: none;
--glow-red: none;
--glow-cyan: none;
--glow-xl: none;
}
/* ── Refresh button loading state ────────────────────────────────── */
[data-action="refresh"].is-loading {
opacity: .5;
pointer-events: none;
cursor: wait;
}
[data-action="refresh"].is-loading::after {
content: '…';
}
/* ── Animations used by custom components ─────────────────────────── */ /* ── Animations used by custom components ─────────────────────────── */
@keyframes pulse-red { @keyframes pulse-red {
0%,100% { box-shadow: 0 0 0 0 rgba(255,45,85,.5); } 0%,100% { box-shadow: 0 0 0 0 rgba(255,45,85,.5); }
@@ -85,6 +110,10 @@
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
padding: 1px 7px; padding: 1px 7px;
} }
.g-section-actions { margin-left: auto; }
.events-filter-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.events-filter-bar .lt-input-sm { width: 220px; }
.sev-pills { display: flex; gap: 4px; }
.g-page-header { margin-bottom: 20px; } .g-page-header { margin-bottom: 20px; }
.g-page-title { .g-page-title {
font-size: 1em; font-size: 1em;
+41
View File
@@ -275,6 +275,17 @@
{% if summary.critical or summary.warning %} {% if summary.critical or summary.warning %}
<span class="g-section-badge">{{ (summary.critical or 0) + (summary.warning or 0) }}</span> <span class="g-section-badge">{{ (summary.critical or 0) + (summary.warning or 0) }}</span>
{% endif %} {% endif %}
<div class="g-section-actions">
<div class="events-filter-bar">
<input type="search" class="lt-input lt-input-sm" id="events-search"
placeholder="Filter by target, type, description…" autocomplete="off">
<div class="sev-pills">
<button type="button" class="pill active" data-sev="">All</button>
<button type="button" class="pill" data-sev="critical">Critical</button>
<button type="button" class="pill" data-sev="warning">Warning</button>
</div>
</div>
</div>
</div> </div>
<div id="events-table-wrap"> <div id="events-table-wrap">
{% if events %} {% if events %}
@@ -462,5 +473,35 @@
document.querySelectorAll('.event-duration[data-first][data-resolved]').forEach(el => { document.querySelectorAll('.event-duration[data-first][data-resolved]').forEach(el => {
el.textContent = fmtDuration(el.dataset.first, el.dataset.resolved); el.textContent = fmtDuration(el.dataset.first, el.dataset.resolved);
}); });
// ── Events table filter ────────────────────────────────────────
let _filterSev = '';
function applyEventsFilter() {
const q = (document.getElementById('events-search')?.value || '').toLowerCase();
const tbody = document.querySelector('#events-table tbody');
if (!tbody) return;
tbody.querySelectorAll('tr').forEach(row => {
if (row.children.length < 3) { row.style.display = ''; return; }
const sevMatch = !_filterSev || row.classList.contains(`row-${_filterSev}`);
const textMatch = !q || row.textContent.toLowerCase().includes(q);
row.style.display = (sevMatch && textMatch) ? '' : 'none';
});
}
document.getElementById('events-search')?.addEventListener('input', applyEventsFilter);
document.querySelector('.sev-pills')?.addEventListener('click', e => {
const pill = e.target.closest('.pill[data-sev]');
if (!pill) return;
document.querySelectorAll('.sev-pills .pill').forEach(p => p.classList.remove('active'));
pill.classList.add('active');
_filterSev = pill.dataset.sev;
applyEventsFilter();
});
// Re-apply filter after dynamic table updates
new MutationObserver(applyEventsFilter)
.observe(document.getElementById('events-table-wrap'), { childList: true, subtree: true });
</script> </script>
{% endblock %} {% endblock %}
+3
View File
@@ -464,6 +464,9 @@ async function loadInspector() {
loadInspector(); loadInspector();
lt.autoRefresh.start(loadInspector, 60000); lt.autoRefresh.start(loadInspector, 60000);
lt.keys.on('Escape', () => {
if (document.getElementById('inspector-panel').classList.contains('open')) closePanel();
});
// ── Link Diagnostics ───────────────────────────────────────────────── // ── Link Diagnostics ─────────────────────────────────────────────────
let _diagPollTimer = null; let _diagPollTimer = null;
+44 -36
View File
@@ -108,9 +108,9 @@ function voltageClass(v) {
function errorBadges(d) { function errorBadges(d) {
const badges = []; const badges = [];
if ((d.tx_errors_per_sec || 0) > 0 || (d.rx_errors_per_sec || 0) > 0) if ((d.tx_errs_rate || 0) > 0.001 || (d.rx_errs_rate || 0) > 0.001)
badges.push('<span class="link-alert-badge">ERR</span>'); badges.push('<span class="link-alert-badge">ERR</span>');
if ((d.tx_drops_per_sec || 0) > 0 || (d.rx_drops_per_sec || 0) > 0) if ((d.tx_drops_rate || 0) > 0.001 || (d.rx_drops_rate || 0) > 0.001)
badges.push('<span class="link-alert-badge link-alert-amber">DROP</span>'); badges.push('<span class="link-alert-badge link-alert-amber">DROP</span>');
if ((d.carrier_changes || 0) > 3) if ((d.carrier_changes || 0) > 3)
badges.push('<span class="link-alert-badge link-alert-amber">FLAP</span>'); badges.push('<span class="link-alert-badge link-alert-amber">FLAP</span>');
@@ -119,14 +119,15 @@ function errorBadges(d) {
// ── Render a single server interface card ───────────────────────── // ── Render a single server interface card ─────────────────────────
function renderIfaceCard(ifaceName, d) { function renderIfaceCard(ifaceName, d) {
const isDown = d.link_detected === false || d.admin_status === 'down'; const isDown = d.link_detected === false;
const mediaTag = d.media_type === 'fibre' ? 'type-fibre' const pt = (d.port_type || '').toUpperCase();
: d.media_type === 'da' ? 'type-da' const mediaTag = pt === 'FIBRE' || pt === 'SFP' || pt.includes('FIBRE') ? 'type-fibre'
: pt === 'DA' ? 'type-da'
: 'type-copper'; : 'type-copper';
const mediaLabel = d.media_type || ''; const mediaLabel = d.port_type || '';
const speedStr = d.speed_mbps ? fmtSpeed(d.speed_mbps) : ''; const speedStr = d.speed_mbps ? fmtSpeed(d.speed_mbps) : '';
const txPct = fmtRateBar(d.tx_bytes_per_sec, d.speed_mbps); const txPct = fmtRateBar(d.tx_bytes_rate, d.speed_mbps);
const rxPct = fmtRateBar(d.rx_bytes_per_sec, d.speed_mbps); const rxPct = fmtRateBar(d.rx_bytes_rate, d.speed_mbps);
let sfpHtml = ''; let sfpHtml = '';
if (d.sfp && Object.keys(d.sfp).length > 0) { if (d.sfp && Object.keys(d.sfp).length > 0) {
@@ -142,7 +143,7 @@ function renderIfaceCard(ifaceName, d) {
<div class="sfp-panel"> <div class="sfp-panel">
<div class="sfp-vendor-row"> <div class="sfp-vendor-row">
${s.vendor ? `<span>${escHtml(s.vendor)}</span>` : ''} ${s.vendor ? `<span>${escHtml(s.vendor)}</span>` : ''}
${s.part_number ? ` / <span>${escHtml(s.part_number)}</span>` : ''} ${s.part_no ? ` / <span>${escHtml(s.part_no)}</span>` : ''}
</div> </div>
<div class="sfp-grid"> <div class="sfp-grid">
<div class="sfp-stat"> <div class="sfp-stat">
@@ -199,7 +200,7 @@ function renderIfaceCard(ifaceName, d) {
</div> </div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">Auto-neg</span> <span class="link-stat-label">Auto-neg</span>
<span class="link-stat-value">${d.auto_negotiation == null ? '' : d.auto_negotiation ? 'On' : 'Off'}</span> <span class="link-stat-value">${d.auto_neg == null ? '' : d.auto_neg ? 'On' : 'Off'}</span>
</div> </div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">Carrier Δ</span> <span class="link-stat-label">Carrier Δ</span>
@@ -207,31 +208,31 @@ function renderIfaceCard(ifaceName, d) {
</div> </div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">TX Err/s</span> <span class="link-stat-label">TX Err/s</span>
<span class="link-stat-value">${fmtErrors(d.tx_errors_per_sec)}</span> <span class="link-stat-value">${fmtErrors(d.tx_errs_rate)}</span>
</div> </div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">RX Err/s</span> <span class="link-stat-label">RX Err/s</span>
<span class="link-stat-value">${fmtErrors(d.rx_errors_per_sec)}</span> <span class="link-stat-value">${fmtErrors(d.rx_errs_rate)}</span>
</div> </div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">TX Drop/s</span> <span class="link-stat-label">TX Drop/s</span>
<span class="link-stat-value">${fmtErrors(d.tx_drops_per_sec)}</span> <span class="link-stat-value">${fmtErrors(d.tx_drops_rate)}</span>
</div> </div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">RX Drop/s</span> <span class="link-stat-label">RX Drop/s</span>
<span class="link-stat-value">${fmtErrors(d.rx_drops_per_sec)}</span> <span class="link-stat-value">${fmtErrors(d.rx_drops_rate)}</span>
</div> </div>
</div> </div>
<div class="traffic-section"> <div class="traffic-section">
<div class="traffic-row"> <div class="traffic-row">
<span class="traffic-label">TX</span> <span class="traffic-label">TX</span>
<div class="traffic-bar-track"><div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div></div> <div class="traffic-bar-track"><div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div></div>
<span class="traffic-value">${fmtRate(d.tx_bytes_per_sec)}</span> <span class="traffic-value">${fmtRate(d.tx_bytes_rate)}</span>
</div> </div>
<div class="traffic-row"> <div class="traffic-row">
<span class="traffic-label">RX</span> <span class="traffic-label">RX</span>
<div class="traffic-bar-track"><div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div></div> <div class="traffic-bar-track"><div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div></div>
<span class="traffic-value">${fmtRate(d.rx_bytes_per_sec)}</span> <span class="traffic-value">${fmtRate(d.rx_bytes_rate)}</span>
</div> </div>
</div> </div>
${sfpHtml} ${sfpHtml}
@@ -242,13 +243,13 @@ function renderIfaceCard(ifaceName, d) {
function renderPortCard(portName, d) { function renderPortCard(portName, d) {
const isDown = !d.up; const isDown = !d.up;
const speedStr = d.speed_mbps ? fmtSpeed(d.speed_mbps) : ''; const speedStr = d.speed_mbps ? fmtSpeed(d.speed_mbps) : '';
const txPct = fmtRateBar(d.tx_bytes_per_sec, d.speed_mbps); const txPct = fmtRateBar(d.tx_bytes_rate, d.speed_mbps);
const rxPct = fmtRateBar(d.rx_bytes_per_sec, d.speed_mbps); const rxPct = fmtRateBar(d.rx_bytes_rate, d.speed_mbps);
const numBadge = d.port_idx != null ? `<span class="port-badge port-badge-num">#${d.port_idx}</span>` : ''; const numBadge = d.port_idx != null ? `<span class="port-badge port-badge-num">#${d.port_idx}</span>` : '';
const uplinkBadge = d.is_uplink ? `<span class="port-badge port-badge-uplink">UPLINK</span>` : ''; const uplinkBadge = d.is_uplink ? `<span class="port-badge port-badge-uplink">UPLINK</span>` : '';
const poeBadge = d.poe_power_w ? `<span class="port-badge port-badge-poe">PoE ${d.poe_power_w.toFixed(1)}W</span>` : ''; const poeBadge = d.poe_power ? `<span class="port-badge port-badge-poe">PoE ${d.poe_power.toFixed(1)}W</span>` : '';
const lldpLine = d.lldp ? `<div class="port-lldp">→ ${escHtml(d.lldp.system_name || '')} (${escHtml(d.lldp.port_id || '')})</div>` : ''; const lldpLine = d.lldp ? `<div class="port-lldp">→ ${escHtml(d.lldp.system_name || '')} (${escHtml(d.lldp.port_id || '')})</div>` : '';
const poeLine = d.poe_class ? `<div class="port-poe-info">PoE ${escHtml(d.poe_class)} · max ${d.poe_power_w_max != null ? d.poe_power_w_max.toFixed(1)+'W' : ''}</div>` : ''; const poeLine = d.poe_class ? `<div class="port-poe-info">PoE ${escHtml(d.poe_class)} · max ${d.poe_max_power != null ? d.poe_max_power.toFixed(1)+'W' : ''}</div>` : '';
return ` return `
<div class="link-iface-card ${isDown ? 'port-down' : ''}"> <div class="link-iface-card ${isDown ? 'port-down' : ''}">
@@ -266,55 +267,60 @@ function renderPortCard(portName, d) {
</div> </div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">Duplex</span> <span class="link-stat-label">Duplex</span>
<span class="link-stat-value">${fmtDuplex(d.duplex)}</span> <span class="link-stat-value">${d.full_duplex == null ? '' : d.full_duplex ? 'Full' : 'Half'}</span>
</div> </div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">Auto-neg</span> <span class="link-stat-label">Auto-neg</span>
<span class="link-stat-value">${d.auto_negotiation == null ? '' : d.auto_negotiation ? 'On' : 'Off'}</span> <span class="link-stat-value">${d.autoneg == null ? '' : d.autoneg ? 'On' : 'Off'}</span>
</div> </div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">TX Err/s</span> <span class="link-stat-label">TX Err/s</span>
<span class="link-stat-value">${fmtErrors(d.tx_errors_per_sec)}</span> <span class="link-stat-value">${fmtErrors(d.tx_errs_rate)}</span>
</div> </div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">RX Err/s</span> <span class="link-stat-label">RX Err/s</span>
<span class="link-stat-value">${fmtErrors(d.rx_errors_per_sec)}</span> <span class="link-stat-value">${fmtErrors(d.rx_errs_rate)}</span>
</div> </div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">TX Drop/s</span> <span class="link-stat-label">TX Drop/s</span>
<span class="link-stat-value">${fmtErrors(d.tx_drops_per_sec)}</span> <span class="link-stat-value">${fmtErrors(d.tx_drops_rate)}</span>
</div> </div>
</div> </div>
<div class="traffic-section"> <div class="traffic-section">
<div class="traffic-row"> <div class="traffic-row">
<span class="traffic-label">TX</span> <span class="traffic-label">TX</span>
<div class="traffic-bar-track"><div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div></div> <div class="traffic-bar-track"><div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div></div>
<span class="traffic-value">${fmtRate(d.tx_bytes_per_sec)}</span> <span class="traffic-value">${fmtRate(d.tx_bytes_rate)}</span>
</div> </div>
<div class="traffic-row"> <div class="traffic-row">
<span class="traffic-label">RX</span> <span class="traffic-label">RX</span>
<div class="traffic-bar-track"><div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div></div> <div class="traffic-bar-track"><div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div></div>
<span class="traffic-value">${fmtRate(d.rx_bytes_per_sec)}</span> <span class="traffic-value">${fmtRate(d.rx_bytes_rate)}</span>
</div> </div>
</div> </div>
</div>`; </div>`;
} }
// ── Render all UniFi switches ───────────────────────────────────── // ── Render all UniFi switches ─────────────────────────────────────
function renderUnifiSwitches(unifiSwitches) { function renderUnifiSwitches(unifiSwitches, dataUpdated) {
if (!unifiSwitches || !Object.keys(unifiSwitches).length) return ''; if (!unifiSwitches || !Object.keys(unifiSwitches).length) return '';
const updStr = dataUpdated
? new Date(dataUpdated.replace(' UTC', 'Z').replace(' ', 'T')).toLocaleTimeString()
: '';
const html = Object.entries(unifiSwitches).map(([swName, sw]) => { const html = Object.entries(unifiSwitches).map(([swName, sw]) => {
const ports = sw.ports || {}; const ports = sw.ports || {};
const portValues = Object.values(ports);
const portCards = Object.entries(ports) const portCards = Object.entries(ports)
.sort(([,a],[,b]) => (a.port_idx||0) - (b.port_idx||0)) .sort(([,a],[,b]) => (a.port_idx||0) - (b.port_idx||0))
.map(([pname, d]) => renderPortCard(pname, d)).join(''); .map(([pname, d]) => renderPortCard(pname, d)).join('');
const updStr = sw.updated ? new Date(sw.updated + (sw.updated.includes('Z') ? '' : 'Z')).toLocaleTimeString() : ''; const poe_total_w = portValues.reduce((s, p) => s + (p.poe_power || 0), 0);
const poeLoad = sw.poe_total_w != null ? ` · PoE ${sw.poe_total_w.toFixed(1)}W` : ''; const poe_max_w = portValues.reduce((s, p) => s + (p.poe_max_power || 0), 0);
const poeLoad = poe_total_w > 0 ? ` · PoE ${poe_total_w.toFixed(1)}W` : '';
// PoE utilisation bar // PoE utilisation bar
let poebar = ''; let poebar = '';
if (sw.poe_total_w != null && sw.poe_max_w) { if (poe_total_w > 0 && poe_max_w > 0) {
const pct = Math.min(100, (sw.poe_total_w / sw.poe_max_w) * 100); const pct = Math.min(100, (poe_total_w / poe_max_w) * 100);
const cls = pct > 80 ? 'poe-bar-crit' : pct > 60 ? 'poe-bar-warn' : 'poe-bar-ok'; const cls = pct > 80 ? 'poe-bar-crit' : pct > 60 ? 'poe-bar-warn' : 'poe-bar-ok';
poebar = `<div class="poe-bar-track"><div class="poe-bar-fill ${cls}" style="width:${pct}%"></div></div>`; poebar = `<div class="poe-bar-track"><div class="poe-bar-fill ${cls}" style="width:${pct}%"></div></div>`;
} }
@@ -324,7 +330,7 @@ function renderUnifiSwitches(unifiSwitches) {
<div class="link-host-title" onclick="togglePanel(this.closest('.link-host-panel'))"> <div class="link-host-title" onclick="togglePanel(this.closest('.link-host-panel'))">
<span class="link-host-name">${escHtml(swName)}</span> <span class="link-host-name">${escHtml(swName)}</span>
<span class="link-host-ip">${escHtml(sw.ip || '')}</span> <span class="link-host-ip">${escHtml(sw.ip || '')}</span>
<span class="link-host-upd">${updStr}${poeLoad}</span> <span class="link-host-upd">${escHtml(sw.model || '')}${updStr ? ' · ' + updStr : ''}${poeLoad}</span>
${poebar} ${poebar}
<span class="panel-toggle">[]</span> <span class="panel-toggle">[]</span>
</div> </div>
@@ -368,11 +374,13 @@ function buildLinkSummary(hosts, unifiSwitches) {
for (const d of Object.values(ifaces)) { for (const d of Object.values(ifaces)) {
totalIfaces++; totalIfaces++;
if (d.link_detected === false) downIfaces++; if (d.link_detected === false) downIfaces++;
if ((d.tx_errors_per_sec || 0) > 0 || (d.rx_errors_per_sec || 0) > 0) errIfaces++; if ((d.tx_errs_rate || 0) > 0 || (d.rx_errs_rate || 0) > 0) errIfaces++;
} }
} }
for (const sw of Object.values(unifiSwitches || {})) { for (const sw of Object.values(unifiSwitches || {})) {
totalPoe += sw.poe_total_w || 0; for (const p of Object.values(sw.ports || {})) {
totalPoe += p.poe_power || 0;
}
} }
const hasAlerts = downIfaces > 0 || errIfaces > 0; const hasAlerts = downIfaces > 0 || errIfaces > 0;
return ` return `
@@ -434,7 +442,7 @@ function renderLinks(data) {
</div>`); </div>`);
} }
parts.push(renderUnifiSwitches(unifiSwitches)); parts.push(renderUnifiSwitches(unifiSwitches, data.updated));
parts.push('</div>'); parts.push('</div>');
document.getElementById('links-container').innerHTML = parts.join(''); document.getElementById('links-container').innerHTML = parts.join('');
restoreCollapseState(); restoreCollapseState();
+73 -6
View File
@@ -29,7 +29,13 @@
<div class="lt-form-group" id="name-group"> <div class="lt-form-group" id="name-group">
<label class="lt-label" for="s-name">Target Name <span class="required">*</span></label> <label class="lt-label" for="s-name">Target Name <span class="required">*</span></label>
<input type="text" class="lt-input" id="s-name" name="target_name" <input type="text" class="lt-input" id="s-name" name="target_name"
placeholder="hostname or device name" autocomplete="off"> placeholder="hostname or device name" autocomplete="off"
list="target-name-list">
<datalist id="target-name-list">
{% for name in snapshot.hosts.keys() | sort %}
<option value="{{ name }}">
{% endfor %}
</datalist>
</div> </div>
<div class="lt-form-group" id="detail-group" style="display:none"> <div class="lt-form-group" id="detail-group" style="display:none">
<label class="lt-label" for="s-detail">Interface Name</label> <label class="lt-label" for="s-detail">Interface Name</label>
@@ -70,11 +76,12 @@
</section> </section>
<!-- ── Active suppressions ────────────────────────────────────────── --> <!-- ── Active suppressions ────────────────────────────────────────── -->
<section class="g-section"> <section class="g-section" id="active-sup-section">
<div class="g-section-header"> <div class="g-section-header">
<h2 class="g-section-title">Active Suppressions</h2> <h2 class="g-section-title">Active Suppressions</h2>
<span class="g-section-badge">{{ active | length }}</span> <span class="g-section-badge" id="active-sup-badge">{{ active | length }}</span>
</div> </div>
<div id="active-sup-wrap">
{% if active %} {% if active %}
<div class="lt-table-wrap"> <div class="lt-table-wrap">
<table class="lt-table" id="active-sup-table"> <table class="lt-table" id="active-sup-table">
@@ -104,8 +111,9 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<p class="empty-state">No active suppressions.</p> <p class="empty-state" id="no-active-msg">No active suppressions.</p>
{% endif %} {% endif %}
</div>
</section> </section>
<!-- ── Suppression history ────────────────────────────────────────── --> <!-- ── Suppression history ────────────────────────────────────────── -->
@@ -191,15 +199,59 @@
const hint = document.getElementById('s-dur-hint'); const hint = document.getElementById('s-dur-hint');
if (mins) { if (mins) {
const h = Math.floor(mins/60), m = mins%60; const h = Math.floor(mins/60), m = mins%60;
hint.textContent = `Expires in ${h?h+'h ':''} ${m?m+'m':''}`.trim()+'.'; hint.textContent = `Expires in ${h?h+'h ':''}${m?m+'m':''}`.trim()+'.';
} else { } else {
hint.textContent = 'Persists until manually removed.'; hint.textContent = 'Persists until manually removed.';
} }
} }
function renderActiveRows(rows) {
const wrap = document.getElementById('active-sup-wrap');
const badge = document.getElementById('active-sup-badge');
if (!rows || !rows.length) {
wrap.innerHTML = '<p class="empty-state" id="no-active-msg">No active suppressions.</p>';
if (badge) badge.textContent = '0';
return;
}
if (badge) badge.textContent = rows.length;
const tbody = rows.map(s => `
<tr id="sup-row-${s.id}">
<td><span class="lt-badge badge-info">${lt.escHtml(s.target_type)}</span></td>
<td>${lt.escHtml(s.target_name || 'all')}</td>
<td>${lt.escHtml(s.target_detail || '')}</td>
<td>${lt.escHtml(s.reason)}</td>
<td>${lt.escHtml(s.suppressed_by)}</td>
<td class="ts-cell">${lt.escHtml(s.created_at || '')}</td>
<td class="ts-cell">${s.expires_at ? lt.escHtml(s.expires_at) : '<em>manual</em>'}</td>
<td><button class="lt-btn lt-btn-danger lt-btn-sm" data-action="remove-sup" data-sup-id="${s.id}">Remove</button></td>
</tr>`).join('');
wrap.innerHTML = `
<div class="lt-table-wrap">
<table class="lt-table" id="active-sup-table">
<caption class="lt-sr-only">Active suppression rules</caption>
<thead><tr>
<th>Type</th><th>Target</th><th>Detail</th><th>Reason</th>
<th>By</th><th>Created</th><th>Expires</th><th>Actions</th>
</tr></thead>
<tbody>${tbody}</tbody>
</table>
</div>`;
}
async function refreshActive() {
try {
const rows = await lt.api.get('/api/suppressions');
renderActiveRows(rows);
} catch (err) {
console.warn('Failed to refresh suppressions:', err);
}
}
async function createSuppression(e) { async function createSuppression(e) {
e.preventDefault(); e.preventDefault();
const form = e.target; const form = e.target;
const btn = form.querySelector('[type="submit"]');
btn.classList.add('is-loading');
const payload = { const payload = {
target_type: form.target_type.value, target_type: form.target_type.value,
target_name: form.target_name ? form.target_name.value : '', target_name: form.target_name ? form.target_name.value : '',
@@ -210,9 +262,16 @@
try { try {
await lt.api.post('/api/suppressions', payload); await lt.api.post('/api/suppressions', payload);
showToast('Suppression applied', 'success'); showToast('Suppression applied', 'success');
setTimeout(() => location.reload(), 800); form.reset();
onTypeChange();
document.querySelectorAll('.duration-pills .pill').forEach(p => p.classList.remove('active'));
document.querySelector('.duration-pills .pill-manual')?.classList.add('active');
document.getElementById('s-dur-hint').textContent = 'Persists until manually removed.';
await refreshActive();
} catch (err) { } catch (err) {
showToast(err.message || 'Error', 'error'); showToast(err.message || 'Error', 'error');
} finally {
btn.classList.remove('is-loading');
} }
} }
@@ -221,6 +280,14 @@
try { try {
await lt.api.delete(`/api/suppressions/${id}`); await lt.api.delete(`/api/suppressions/${id}`);
document.getElementById(`sup-row-${id}`)?.remove(); document.getElementById(`sup-row-${id}`)?.remove();
const badge = document.getElementById('active-sup-badge');
if (badge) badge.textContent = Math.max(0, parseInt(badge.textContent || '0') - 1);
const tbody = document.querySelector('#active-sup-table tbody');
if (tbody && !tbody.children.length) {
document.getElementById('active-sup-wrap').innerHTML =
'<p class="empty-state" id="no-active-msg">No active suppressions.</p>';
if (badge) badge.textContent = '0';
}
showToast('Suppression removed', 'success'); showToast('Suppression removed', 'success');
} catch (err) { } catch (err) {
showToast(err.message || 'Failed to remove suppression', 'error'); showToast(err.message || 'Failed to remove suppression', 'error');