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
+21 -14
View File
@@ -4,6 +4,7 @@ Flask web application serving the monitoring dashboard and suppression
management UI. Authentication via Authelia forward-auth headers.
All monitoring and alerting is handled by the separate monitor.py daemon.
"""
import hashlib
import ipaddress
import json
import logging
@@ -11,6 +12,7 @@ import re
import threading
import time
import uuid
from datetime import datetime, timezone
from functools import wraps
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')
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_lock = threading.Lock()
@app.context_processor
@@ -54,7 +57,6 @@ _diag_jobs: dict = {}
_diag_lock = threading.Lock()
_last_event_purge = [0.0] # mutable container so the thread can update it
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]
for jid in stale:
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:
j['status'] = 'done'
j['result'] = {'status': 'error', 'error': 'Diagnostic timed out (thread crash)'}
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.start()
@@ -89,12 +82,25 @@ _purge_thread.start()
def _config() -> dict:
global _cfg
if _cfg is None:
with _cfg_lock:
if _cfg is None:
with open('config.json') as f:
_cfg = json.load(f)
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
# ---------------------------------------------------------------------------
@@ -206,11 +212,13 @@ def suppressions_page():
@require_auth
def api_status():
active = db.get_active_events(limit=_PAGE_LIMIT)
last_check = db.get_state('last_check', 'Never')
return jsonify({
'summary': db.get_status_summary(),
'last_check': db.get_state('last_check', 'Never'),
'last_check': last_check,
'events': active,
'total_active': db.count_active_events(),
'daemon_ok': _daemon_ok(last_check),
})
@@ -453,7 +461,6 @@ def health():
try:
last_check = db.get_state('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)
age_s = (datetime.now(timezone.utc) - ts).total_seconds()
if age_s > 1200:
+20 -7
View File
@@ -23,13 +23,8 @@ def _config() -> dict:
return _config_cache
@contextmanager
def get_conn():
"""Yield a per-thread cached database connection, reconnecting as needed."""
cfg = _config()
conn = getattr(_local, 'conn', None)
if conn is None:
conn = pymysql.connect(
def _new_conn(cfg: dict):
return pymysql.connect(
host=cfg['host'],
port=cfg.get('port', 3306),
user=cfg['user'],
@@ -40,9 +35,27 @@ def get_conn():
connect_timeout=10,
charset='utf8mb4',
)
@contextmanager
def get_conn():
"""Yield a per-thread cached database connection, reconnecting as needed."""
cfg = _config()
conn = getattr(_local, 'conn', None)
if conn is None:
conn = _new_conn(cfg)
_local.conn = conn
else:
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
+9 -9
View File
@@ -325,6 +325,7 @@ class LinkStatsCollector:
def __init__(self, cfg: dict, prom: 'PrometheusClient',
unifi: Optional['UnifiClient'] = None):
self.cfg = cfg
self.prom = prom
self.pulse = PulseClient(cfg)
self.unifi = unifi
@@ -876,8 +877,7 @@ class NetworkMonitor:
# ------------------------------------------------------------------
# Snapshot collection (for dashboard)
# ------------------------------------------------------------------
def _collect_snapshot(self) -> dict:
iface_states = self.prom.get_interface_states()
def _collect_snapshot(self, iface_states: Dict[str, Dict[str, bool]]) -> dict:
unifi_devices = self.unifi.get_devices() or []
hosts = {}
@@ -930,23 +930,23 @@ class NetworkMonitor:
try:
logger.info('Starting network check cycle')
# 1. Collect and store snapshot for dashboard
snapshot = self._collect_snapshot()
# 1. Fetch interface states once — shared by snapshot and alert processing
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('last_check', _now_utc())
# 2. Collect link stats (ethtool + traffic metrics)
# 3. Collect link stats (ethtool + traffic metrics)
try:
link_data = self.link_stats.collect(self._instance_map)
db.set_state('link_stats', link_data)
except Exception as e:
logger.error(f'Link stats collection failed: {e}', exc_info=True)
# 3. Process alerts (separate Prometheus call for fresh data)
# Load suppressions once per cycle to avoid N*M DB queries
# 4. Process alerts using already-fetched interface states
suppressions = db.get_active_suppressions()
iface_states = self.prom.get_interface_states()
self._process_interfaces(iface_states, suppressions)
unifi_devices = self.unifi.get_devices()
+24 -8
View File
@@ -7,7 +7,10 @@
const _fetch = window.fetch;
window.fetch = async function (...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;
};
})();
@@ -29,28 +32,41 @@ function _toIso(s) {
// ── Dashboard auto-refresh ────────────────────────────────────────────
async function refreshAll() {
const refreshBtn = document.querySelector('[data-action="refresh"]');
if (refreshBtn) refreshBtn.classList.add('is-loading');
try {
const [net, status] = await Promise.all([
const [netResult, statusResult] = await Promise.allSettled([
lt.api.get('/api/network'),
lt.api.get('/api/status'),
]);
if (netResult.status === 'fulfilled') {
const net = netResult.value;
updateHostGrid(net.hosts || {});
updateUnifiTable(net.unifi || []);
updateEventsTable(status.events || [], status.total_active);
updateStatusBar(status.summary || {}, status.last_check || '');
updateTopology(net.hosts || {});
} catch (e) {
console.warn('Refresh failed:', e);
} else {
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');
if (!bar) return;
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.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('');
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);
}
/* ── 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 ─────────────────────────── */
@keyframes pulse-red {
0%,100% { box-shadow: 0 0 0 0 rgba(255,45,85,.5); }
@@ -85,6 +110,10 @@
border: 1px solid var(--border-color);
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-title {
font-size: 1em;
+41
View File
@@ -275,6 +275,17 @@
{% if summary.critical or summary.warning %}
<span class="g-section-badge">{{ (summary.critical or 0) + (summary.warning or 0) }}</span>
{% 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 id="events-table-wrap">
{% if events %}
@@ -462,5 +473,35 @@
document.querySelectorAll('.event-duration[data-first][data-resolved]').forEach(el => {
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>
{% endblock %}
+3
View File
@@ -464,6 +464,9 @@ async function loadInspector() {
loadInspector();
lt.autoRefresh.start(loadInspector, 60000);
lt.keys.on('Escape', () => {
if (document.getElementById('inspector-panel').classList.contains('open')) closePanel();
});
// ── Link Diagnostics ─────────────────────────────────────────────────
let _diagPollTimer = null;
+44 -36
View File
@@ -108,9 +108,9 @@ function voltageClass(v) {
function errorBadges(d) {
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>');
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>');
if ((d.carrier_changes || 0) > 3)
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 ─────────────────────────
function renderIfaceCard(ifaceName, d) {
const isDown = d.link_detected === false || d.admin_status === 'down';
const mediaTag = d.media_type === 'fibre' ? 'type-fibre'
: d.media_type === 'da' ? 'type-da'
const isDown = d.link_detected === false;
const pt = (d.port_type || '').toUpperCase();
const mediaTag = pt === 'FIBRE' || pt === 'SFP' || pt.includes('FIBRE') ? 'type-fibre'
: pt === 'DA' ? 'type-da'
: 'type-copper';
const mediaLabel = d.media_type || '';
const mediaLabel = d.port_type || '';
const speedStr = d.speed_mbps ? fmtSpeed(d.speed_mbps) : '';
const txPct = fmtRateBar(d.tx_bytes_per_sec, d.speed_mbps);
const rxPct = fmtRateBar(d.rx_bytes_per_sec, d.speed_mbps);
const txPct = fmtRateBar(d.tx_bytes_rate, d.speed_mbps);
const rxPct = fmtRateBar(d.rx_bytes_rate, d.speed_mbps);
let sfpHtml = '';
if (d.sfp && Object.keys(d.sfp).length > 0) {
@@ -142,7 +143,7 @@ function renderIfaceCard(ifaceName, d) {
<div class="sfp-panel">
<div class="sfp-vendor-row">
${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 class="sfp-grid">
<div class="sfp-stat">
@@ -199,7 +200,7 @@ function renderIfaceCard(ifaceName, d) {
</div>
<div class="link-stat">
<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 class="link-stat">
<span class="link-stat-label">Carrier Δ</span>
@@ -207,31 +208,31 @@ function renderIfaceCard(ifaceName, d) {
</div>
<div class="link-stat">
<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 class="link-stat">
<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 class="link-stat">
<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 class="link-stat">
<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 class="traffic-section">
<div class="traffic-row">
<span class="traffic-label">TX</span>
<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 class="traffic-row">
<span class="traffic-label">RX</span>
<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>
${sfpHtml}
@@ -242,13 +243,13 @@ function renderIfaceCard(ifaceName, d) {
function renderPortCard(portName, d) {
const isDown = !d.up;
const speedStr = d.speed_mbps ? fmtSpeed(d.speed_mbps) : '';
const txPct = fmtRateBar(d.tx_bytes_per_sec, d.speed_mbps);
const rxPct = fmtRateBar(d.rx_bytes_per_sec, d.speed_mbps);
const txPct = fmtRateBar(d.tx_bytes_rate, 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 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 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 `
<div class="link-iface-card ${isDown ? 'port-down' : ''}">
@@ -266,55 +267,60 @@ function renderPortCard(portName, d) {
</div>
<div class="link-stat">
<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 class="link-stat">
<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 class="link-stat">
<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 class="link-stat">
<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 class="link-stat">
<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="traffic-section">
<div class="traffic-row">
<span class="traffic-label">TX</span>
<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 class="traffic-row">
<span class="traffic-label">RX</span>
<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>`;
}
// ── Render all UniFi switches ─────────────────────────────────────
function renderUnifiSwitches(unifiSwitches) {
function renderUnifiSwitches(unifiSwitches, dataUpdated) {
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 ports = sw.ports || {};
const portValues = Object.values(ports);
const portCards = Object.entries(ports)
.sort(([,a],[,b]) => (a.port_idx||0) - (b.port_idx||0))
.map(([pname, d]) => renderPortCard(pname, d)).join('');
const updStr = sw.updated ? new Date(sw.updated + (sw.updated.includes('Z') ? '' : 'Z')).toLocaleTimeString() : '';
const poeLoad = sw.poe_total_w != null ? ` · PoE ${sw.poe_total_w.toFixed(1)}W` : '';
const poe_total_w = portValues.reduce((s, p) => s + (p.poe_power || 0), 0);
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
let poebar = '';
if (sw.poe_total_w != null && sw.poe_max_w) {
const pct = Math.min(100, (sw.poe_total_w / sw.poe_max_w) * 100);
if (poe_total_w > 0 && poe_max_w > 0) {
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';
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'))">
<span class="link-host-name">${escHtml(swName)}</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}
<span class="panel-toggle">[]</span>
</div>
@@ -368,11 +374,13 @@ function buildLinkSummary(hosts, unifiSwitches) {
for (const d of Object.values(ifaces)) {
totalIfaces++;
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 || {})) {
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;
return `
@@ -434,7 +442,7 @@ function renderLinks(data) {
</div>`);
}
parts.push(renderUnifiSwitches(unifiSwitches));
parts.push(renderUnifiSwitches(unifiSwitches, data.updated));
parts.push('</div>');
document.getElementById('links-container').innerHTML = parts.join('');
restoreCollapseState();
+73 -6
View File
@@ -29,7 +29,13 @@
<div class="lt-form-group" id="name-group">
<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"
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 class="lt-form-group" id="detail-group" style="display:none">
<label class="lt-label" for="s-detail">Interface Name</label>
@@ -70,11 +76,12 @@
</section>
<!-- ── Active suppressions ────────────────────────────────────────── -->
<section class="g-section">
<section class="g-section" id="active-sup-section">
<div class="g-section-header">
<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 id="active-sup-wrap">
{% if active %}
<div class="lt-table-wrap">
<table class="lt-table" id="active-sup-table">
@@ -104,8 +111,9 @@
</table>
</div>
{% else %}
<p class="empty-state">No active suppressions.</p>
<p class="empty-state" id="no-active-msg">No active suppressions.</p>
{% endif %}
</div>
</section>
<!-- ── Suppression history ────────────────────────────────────────── -->
@@ -191,15 +199,59 @@
const hint = document.getElementById('s-dur-hint');
if (mins) {
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 {
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) {
e.preventDefault();
const form = e.target;
const btn = form.querySelector('[type="submit"]');
btn.classList.add('is-loading');
const payload = {
target_type: form.target_type.value,
target_name: form.target_name ? form.target_name.value : '',
@@ -210,9 +262,16 @@
try {
await lt.api.post('/api/suppressions', payload);
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) {
showToast(err.message || 'Error', 'error');
} finally {
btn.classList.remove('is-loading');
}
}
@@ -221,6 +280,14 @@
try {
await lt.api.delete(`/api/suppressions/${id}`);
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');
} catch (err) {
showToast(err.message || 'Failed to remove suppression', 'error');