10 Commits

Author SHA1 Message Date
jared 40a0c2af78 Dynamic resolved count, host search filter, lt-divider for UniFi section
Lint / Python (flake8) (push) Successful in 38s
Lint / JS (eslint) (push) Successful in 8s
Security / Python Security (bandit) (push) Successful in 38s
Test / Python Tests (pytest) (push) Successful in 50s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 3s
- db.py: add resolved_24h to get_status_summary() so each /api/status
  poll carries the fresh 24h resolved count
- app.js: wire stat-resolved-val to update from summary.resolved_24h
  so the Resolved 24h card stays accurate after auto-refresh
- index.html: add lt-toolbar/lt-search above host grid for quick
  client-side host filtering by name
- links.html: replace custom unifi-section-header div with lt-divider
- style.css: remove unused .unifi-section-header rules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 18:36:57 -04:00
jared 08543ac25a Fix B108: replace hardcoded /tmp with tempfile.gettempdir()
Lint / Python (flake8) (push) Successful in 40s
Lint / JS (eslint) (push) Successful in 8s
Security / Python Security (bandit) (push) Successful in 42s
Test / Python Tests (pytest) (push) Successful in 1m18s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 8s
Bandit flags hardcoded /tmp strings as CWE-377 (insecure temp file).
Use tempfile.gettempdir() for the avatar cache dir default so the
path resolves correctly on all platforms and passes the security scan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 13:34:37 -04:00
jared 760e45bb68 TDS polish: lt-frame tables, links search toolbar, dead CSS cleanup
Lint / Python (flake8) (push) Successful in 56s
Lint / JS (eslint) (push) Successful in 8s
Security / Python Security (bandit) (push) Failing after 40s
Test / Python Tests (pytest) (push) Successful in 50s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 3s
- index.html: wrap UniFi devices table in lt-frame with section header
- links.html: add static lt-toolbar with lt-search filter and collapse
  controls above the dynamic container; remove collapse bar from
  renderLinks() since it's now static; add applyLinksSearch() to
  filter host/switch panels by name as user types
- suppressions.html: wrap Available Targets section in lt-frame
- style.css: remove unused .link-summary-panel and related rules
  (replaced by lt-stats-grid in previous commit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 17:39:11 -04:00
jared c3aa3bea6f TDS polish: lt-frame tables, lt-stats-grid link summary, settings-aware refresh
Lint / Python (flake8) (push) Successful in 40s
Lint / JS (eslint) (push) Successful in 8s
Security / Python Security (bandit) (push) Failing after 42s
Test / Python Tests (pytest) (push) Successful in 50s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 3s
- links.html: replace custom link-summary-panel with lt-stats-grid/lt-stat-card
  showing total interfaces, ports down, errors, and PoE load
- suppressions.html: wrap active suppressions and history tables in lt-frame
  with lt-section-header labels
- inspector.html: wire auto-refresh to gandalfSettings (respects interval pill),
  fix updated timestamp to use locale time

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 17:15:48 -04:00
jared b393d94e81 Upgrade page headers to lt-page-header/lt-page-title across all pages
Lint / Python (flake8) (push) Successful in 1m7s
Lint / JS (eslint) (push) Successful in 10s
Security / Python Security (bandit) (push) Failing after 1m17s
Test / Python Tests (pytest) (push) Successful in 1m23s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 4s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 01:09:30 -04:00
jared 4cb36a47a9 Add stat cards, lt-frame alert queue, and timeline for resolved alerts
Lint / Python (flake8) (push) Successful in 54s
Lint / JS (eslint) (push) Successful in 7s
Security / Python Security (bandit) (push) Failing after 40s
Test / Python Tests (pytest) (push) Successful in 50s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 3s
- Four lt-stat-card widgets (Critical, Warning, Hosts, Resolved 24h)
  below the status bar; Critical card pulses red when count > 0
- Clicking Critical or Warning card filters the events table by severity
- Events table wrapped in lt-frame with ASCII corner ornaments and
  lt-section-header; filter bar moved to lt-toolbar with lt-search icon
- Recently Resolved table replaced with lt-timeline component
- updateStatusBar() and updateHostGrid() keep stat card values live

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 22:19:50 -04:00
jared 7922d4bc79 Add notification bell, settings modal, and context-sensitive footer
Lint / Python (flake8) (push) Successful in 40s
Lint / JS (eslint) (push) Successful in 9s
Security / Python Security (bandit) (push) Failing after 1m11s
Test / Python Tests (pytest) (push) Successful in 1m3s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 2s
- Notification bell in header polls /api/status and shows active alerts
  with severity-colored dots; badge counts unread items via localStorage
- Settings modal ([ * ] CFG) controls auto-refresh interval (15s/30s/1m/5m/off)
  persisted to localStorage and wired into lt.autoRefresh on all pages
- Context-sensitive footer hints: Dashboard shows REFRESH + SUPPRESS,
  Link Debug shows REFRESH, all pages show CFG + HELP
- Added S key (quick suppress) and * key (settings) shortcuts
- ⌘K affordance button added to header-right
- R key now uses lt.autoRefresh.now() so it works on any page
- refreshAll() pushes fresh events to notification bell on each poll

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 21:33:02 -04:00
jared 1f8a99bbd4 Switch LDAP bind to dedicated gandalf service account
Lint / Python (flake8) (push) Successful in 39s
Lint / JS (eslint) (push) Successful in 9s
Security / Python Security (bandit) (push) Failing after 1m5s
Test / Python Tests (pytest) (push) Successful in 49s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 5s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 21:21:04 -04:00
jared 9d6583a08a Add LDAP avatar photos, UX polish, and TDS component upgrades
Lint / Python (flake8) (push) Successful in 1m13s
Lint / JS (eslint) (push) Successful in 9s
Security / Python Security (bandit) (push) Failing after 45s
Test / Python Tests (pytest) (push) Successful in 57s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 5s
- Add /api/avatar endpoint querying lldap for user jpegPhoto; disk cache
  with sentinel pattern avoids repeat LDAP hits for users without photos
- Add ldap3 dependency and ldap config block to config.json
- Wire lt-avatar img overlay in base.html with capture-phase error
  fallback (lt-avatar-img-err) to reveal initials when image is absent
- Fix lt-avatar CSS shim: position:relative + absolute inset on img
  (local base.css was missing these; added to style.css)
- Replace all empty-state paragraphs with proper lt-empty-state markup
  (icon + title + body) across index, suppressions, inspector, app.js
- Add lt-spinner--cyan next to refresh button; shows during refreshAll()
- Replace inspector panel-section-title with lt-divider throughout
- Add data-tooltip attributes to SFP DOM metrics, TX/RX/Carrier/Duplex/
  Auto-neg/Error labels in links.html and inspector panel
- Add tooltips to events table column headers (Sev, First Seen, Failures)
- Fix links.html host panel timestamp (was reading sample.updated which
  is always undefined; now uses data.updated)
- Fix UniFi status text casing (Online→ONLINE to match server render)
- Remove dead topo-status-* class manipulation from updateTopology()
- Always render alert-count-badge; toggle display:none when count is 0
- Fix double UniFi get_devices() call in monitor.py run loop
- Fix chip-critical animation (was using green pulse-glow; now red)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 21:09:56 -04:00
jared 29267c9933 Integrate test code improvements using web_template components
Lint / Python (flake8) (push) Successful in 45s
Lint / JS (eslint) (push) Successful in 8s
Security / Python Security (bandit) (push) Successful in 41s
Test / Python Tests (pytest) (push) Successful in 52s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 3s
lt-alert:
- Replace custom .stale-banner with lt-alert lt-alert--warning in app.js
  and links.html; remove stale-banner CSS, reuse lt-alert margin rule

lt-progress:
- Replace custom .traffic-bar-track/.traffic-bar-fill in links.html with
  lt-progress from base.css; TX uses default (orange), RX uses --cyan,
  both flip to --red when utilisation >85% (trafficBarClass helper)
- Keep traffic layout classes (.traffic-section/.traffic-row etc.) for structure

Suppression type badges:
- Map target_type to distinct badge colors: host→badge-warning (orange),
  interface→badge-info (cyan), unifi_device→badge-purple (new alias using
  --accent-purple from base.css), all→badge-critical (red)
- Applied in both server-rendered table (Jinja2 dict lookup) and
  renderActiveRows() JS

Topology animated down-wire:
- Add data-host attribute to .topo-v2-wire-10g/.topo-v2-wire-1g elements
- updateTopology() toggles .wire-down class on the 10G drop-wire when
  host.status === 'down'
- .wire-down CSS: animated repeating-linear-gradient dashed red line
  via wire-dash-anim @keyframes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 23:37:47 -04:00
12 changed files with 970 additions and 359 deletions
+80 -1
View File
@@ -8,14 +8,16 @@ import hashlib
import ipaddress
import json
import logging
import os
import re
import tempfile
import threading
import time
import uuid
from datetime import datetime, timezone
from functools import wraps
from flask import Flask, jsonify, render_template, request
from flask import Flask, jsonify, make_response, render_template, request, send_file
import db
import diagnose
@@ -169,6 +171,7 @@ def index():
last_check=last_check,
suppressions=suppressions,
recent_resolved=recent_resolved,
daemon_ok=_daemon_ok(last_check),
)
@@ -442,6 +445,82 @@ def api_diagnose_poll(job_id: str):
return jsonify({'status': job['status'], 'result': job.get('result')})
@app.route('/api/avatar')
@require_auth
def api_avatar():
"""Serve the current user's LDAP avatar photo (JPEG), cached to disk."""
username = request.headers.get('Remote-User', '').strip()
if not username:
return '', 404
ldap_cfg = _config().get('ldap', {})
if not ldap_cfg.get('host') or not ldap_cfg.get('bind_dn'):
return '', 404
# Build a safe cache filename from the username (alphanumeric + - _ .)
safe_name = re.sub(r'[^a-zA-Z0-9._-]', '_', username)
cache_dir = ldap_cfg.get('cache_dir', os.path.join(tempfile.gettempdir(), 'gandalf_avatars'))
os.makedirs(cache_dir, exist_ok=True)
cache_file = os.path.join(cache_dir, f'user_{safe_name}.jpg')
sentinel = os.path.join(cache_dir, f'user_{safe_name}.none')
cache_ttl = int(ldap_cfg.get('cache_ttl', 3600))
now = time.time()
# Serve cached image if fresh
if os.path.exists(cache_file) and now - os.path.getmtime(cache_file) < cache_ttl:
return send_file(cache_file, mimetype='image/jpeg',
max_age=cache_ttl, conditional=True)
# Skip LDAP if we already know this user has no avatar
if os.path.exists(sentinel) and now - os.path.getmtime(sentinel) < cache_ttl:
return '', 404
# Query lldap
avatar_data = None
try:
import ldap3
server = ldap3.Server(ldap_cfg['host'], port=int(ldap_cfg.get('port', 3890)))
conn = ldap3.Connection(server,
user=ldap_cfg['bind_dn'],
password=ldap_cfg.get('bind_pw', ''),
auto_bind=True, receive_timeout=5)
safe_uid = ldap3.utils.conv.escape_filter_chars(username)
conn.search(ldap_cfg.get('user_base', 'ou=people,dc=example,dc=com'),
f'(uid={safe_uid})', attributes=['avatar'])
if conn.entries and conn.entries[0]['avatar'].value:
avatar_data = conn.entries[0]['avatar'].value
conn.unbind()
except ImportError:
logger.error('ldap3 not installed — run: pip install ldap3')
return '', 404
except Exception as e:
logger.error(f'LDAP avatar lookup failed for {username}: {e}')
return '', 404
if not avatar_data or len(avatar_data) < 100:
open(sentinel, 'w').close()
return '', 404
# Validate JPEG magic bytes (FF D8 FF)
if isinstance(avatar_data, str):
avatar_data = avatar_data.encode('latin-1')
if avatar_data[:3] != b'\xFF\xD8\xFF':
logger.warning(f'Non-JPEG avatar data for {username}')
open(sentinel, 'w').close()
return '', 404
with open(cache_file, 'wb') as f:
f.write(avatar_data)
if os.path.exists(sentinel):
os.unlink(sentinel)
resp = make_response(avatar_data)
resp.headers['Content-Type'] = 'image/jpeg'
resp.headers['Cache-Control'] = f'private, max-age={cache_ttl}'
return resp
@app.route('/health')
def health():
"""Health check endpoint (no auth). Checks DB and monitor freshness."""
+9
View File
@@ -24,6 +24,15 @@
"url": "http://10.10.10.45/create_ticket_api.php",
"api_key": "5acc5d3c647b84f7c6f59082ce4450ee772e2d1633238b960136f653d20c93af"
},
"ldap": {
"host": "10.10.10.39",
"port": 3890,
"bind_dn": "uid=gandalf,ou=people,dc=example,dc=com",
"bind_pw": "AZ-eUFRxamnlLEELuDy5-Mh6z-2cJbL_YJZnu64Tf0A",
"user_base": "ou=people,dc=example,dc=com",
"cache_dir": "/tmp/gandalf_avatars",
"cache_ttl": 3600
},
"auth": {
"allowed_groups": ["admin"]
},
+7
View File
@@ -222,10 +222,17 @@ def get_status_summary() -> dict:
WHERE resolved_at IS NULL GROUP BY severity"""
)
counts = {r['severity']: r['cnt'] for r in cur.fetchall()}
cur.execute(
"""SELECT COUNT(*) as cnt FROM network_events
WHERE resolved_at IS NOT NULL
AND resolved_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)"""
)
resolved_24h = cur.fetchone()['cnt']
return {
'critical': counts.get('critical', 0),
'warning': counts.get('warning', 0),
'info': counts.get('info', 0),
'resolved_24h': resolved_24h,
}
+14 -9
View File
@@ -877,8 +877,12 @@ class NetworkMonitor:
# ------------------------------------------------------------------
# Snapshot collection (for dashboard)
# ------------------------------------------------------------------
def _collect_snapshot(self, iface_states: Dict[str, Dict[str, bool]]) -> dict:
unifi_devices = self.unifi.get_devices() or []
def _collect_snapshot(
self, iface_states: Dict[str, Dict[str, bool]],
unifi_devices: Optional[List[dict]] = None,
) -> dict:
# Accept pre-fetched devices; fall back to empty list if unavailable
display_unifi = unifi_devices if unifi_devices is not None else []
hosts = {}
for instance, ifaces in iface_states.items():
@@ -914,7 +918,7 @@ class NetworkMonitor:
return {
'hosts': hosts,
'unifi': unifi_devices,
'unifi': display_unifi,
'updated': datetime.utcnow().isoformat(),
}
@@ -933,23 +937,24 @@ class NetworkMonitor:
# 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)
# 2. Fetch UniFi devices once — used by both snapshot and alert processing
unifi_devices = self.unifi.get_devices()
# 3. Collect and store snapshot for dashboard
snapshot = self._collect_snapshot(iface_states, unifi_devices)
db.set_state('network_snapshot', snapshot)
db.set_state('last_check', _now_utc())
# 3. Collect link stats (ethtool + traffic metrics)
# 4. 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)
# 4. Process alerts using already-fetched interface states
# 5. Process alerts using already-fetched data
suppressions = db.get_active_suppressions()
self._process_interfaces(iface_states, suppressions)
unifi_devices = self.unifi.get_devices()
self._process_unifi(unifi_devices, suppressions)
self._process_ping_hosts(suppressions)
+1
View File
@@ -1,5 +1,6 @@
flask>=2.2.0
gunicorn>=20.1.0
ldap3>=2.9
pymysql>=1.1.0
requests>=2.31.0
urllib3>=2.0.0
+35 -6
View File
@@ -33,7 +33,9 @@ function _toIso(s) {
// ── Dashboard auto-refresh ────────────────────────────────────────────
async function refreshAll() {
const refreshBtn = document.querySelector('[data-action="refresh"]');
const spinner = document.getElementById('refresh-spinner');
if (refreshBtn) refreshBtn.classList.add('is-loading');
if (spinner) spinner.style.display = '';
try {
const [netResult, statusResult] = await Promise.allSettled([
lt.api.get('/api/network'),
@@ -51,11 +53,13 @@ async function refreshAll() {
const status = statusResult.value;
updateEventsTable(status.events || [], status.total_active);
updateStatusBar(status.summary || {}, status.last_check || '', status.daemon_ok);
if (typeof window.gandalfNotifUpdate === 'function') window.gandalfNotifUpdate(status.events || []);
} else {
showToast('Status data unavailable', 'warning');
}
} finally {
if (refreshBtn) refreshBtn.classList.remove('is-loading');
if (spinner) spinner.style.display = 'none';
}
}
@@ -78,6 +82,23 @@ function updateStatusBar(summary, lastCheck, daemonOk) {
else if (warnCount) document.title = `(${warnCount} WARN) GANDALF`;
else document.title = 'GANDALF';
const alertBadge = document.getElementById('alert-count-badge');
if (alertBadge) {
const total = critCount + warnCount;
alertBadge.textContent = total;
alertBadge.style.display = total ? '' : 'none';
}
// Update stat cards
const scCrit = document.getElementById('stat-critical-val');
const scWarn = document.getElementById('stat-warning-val');
const scRes = document.getElementById('stat-resolved-val');
if (scCrit) scCrit.textContent = critCount;
if (scWarn) scWarn.textContent = warnCount;
if (scRes && summary.resolved_24h != null) scRes.textContent = summary.resolved_24h;
const statCritCard = document.getElementById('stat-critical');
if (statCritCard) statCritCard.classList.toggle('lt-stat-card--alert', critCount > 0);
// Stale data banner: warn if last_check is older than 15 minutes
let staleBanner = document.getElementById('stale-banner');
if (lastCheck) {
@@ -86,11 +107,13 @@ function updateStatusBar(summary, lastCheck, daemonOk) {
if (!staleBanner) {
staleBanner = document.createElement('div');
staleBanner.id = 'stale-banner';
staleBanner.className = 'stale-banner';
staleBanner.className = 'lt-alert lt-alert--warning';
staleBanner.innerHTML = '<span class="lt-alert-icon">⚠</span><div class="lt-alert-body"><div class="lt-alert-msg"></div></div>';
document.querySelector('.lt-main').prepend(staleBanner);
}
const mins = Math.floor(checkAge / 60);
staleBanner.textContent = `⚠ Monitoring data is stale — last check was ${mins} minute${mins !== 1 ? 's' : ''} ago. The monitor daemon may be down.`;
staleBanner.querySelector('.lt-alert-msg').textContent =
`Monitoring data is stale — last check was ${mins} minute${mins !== 1 ? 's' : ''} ago. The monitor daemon may be down.`;
staleBanner.style.display = '';
} else if (staleBanner) {
staleBanner.style.display = 'none';
@@ -99,6 +122,9 @@ function updateStatusBar(summary, lastCheck, daemonOk) {
}
function updateHostGrid(hosts) {
const scHosts = document.getElementById('stat-hosts-val');
if (scHosts) scHosts.textContent = Object.keys(hosts).length;
for (const [name, host] of Object.entries(hosts)) {
const card = document.querySelector(`.host-card[data-host="${CSS.escape(name)}"]`);
if (!card) continue;
@@ -130,14 +156,17 @@ function updateTopology(hosts) {
const host = hosts[name];
if (!host) return;
node.className = node.className.replace(/topo-v2-status-(up|down|degraded|unknown)/g, '');
node.className = node.className.replace(/topo-status-(up|down|degraded|unknown)/g, '');
node.classList.add(`topo-v2-status-${host.status}`);
node.classList.add(`topo-status-${host.status}`);
const badge = node.querySelector('.topo-badge');
if (badge) {
badge.className = `topo-badge topo-badge-${host.status}`;
badge.textContent = host.status;
}
// Animate the 10G drop-wire red+dashed when host is down
document.querySelectorAll(`.topo-v2-wire-10g[data-host="${CSS.escape(name)}"]`).forEach(wire => {
wire.classList.toggle('wire-down', host.status === 'down');
});
});
}
@@ -148,7 +177,7 @@ function updateUnifiTable(devices) {
tbody.innerHTML = devices.map(d => {
const statusClass = d.connected ? '' : 'row-critical';
const dotClass = d.connected ? 'dot-up' : 'dot-down';
const statusText = d.connected ? 'Online' : 'Offline';
const statusText = d.connected ? 'ONLINE' : 'OFFLINE';
const suppressBtn = !d.connected
? `<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
data-sup-type="unifi_device"
@@ -174,7 +203,7 @@ function updateEventsTable(events, totalActive) {
const active = (events || []).filter(e => e.severity !== 'info');
if (!active.length) {
wrap.innerHTML = '<p class="empty-state">No active alerts ✔</p>';
wrap.innerHTML = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-icon">✔</div><div class="lt-empty-state-title">No active alerts</div></div>';
return;
}
+118 -69
View File
@@ -5,6 +5,12 @@
This file adds only gandalf-specific components.
══════════════════════════════════════════════════════════════════════ */
/* ── lt-avatar image overlay (base.css compat shim) ───────────────── */
/* Older base.css missing position:relative + position:absolute on img */
.lt-avatar { position: relative; }
.lt-avatar img { position: absolute; inset: 0; }
.lt-avatar img.lt-avatar-img-err { display: none; }
/* ── Variable aliases bridging to base.css palette ────────────────── */
:root {
/* Short names used throughout custom components */
@@ -54,6 +60,28 @@
--glow-cyan: none;
--glow-xl: none;
}
[data-theme="light"] .topology {
background-image: radial-gradient(circle, rgba(0,100,160,0.07) 1px, transparent 1px);
}
[data-theme="light"] .topo-vc-label {
background: rgba(235,238,242,.88);
}
/* ── Header overlap fix ───────────────────────────────────────────
.lt-container's padding shorthand resets padding-top, defeating
.lt-main's padding-top. The combined selector restores it. */
.lt-main.lt-container {
padding-top: calc(var(--header-height) + var(--space-lg));
}
@media (max-height: 500px) and (orientation: landscape) {
.lt-main.lt-container { padding-top: calc(42px + var(--space-md)); }
}
@media (max-width: 767px) {
.lt-main.lt-container { padding-top: calc(50px + var(--space-md)); }
}
@media (max-width: 479px) {
.lt-main.lt-container { padding-top: calc(46px + var(--space-sm)); }
}
/* ── Refresh button loading state ────────────────────────────────── */
[data-action="refresh"].is-loading {
@@ -132,6 +160,7 @@
.badge-neutral { color: var(--text-muted); border-color: var(--text-muted); }
.badge-resolved { color: var(--text-muted); border-color: var(--border-color); text-decoration: line-through; }
.badge-suppressed { font-size: .9em; padding: 0; border: none; color: var(--text-muted); }
.badge-purple { color: var(--accent-purple); border-color: var(--accent-purple); }
/* ── Table row state colors ───────────────────────────────────────── */
.lt-table tr.row-critical td { background: rgba(255,45,85,.04); }
@@ -172,22 +201,14 @@
border: 1px solid;
letter-spacing: .04em;
}
.chip-critical { color: var(--red); border-color: var(--red); text-shadow: var(--glow-red); animation: pulse-glow 2s infinite; }
.chip-critical { color: var(--red); border-color: var(--red); text-shadow: var(--glow-red); animation: topo-pulse-down 2s ease-in-out infinite; }
.chip-warning { color: var(--orange); border-color: var(--orange); }
.chip-ok { color: var(--green); border-color: var(--green-muted); text-shadow: var(--glow); }
.status-meta { display: flex; align-items: center; gap: 10px; white-space: nowrap; }
.last-check { font-size: .72em; color: var(--text-muted); }
/* ── Stale monitoring banner ──────────────────────────────────────── */
.stale-banner {
background: var(--amber-dim);
border: 1px solid var(--amber);
border-left: 4px solid var(--amber);
color: var(--amber);
padding: 10px 16px;
margin: 0 0 14px;
font-size: .88em;
}
/* .stale-banner replaced by lt-alert--warning */
/* ── Error / empty state containers ───────────────────────────────── */
.error-state {
@@ -224,12 +245,27 @@
border: 1px solid var(--border-color);
padding: 12px;
position: relative;
overflow: hidden;
transition: border-color .2s, box-shadow .2s;
}
/* Corner accent triangle — mirrors test code's status-tinted corner */
.host-card::after {
content: '';
position: absolute;
top: 0; right: 0;
width: 0; height: 0;
border-style: solid;
border-width: 0 10px 10px 0;
border-color: transparent var(--border-color) transparent transparent;
transition: border-color .2s;
}
.host-card:hover { border-color: var(--accent-orange); box-shadow: 0 0 12px rgba(255,107,0,.1); }
.host-card-up { border-left: 3px solid var(--green); }
.host-card-up::after { border-color: transparent rgba(0,255,136,.45) transparent transparent; }
.host-card-down { border-left: 3px solid var(--red); box-shadow: inset 3px 0 10px rgba(255,45,85,.08); }
.host-card-down::after { border-color: transparent rgba(255,45,85,.55) transparent transparent; }
.host-card-degraded { border-left: 3px solid var(--orange); }
.host-card-degraded::after { border-color: transparent rgba(255,107,0,.45) transparent transparent; }
.host-card-header { margin-bottom: 8px; }
.host-name-row { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }
.host-name { font-weight: bold; font-size: .88em; color: var(--amber); letter-spacing: .04em; }
@@ -293,7 +329,9 @@
/* ── Topology diagram ─────────────────────────────────────────────── */
.topology {
background: var(--bg2);
background-color: var(--bg2);
background-image: radial-gradient(circle, rgba(0,212,255,0.07) 1px, transparent 1px);
background-size: 22px 22px;
border: 1px solid var(--border-color);
padding: 20px 16px 16px;
margin-bottom: 16px;
@@ -336,9 +374,18 @@
background: linear-gradient(to bottom, var(--cyan), var(--green));
opacity: .7;
}
/* Blurred copy of the wire for a soft glow halo */
.topo-vc-wire::before {
content: '';
position: absolute;
inset: 0;
background: inherit;
filter: blur(5px);
opacity: .5;
}
.topo-vc-label {
position: absolute;
left: calc(50% + 6px);
left: calc(50% + 7px);
top: 50%;
transform: translateY(-50%);
font-size: .58em;
@@ -346,6 +393,8 @@
white-space: nowrap;
letter-spacing: .06em;
font-family: var(--font);
background: rgba(3,5,8,.7);
padding: 1px 4px;
}
.topo-v2-node {
@@ -362,26 +411,45 @@
min-width: 110px;
text-align: center;
transition: border-color .2s, box-shadow .2s;
overflow: hidden;
}
/* Top highlight strip — color matches node type / status */
.topo-v2-node::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: currentColor;
opacity: .4;
}
.topo-v2-status-up.topo-v2-node::before { background: var(--green); opacity: .65; }
.topo-v2-status-down.topo-v2-node::before { background: var(--red); opacity: .75; }
.topo-v2-status-degraded.topo-v2-node::before { background: var(--orange); opacity: .65; }
.topo-v2-status-unknown.topo-v2-node::before { opacity: .15; }
.topo-v2-icon { font-size: 1.3em; line-height: 1; }
.topo-v2-label { font-weight: bold; letter-spacing: .04em; }
.topo-v2-sub { font-size: .58em; color: var(--text-muted); letter-spacing: .02em; }
.topo-v2-vlan { font-size: .54em; color: var(--cyan); opacity: .75; }
.topo-v2-internet { border-color: var(--cyan); color: var(--cyan); text-shadow: var(--glow-cyan); }
.topo-v2-router { border-color: var(--cyan); color: var(--cyan); text-shadow: var(--glow-cyan); }
.topo-v2-switch { border-color: var(--amber); color: var(--amber); text-shadow: var(--glow-amber); }
.topo-v2-internet { border-color: var(--cyan); color: var(--cyan); text-shadow: var(--glow-cyan); box-shadow: 0 0 12px rgba(0,212,255,.12); }
.topo-v2-router { border-color: var(--cyan); color: var(--cyan); text-shadow: var(--glow-cyan); box-shadow: 0 0 12px rgba(0,212,255,.14); }
.topo-v2-switch { border-color: var(--amber); color: var(--amber); text-shadow: var(--glow-amber); box-shadow: 0 0 12px rgba(255,179,0,.12); }
.topo-v2-host { border-color: var(--border-color); color: var(--text); cursor: default; }
@keyframes topo-pulse-down {
0%,100% { box-shadow: 0 0 6px rgba(255,45,85,.3); }
50% { box-shadow: 0 0 18px rgba(255,45,85,.75), 0 0 30px rgba(255,45,85,.2); }
}
.topo-v2-status-up { border-color: var(--green); box-shadow: 0 0 8px rgba(0,255,136,.2); }
.topo-v2-status-down { border-color: var(--red); box-shadow: 0 0 8px rgba(255,45,85,.35); animation: pulse-glow 2s infinite; }
.topo-v2-status-down { border-color: var(--red); animation: topo-pulse-down 2s ease-in-out infinite; }
.topo-v2-status-degraded { border-color: var(--orange); box-shadow: 0 0 8px rgba(255,107,0,.2); }
.topo-v2-status-unknown { border-color: var(--border-color); }
.topo-v2-offrack { border-style: dashed !important; }
.topo-badge { font-size: .68em; padding: 1px 5px; border: 1px solid; letter-spacing: .03em; }
.topo-badge-up { color: var(--green); border-color: var(--green); text-shadow: var(--glow); }
.topo-badge-down { color: var(--red); border-color: var(--red); animation: pulse-glow 1.5s infinite; }
.topo-badge-down { color: var(--red); border-color: var(--red); animation: topo-pulse-down 1.5s ease-in-out infinite; }
.topo-badge-degraded { color: var(--orange); border-color: var(--orange); }
.topo-badge-unknown { color: var(--text-muted); border-color: var(--border-color); }
@@ -397,9 +465,18 @@
height: 28px;
align-items: flex-start;
}
.topo-v2-wire-10g { width: 2px; height: 100%; background: var(--green); opacity: .55; }
.topo-v2-wire-10g { width: 2px; height: 100%; background: var(--green); opacity: .55; transition: background .3s, opacity .3s; }
.topo-v2-wire-1g { width: 0; height: 100%; border-left: 2px dashed var(--amber); opacity: .45; }
@keyframes wire-dash-anim { to { background-position: 0 -20px; } }
.topo-v2-wire-10g.wire-down {
background: repeating-linear-gradient(to bottom, var(--red) 0 6px, transparent 6px 10px) !important;
background-size: 2px 10px !important;
opacity: .9 !important;
transition: none !important;
animation: wire-dash-anim .7s linear infinite;
}
/* Bus rails */
.topo-bus-section {
width: 100%;
@@ -418,8 +495,9 @@
flex: 1;
height: 2px;
background: var(--green);
opacity: .45;
opacity: .55;
margin: 0 4px;
box-shadow: 0 0 6px rgba(0,255,136,.4);
}
.topo-bus-10g-label {
font-size: .56em;
@@ -482,7 +560,7 @@
color: var(--text-muted);
font-family: var(--font);
}
.topo-legend-line-10g { width: 24px; height: 2px; background: var(--green); display: inline-block; }
.topo-legend-line-10g { width: 24px; height: 2px; background: var(--green); display: inline-block; box-shadow: 0 0 4px rgba(0,255,136,.5); }
.topo-legend-line-1g { width: 24px; height: 0; border-top: 2px dashed var(--amber); display: inline-block; }
.topo-legend-line-wan { width: 24px; height: 2px; background: linear-gradient(to right, var(--cyan), var(--green)); display: inline-block; }
@@ -557,15 +635,14 @@
.counter-zero { color: var(--green); }
.counter-nonzero { color: var(--red); text-shadow: var(--glow-red); }
/* Traffic bars */
/* Traffic bars — use lt-progress from base.css */
.traffic-section { margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,107,0,.08); }
.traffic-row { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; }
.traffic-label { font-size: .62em; color: var(--text-muted); width: 20px; text-transform: uppercase; letter-spacing: .04em; flex-shrink: 0; }
.traffic-bar-track{ flex: 1; height: 5px; background: var(--bg-primary); border: 1px solid rgba(255,107,0,.15); position: relative; overflow: hidden; }
.traffic-bar-fill { height: 100%; position: absolute; left: 0; top: 0; transition: width .4s; }
.traffic-tx { background: var(--cyan); box-shadow: 0 0 3px rgba(0,212,255,.4); }
.traffic-rx { background: var(--green); box-shadow: 0 0 3px rgba(0,255,136,.4); }
.traffic-row .lt-progress { flex: 1; height: 5px; }
.traffic-value { font-size: .7em; color: var(--text-dim); width: 68px; text-align: right; flex-shrink: 0; }
/* Amber variant for lt-progress (65-85% utilisation warning) */
.lt-progress--amber .lt-progress-bar { background: var(--amber); box-shadow: 0 0 5px var(--amber), 0 0 10px rgba(255,179,0,.35); }
/* SFP / optical panel */
.sfp-panel {
@@ -629,43 +706,12 @@
.poe-bar-warn { background: var(--amber); }
.poe-bar-crit { background: var(--red); }
/* UniFi section divider */
.unifi-section-header {
display: flex;
align-items: center;
gap: 12px;
margin: 24px 0 12px;
color: var(--cyan);
font-size: .75em;
letter-spacing: .1em;
}
.unifi-section-header::before,
.unifi-section-header::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(90deg, transparent, var(--cyan), transparent);
}
/* Link health summary */
.link-summary-panel {
background: var(--bg2);
border: 1px solid var(--border-color);
padding: 12px 16px;
margin-bottom: 12px;
}
.link-summary-panel.link-summary-has-alerts { border-color: var(--amber); }
.link-summary-grid { display: flex; flex-wrap: wrap; gap: 20px; align-items: flex-end; }
.link-summary-stat { min-width: 80px; }
.link-summary-stat.lss-alert .lss-label { color: var(--amber); }
.lss-label { display: block; font-size: .62em; color: var(--text-muted); text-transform: uppercase; letter-spacing: .05em; margin-bottom: 2px; }
.lss-value { font-size: 1.2em; font-weight: bold; color: var(--text); }
.lss-sub { font-size: .7em; color: var(--text-muted); font-weight: normal; }
.link-loading { padding: 20px; text-align: center; color: var(--text-muted); font-size: .8em; }
.link-loading::after { content: ' ...'; animation: blink 1s step-end infinite; }
.link-no-data { padding: 14px; color: var(--text-muted); font-size: .78em; text-align: center; }
.stale-banner { margin-bottom: 12px; }
.lt-alert { margin-bottom: 12px; }
/* ── Inspector page ───────────────────────────────────────────────── */
.inspector-layout {
@@ -753,7 +799,7 @@
.switch-port-block.poe-active:hover { box-shadow: var(--glow-amber); }
.switch-port-block.uplink { background: var(--cyan-dim); border-color: var(--cyan); color: var(--cyan); }
.switch-port-block.uplink:hover { box-shadow: var(--glow-cyan); }
.switch-port-block.selected { outline: 2px solid #fff; outline-offset: 1px; }
.switch-port-block.selected { outline: 2px solid rgba(255,255,255,.85); outline-offset: 1px; box-shadow: 0 0 8px rgba(255,255,255,.5); }
.port-num { line-height: 1; font-weight: bold; }
.port-speed { font-size: .72em; opacity: .7; line-height: 1; font-weight: normal; }
.port-lldp { font-size: .62em; opacity: .65; line-height: 1; max-width: 32px; overflow: hidden; white-space: nowrap; text-overflow: clip; font-weight: normal; }
@@ -822,17 +868,9 @@
transition: all .15s;
}
.panel-close:hover { color: var(--red); border-color: var(--red); }
.panel-section-title {
font-size: .62em;
font-weight: bold;
color: var(--amber);
text-transform: uppercase;
letter-spacing: .1em;
margin: 10px 0 5px;
padding-bottom: 3px;
border-bottom: 1px solid rgba(255,107,0,.12);
}
.panel-section-title:first-of-type { margin-top: 0; }
/* Inspector panel uses lt-divider — compact spacing overrides */
.inspector-panel .lt-divider { margin: 8px 0 4px; }
.inspector-panel .lt-divider-label { color: var(--amber); font-size: .6em; }
.panel-row {
display: flex;
justify-content: space-between;
@@ -926,6 +964,17 @@
.diag-pulse-link a { color: var(--cyan); }
.diag-pulse-link a:hover { text-shadow: var(--glow-cyan); }
/* ── Stat card alert variant (pulsing border when critical > 0) ─── */
.lt-stat-card--alert {
border-color: var(--red) !important;
box-shadow: 0 0 8px rgba(255,45,85,.25) !important;
animation: topo-pulse-down 2s ease-in-out infinite;
}
.lt-stat-card--alert::before { background: var(--red); box-shadow: var(--glow-red); }
/* ── lt-frame inside g-section: no extra bottom margin ────────────── */
.g-section > .lt-frame { margin-bottom: 0; }
/* ── Responsive ───────────────────────────────────────────────────── */
@media (max-width: 768px) {
.host-grid { grid-template-columns: 1fr; }
+256 -14
View File
@@ -133,12 +133,45 @@
{% set _initials = (_words[0][0] ~ (_words[1][0] if _words|length > 1 else ''))|upper %}
<div class="lt-avatar lt-avatar--sm {{ _uname | avatar_color }}"
aria-hidden="true" title="{{ _uname }}">
<img src="{{ url_for('api_avatar') }}" alt="" class="lt-avatar-img">
<span class="lt-avatar-initials">{{ _initials }}</span>
</div>
<span class="lt-header-user">{{ _uname }}</span>
{% if user.groups and 'admin' in user.groups %}
<span class="lt-badge lt-badge-admin" aria-label="Administrator">ADMIN</span>
{% endif %}
<!-- Notification bell — shows active monitoring alerts -->
<div class="lt-notif-dropdown-wrap" id="lt-notif-wrap">
<button type="button"
class="lt-btn lt-btn-ghost lt-btn-sm lt-notif-bell-btn"
id="lt-notif-bell"
aria-label="Active alerts"
aria-expanded="false"
aria-controls="lt-notif-panel"
title="Active alerts">&#x1F514;</button>
<div class="lt-notif-panel" id="lt-notif-panel" aria-hidden="true" role="dialog" aria-label="Active alerts">
<div class="lt-notif-panel-header">
<span>Active Alerts</span>
<button type="button" class="lt-notif-panel-clear" id="lt-notif-clear-btn">Mark all read</button>
</div>
<div class="lt-notif-panel-list" id="lt-notif-list">
<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Loading&hellip;</div>
</div>
<div class="lt-notif-panel-footer">
<a href="{{ url_for('index') }}" class="lt-btn lt-btn-ghost lt-btn-sm" style="width:100%;text-align:center;display:block;font-size:0.72rem">View dashboard</a>
</div>
</div>
</div>
<!-- ⌘K affordance -->
<button type="button"
class="lt-btn lt-btn-ghost lt-btn-sm"
title="Command palette (Ctrl+K)"
aria-label="Open command palette"
onclick="if(window.lt&&lt.cmdPalette)lt.cmdPalette.open()"
style="font-size:0.65rem;opacity:0.55;letter-spacing:0.03em;padding:0.2rem 0.45rem">&#x2315;&nbsp;K</button>
<button type="button" class="lt-theme-btn" id="lt-theme-btn"
aria-label="Toggle theme" title="Toggle light/dark mode">&#x2600;</button>
</div>
@@ -169,13 +202,20 @@
{% block content %}{% endblock %}
</main>
<!-- FOOTER -->
<!-- FOOTER — context-sensitive per page -->
<footer class="lt-footer" role="contentinfo">
<nav class="lt-footer-hints" aria-label="Keyboard shortcuts">
<span class="lt-footer-hint"><span class="lt-footer-key">[ Ctrl+K ]</span> SEARCH</span>
<span class="lt-footer-sep">|</span>
{% if request.endpoint == 'index' %}
<span class="lt-footer-hint"><span class="lt-footer-key">[ R ]</span> REFRESH</span>
<span class="lt-footer-sep">|</span>
<span class="lt-footer-hint"><span class="lt-footer-key">[ S ]</span> SUPPRESS</span>
<span class="lt-footer-sep">|</span>
{% elif request.endpoint == 'links_page' %}
<span class="lt-footer-hint"><span class="lt-footer-key">[ R ]</span> REFRESH</span>
<span class="lt-footer-sep">|</span>
{% endif %}
<button type="button" class="lt-footer-hint" data-action="open-settings"><span class="lt-footer-key">[ * ]</span> CFG</button>
<span class="lt-footer-sep">|</span>
<button type="button" class="lt-footer-hint" data-action="show-keyboard-help"><span class="lt-footer-key">[ ? ]</span> HELP</button>
</nav>
<span>GANDALF &mdash; TDS v1.2</span>
@@ -193,9 +233,11 @@
<thead><tr><th>Shortcut</th><th>Action</th></tr></thead>
<tbody>
<tr><td>Ctrl / &#x2318; + K</td><td>Command palette</td></tr>
<tr><td>R</td><td>Refresh dashboard data</td></tr>
<tr><td>R</td><td>Refresh data (Dashboard / Link Debug)</td></tr>
<tr><td>S</td><td>Quick-suppress alert (Dashboard)</td></tr>
<tr><td>*</td><td>Open settings</td></tr>
<tr><td>?</td><td>Show this help</td></tr>
<tr><td>ESC</td><td>Close modal</td></tr>
<tr><td>ESC</td><td>Close modal / panel</td></tr>
</tbody>
</table>
</div>
@@ -205,6 +247,45 @@
</div>
</div>
<!-- SETTINGS MODAL -->
<div id="lt-settings-modal" class="lt-modal-overlay" aria-hidden="true">
<div class="lt-modal" role="dialog" aria-modal="true" aria-labelledby="settings-modal-title">
<div class="lt-modal-header">
<span class="lt-modal-title" id="settings-modal-title">Settings</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<div class="lt-modal-body">
<div class="lt-form-group">
<label class="lt-label">Auto-refresh interval</label>
<div class="duration-pills" id="settings-refresh-pills">
<button type="button" class="pill" data-refresh-interval="15">15 s</button>
<button type="button" class="pill" data-refresh-interval="30">30 s</button>
<button type="button" class="pill" data-refresh-interval="60">1 min</button>
<button type="button" class="pill" data-refresh-interval="300">5 min</button>
<button type="button" class="pill" data-refresh-interval="0">Off</button>
</div>
<div class="lt-field-hint" id="settings-refresh-hint"></div>
</div>
<div class="lt-divider" style="margin:1rem 0 0.75rem"></div>
<div class="lt-kv-grid">
<div class="lt-kv-row">
<span class="lt-kv-label">User</span>
<span class="lt-kv-value lt-text-cyan">{{ user.name or user.username }}</span>
</div>
{% if user.groups %}
<div class="lt-kv-row">
<span class="lt-kv-label">Groups</span>
<span class="lt-kv-value">{{ user.groups | join(', ') }}</span>
</div>
{% endif %}
</div>
</div>
<div class="lt-modal-footer">
<button type="button" class="lt-btn" data-modal-close>Close</button>
</div>
</div>
</div>
<script>
const GANDALF_CONFIG = {
ticket_web_url: "{{ config.get('ticket_api', {}).get('web_url', 'http://t.lotusguild.org/ticket/') }}"
@@ -227,24 +308,185 @@
{ id: 'nav-links', group: 'Navigate', icon: '↗', label: 'Link Debug', action: function() { window.location.href = '/links'; } },
{ id: 'nav-inspector', group: 'Navigate', icon: '⬡', label: 'Inspector', action: function() { window.location.href = '/inspector'; } },
{ id: 'nav-suppressions', group: 'Navigate', icon: '🔕', label: 'Suppressions', action: function() { window.location.href = '/suppressions'; } },
{ id: 'help-shortcuts', group: 'Help', icon: '?', label: 'Keyboard Shortcuts', action: function() { lt.modal.open('lt-keys-help'); } },
{ id: 'help-theme', group: 'Help', icon: '*', label: 'Toggle Theme', action: function() { lt.theme.toggle(); } },
{ id: 'action-refresh', group: 'Actions', icon: '', label: 'Refresh Data', kbd: 'R', action: function() { if (typeof refreshAll === 'function') refreshAll(); } },
{ id: 'action-refresh', group: 'Actions', icon: '', label: 'Refresh Data', kbd: 'R', action: function() { lt.autoRefresh.now(); } },
{ id: 'action-suppress', group: 'Actions', icon: '🔕', label: 'New Suppression', kbd: 'S', action: function() { if (typeof openSuppressModal === 'function') openSuppressModal('host','',''); else window.location.href='/suppressions'; } },
{ id: 'help-shortcuts', group: 'Help', icon: '?', label: 'Keyboard Shortcuts', kbd: '?', action: function() { lt.modal.open('lt-keys-help'); } },
{ id: 'help-settings', group: 'Help', icon: '*', label: 'Settings', kbd: '*', action: function() { lt.modal.open('lt-settings-modal'); } },
{ id: 'help-theme', group: 'Help', icon: '☀', label: 'Toggle Theme', action: function() { lt.theme.toggle(); } },
]);
}
// Footer hint actions
// ── Global footer + key actions ───────────────────────────────────────
document.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
if (btn.getAttribute('data-action') === 'show-keyboard-help' && window.lt) {
lt.modal.open('lt-keys-help');
}
var action = btn.getAttribute('data-action');
if (action === 'show-keyboard-help' && window.lt) lt.modal.open('lt-keys-help');
if (action === 'open-settings' && window.lt) lt.modal.open('lt-settings-modal');
});
lt.keys.on('r', function() {
if (typeof refreshAll === 'function') refreshAll();
lt.keys.on('r', function() { lt.autoRefresh.now(); });
lt.keys.on('?', function() { if (window.lt) lt.modal.open('lt-keys-help'); });
lt.keys.on('*', function() { if (window.lt) lt.modal.open('lt-settings-modal'); });
lt.keys.on('s', function() {
if (typeof openSuppressModal === 'function') openSuppressModal('host', '', '');
});
// ── Avatar image error fallback ───────────────────────────────────────
document.addEventListener('error', function(e) {
if (e.target.tagName === 'IMG' && e.target.closest('.lt-avatar')) {
e.target.classList.add('lt-avatar-img-err');
}
}, true);
// ── Settings modal ────────────────────────────────────────────────────
(function() {
var LS_KEY = 'gandalf_settings';
var DEFAULT = { refreshInterval: 30 };
function loadSettings() {
try { return Object.assign({}, DEFAULT, JSON.parse(localStorage.getItem(LS_KEY) || '{}')); }
catch(_) { return Object.assign({}, DEFAULT); }
}
function saveSettings(s) {
try { localStorage.setItem(LS_KEY, JSON.stringify(s)); } catch(_) {}
if (typeof window.onGandalfSettingsChanged === 'function') window.onGandalfSettingsChanged(s);
}
function applyRefreshPillUI(interval) {
document.querySelectorAll('#settings-refresh-pills .pill').forEach(function(p) {
p.classList.toggle('active', parseInt(p.dataset.refreshInterval) === interval);
});
var hint = document.getElementById('settings-refresh-hint');
if (hint) {
if (interval === 0) hint.textContent = 'Auto-refresh disabled.';
else if (interval < 60) hint.textContent = 'Refreshes every ' + interval + ' seconds.';
else hint.textContent = 'Refreshes every ' + Math.floor(interval/60) + ' minute' + (interval > 60 ? 's' : '') + '.';
}
}
// Init pill UI from saved settings
var _settings = loadSettings();
applyRefreshPillUI(_settings.refreshInterval);
// Expose for pages that need to read it (e.g. index.html for autoRefresh)
window.gandalfSettings = _settings;
document.addEventListener('click', function(e) {
var pill = e.target.closest('#settings-refresh-pills .pill[data-refresh-interval]');
if (!pill) return;
var interval = parseInt(pill.dataset.refreshInterval);
_settings.refreshInterval = interval;
saveSettings(_settings);
applyRefreshPillUI(interval);
});
})();
// ── Notification Bell — shows active monitoring alerts ────────────────
(function() {
var bell = document.getElementById('lt-notif-bell');
var panel = document.getElementById('lt-notif-panel');
var list = document.getElementById('lt-notif-list');
var clearBtn = document.getElementById('lt-notif-clear-btn');
var wrapEl = document.getElementById('lt-notif-wrap');
if (!bell || !panel) return;
var _open = false;
var _lastEvents = [];
var LS_READ_KEY = 'gandalf_notif_read_before';
function getReadBefore() {
try { return parseInt(localStorage.getItem(LS_READ_KEY) || '0'); } catch(_) { return 0; }
}
function setReadBefore(ts) {
try { localStorage.setItem(LS_READ_KEY, String(ts)); } catch(_) {}
}
function esc(s) {
return (window.lt && lt.escHtml) ? lt.escHtml(String(s)) : String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function toMs(dateStr) {
if (!dateStr) return 0;
return new Date(dateStr.replace(' UTC','Z').replace(' ','T')).getTime();
}
function fmtAgo(dateStr) {
var diff = Math.floor((Date.now() - toMs(dateStr)) / 1000);
if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff/60) + 'm ago';
if (diff < 86400) return Math.floor(diff/3600) + 'h ago';
return Math.floor(diff/86400) + 'd ago';
}
var SEV_DOT = { critical: 'var(--red)', warning: 'var(--amber)' };
function renderAlerts(events) {
_lastEvents = events || [];
var readBefore = getReadBefore();
var active = _lastEvents.filter(function(e) { return e.severity !== 'info'; });
var unreadCount = active.filter(function(e) { return toMs(e.last_seen) > readBefore; }).length;
lt.notif.set(bell, unreadCount);
if (!active.length) {
list.innerHTML = '<div style="padding:1rem;font-size:0.75rem;color:var(--text-muted);text-align:center">&#x2714; No active alerts</div>';
return;
}
list.innerHTML = active.slice(0, 25).map(function(e) {
var isUnread = toMs(e.last_seen) > readBefore;
var dotColor = SEV_DOT[e.severity] || 'var(--text-muted)';
return '<div class="lt-notif-item' + (isUnread ? ' lt-notif-item--unread' : '') + '">' +
'<div class="lt-notif-dot' + (isUnread ? '' : ' lt-notif-dot--read') + '" style="background:' + dotColor + ';border-radius:50%;margin-top:4px"></div>' +
'<div class="lt-notif-item-body">' +
'<div class="lt-notif-item-title">' + esc(e.target_name) + (e.target_detail ? ' &middot; ' + esc(e.target_detail) : '') + '</div>' +
'<div class="lt-notif-item-time">' + esc(e.event_type.replace(/_/g,' ')) + ' &middot; ' + fmtAgo(e.last_seen) + '</div>' +
'</div></div>';
}).join('');
}
function fetchAlerts(andRender) {
fetch('/api/status', { credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(data) {
var events = data.events || [];
if (andRender) {
renderAlerts(events);
} else {
_lastEvents = events;
var readBefore = getReadBefore();
var active = events.filter(function(e) { return e.severity !== 'info'; });
var unread = active.filter(function(e) { return toMs(e.last_seen) > readBefore; }).length;
lt.notif.set(bell, unread);
}
})
.catch(function() {
if (andRender) list.innerHTML = '<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Could not load</div>';
});
}
function openPanel() { _open = true; panel.removeAttribute('aria-hidden'); bell.setAttribute('aria-expanded','true'); fetchAlerts(true); }
function closePanel() { _open = false; panel.setAttribute('aria-hidden','true'); bell.setAttribute('aria-expanded','false'); }
bell.addEventListener('click', function(e) { e.stopPropagation(); _open ? closePanel() : openPanel(); });
if (clearBtn) {
clearBtn.addEventListener('click', function() {
setReadBefore(Date.now());
renderAlerts(_lastEvents);
});
}
document.addEventListener('click', function(e) { if (_open && wrapEl && !wrapEl.contains(e.target)) closePanel(); });
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && _open) closePanel(); });
// Initial badge load + poll every 60 s
fetchAlerts(false);
setInterval(function() { fetchAlerts(_open); }, 60000);
// Allow refreshAll() to also push fresh events to the bell
window.gandalfNotifUpdate = function(events) { renderAlerts(events); };
})();
</script>
</body>
+141 -48
View File
@@ -6,19 +6,59 @@
<!-- ── Status bar ──────────────────────────────────────────────────── -->
<div class="status-bar">
<div class="status-chips">
{% if not daemon_ok %}
<span class="chip chip-critical">⚠ MONITOR OFFLINE</span>
{% endif %}
{% if summary.critical %}
<span class="chip chip-critical">● {{ summary.critical }} CRITICAL</span>
{% endif %}
{% if summary.warning %}
<span class="chip chip-warning">● {{ summary.warning }} WARNING</span>
{% endif %}
{% if not summary.critical and not summary.warning %}
{% if daemon_ok and not summary.critical and not summary.warning %}
<span class="chip chip-ok">✔ ALL SYSTEMS NOMINAL</span>
{% endif %}
</div>
<div class="status-meta">
<span class="last-check" id="last-check">{{ last_check }}</span>
<button class="lt-btn lt-btn-ghost lt-btn-sm" data-action="refresh">↻ REFRESH</button>
<span class="last-check" id="last-check" data-tooltip="Last monitor poll timestamp" data-tooltip-pos="bottom">{{ last_check }}</span>
<span class="lt-spinner lt-spinner--sm lt-spinner--cyan" id="refresh-spinner" style="display:none"></span>
<button class="lt-btn lt-btn-ghost lt-btn-sm" data-action="refresh" data-tooltip="Refresh all data now" data-tooltip-pos="bottom">↻ REFRESH</button>
</div>
</div>
<!-- ── Stats summary cards ──────────────────────────────────────────── -->
<div class="lt-stats-grid">
<div class="lt-stat-card{% if summary.critical %} lt-stat-card--alert{% endif %}"
id="stat-critical" role="button" tabindex="0"
data-stat-filter="critical" aria-label="{{ summary.critical or 0 }} critical alerts">
<span class="lt-stat-icon" aria-hidden="true" style="color:var(--red);text-shadow:var(--glow-red)"></span>
<div class="lt-stat-info">
<span class="lt-stat-value" id="stat-critical-val" style="color:var(--red)">{{ summary.critical or 0 }}</span>
<span class="lt-stat-label">Critical</span>
</div>
</div>
<div class="lt-stat-card"
id="stat-warning" role="button" tabindex="0"
data-stat-filter="warning" aria-label="{{ summary.warning or 0 }} warning alerts">
<span class="lt-stat-icon" aria-hidden="true" style="color:var(--amber)"></span>
<div class="lt-stat-info">
<span class="lt-stat-value" id="stat-warning-val" style="color:var(--amber)">{{ summary.warning or 0 }}</span>
<span class="lt-stat-label">Warning</span>
</div>
</div>
<div class="lt-stat-card" id="stat-hosts" aria-label="Monitored hosts">
<span class="lt-stat-icon" aria-hidden="true" style="color:var(--cyan)"></span>
<div class="lt-stat-info">
<span class="lt-stat-value" id="stat-hosts-val" style="color:var(--cyan)">{{ snapshot.hosts | length }}</span>
<span class="lt-stat-label">Hosts</span>
</div>
</div>
<div class="lt-stat-card" id="stat-resolved" aria-label="{{ recent_resolved | length }} alerts resolved in last 24 hours">
<span class="lt-stat-icon" aria-hidden="true" style="color:var(--green);text-shadow:var(--glow)"></span>
<div class="lt-stat-info">
<span class="lt-stat-value" id="stat-resolved-val" style="color:var(--green)">{{ recent_resolved | length }}</span>
<span class="lt-stat-label">Resolved 24h</span>
</div>
</div>
</div>
@@ -46,7 +86,7 @@
<!-- WAN wire: cyan → green gradient, labeled -->
<div class="topo-vc">
<div class="topo-vc-wire" style="background:linear-gradient(to bottom,var(--cyan),var(--cyan)); opacity:.55;"></div>
<div class="topo-vc-wire" style="background:linear-gradient(to bottom,var(--cyan),rgba(0,212,255,.3)); opacity:.7;"></div>
<span class="topo-vc-label">WAN · 10G SFP+</span>
</div>
@@ -138,8 +178,8 @@
<div class="topo-v2-host-wrap">
<!-- dual-homing wires: 10G solid green + 1G dashed amber -->
<div class="topo-v2-host-wires">
<div class="topo-v2-wire-10g" title="10G SFP+ → USW-Agg"></div>
<div class="topo-v2-wire-1g" title="1G → Pro 24 PoE"></div>
<div class="topo-v2-wire-10g" data-host="{{ hname }}" title="10G SFP+ → USW-Agg"></div>
<div class="topo-v2-wire-1g" data-host="{{ hname }}" title="1G → Pro 24 PoE"></div>
</div>
<!-- host box -->
<div class="topo-v2-node topo-v2-host topo-host topo-v2-status-{{ st }}{{ ' topo-v2-offrack' if off_rack else '' }}"
@@ -168,6 +208,14 @@
</div>
<!-- Host cards -->
<div class="lt-toolbar" style="margin-bottom:10px" id="host-toolbar">
<div class="lt-toolbar-left">
<div class="lt-search">
<input type="search" class="lt-input lt-search-input" id="host-search"
placeholder="Filter hosts…" autocomplete="off" style="width:180px">
</div>
</div>
</div>
<div class="host-grid" id="host-grid">
{% for name, host in snapshot.hosts.items() %}
{% set suppressed = suppressions | selectattr('target_name', 'equalto', name) | list %}
@@ -215,7 +263,11 @@
</div>
</div>
{% else %}
<p class="empty-state">No host data yet monitor is initializing.</p>
<div class="lt-empty-state lt-empty-state--sm">
<div class="lt-empty-state-icon"></div>
<div class="lt-empty-state-title">No host data yet</div>
<div class="lt-empty-state-body">The monitor daemon may still be starting up.</div>
</div>
{% endfor %}
</div>
</section>
@@ -226,6 +278,10 @@
<div class="g-section-header">
<h2 class="g-section-title">UniFi Devices</h2>
</div>
<div class="lt-frame">
<span class="lt-frame-bl">&#x255A;</span>
<span class="lt-frame-br">&#x255D;</span>
<div class="lt-section-header">Device Inventory</div>
<div class="lt-table-wrap">
<table class="lt-table" id="unifi-table">
<caption class="lt-sr-only">UniFi network devices</caption>
@@ -265,6 +321,7 @@
</tbody>
</table>
</div>
</div>
</section>
{% endif %}
@@ -272,13 +329,15 @@
<section class="g-section">
<div class="g-section-header">
<h2 class="g-section-title">Active Alerts</h2>
{% 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"
<span class="g-section-badge" id="alert-count-badge"
{% if not summary.critical and not summary.warning %}style="display:none"{% endif %}>{{ (summary.critical or 0) + (summary.warning or 0) }}</span>
</div>
<div class="lt-toolbar">
<div class="lt-toolbar-left">
<div class="lt-search">
<input type="search" class="lt-input lt-search-input" id="events-search"
placeholder="Filter by target, type, description…" autocomplete="off">
</div>
<div class="sev-pills">
<button type="button" class="pill active" data-sev="">All</button>
<button type="button" class="pill" data-sev="critical">Critical</button>
@@ -286,7 +345,10 @@
</div>
</div>
</div>
</div>
<div class="lt-frame">
<span class="lt-frame-bl">&#x255A;</span>
<span class="lt-frame-br">&#x255D;</span>
<div class="lt-section-header">Alert Queue</div>
<div id="events-table-wrap">
{% if events %}
{% if total_active is defined and total_active > events|length %}
@@ -297,14 +359,14 @@
<caption class="lt-sr-only">Active network alerts</caption>
<thead>
<tr>
<th>Sev</th>
<th data-tooltip="Alert severity level" data-tooltip-pos="bottom">Sev</th>
<th>Type</th>
<th>Target</th>
<th>Detail</th>
<th>Description</th>
<th>First Seen</th>
<th>Last Seen</th>
<th>Failures</th>
<th data-tooltip="When this alert was first raised" data-tooltip-pos="bottom">First Seen</th>
<th data-tooltip="Most recent check failure" data-tooltip-pos="bottom">Last Seen</th>
<th data-tooltip="Consecutive check failures since first seen" data-tooltip-pos="bottom">Failures</th>
<th>Ticket</th>
<th>Actions</th>
</tr>
@@ -341,15 +403,24 @@
</tr>
{% endif %}
{% else %}
<tr><td colspan="10" class="empty-state">No active alerts ✔</td></tr>
<tr><td colspan="10">
<div class="lt-empty-state lt-empty-state--sm">
<div class="lt-empty-state-icon"></div>
<div class="lt-empty-state-title">No active alerts</div>
</div>
</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty-state">No active alerts ✔</p>
{% endif %}
<div class="lt-empty-state lt-empty-state--sm">
<div class="lt-empty-state-icon"></div>
<div class="lt-empty-state-title">No active alerts</div>
</div>
{% endif %}
</div><!-- /#events-table-wrap -->
</div><!-- /.lt-frame -->
</section>
<!-- ── Recently Resolved (last 24h) ───────────────────────────────── -->
@@ -359,34 +430,24 @@
<h2 class="g-section-title">Recently Resolved</h2>
<span class="g-section-badge g-section-badge-resolved">{{ recent_resolved | length }} in last 24h</span>
</div>
<div class="lt-table-wrap">
<table class="lt-table">
<caption class="lt-sr-only">Recently resolved alerts</caption>
<thead>
<tr>
<th>Sev</th>
<th>Type</th>
<th>Target</th>
<th>Detail</th>
<th>Resolved</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
<div class="lt-timeline">
{% for e in recent_resolved %}
<tr class="row-resolved">
<td><span class="lt-badge badge-resolved">{{ e.severity }}</span></td>
<td>{{ e.event_type | replace('_', ' ') }}</td>
<td><strong>{{ e.target_name }}</strong></td>
<td>{{ e.target_detail or '' }}</td>
<td class="ts-cell">
<span class="event-age" data-ts="{{ e.resolved_at }}">{{ e.resolved_at }}</span>
</td>
<td class="ts-cell event-duration" data-first="{{ e.first_seen }}" data-resolved="{{ e.resolved_at }}"></td>
</tr>
{%- set dot_cls = 'lt-timeline-item--green' if e.severity == 'info' else 'lt-timeline-item--dim' -%}
<div class="lt-timeline-item {{ dot_cls }}">
<div class="lt-timeline-meta">
<strong class="lt-timeline-actor">{{ e.target_name }}</strong>
{% if e.target_detail %}<span>· {{ e.target_detail }}</span>{% endif %}
<span class="lt-timeline-time event-age" data-ts="{{ e.resolved_at }}">{{ e.resolved_at }}</span>
</div>
<div class="lt-timeline-body">
{{ e.event_type | replace('_', ' ') }}
&nbsp;·&nbsp;
<span class="lt-badge badge-resolved">{{ e.severity }}</span>
&nbsp;·&nbsp; duration
<span class="event-duration" data-first="{{ e.first_seen }}" data-resolved="{{ e.resolved_at }}"></span>
</div>
</div>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endif %}
@@ -448,7 +509,16 @@
{% block scripts %}
<script>
lt.autoRefresh.start(refreshAll, 30000);
// Start auto-refresh using saved settings interval (default 30 s)
var _savedInterval = (window.gandalfSettings && window.gandalfSettings.refreshInterval) || 30;
if (_savedInterval > 0) lt.autoRefresh.start(refreshAll, _savedInterval * 1000);
// When settings change, restart auto-refresh with new interval
window.onGandalfSettingsChanged = function(s) {
lt.autoRefresh.stop();
if (s.refreshInterval > 0) lt.autoRefresh.start(refreshAll, s.refreshInterval * 1000);
};
document.getElementById('suppress-form')?.addEventListener('submit', submitSuppress);
document.getElementById('sup-type')?.addEventListener('change', updateSuppressForm);
@@ -505,5 +575,28 @@
// Re-apply filter after dynamic table updates
new MutationObserver(applyEventsFilter)
.observe(document.getElementById('events-table-wrap'), { childList: true, subtree: true });
// Host grid search filter
document.getElementById('host-search')?.addEventListener('input', function() {
const q = this.value.trim().toLowerCase();
document.querySelectorAll('#host-grid .host-card').forEach(card => {
const name = (card.dataset.host || '').toLowerCase();
card.style.display = (!q || name.includes(q)) ? '' : 'none';
});
});
// Stat card clicks — filter events table by severity
document.querySelectorAll('.lt-stat-card[data-stat-filter]').forEach(card => {
card.addEventListener('click', () => {
const sev = card.dataset.statFilter;
document.querySelectorAll('.sev-pills .pill').forEach(p => p.classList.remove('active'));
const matchPill = document.querySelector(`.sev-pills .pill[data-sev="${sev}"]`);
if (matchPill) matchPill.classList.add('active');
_filterSev = sev;
applyEventsFilter();
document.getElementById('events-table-wrap')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
card.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); card.click(); } });
});
</script>
{% endblock %}
+27 -18
View File
@@ -3,12 +3,14 @@
{% block content %}
<div class="g-page-header">
<h1 class="g-page-title">Network Inspector</h1>
<p class="g-page-sub">
<div class="lt-page-header">
<div>
<h1 class="lt-page-title">Network Inspector</h1>
<p class="g-page-sub" style="margin-top:4px">
Visual switch chassis diagrams. Click a port to see detailed stats and LLDP path debug.
<span id="inspector-updated" style="margin-left:10px; color:var(--text-muted); font-size:.85em;"></span>
<span id="inspector-updated" style="margin-left:8px"></span>
</p>
</div>
</div>
<div class="inspector-layout">
@@ -114,7 +116,7 @@ function portBlockHtml(idx, port, swName, sfpBlock) {
const speedHtml = speedTxt ? `<span class="port-speed">${speedTxt}</span>` : '';
return `<div class="switch-port-block ${state}${sfpCls}"
data-switch="${escHtml(swName)}" data-port-idx="${idx}"
title="${title}"
title="${title}" aria-label="${title}"
data-action="select-port"><span class="port-num">${numLabel}</span>${speedHtml}${lldpHtml}</div>`;
}
@@ -259,7 +261,7 @@ function renderPanel(swName, idx) {
const poeMaxStr = d.poe_max_power != null ? ` / max ${d.poe_max_power.toFixed(1)}W` : '';
const poeCurStr = (d.poe_power != null && d.poe_power > 0) ? ` / draw <span class="val-amber">${d.poe_power.toFixed(1)}W</span>` : '';
poeHtml = `
<div class="panel-section-title">PoE</div>
<div class="lt-divider"><span class="lt-divider-label">PoE</span></div>
<div class="panel-row"><span class="panel-label">Class</span><span class="panel-val">class ${d.poe_class}${poeMaxStr}</span></div>
${d.poe_power != null ? `<div class="panel-row"><span class="panel-label">Draw</span><span class="panel-val">${d.poe_power > 0 ? `<span class="val-amber">${d.poe_power.toFixed(1)}W</span>` : '0W'}</span></div>` : ''}
${d.poe_mode ? `<div class="panel-row"><span class="panel-label">Mode</span><span class="panel-val">${escHtml(d.poe_mode)}</span></div>` : ''}`;
@@ -269,16 +271,16 @@ function renderPanel(swName, idx) {
let trafficHtml = '';
if (d.tx_bytes_rate != null || d.rx_bytes_rate != null) {
trafficHtml = `
<div class="panel-section-title">Traffic</div>
<div class="panel-row"><span class="panel-label">TX</span><span class="panel-val">${fmtRate(d.tx_bytes_rate)}</span></div>
<div class="panel-row"><span class="panel-label">RX</span><span class="panel-val">${fmtRate(d.rx_bytes_rate)}</span></div>`;
<div class="lt-divider"><span class="lt-divider-label">Traffic</span></div>
<div class="panel-row"><span class="panel-label" data-tooltip="Transmit — outgoing from this port">TX</span><span class="panel-val">${fmtRate(d.tx_bytes_rate)}</span></div>
<div class="panel-row"><span class="panel-label" data-tooltip="Receive — incoming to this port">RX</span><span class="panel-val">${fmtRate(d.rx_bytes_rate)}</span></div>`;
}
// Errors / drops section
let errHtml = '';
if (d.tx_errs_rate != null || d.rx_errs_rate != null) {
errHtml = `
<div class="panel-section-title">Errors / Drops</div>
<div class="lt-divider"><span class="lt-divider-label">Errors / Drops</span></div>
<div class="panel-row"><span class="panel-label">TX Err</span><span class="panel-val">${fmtErrors(d.tx_errs_rate)}</span></div>
<div class="panel-row"><span class="panel-label">RX Err</span><span class="panel-val">${fmtErrors(d.rx_errs_rate)}</span></div>
<div class="panel-row"><span class="panel-label">TX Drop</span><span class="panel-val">${fmtErrors(d.tx_drops_rate)}</span></div>
@@ -291,7 +293,7 @@ function renderPanel(swName, idx) {
if (d.lldp && d.lldp.system_name) {
const l = d.lldp;
lldpHtml = `
<div class="panel-section-title">LLDP Neighbor</div>
<div class="lt-divider"><span class="lt-divider-label">LLDP Neighbor</span></div>
<div class="panel-row"><span class="panel-label">System</span><span class="panel-val val-cyan">${escHtml(l.system_name)}</span></div>
${l.port_id ? `<div class="panel-row"><span class="panel-label">Port</span><span class="panel-val">${escHtml(l.port_id)}</span></div>` : ''}
${l.port_desc ? `<div class="panel-row"><span class="panel-label">Port Desc</span><span class="panel-val">${escHtml(l.port_desc)}</span></div>` : ''}
@@ -333,11 +335,11 @@ function renderPanel(swName, idx) {
<button class="panel-close" data-action="close-panel" aria-label="Close panel">✕</button>
</div>
<div class="panel-section-title">Link</div>
<div class="lt-divider"><span class="lt-divider-label">Link</span></div>
<div class="panel-row"><span class="panel-label">Status</span><span class="panel-val">${upStr}</span></div>
<div class="panel-row"><span class="panel-label">Speed</span><span class="panel-val">${speedStr}</span></div>
<div class="panel-row"><span class="panel-label">Duplex</span><span class="panel-val">${duplexStr}</span></div>
<div class="panel-row"><span class="panel-label">Auto-neg</span><span class="panel-val val-neutral">${autoneg}</span></div>
<div class="panel-row"><span class="panel-label" data-tooltip="Full = simultaneous send/receive. Half = one direction at a time.">Duplex</span><span class="panel-val">${duplexStr}</span></div>
<div class="panel-row"><span class="panel-label" data-tooltip="Autonegotiation: NIC and switch automatically agree speed and duplex.">Auto-neg</span><span class="panel-val val-neutral">${autoneg}</span></div>
<div class="panel-row"><span class="panel-label">Media</span><span class="panel-val">${escHtml(mediaStr)}</span></div>
${poeHtml}
@@ -392,7 +394,7 @@ function buildPathDebug(swName, swPort, serverName, ifaceName, svrData) {
: '';
return `
<div class="panel-section-title">Path Debug <span class="path-conn-type">${escHtml(connType)}</span></div>
<div class="lt-divider"><span class="lt-divider-label">Path Debug · ${escHtml(connType)}</span></div>
${duplexWarnHtml}${speedWarnHtml}
<div class="path-debug-cols">
<div class="path-col">
@@ -425,9 +427,10 @@ function renderInspector(data) {
const main = document.getElementById('inspector-main');
const switches = data.unifi_switches || {};
const upd = data.updated ? `Updated: ${data.updated}` : '';
const updEl = document.getElementById('inspector-updated');
if (updEl) updEl.textContent = upd;
if (updEl && data.updated) {
updEl.textContent = 'Updated: ' + new Date(data.updated + (data.updated.includes('Z') ? '' : 'Z')).toLocaleTimeString();
}
if (!Object.keys(switches).length) {
main.innerHTML = '<p class="empty-state">No switch data available. Monitor may still be initialising.</p>';
@@ -463,7 +466,13 @@ async function loadInspector() {
}
loadInspector();
lt.autoRefresh.start(loadInspector, 60000);
var _inspInterval = (window.gandalfSettings && window.gandalfSettings.refreshInterval) || 60;
if (_inspInterval > 0) lt.autoRefresh.start(loadInspector, Math.max(_inspInterval, 15) * 1000);
window.onGandalfSettingsChanged = function(s) {
lt.autoRefresh.stop();
if (s.refreshInterval > 0) lt.autoRefresh.start(loadInspector, Math.max(s.refreshInterval, 15) * 1000);
};
lt.keys.on('Escape', () => {
if (document.getElementById('inspector-panel').classList.contains('open')) closePanel();
});
+106 -51
View File
@@ -3,13 +3,27 @@
{% block content %}
<div class="g-page-header">
<h1 class="g-page-title">Link Debug</h1>
<p class="g-page-sub">
<div class="lt-page-header">
<div>
<h1 class="lt-page-title">Link Debug</h1>
<p class="g-page-sub" style="margin-top:4px">
Per-interface stats: speed, duplex, SFP optical levels, TX/RX rates, errors, and carrier changes.
Data collected via Prometheus node_exporter + SSH ethtool (servers) and UniFi API (switches) every poll cycle.
<span id="links-updated" style="margin-left:10px; color:var(--text-muted); font-size:.85em;"></span>
<span id="links-updated" style="margin-left:8px"></span>
</p>
</div>
</div>
<div class="lt-toolbar" id="links-toolbar" style="display:none">
<div class="lt-toolbar-left">
<div class="lt-search">
<input type="search" class="lt-input lt-search-input" id="links-search"
placeholder="Filter by host or switch name…" autocomplete="off">
</div>
</div>
<div class="lt-toolbar-right">
<button class="lt-btn lt-btn-ghost lt-btn-sm" data-action="collapse-all">Collapse All</button>
<button class="lt-btn lt-btn-ghost lt-btn-sm" data-action="expand-all">Expand All</button>
</div>
</div>
<div id="links-container">
@@ -38,6 +52,12 @@ function fmtRateBar(bytesPerSec, linkSpeedMbps) {
return Math.min(100, (mbps / linkSpeedMbps) * 100);
}
function trafficBarClass(pct, isTx) {
if (pct > 85) return 'lt-progress--red';
if (pct > 65) return 'lt-progress--amber';
return isTx ? '' : 'lt-progress--cyan';
}
function fmtSpeed(mbps) {
if (mbps === null || mbps === undefined) return '';
if (mbps >= 1000) return (mbps/1000).toFixed(0) + ' Gbps';
@@ -147,26 +167,26 @@ function renderIfaceCard(ifaceName, d) {
</div>
<div class="sfp-grid">
<div class="sfp-stat">
<span class="sfp-stat-label">Temp</span>
<span class="sfp-stat-label" data-tooltip="SFP module temperature. Normal: below 70°C. Warn: 7085°C. Critical: above 85°C.">Temp</span>
<span class="sfp-stat-value ${tmpClass}">${fmtTemp(s.temp_c)}</span>
</div>
<div class="sfp-stat">
<span class="sfp-stat-label">Voltage</span>
<span class="sfp-stat-label" data-tooltip="SFP supply voltage. Normal: 3.13.5V.">Voltage</span>
<span class="sfp-stat-value ${vClass}">${fmtVoltage(s.voltage_v)}</span>
</div>
<div class="sfp-stat">
<span class="sfp-stat-label">Bias</span>
<span class="sfp-stat-label" data-tooltip="Laser bias current in mA. High values may indicate end-of-life laser diode.">Bias</span>
<span class="sfp-stat-value">${fmtBias(s.bias_ma)}</span>
</div>
<div class="sfp-stat">
<span class="sfp-stat-label">TX Power</span>
<span class="sfp-stat-label" data-tooltip="Optical transmit power in dBm. Typical good range: -3 to -9 dBm.">TX Power</span>
<span class="sfp-stat-value ${txClass}">${fmtPower(s.tx_power_dbm)}</span>
<div class="power-row">
<div class="power-track"><div class="power-fill ${txClass === 'val-good' ? 'power-ok' : txClass === 'val-warn' ? 'power-warn' : 'power-crit'}" style="width:${txPct2}%"></div></div>
</div>
</div>
<div class="sfp-stat">
<span class="sfp-stat-label">RX Power</span>
<span class="sfp-stat-label" data-tooltip="Optical receive power in dBm. Typical good range: -3 to -18 dBm. Below -20 dBm may indicate dirty/damaged fiber.">RX Power</span>
<span class="sfp-stat-value ${rxClass}">${fmtPower(s.rx_power_dbm)}</span>
<div class="power-row">
<div class="power-track"><div class="power-fill ${rxClass === 'val-good' ? 'power-ok' : rxClass === 'val-warn' ? 'power-warn' : 'power-crit'}" style="width:${rxPct2}%"></div></div>
@@ -174,7 +194,7 @@ function renderIfaceCard(ifaceName, d) {
</div>
${s.rx_power_dbm != null && s.tx_power_dbm != null ? `
<div class="sfp-stat">
<span class="sfp-stat-label">RXTX Δ</span>
<span class="sfp-stat-label" data-tooltip="Insertion loss: difference between transmit and receive power. Large negative values indicate fiber loss or connector issues.">RXTX Δ</span>
<span class="sfp-stat-value">${(s.rx_power_dbm - s.tx_power_dbm).toFixed(2)} dBm</span>
</div>` : ''}
</div>
@@ -195,43 +215,43 @@ function renderIfaceCard(ifaceName, d) {
<span class="link-stat-value ${isDown ? 'val-crit' : 'val-good'}">${isDown ? 'DOWN' : 'UP'}</span>
</div>
<div class="link-stat">
<span class="link-stat-label">Duplex</span>
<span class="link-stat-label" data-tooltip="Full = simultaneous send/receive at full speed. Half = one direction at a time, can cause collisions.">Duplex</span>
<span class="link-stat-value">${fmtDuplex(d.duplex)}</span>
</div>
<div class="link-stat">
<span class="link-stat-label">Auto-neg</span>
<span class="link-stat-label" data-tooltip="Autonegotiation: NIC and switch automatically agree on link speed and duplex mode.">Auto-neg</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>
<span class="link-stat-label" data-tooltip="Carrier changes: number of times the link went up or down. High values indicate a flapping or unstable cable/SFP.">Carrier Δ</span>
<span class="link-stat-value">${fmtCarrier(d.carrier_changes)}</span>
</div>
<div class="link-stat">
<span class="link-stat-label">TX Err/s</span>
<span class="link-stat-label" data-tooltip="Transmit errors per second reported by the kernel network driver.">TX Err/s</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-label" data-tooltip="Receive errors per second reported by the kernel network driver.">RX Err/s</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-label" data-tooltip="Transmit packets dropped per second (ring buffer full or driver overrun).">TX Drop/s</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-label" data-tooltip="Receive packets dropped per second (ring buffer full or driver overrun).">RX Drop/s</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-label" data-tooltip="Transmit — outgoing traffic from this server">TX</span>
<div class="lt-progress ${trafficBarClass(txPct, true)}"><div class="lt-progress-bar" style="width:${txPct}%"></div></div>
<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-label" data-tooltip="Receive — incoming traffic to this server">RX</span>
<div class="lt-progress ${trafficBarClass(rxPct, false)}"><div class="lt-progress-bar" style="width:${rxPct}%"></div></div>
<span class="traffic-value">${fmtRate(d.rx_bytes_rate)}</span>
</div>
</div>
@@ -289,12 +309,12 @@ function renderPortCard(portName, d) {
<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>
<div class="lt-progress ${trafficBarClass(txPct, true)}"><div class="lt-progress-bar" style="width:${txPct}%"></div></div>
<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>
<div class="lt-progress ${trafficBarClass(rxPct, false)}"><div class="lt-progress-bar" style="width:${rxPct}%"></div></div>
<span class="traffic-value">${fmtRate(d.rx_bytes_rate)}</span>
</div>
</div>
@@ -338,7 +358,7 @@ function renderUnifiSwitches(unifiSwitches, dataUpdated) {
</div>`;
}).join('');
return `<div class="unifi-section-header">UNIFI SWITCH PORTS</div>${html}`;
return `<div class="lt-divider" style="margin:20px 0 12px"><span class="lt-divider-label" style="color:var(--cyan);letter-spacing:.1em">UNIFI SWITCH PORTS</span></div>${html}`;
}
// ── Panel collapse / expand ───────────────────────────────────────
@@ -377,33 +397,51 @@ function buildLinkSummary(hosts, unifiSwitches) {
if ((d.tx_errs_rate || 0) > 0 || (d.rx_errs_rate || 0) > 0) errIfaces++;
}
}
let swTotal = 0, swDown = 0;
for (const sw of Object.values(unifiSwitches || {})) {
for (const p of Object.values(sw.ports || {})) {
totalPoe += p.poe_power || 0;
swTotal++;
if (!p.up) swDown++;
}
}
const hasAlerts = downIfaces > 0 || errIfaces > 0;
const allTotal = totalIfaces + swTotal;
const allDown = downIfaces + swDown;
const downColor = allDown > 0 ? 'var(--red)' : 'var(--green)';
const errColor = errIfaces > 0 ? 'var(--amber)' : 'var(--green)';
const downCardCls = allDown > 0 ? ' lt-stat-card--alert' : '';
const poeCard = totalPoe > 0 ? `
<div class="lt-stat-card">
<span class="lt-stat-icon" aria-hidden="true" style="color:var(--amber)">⚡</span>
<div class="lt-stat-info">
<span class="lt-stat-value" style="color:var(--amber)">${totalPoe.toFixed(1)}</span>
<span class="lt-stat-label">PoE Load (W)</span>
</div>
</div>` : '';
return `
<div class="link-summary-panel ${hasAlerts ? 'link-summary-has-alerts' : ''}">
<div class="link-summary-grid">
<div class="link-summary-stat">
<span class="lss-label">Total Interfaces</span>
<span class="lss-value">${totalIfaces}</span>
<div class="lt-stats-grid" style="margin-bottom:16px">
<div class="lt-stat-card">
<span class="lt-stat-icon" aria-hidden="true" style="color:var(--cyan)">⬡</span>
<div class="lt-stat-info">
<span class="lt-stat-value" style="color:var(--cyan)">${allTotal}</span>
<span class="lt-stat-label">Interfaces</span>
</div>
<div class="link-summary-stat ${downIfaces ? 'lss-alert' : ''}">
<span class="lss-label">Interfaces Down</span>
<span class="lss-value ${downIfaces ? 'val-crit' : 'val-good'}">${downIfaces}</span>
</div>
<div class="link-summary-stat ${errIfaces ? 'lss-alert' : ''}">
<span class="lss-label">With Errors</span>
<span class="lss-value ${errIfaces ? 'val-warn' : 'val-good'}">${errIfaces}</span>
<div class="lt-stat-card${downCardCls}">
<span class="lt-stat-icon" aria-hidden="true" style="color:${downColor}">●</span>
<div class="lt-stat-info">
<span class="lt-stat-value" style="color:${downColor}">${allDown}</span>
<span class="lt-stat-label">Ports Down</span>
</div>
${totalPoe > 0 ? `
<div class="link-summary-stat">
<span class="lss-label">PoE Load</span>
<span class="lss-value">${totalPoe.toFixed(1)} <span class="lss-sub">W</span></span>
</div>` : ''}
</div>
<div class="lt-stat-card">
<span class="lt-stat-icon" aria-hidden="true" style="color:${errColor}">▲</span>
<div class="lt-stat-info">
<span class="lt-stat-value" style="color:${errColor}">${errIfaces}</span>
<span class="lt-stat-label">With Errors</span>
</div>
</div>
${poeCard}
</div>`;
}
@@ -414,10 +452,6 @@ function renderLinks(data) {
const parts = [];
parts.push(buildLinkSummary(hosts, unifiSwitches));
parts.push(`<div class="link-collapse-bar">
<button class="lt-btn lt-btn-ghost lt-btn-sm" data-action="collapse-all">Collapse All</button>
<button class="lt-btn lt-btn-ghost lt-btn-sm" data-action="expand-all">Expand All</button>
</div>`);
parts.push('<div class="link-host-list">');
for (const [hostname, ifaces] of Object.entries(hosts)) {
@@ -426,8 +460,8 @@ function renderLinks(data) {
.map(([iname, d]) => renderIfaceCard(iname, d)).join('');
const sample = Object.values(ifaces)[0] || {};
const ip = sample.host_ip || '';
const updStr = sample.updated
? new Date(sample.updated + (sample.updated.includes('Z') ? '' : 'Z')).toLocaleTimeString()
const updStr = data.updated
? new Date(data.updated.replace(' UTC', 'Z').replace(' ', 'T')).toLocaleTimeString()
: '';
parts.push(`
@@ -446,6 +480,17 @@ function renderLinks(data) {
parts.push('</div>');
document.getElementById('links-container').innerHTML = parts.join('');
restoreCollapseState();
document.getElementById('links-toolbar').style.display = '';
applyLinksSearch();
}
// ── Host/switch search filter ─────────────────────────────────────
function applyLinksSearch() {
const q = (document.getElementById('links-search')?.value || '').trim().toLowerCase();
document.querySelectorAll('.link-host-panel').forEach(panel => {
const text = (panel.querySelector('.link-host-name')?.textContent || '').toLowerCase();
panel.style.display = (!q || text.includes(q)) ? '' : 'none';
});
}
function collapseAll() {
@@ -477,10 +522,12 @@ function checkLinksStale(updatedStr) {
if (!banner) {
banner = document.createElement('div');
banner.id = 'links-stale-banner';
banner.className = 'stale-banner';
banner.className = 'lt-alert lt-alert--warning';
banner.innerHTML = '<span class="lt-alert-icon">⚠</span><div class="lt-alert-body"><div class="lt-alert-msg"></div></div>';
document.getElementById('links-container').prepend(banner);
}
banner.textContent = `⚠ Link data may be stale — last updated ${Math.floor(age/60)}m ago.`;
banner.querySelector('.lt-alert-msg').textContent =
`Link data may be stale — last updated ${Math.floor(age/60)}m ago.`;
banner.style.display = '';
} else if (banner) {
banner.style.display = 'none';
@@ -510,7 +557,13 @@ async function loadLinks() {
}
loadLinks();
lt.autoRefresh.start(loadLinks, 60000);
var _linksInterval = (window.gandalfSettings && window.gandalfSettings.refreshInterval) || 60;
if (_linksInterval > 0) lt.autoRefresh.start(loadLinks, Math.max(_linksInterval, 15) * 1000);
window.onGandalfSettingsChanged = function(s) {
lt.autoRefresh.stop();
if (s.refreshInterval > 0) lt.autoRefresh.start(loadLinks, Math.max(s.refreshInterval, 15) * 1000);
};
document.addEventListener('click', e => {
const toggleTitle = e.target.closest('[data-action="toggle-panel"]');
@@ -519,5 +572,7 @@ document.addEventListener('click', e => {
if (e.target.closest('[data-action="collapse-all"]')) { collapseAll(); return; }
if (e.target.closest('[data-action="expand-all"]')) { expandAll(); return; }
});
document.getElementById('links-search')?.addEventListener('input', applyLinksSearch);
</script>
{% endblock %}
+43 -10
View File
@@ -3,9 +3,11 @@
{% block content %}
<div class="g-page-header">
<h1 class="g-page-title">Alert Suppressions</h1>
<p class="g-page-sub">Manage maintenance windows and per-target alert suppression rules.</p>
<div class="lt-page-header">
<div>
<h1 class="lt-page-title">Alert Suppressions</h1>
<p class="g-page-sub" style="margin-top:4px">Manage maintenance windows and per-target alert suppression rules.</p>
</div>
</div>
<!-- ── Create suppression ─────────────────────────────────────────── -->
@@ -83,6 +85,10 @@
</div>
<div id="active-sup-wrap">
{% if active %}
<div class="lt-frame">
<span class="lt-frame-bl">&#x255A;</span>
<span class="lt-frame-br">&#x255D;</span>
<div class="lt-section-header">Active Rules</div>
<div class="lt-table-wrap">
<table class="lt-table" id="active-sup-table">
<caption class="lt-sr-only">Active suppression rules</caption>
@@ -95,7 +101,8 @@
<tbody>
{% for s in active %}
<tr id="sup-row-{{ s.id }}">
<td><span class="lt-badge badge-info">{{ s.target_type }}</span></td>
{%- set _sup_badge = {'host':'badge-warning','interface':'badge-info','unifi_device':'badge-purple','all':'badge-critical'} -%}
<td><span class="lt-badge {{ _sup_badge.get(s.target_type, 'badge-neutral') }}">{{ s.target_type }}</span></td>
<td>{{ s.target_name or 'all' }}</td>
<td>{{ s.target_detail or '' }}</td>
<td>{{ s.reason }}</td>
@@ -110,8 +117,13 @@
</tbody>
</table>
</div>
</div>
{% else %}
<p class="empty-state" id="no-active-msg">No active suppressions.</p>
<div class="lt-empty-state lt-empty-state--sm" id="no-active-msg">
<div class="lt-empty-state-icon">🔕</div>
<div class="lt-empty-state-title">No active suppressions</div>
<div class="lt-empty-state-body">All alerts are active. Use the form above to silence a host or interface.</div>
</div>
{% endif %}
</div>
</section>
@@ -123,6 +135,10 @@
<span class="g-section-badge">{{ history | length }}</span>
</div>
{% if history %}
<div class="lt-frame">
<span class="lt-frame-bl">&#x255A;</span>
<span class="lt-frame-br">&#x255D;</span>
<div class="lt-section-header">Suppression Log</div>
<div class="lt-table-wrap">
<table class="lt-table lt-table-sm">
<caption class="lt-sr-only">Suppression history</caption>
@@ -154,8 +170,12 @@
</tbody>
</table>
</div>
</div>
{% else %}
<p class="empty-state">No suppression history yet.</p>
<div class="lt-empty-state lt-empty-state--sm">
<div class="lt-empty-state-icon">📋</div>
<div class="lt-empty-state-title">No suppression history yet</div>
</div>
{% endif %}
</section>
@@ -164,6 +184,11 @@
<div class="g-section-header">
<h2 class="g-section-title">Available Targets</h2>
</div>
<div class="lt-frame">
<span class="lt-frame-bl">&#x255A;</span>
<span class="lt-frame-br">&#x255D;</span>
<div class="lt-section-header">Host &amp; Interface Reference</div>
<div style="padding:12px 14px">
<div class="targets-grid">
{% for name, host in snapshot.hosts.items() %}
<div class="target-card">
@@ -179,6 +204,8 @@
</div>
{% endfor %}
</div>
</div>
</div>
</section>
{% endblock %}
@@ -209,14 +236,15 @@
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>';
wrap.innerHTML = '<div class="lt-empty-state lt-empty-state--sm" id="no-active-msg"><div class="lt-empty-state-icon">🔕</div><div class="lt-empty-state-title">No active suppressions</div><div class="lt-empty-state-body">All alerts are active. Use the form above to silence a host or interface.</div></div>';
if (badge) badge.textContent = '0';
return;
}
if (badge) badge.textContent = rows.length;
const SUP_BADGE = {host:'badge-warning', interface:'badge-info', unifi_device:'badge-purple', all:'badge-critical'};
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><span class="lt-badge ${SUP_BADGE[s.target_type] || 'badge-neutral'}">${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>
@@ -226,6 +254,10 @@
<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-frame">
<span class="lt-frame-bl">&#x255A;</span>
<span class="lt-frame-br">&#x255D;</span>
<div class="lt-section-header">Active Rules</div>
<div class="lt-table-wrap">
<table class="lt-table" id="active-sup-table">
<caption class="lt-sr-only">Active suppression rules</caption>
@@ -235,6 +267,7 @@
</tr></thead>
<tbody>${tbody}</tbody>
</table>
</div>
</div>`;
}
@@ -243,7 +276,7 @@
const rows = await lt.api.get('/api/suppressions');
renderActiveRows(rows);
} catch (err) {
console.warn('Failed to refresh suppressions:', err);
showToast('Failed to refresh suppressions', 'warning');
}
}
@@ -285,7 +318,7 @@
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>';
'<div class="lt-empty-state lt-empty-state--sm" id="no-active-msg"><div class="lt-empty-state-icon">🔕</div><div class="lt-empty-state-title">No active suppressions</div><div class="lt-empty-state-body">All alerts are active. Use the form above to silence a host or interface.</div></div>';
if (badge) badge.textContent = '0';
}
showToast('Suppression removed', 'success');