11 Commits

Author SHA1 Message Date
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
jared 03375ef22f Remove all inline event handlers; replace with data-action delegation
Lint / Python (flake8) (push) Successful in 39s
Lint / JS (eslint) (push) Successful in 6s
Security / Python Security (bandit) (push) Successful in 50s
Test / Python Tests (pytest) (push) Successful in 51s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 2s
- inspector.html: onclick on port blocks, close button, run-diagnostic button,
  and diag-toggle sections all converted to data-action attributes; single
  delegated click listener handles all cases + Escape key closes panel
- links.html: onclick on panel title headers, Collapse All, Expand All
  converted to data-action with delegated listener
- suppressions.html: onsubmit/onchange wired via addEventListener at init
- index.html: onsubmit/onchange on suppress modal form wired at init

No behavioural changes — pure event-handling refactor for TDS compliance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:53:48 -04:00
jared c025da85c1 Audit quick wins: null guard, API error toasts, aria-labels on suppress buttons
- app.js: guard events array with || [] before .filter() to prevent crash on null
- app.js: show warning toast when /api/network or /api/status fail (was silent)
- app.js: add aria-label to all dynamically-generated suppress buttons
- index.html: add aria-label to server-rendered suppress buttons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:50:00 -04:00
11 changed files with 991 additions and 360 deletions
+80 -1
View File
@@ -8,14 +8,16 @@ import hashlib
import ipaddress import ipaddress
import json import json
import logging import logging
import os
import re import re
import tempfile
import threading import threading
import time import time
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from functools import wraps from functools import wraps
from flask import Flask, jsonify, render_template, request from flask import Flask, jsonify, make_response, render_template, request, send_file
import db import db
import diagnose import diagnose
@@ -169,6 +171,7 @@ def index():
last_check=last_check, last_check=last_check,
suppressions=suppressions, suppressions=suppressions,
recent_resolved=recent_resolved, 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')}) 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') @app.route('/health')
def health(): def health():
"""Health check endpoint (no auth). Checks DB and monitor freshness.""" """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", "url": "http://10.10.10.45/create_ticket_api.php",
"api_key": "5acc5d3c647b84f7c6f59082ce4450ee772e2d1633238b960136f653d20c93af" "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": { "auth": {
"allowed_groups": ["admin"] "allowed_groups": ["admin"]
}, },
+14 -9
View File
@@ -877,8 +877,12 @@ class NetworkMonitor:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Snapshot collection (for dashboard) # Snapshot collection (for dashboard)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _collect_snapshot(self, iface_states: Dict[str, Dict[str, bool]]) -> dict: def _collect_snapshot(
unifi_devices = self.unifi.get_devices() or [] 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 = {} hosts = {}
for instance, ifaces in iface_states.items(): for instance, ifaces in iface_states.items():
@@ -914,7 +918,7 @@ class NetworkMonitor:
return { return {
'hosts': hosts, 'hosts': hosts,
'unifi': unifi_devices, 'unifi': display_unifi,
'updated': datetime.utcnow().isoformat(), 'updated': datetime.utcnow().isoformat(),
} }
@@ -933,23 +937,24 @@ class NetworkMonitor:
# 1. Fetch interface states once — shared by snapshot and alert processing # 1. Fetch interface states once — shared by snapshot and alert processing
iface_states = self.prom.get_interface_states() iface_states = self.prom.get_interface_states()
# 2. Collect and store snapshot for dashboard # 2. Fetch UniFi devices once — used by both snapshot and alert processing
snapshot = self._collect_snapshot(iface_states) 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('network_snapshot', snapshot)
db.set_state('last_check', _now_utc()) db.set_state('last_check', _now_utc())
# 3. Collect link stats (ethtool + traffic metrics) # 4. Collect link stats (ethtool + traffic metrics)
try: try:
link_data = self.link_stats.collect(self._instance_map) link_data = self.link_stats.collect(self._instance_map)
db.set_state('link_stats', link_data) db.set_state('link_stats', link_data)
except Exception as e: except Exception as e:
logger.error(f'Link stats collection failed: {e}', exc_info=True) logger.error(f'Link stats collection failed: {e}', exc_info=True)
# 4. Process alerts using already-fetched interface states # 5. Process alerts using already-fetched data
suppressions = db.get_active_suppressions() suppressions = db.get_active_suppressions()
self._process_interfaces(iface_states, suppressions) self._process_interfaces(iface_states, suppressions)
unifi_devices = self.unifi.get_devices()
self._process_unifi(unifi_devices, suppressions) self._process_unifi(unifi_devices, suppressions)
self._process_ping_hosts(suppressions) self._process_ping_hosts(suppressions)
+1
View File
@@ -1,5 +1,6 @@
flask>=2.2.0 flask>=2.2.0
gunicorn>=20.1.0 gunicorn>=20.1.0
ldap3>=2.9
pymysql>=1.1.0 pymysql>=1.1.0
requests>=2.31.0 requests>=2.31.0
urllib3>=2.0.0 urllib3>=2.0.0
+40 -11
View File
@@ -33,7 +33,9 @@ function _toIso(s) {
// ── Dashboard auto-refresh ──────────────────────────────────────────── // ── Dashboard auto-refresh ────────────────────────────────────────────
async function refreshAll() { async function refreshAll() {
const refreshBtn = document.querySelector('[data-action="refresh"]'); const refreshBtn = document.querySelector('[data-action="refresh"]');
const spinner = document.getElementById('refresh-spinner');
if (refreshBtn) refreshBtn.classList.add('is-loading'); if (refreshBtn) refreshBtn.classList.add('is-loading');
if (spinner) spinner.style.display = '';
try { try {
const [netResult, statusResult] = await Promise.allSettled([ const [netResult, statusResult] = await Promise.allSettled([
lt.api.get('/api/network'), lt.api.get('/api/network'),
@@ -45,17 +47,19 @@ async function refreshAll() {
updateUnifiTable(net.unifi || []); updateUnifiTable(net.unifi || []);
updateTopology(net.hosts || {}); updateTopology(net.hosts || {});
} else { } else {
console.warn('Network API failed:', netResult.reason); showToast('Network data unavailable', 'warning');
} }
if (statusResult.status === 'fulfilled') { if (statusResult.status === 'fulfilled') {
const status = statusResult.value; const status = statusResult.value;
updateEventsTable(status.events || [], status.total_active); updateEventsTable(status.events || [], status.total_active);
updateStatusBar(status.summary || {}, status.last_check || '', status.daemon_ok); updateStatusBar(status.summary || {}, status.last_check || '', status.daemon_ok);
if (typeof window.gandalfNotifUpdate === 'function') window.gandalfNotifUpdate(status.events || []);
} else { } else {
console.warn('Status API failed:', statusResult.reason); showToast('Status data unavailable', 'warning');
} }
} finally { } finally {
if (refreshBtn) refreshBtn.classList.remove('is-loading'); if (refreshBtn) refreshBtn.classList.remove('is-loading');
if (spinner) spinner.style.display = 'none';
} }
} }
@@ -78,6 +82,21 @@ function updateStatusBar(summary, lastCheck, daemonOk) {
else if (warnCount) document.title = `(${warnCount} WARN) GANDALF`; else if (warnCount) document.title = `(${warnCount} WARN) GANDALF`;
else document.title = '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');
if (scCrit) scCrit.textContent = critCount;
if (scWarn) scWarn.textContent = warnCount;
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 // Stale data banner: warn if last_check is older than 15 minutes
let staleBanner = document.getElementById('stale-banner'); let staleBanner = document.getElementById('stale-banner');
if (lastCheck) { if (lastCheck) {
@@ -86,11 +105,13 @@ function updateStatusBar(summary, lastCheck, daemonOk) {
if (!staleBanner) { if (!staleBanner) {
staleBanner = document.createElement('div'); staleBanner = document.createElement('div');
staleBanner.id = 'stale-banner'; 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); document.querySelector('.lt-main').prepend(staleBanner);
} }
const mins = Math.floor(checkAge / 60); 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 = ''; staleBanner.style.display = '';
} else if (staleBanner) { } else if (staleBanner) {
staleBanner.style.display = 'none'; staleBanner.style.display = 'none';
@@ -99,6 +120,9 @@ function updateStatusBar(summary, lastCheck, daemonOk) {
} }
function updateHostGrid(hosts) { 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)) { for (const [name, host] of Object.entries(hosts)) {
const card = document.querySelector(`.host-card[data-host="${CSS.escape(name)}"]`); const card = document.querySelector(`.host-card[data-host="${CSS.escape(name)}"]`);
if (!card) continue; if (!card) continue;
@@ -130,14 +154,17 @@ function updateTopology(hosts) {
const host = hosts[name]; const host = hosts[name];
if (!host) return; if (!host) return;
node.className = node.className.replace(/topo-v2-status-(up|down|degraded|unknown)/g, ''); 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-v2-status-${host.status}`);
node.classList.add(`topo-status-${host.status}`);
const badge = node.querySelector('.topo-badge'); const badge = node.querySelector('.topo-badge');
if (badge) { if (badge) {
badge.className = `topo-badge topo-badge-${host.status}`; badge.className = `topo-badge topo-badge-${host.status}`;
badge.textContent = 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,12 +175,13 @@ function updateUnifiTable(devices) {
tbody.innerHTML = devices.map(d => { tbody.innerHTML = devices.map(d => {
const statusClass = d.connected ? '' : 'row-critical'; const statusClass = d.connected ? '' : 'row-critical';
const dotClass = d.connected ? 'dot-up' : 'dot-down'; const dotClass = d.connected ? 'dot-up' : 'dot-down';
const statusText = d.connected ? 'Online' : 'Offline'; const statusText = d.connected ? 'ONLINE' : 'OFFLINE';
const suppressBtn = !d.connected const suppressBtn = !d.connected
? `<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress" ? `<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
data-sup-type="unifi_device" data-sup-type="unifi_device"
data-sup-name="${lt.escHtml(d.name)}" data-sup-name="${lt.escHtml(d.name)}"
data-sup-detail="">🔕 Suppress</button>` data-sup-detail=""
aria-label="Suppress alerts for ${lt.escHtml(d.name)}">🔕 Suppress</button>`
: ''; : '';
return ` return `
<tr class="${statusClass}"> <tr class="${statusClass}">
@@ -171,9 +199,9 @@ function updateEventsTable(events, totalActive) {
const wrap = document.getElementById('events-table-wrap'); const wrap = document.getElementById('events-table-wrap');
if (!wrap) return; if (!wrap) return;
const active = events.filter(e => e.severity !== 'info'); const active = (events || []).filter(e => e.severity !== 'info');
if (!active.length) { 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; return;
} }
@@ -207,7 +235,8 @@ function updateEventsTable(events, totalActive) {
<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress" <button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
data-sup-type="${lt.escHtml(supType)}" data-sup-type="${lt.escHtml(supType)}"
data-sup-name="${lt.escHtml(e.target_name)}" data-sup-name="${lt.escHtml(e.target_name)}"
data-sup-detail="${lt.escHtml(e.target_detail||'')}">🔕</button> data-sup-detail="${lt.escHtml(e.target_detail||'')}"
aria-label="Suppress alert for ${lt.escHtml(e.target_name)}">🔕</button>
</td> </td>
</tr>`; </tr>`;
}).join(''); }).join('');
+118 -52
View File
@@ -5,6 +5,12 @@
This file adds only gandalf-specific components. 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 ────────────────── */ /* ── Variable aliases bridging to base.css palette ────────────────── */
:root { :root {
/* Short names used throughout custom components */ /* Short names used throughout custom components */
@@ -54,6 +60,28 @@
--glow-cyan: none; --glow-cyan: none;
--glow-xl: 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 ────────────────────────────────── */ /* ── Refresh button loading state ────────────────────────────────── */
[data-action="refresh"].is-loading { [data-action="refresh"].is-loading {
@@ -132,6 +160,7 @@
.badge-neutral { color: var(--text-muted); border-color: var(--text-muted); } .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-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-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 ───────────────────────────────────────── */ /* ── Table row state colors ───────────────────────────────────────── */
.lt-table tr.row-critical td { background: rgba(255,45,85,.04); } .lt-table tr.row-critical td { background: rgba(255,45,85,.04); }
@@ -172,22 +201,14 @@
border: 1px solid; border: 1px solid;
letter-spacing: .04em; 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-warning { color: var(--orange); border-color: var(--orange); }
.chip-ok { color: var(--green); border-color: var(--green-muted); text-shadow: var(--glow); } .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; } .status-meta { display: flex; align-items: center; gap: 10px; white-space: nowrap; }
.last-check { font-size: .72em; color: var(--text-muted); } .last-check { font-size: .72em; color: var(--text-muted); }
/* ── Stale monitoring banner ──────────────────────────────────────── */ /* ── Stale monitoring banner ──────────────────────────────────────── */
.stale-banner { /* .stale-banner replaced by lt-alert--warning */
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;
}
/* ── Error / empty state containers ───────────────────────────────── */ /* ── Error / empty state containers ───────────────────────────────── */
.error-state { .error-state {
@@ -224,12 +245,27 @@
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
padding: 12px; padding: 12px;
position: relative; position: relative;
overflow: hidden;
transition: border-color .2s, box-shadow .2s; 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: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 { 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 { 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 { 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-card-header { margin-bottom: 8px; }
.host-name-row { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; } .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; } .host-name { font-weight: bold; font-size: .88em; color: var(--amber); letter-spacing: .04em; }
@@ -293,7 +329,9 @@
/* ── Topology diagram ─────────────────────────────────────────────── */ /* ── Topology diagram ─────────────────────────────────────────────── */
.topology { .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); border: 1px solid var(--border-color);
padding: 20px 16px 16px; padding: 20px 16px 16px;
margin-bottom: 16px; margin-bottom: 16px;
@@ -336,9 +374,18 @@
background: linear-gradient(to bottom, var(--cyan), var(--green)); background: linear-gradient(to bottom, var(--cyan), var(--green));
opacity: .7; 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 { .topo-vc-label {
position: absolute; position: absolute;
left: calc(50% + 6px); left: calc(50% + 7px);
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
font-size: .58em; font-size: .58em;
@@ -346,6 +393,8 @@
white-space: nowrap; white-space: nowrap;
letter-spacing: .06em; letter-spacing: .06em;
font-family: var(--font); font-family: var(--font);
background: rgba(3,5,8,.7);
padding: 1px 4px;
} }
.topo-v2-node { .topo-v2-node {
@@ -362,26 +411,45 @@
min-width: 110px; min-width: 110px;
text-align: center; text-align: center;
transition: border-color .2s, box-shadow .2s; 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-icon { font-size: 1.3em; line-height: 1; }
.topo-v2-label { font-weight: bold; letter-spacing: .04em; } .topo-v2-label { font-weight: bold; letter-spacing: .04em; }
.topo-v2-sub { font-size: .58em; color: var(--text-muted); letter-spacing: .02em; } .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-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-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); } .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); } .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; } .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-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-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-status-unknown { border-color: var(--border-color); }
.topo-v2-offrack { border-style: dashed !important; } .topo-v2-offrack { border-style: dashed !important; }
.topo-badge { font-size: .68em; padding: 1px 5px; border: 1px solid; letter-spacing: .03em; } .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-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-degraded { color: var(--orange); border-color: var(--orange); }
.topo-badge-unknown { color: var(--text-muted); border-color: var(--border-color); } .topo-badge-unknown { color: var(--text-muted); border-color: var(--border-color); }
@@ -397,9 +465,18 @@
height: 28px; height: 28px;
align-items: flex-start; 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; } .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 */ /* Bus rails */
.topo-bus-section { .topo-bus-section {
width: 100%; width: 100%;
@@ -418,8 +495,9 @@
flex: 1; flex: 1;
height: 2px; height: 2px;
background: var(--green); background: var(--green);
opacity: .45; opacity: .55;
margin: 0 4px; margin: 0 4px;
box-shadow: 0 0 6px rgba(0,255,136,.4);
} }
.topo-bus-10g-label { .topo-bus-10g-label {
font-size: .56em; font-size: .56em;
@@ -482,7 +560,7 @@
color: var(--text-muted); color: var(--text-muted);
font-family: var(--font); 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-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; } .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-zero { color: var(--green); }
.counter-nonzero { color: var(--red); text-shadow: var(--glow-red); } .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-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-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-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-row .lt-progress { flex: 1; height: 5px; }
.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-value { font-size: .7em; color: var(--text-dim); width: 68px; text-align: right; flex-shrink: 0; } .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 / optical panel */
.sfp-panel { .sfp-panel {
@@ -647,25 +724,11 @@
background: linear-gradient(90deg, transparent, var(--cyan), transparent); 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 { padding: 20px; text-align: center; color: var(--text-muted); font-size: .8em; }
.link-loading::after { content: ' ...'; animation: blink 1s step-end infinite; } .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; } .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 page ───────────────────────────────────────────────── */
.inspector-layout { .inspector-layout {
@@ -753,7 +816,7 @@
.switch-port-block.poe-active:hover { box-shadow: var(--glow-amber); } .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 { background: var(--cyan-dim); border-color: var(--cyan); color: var(--cyan); }
.switch-port-block.uplink:hover { box-shadow: var(--glow-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-num { line-height: 1; font-weight: bold; }
.port-speed { font-size: .72em; opacity: .7; line-height: 1; font-weight: normal; } .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; } .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 +885,9 @@
transition: all .15s; transition: all .15s;
} }
.panel-close:hover { color: var(--red); border-color: var(--red); } .panel-close:hover { color: var(--red); border-color: var(--red); }
.panel-section-title { /* Inspector panel uses lt-divider — compact spacing overrides */
font-size: .62em; .inspector-panel .lt-divider { margin: 8px 0 4px; }
font-weight: bold; .inspector-panel .lt-divider-label { color: var(--amber); font-size: .6em; }
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; }
.panel-row { .panel-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -926,6 +981,17 @@
.diag-pulse-link a { color: var(--cyan); } .diag-pulse-link a { color: var(--cyan); }
.diag-pulse-link a:hover { text-shadow: var(--glow-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 ───────────────────────────────────────────────────── */ /* ── Responsive ───────────────────────────────────────────────────── */
@media (max-width: 768px) { @media (max-width: 768px) {
.host-grid { grid-template-columns: 1fr; } .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 %} {% set _initials = (_words[0][0] ~ (_words[1][0] if _words|length > 1 else ''))|upper %}
<div class="lt-avatar lt-avatar--sm {{ _uname | avatar_color }}" <div class="lt-avatar lt-avatar--sm {{ _uname | avatar_color }}"
aria-hidden="true" title="{{ _uname }}"> aria-hidden="true" title="{{ _uname }}">
<img src="{{ url_for('api_avatar') }}" alt="" class="lt-avatar-img">
<span class="lt-avatar-initials">{{ _initials }}</span> <span class="lt-avatar-initials">{{ _initials }}</span>
</div> </div>
<span class="lt-header-user">{{ _uname }}</span> <span class="lt-header-user">{{ _uname }}</span>
{% if user.groups and 'admin' in user.groups %} {% if user.groups and 'admin' in user.groups %}
<span class="lt-badge lt-badge-admin" aria-label="Administrator">ADMIN</span> <span class="lt-badge lt-badge-admin" aria-label="Administrator">ADMIN</span>
{% endif %} {% 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" <button type="button" class="lt-theme-btn" id="lt-theme-btn"
aria-label="Toggle theme" title="Toggle light/dark mode">&#x2600;</button> aria-label="Toggle theme" title="Toggle light/dark mode">&#x2600;</button>
</div> </div>
@@ -169,13 +202,20 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<!-- FOOTER --> <!-- FOOTER — context-sensitive per page -->
<footer class="lt-footer" role="contentinfo"> <footer class="lt-footer" role="contentinfo">
<nav class="lt-footer-hints" aria-label="Keyboard shortcuts"> <nav class="lt-footer-hints" aria-label="Keyboard shortcuts">
<span class="lt-footer-hint"><span class="lt-footer-key">[ Ctrl+K ]</span> SEARCH</span> {% if request.endpoint == 'index' %}
<span class="lt-footer-sep">|</span>
<span class="lt-footer-hint"><span class="lt-footer-key">[ R ]</span> REFRESH</span> <span class="lt-footer-hint"><span class="lt-footer-key">[ R ]</span> REFRESH</span>
<span class="lt-footer-sep">|</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> <button type="button" class="lt-footer-hint" data-action="show-keyboard-help"><span class="lt-footer-key">[ ? ]</span> HELP</button>
</nav> </nav>
<span>GANDALF &mdash; TDS v1.2</span> <span>GANDALF &mdash; TDS v1.2</span>
@@ -193,9 +233,11 @@
<thead><tr><th>Shortcut</th><th>Action</th></tr></thead> <thead><tr><th>Shortcut</th><th>Action</th></tr></thead>
<tbody> <tbody>
<tr><td>Ctrl / &#x2318; + K</td><td>Command palette</td></tr> <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>?</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> </tbody>
</table> </table>
</div> </div>
@@ -205,6 +247,45 @@
</div> </div>
</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> <script>
const GANDALF_CONFIG = { const GANDALF_CONFIG = {
ticket_web_url: "{{ config.get('ticket_api', {}).get('web_url', 'http://t.lotusguild.org/ticket/') }}" 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-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-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: '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: 'action-refresh', group: 'Actions', icon: '', label: 'Refresh Data', kbd: 'R', action: function() { lt.autoRefresh.now(); } },
{ id: 'help-theme', group: 'Help', icon: '*', label: 'Toggle Theme', action: function() { lt.theme.toggle(); } }, { 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: 'action-refresh', group: 'Actions', icon: '', label: 'Refresh Data', kbd: 'R', action: function() { if (typeof refreshAll === 'function') refreshAll(); } }, { 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) { document.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action]'); var btn = e.target.closest('[data-action]');
if (!btn) return; if (!btn) return;
if (btn.getAttribute('data-action') === 'show-keyboard-help' && window.lt) { var action = btn.getAttribute('data-action');
lt.modal.open('lt-keys-help'); 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() { lt.keys.on('r', function() { lt.autoRefresh.now(); });
if (typeof refreshAll === 'function') refreshAll(); 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> </script>
</body> </body>
+129 -51
View File
@@ -6,19 +6,59 @@
<!-- ── Status bar ──────────────────────────────────────────────────── --> <!-- ── Status bar ──────────────────────────────────────────────────── -->
<div class="status-bar"> <div class="status-bar">
<div class="status-chips"> <div class="status-chips">
{% if not daemon_ok %}
<span class="chip chip-critical">⚠ MONITOR OFFLINE</span>
{% endif %}
{% if summary.critical %} {% if summary.critical %}
<span class="chip chip-critical">● {{ summary.critical }} CRITICAL</span> <span class="chip chip-critical">● {{ summary.critical }} CRITICAL</span>
{% endif %} {% endif %}
{% if summary.warning %} {% if summary.warning %}
<span class="chip chip-warning">● {{ summary.warning }} WARNING</span> <span class="chip chip-warning">● {{ summary.warning }} WARNING</span>
{% endif %} {% 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> <span class="chip chip-ok">✔ ALL SYSTEMS NOMINAL</span>
{% endif %} {% endif %}
</div> </div>
<div class="status-meta"> <div class="status-meta">
<span class="last-check" id="last-check">{{ last_check }}</span> <span class="last-check" id="last-check" data-tooltip="Last monitor poll timestamp" data-tooltip-pos="bottom">{{ last_check }}</span>
<button class="lt-btn lt-btn-ghost lt-btn-sm" data-action="refresh">↻ REFRESH</button> <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>
</div> </div>
@@ -46,7 +86,7 @@
<!-- WAN wire: cyan → green gradient, labeled --> <!-- WAN wire: cyan → green gradient, labeled -->
<div class="topo-vc"> <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> <span class="topo-vc-label">WAN · 10G SFP+</span>
</div> </div>
@@ -138,8 +178,8 @@
<div class="topo-v2-host-wrap"> <div class="topo-v2-host-wrap">
<!-- dual-homing wires: 10G solid green + 1G dashed amber --> <!-- dual-homing wires: 10G solid green + 1G dashed amber -->
<div class="topo-v2-host-wires"> <div class="topo-v2-host-wires">
<div class="topo-v2-wire-10g" title="10G SFP+ → USW-Agg"></div> <div class="topo-v2-wire-10g" data-host="{{ hname }}" title="10G SFP+ → USW-Agg"></div>
<div class="topo-v2-wire-1g" title="1G → Pro 24 PoE"></div> <div class="topo-v2-wire-1g" data-host="{{ hname }}" title="1G → Pro 24 PoE"></div>
</div> </div>
<!-- host box --> <!-- host box -->
<div class="topo-v2-node topo-v2-host topo-host topo-v2-status-{{ st }}{{ ' topo-v2-offrack' if off_rack else '' }}" <div class="topo-v2-node topo-v2-host topo-host topo-v2-status-{{ st }}{{ ' topo-v2-offrack' if off_rack else '' }}"
@@ -215,7 +255,11 @@
</div> </div>
</div> </div>
{% else %} {% 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 %} {% endfor %}
</div> </div>
</section> </section>
@@ -226,6 +270,10 @@
<div class="g-section-header"> <div class="g-section-header">
<h2 class="g-section-title">UniFi Devices</h2> <h2 class="g-section-title">UniFi Devices</h2>
</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">Device Inventory</div>
<div class="lt-table-wrap"> <div class="lt-table-wrap">
<table class="lt-table" id="unifi-table"> <table class="lt-table" id="unifi-table">
<caption class="lt-sr-only">UniFi network devices</caption> <caption class="lt-sr-only">UniFi network devices</caption>
@@ -265,6 +313,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
</section> </section>
{% endif %} {% endif %}
@@ -272,13 +321,15 @@
<section class="g-section"> <section class="g-section">
<div class="g-section-header"> <div class="g-section-header">
<h2 class="g-section-title">Active Alerts</h2> <h2 class="g-section-title">Active Alerts</h2>
{% if summary.critical or summary.warning %} <span class="g-section-badge" id="alert-count-badge"
<span class="g-section-badge">{{ (summary.critical or 0) + (summary.warning or 0) }}</span> {% if not summary.critical and not summary.warning %}style="display:none"{% endif %}>{{ (summary.critical or 0) + (summary.warning or 0) }}</span>
{% endif %} </div>
<div class="g-section-actions"> <div class="lt-toolbar">
<div class="events-filter-bar"> <div class="lt-toolbar-left">
<input type="search" class="lt-input lt-input-sm" id="events-search" <div class="lt-search">
<input type="search" class="lt-input lt-search-input" id="events-search"
placeholder="Filter by target, type, description…" autocomplete="off"> placeholder="Filter by target, type, description…" autocomplete="off">
</div>
<div class="sev-pills"> <div class="sev-pills">
<button type="button" class="pill active" data-sev="">All</button> <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="critical">Critical</button>
@@ -286,7 +337,10 @@
</div> </div>
</div> </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"> <div id="events-table-wrap">
{% if events %} {% if events %}
{% if total_active is defined and total_active > events|length %} {% if total_active is defined and total_active > events|length %}
@@ -297,14 +351,14 @@
<caption class="lt-sr-only">Active network alerts</caption> <caption class="lt-sr-only">Active network alerts</caption>
<thead> <thead>
<tr> <tr>
<th>Sev</th> <th data-tooltip="Alert severity level" data-tooltip-pos="bottom">Sev</th>
<th>Type</th> <th>Type</th>
<th>Target</th> <th>Target</th>
<th>Detail</th> <th>Detail</th>
<th>Description</th> <th>Description</th>
<th>First Seen</th> <th data-tooltip="When this alert was first raised" data-tooltip-pos="bottom">First Seen</th>
<th>Last Seen</th> <th data-tooltip="Most recent check failure" data-tooltip-pos="bottom">Last Seen</th>
<th>Failures</th> <th data-tooltip="Consecutive check failures since first seen" data-tooltip-pos="bottom">Failures</th>
<th>Ticket</th> <th>Ticket</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
@@ -336,20 +390,29 @@
data-sup-type="{{ 'unifi_device' if e.event_type == 'unifi_device_offline' else 'interface' if e.event_type == 'interface_down' else 'host' }}" data-sup-type="{{ 'unifi_device' if e.event_type == 'unifi_device_offline' else 'interface' if e.event_type == 'interface_down' else 'host' }}"
data-sup-name="{{ e.target_name }}" data-sup-name="{{ e.target_name }}"
data-sup-detail="{{ e.target_detail or '' }}" data-sup-detail="{{ e.target_detail or '' }}"
title="Suppress">🔕</button> title="Suppress" aria-label="Suppress alert for {{ e.target_name }}">🔕</button>
</td> </td>
</tr> </tr>
{% endif %} {% endif %}
{% else %} {% 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 %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% else %} {% else %}
<p class="empty-state">No active alerts ✔</p> <div class="lt-empty-state lt-empty-state--sm">
{% endif %} <div class="lt-empty-state-icon"></div>
<div class="lt-empty-state-title">No active alerts</div>
</div> </div>
{% endif %}
</div><!-- /#events-table-wrap -->
</div><!-- /.lt-frame -->
</section> </section>
<!-- ── Recently Resolved (last 24h) ───────────────────────────────── --> <!-- ── Recently Resolved (last 24h) ───────────────────────────────── -->
@@ -359,34 +422,24 @@
<h2 class="g-section-title">Recently Resolved</h2> <h2 class="g-section-title">Recently Resolved</h2>
<span class="g-section-badge g-section-badge-resolved">{{ recent_resolved | length }} in last 24h</span> <span class="g-section-badge g-section-badge-resolved">{{ recent_resolved | length }} in last 24h</span>
</div> </div>
<div class="lt-table-wrap"> <div class="lt-timeline">
<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>
{% for e in recent_resolved %} {% for e in recent_resolved %}
<tr class="row-resolved"> {%- set dot_cls = 'lt-timeline-item--green' if e.severity == 'info' else 'lt-timeline-item--dim' -%}
<td><span class="lt-badge badge-resolved">{{ e.severity }}</span></td> <div class="lt-timeline-item {{ dot_cls }}">
<td>{{ e.event_type | replace('_', ' ') }}</td> <div class="lt-timeline-meta">
<td><strong>{{ e.target_name }}</strong></td> <strong class="lt-timeline-actor">{{ e.target_name }}</strong>
<td>{{ e.target_detail or '' }}</td> {% if e.target_detail %}<span>· {{ e.target_detail }}</span>{% endif %}
<td class="ts-cell"> <span class="lt-timeline-time event-age" data-ts="{{ e.resolved_at }}">{{ e.resolved_at }}</span>
<span class="event-age" data-ts="{{ e.resolved_at }}">{{ e.resolved_at }}</span> </div>
</td> <div class="lt-timeline-body">
<td class="ts-cell event-duration" data-first="{{ e.first_seen }}" data-resolved="{{ e.resolved_at }}"></td> {{ e.event_type | replace('_', ' ') }}
</tr> &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 %} {% endfor %}
</tbody>
</table>
</div> </div>
</section> </section>
{% endif %} {% endif %}
@@ -399,11 +452,11 @@
<h3 class="lt-modal-title" id="suppress-modal-title">Suppress Alert</h3> <h3 class="lt-modal-title" id="suppress-modal-title">Suppress Alert</h3>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close"></button> <button type="button" class="lt-modal-close" data-modal-close aria-label="Close"></button>
</div> </div>
<form id="suppress-form" onsubmit="submitSuppress(event)"> <form id="suppress-form">
<div class="lt-modal-body"> <div class="lt-modal-body">
<div class="lt-form-group" style="margin-bottom:12px"> <div class="lt-form-group" style="margin-bottom:12px">
<label class="lt-label" for="sup-type">Target Type</label> <label class="lt-label" for="sup-type">Target Type</label>
<select class="lt-select" id="sup-type" name="target_type" onchange="updateSuppressForm()"> <select class="lt-select" id="sup-type" name="target_type">
<option value="host">Host (all interfaces)</option> <option value="host">Host (all interfaces)</option>
<option value="interface">Specific Interface</option> <option value="interface">Specific Interface</option>
<option value="unifi_device">UniFi Device</option> <option value="unifi_device">UniFi Device</option>
@@ -448,7 +501,18 @@
{% block scripts %} {% block scripts %}
<script> <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);
function updateEventAges() { function updateEventAges() {
document.querySelectorAll('.event-age[data-ts]').forEach(el => { document.querySelectorAll('.event-age[data-ts]').forEach(el => {
@@ -503,5 +567,19 @@
// Re-apply filter after dynamic table updates // Re-apply filter after dynamic table updates
new MutationObserver(applyEventsFilter) new MutationObserver(applyEventsFilter)
.observe(document.getElementById('events-table-wrap'), { childList: true, subtree: true }); .observe(document.getElementById('events-table-wrap'), { childList: true, subtree: true });
// 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> </script>
{% endblock %} {% endblock %}
+48 -25
View File
@@ -3,12 +3,14 @@
{% block content %} {% block content %}
<div class="g-page-header"> <div class="lt-page-header">
<h1 class="g-page-title">Network Inspector</h1> <div>
<p class="g-page-sub"> <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. 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> </p>
</div>
</div> </div>
<div class="inspector-layout"> <div class="inspector-layout">
@@ -114,8 +116,8 @@ function portBlockHtml(idx, port, swName, sfpBlock) {
const speedHtml = speedTxt ? `<span class="port-speed">${speedTxt}</span>` : ''; const speedHtml = speedTxt ? `<span class="port-speed">${speedTxt}</span>` : '';
return `<div class="switch-port-block ${state}${sfpCls}" return `<div class="switch-port-block ${state}${sfpCls}"
data-switch="${escHtml(swName)}" data-port-idx="${idx}" data-switch="${escHtml(swName)}" data-port-idx="${idx}"
title="${title}" title="${title}" aria-label="${title}"
onclick="selectPort(this)"><span class="port-num">${numLabel}</span>${speedHtml}${lldpHtml}</div>`; data-action="select-port"><span class="port-num">${numLabel}</span>${speedHtml}${lldpHtml}</div>`;
} }
// ── Chassis legend HTML ────────────────────────────────────────────────── // ── Chassis legend HTML ──────────────────────────────────────────────────
@@ -169,8 +171,8 @@ function renderChassis(swName, sw) {
const lldpHtml = lldpName ? `<span class="port-lldp">${lldpName}</span>` : ''; const lldpHtml = lldpName ? `<span class="port-lldp">${lldpName}</span>` : '';
chassisHtml += `<div class="switch-port-block ${state}${sfpCls}" chassisHtml += `<div class="switch-port-block ${state}${sfpCls}"
data-switch="${escHtml(swName)}" data-port-idx="${idx}" data-switch="${escHtml(swName)}" data-port-idx="${idx}"
title="${title}" title="${title}" aria-label="${title}"
onclick="selectPort(this)"><span class="port-num">${idx}</span>${speedHtml}${lldpHtml}</div>`; data-action="select-port"><span class="port-num">${idx}</span>${speedHtml}${lldpHtml}</div>`;
} }
chassisHtml += '</div>'; chassisHtml += '</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 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>` : ''; const poeCurStr = (d.poe_power != null && d.poe_power > 0) ? ` / draw <span class="val-amber">${d.poe_power.toFixed(1)}W</span>` : '';
poeHtml = ` 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> <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_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>` : ''}`; ${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 = ''; let trafficHtml = '';
if (d.tx_bytes_rate != null || d.rx_bytes_rate != null) { if (d.tx_bytes_rate != null || d.rx_bytes_rate != null) {
trafficHtml = ` trafficHtml = `
<div class="panel-section-title">Traffic</div> <div class="lt-divider"><span class="lt-divider-label">Traffic</span></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" 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">RX</span><span class="panel-val">${fmtRate(d.rx_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 // Errors / drops section
let errHtml = ''; let errHtml = '';
if (d.tx_errs_rate != null || d.rx_errs_rate != null) { if (d.tx_errs_rate != null || d.rx_errs_rate != null) {
errHtml = ` 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">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">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> <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) { if (d.lldp && d.lldp.system_name) {
const l = d.lldp; const l = d.lldp;
lldpHtml = ` 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> <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_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>` : ''} ${l.port_desc ? `<div class="panel-row"><span class="panel-label">Port Desc</span><span class="panel-val">${escHtml(l.port_desc)}</span></div>` : ''}
@@ -318,7 +320,7 @@ function renderPanel(swName, idx) {
_apiData.hosts && _apiData.hosts[d.lldp.system_name]); _apiData.hosts && _apiData.hosts[d.lldp.system_name]);
const diagHtml = hasDiagTarget ? ` const diagHtml = hasDiagTarget ? `
<div class="diag-bar"> <div class="diag-bar">
<button class="btn-diag" onclick="runDiagnostic('${escHtml(swName)}', ${idx})">Run Link Diagnostics</button> <button class="btn-diag" data-action="run-diagnostic" data-sw="${escHtml(swName)}" data-idx="${idx}">Run Link Diagnostics</button>
<span class="diag-status" id="diag-status"></span> <span class="diag-status" id="diag-status"></span>
</div> </div>
<div class="diag-results" id="diag-results"></div>` : ''; <div class="diag-results" id="diag-results"></div>` : '';
@@ -330,14 +332,14 @@ function renderPanel(swName, idx) {
<span class="panel-port-name">${escHtml(d.name)}</span>${isUplinkBadge} <span class="panel-port-name">${escHtml(d.name)}</span>${isUplinkBadge}
<div class="panel-meta">${escHtml(swName)} · port #${idx}</div> <div class="panel-meta">${escHtml(swName)} · port #${idx}</div>
</div> </div>
<button class="panel-close" onclick="closePanel()">✕</button> <button class="panel-close" data-action="close-panel" aria-label="Close panel">✕</button>
</div> </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">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">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" 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">Auto-neg</span><span class="panel-val val-neutral">${autoneg}</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> <div class="panel-row"><span class="panel-label">Media</span><span class="panel-val">${escHtml(mediaStr)}</span></div>
${poeHtml} ${poeHtml}
@@ -392,7 +394,7 @@ function buildPathDebug(swName, swPort, serverName, ifaceName, svrData) {
: ''; : '';
return ` 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} ${duplexWarnHtml}${speedWarnHtml}
<div class="path-debug-cols"> <div class="path-debug-cols">
<div class="path-col"> <div class="path-col">
@@ -425,9 +427,10 @@ function renderInspector(data) {
const main = document.getElementById('inspector-main'); const main = document.getElementById('inspector-main');
const switches = data.unifi_switches || {}; const switches = data.unifi_switches || {};
const upd = data.updated ? `Updated: ${data.updated}` : '';
const updEl = document.getElementById('inspector-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) { if (!Object.keys(switches).length) {
main.innerHTML = '<p class="empty-state">No switch data available. Monitor may still be initialising.</p>'; main.innerHTML = '<p class="empty-state">No switch data available. Monitor may still be initialising.</p>';
@@ -463,11 +466,31 @@ async function loadInspector() {
} }
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', () => { lt.keys.on('Escape', () => {
if (document.getElementById('inspector-panel').classList.contains('open')) closePanel(); if (document.getElementById('inspector-panel').classList.contains('open')) closePanel();
}); });
document.addEventListener('click', e => {
const portBlock = e.target.closest('[data-action="select-port"]');
if (portBlock) { selectPort(portBlock); return; }
const closeBtn = e.target.closest('[data-action="close-panel"]');
if (closeBtn) { closePanel(); return; }
const diagBtn = e.target.closest('[data-action="run-diagnostic"]');
if (diagBtn) { runDiagnostic(diagBtn.dataset.sw, parseInt(diagBtn.dataset.idx, 10)); return; }
const toggleDiag = e.target.closest('[data-action="toggle-diag"]');
if (toggleDiag) { toggleDiag.parentElement.classList.toggle('diag-open'); return; }
});
// ── Link Diagnostics ───────────────────────────────────────────────── // ── Link Diagnostics ─────────────────────────────────────────────────
let _diagPollTimer = null; let _diagPollTimer = null;
@@ -635,7 +658,7 @@ function renderDiagnosticResults(d, container) {
}).join(''); }).join('');
nicStatHtml = ` nicStatHtml = `
<div class="diag-section diag-collapsible"> <div class="diag-section diag-collapsible">
<div class="diag-section-header diag-toggle" onclick="this.parentElement.classList.toggle('diag-open')"> <div class="diag-section-header diag-toggle" data-action="toggle-diag">
ethtool -S (NIC stats) <span class="diag-toggle-hint">[expand]</span> ethtool -S (NIC stats) <span class="diag-toggle-hint">[expand]</span>
</div> </div>
<div class="diag-section-body"> <div class="diag-section-body">
@@ -711,7 +734,7 @@ function renderDiagnosticResults(d, container) {
}).join(''); }).join('');
dmesgHtml = ` dmesgHtml = `
<div class="diag-section diag-collapsible"> <div class="diag-section diag-collapsible">
<div class="diag-section-header diag-toggle" onclick="this.parentElement.classList.toggle('diag-open')"> <div class="diag-section-header diag-toggle" data-action="toggle-diag">
Kernel Events (dmesg) <span class="diag-toggle-hint">[expand]</span> Kernel Events (dmesg) <span class="diag-toggle-hint">[expand]</span>
</div> </div>
<div class="diag-section-body"> <div class="diag-section-body">
+115 -52
View File
@@ -3,13 +3,27 @@
{% block content %} {% block content %}
<div class="g-page-header"> <div class="lt-page-header">
<h1 class="g-page-title">Link Debug</h1> <div>
<p class="g-page-sub"> <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. 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:8px"></span>
<span id="links-updated" style="margin-left:10px; color:var(--text-muted); font-size:.85em;"></span>
</p> </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>
<div id="links-container"> <div id="links-container">
@@ -38,6 +52,12 @@ function fmtRateBar(bytesPerSec, linkSpeedMbps) {
return Math.min(100, (mbps / linkSpeedMbps) * 100); 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) { function fmtSpeed(mbps) {
if (mbps === null || mbps === undefined) return ''; if (mbps === null || mbps === undefined) return '';
if (mbps >= 1000) return (mbps/1000).toFixed(0) + ' Gbps'; if (mbps >= 1000) return (mbps/1000).toFixed(0) + ' Gbps';
@@ -147,26 +167,26 @@ function renderIfaceCard(ifaceName, d) {
</div> </div>
<div class="sfp-grid"> <div class="sfp-grid">
<div class="sfp-stat"> <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> <span class="sfp-stat-value ${tmpClass}">${fmtTemp(s.temp_c)}</span>
</div> </div>
<div class="sfp-stat"> <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> <span class="sfp-stat-value ${vClass}">${fmtVoltage(s.voltage_v)}</span>
</div> </div>
<div class="sfp-stat"> <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> <span class="sfp-stat-value">${fmtBias(s.bias_ma)}</span>
</div> </div>
<div class="sfp-stat"> <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> <span class="sfp-stat-value ${txClass}">${fmtPower(s.tx_power_dbm)}</span>
<div class="power-row"> <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 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> </div>
<div class="sfp-stat"> <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> <span class="sfp-stat-value ${rxClass}">${fmtPower(s.rx_power_dbm)}</span>
<div class="power-row"> <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> <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> </div>
${s.rx_power_dbm != null && s.tx_power_dbm != null ? ` ${s.rx_power_dbm != null && s.tx_power_dbm != null ? `
<div class="sfp-stat"> <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> <span class="sfp-stat-value">${(s.rx_power_dbm - s.tx_power_dbm).toFixed(2)} dBm</span>
</div>` : ''} </div>` : ''}
</div> </div>
@@ -195,43 +215,43 @@ function renderIfaceCard(ifaceName, d) {
<span class="link-stat-value ${isDown ? 'val-crit' : 'val-good'}">${isDown ? 'DOWN' : 'UP'}</span> <span class="link-stat-value ${isDown ? 'val-crit' : 'val-good'}">${isDown ? 'DOWN' : 'UP'}</span>
</div> </div>
<div class="link-stat"> <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> <span class="link-stat-value">${fmtDuplex(d.duplex)}</span>
</div> </div>
<div class="link-stat"> <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> <span class="link-stat-value">${d.auto_neg == null ? '' : d.auto_neg ? 'On' : 'Off'}</span>
</div> </div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">Carrier Δ</span> <span class="link-stat-label" 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> <span class="link-stat-value">${fmtCarrier(d.carrier_changes)}</span>
</div> </div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">TX Err/s</span> <span class="link-stat-label" 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> <span class="link-stat-value">${fmtErrors(d.tx_errs_rate)}</span>
</div> </div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">RX Err/s</span> <span class="link-stat-label" 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> <span class="link-stat-value">${fmtErrors(d.rx_errs_rate)}</span>
</div> </div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">TX Drop/s</span> <span class="link-stat-label" 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> <span class="link-stat-value">${fmtErrors(d.tx_drops_rate)}</span>
</div> </div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">RX Drop/s</span> <span class="link-stat-label" 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> <span class="link-stat-value">${fmtErrors(d.rx_drops_rate)}</span>
</div> </div>
</div> </div>
<div class="traffic-section"> <div class="traffic-section">
<div class="traffic-row"> <div class="traffic-row">
<span class="traffic-label">TX</span> <span class="traffic-label" data-tooltip="Transmit — outgoing traffic from this server">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> <span class="traffic-value">${fmtRate(d.tx_bytes_rate)}</span>
</div> </div>
<div class="traffic-row"> <div class="traffic-row">
<span class="traffic-label">RX</span> <span class="traffic-label" data-tooltip="Receive — incoming traffic to this server">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> <span class="traffic-value">${fmtRate(d.rx_bytes_rate)}</span>
</div> </div>
</div> </div>
@@ -289,12 +309,12 @@ function renderPortCard(portName, d) {
<div class="traffic-section"> <div class="traffic-section">
<div class="traffic-row"> <div class="traffic-row">
<span class="traffic-label">TX</span> <span class="traffic-label">TX</span>
<div class="traffic-bar-track"><div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div></div> <div class="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> <span class="traffic-value">${fmtRate(d.tx_bytes_rate)}</span>
</div> </div>
<div class="traffic-row"> <div class="traffic-row">
<span class="traffic-label">RX</span> <span class="traffic-label">RX</span>
<div class="traffic-bar-track"><div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div></div> <div class="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> <span class="traffic-value">${fmtRate(d.rx_bytes_rate)}</span>
</div> </div>
</div> </div>
@@ -327,7 +347,7 @@ function renderUnifiSwitches(unifiSwitches, dataUpdated) {
return ` return `
<div class="link-host-panel" id="panel-${CSS.escape(swName)}"> <div class="link-host-panel" id="panel-${CSS.escape(swName)}">
<div class="link-host-title" onclick="togglePanel(this.closest('.link-host-panel'))"> <div class="link-host-title" data-action="toggle-panel">
<span class="link-host-name">${escHtml(swName)}</span> <span class="link-host-name">${escHtml(swName)}</span>
<span class="link-host-ip">${escHtml(sw.ip || '')}</span> <span class="link-host-ip">${escHtml(sw.ip || '')}</span>
<span class="link-host-upd">${escHtml(sw.model || '')}${updStr ? ' · ' + updStr : ''}${poeLoad}</span> <span class="link-host-upd">${escHtml(sw.model || '')}${updStr ? ' · ' + updStr : ''}${poeLoad}</span>
@@ -377,33 +397,51 @@ function buildLinkSummary(hosts, unifiSwitches) {
if ((d.tx_errs_rate || 0) > 0 || (d.rx_errs_rate || 0) > 0) errIfaces++; 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 sw of Object.values(unifiSwitches || {})) {
for (const p of Object.values(sw.ports || {})) { for (const p of Object.values(sw.ports || {})) {
totalPoe += p.poe_power || 0; 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 ` return `
<div class="link-summary-panel ${hasAlerts ? 'link-summary-has-alerts' : ''}"> <div class="lt-stats-grid" style="margin-bottom:16px">
<div class="link-summary-grid"> <div class="lt-stat-card">
<div class="link-summary-stat"> <span class="lt-stat-icon" aria-hidden="true" style="color:var(--cyan)">⬡</span>
<span class="lss-label">Total Interfaces</span> <div class="lt-stat-info">
<span class="lss-value">${totalIfaces}</span> <span class="lt-stat-value" style="color:var(--cyan)">${allTotal}</span>
<span class="lt-stat-label">Interfaces</span>
</div> </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>
<div class="link-summary-stat ${errIfaces ? 'lss-alert' : ''}"> <div class="lt-stat-card${downCardCls}">
<span class="lss-label">With Errors</span> <span class="lt-stat-icon" aria-hidden="true" style="color:${downColor}">●</span>
<span class="lss-value ${errIfaces ? 'val-warn' : 'val-good'}">${errIfaces}</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> </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>
<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>`; </div>`;
} }
@@ -414,10 +452,6 @@ function renderLinks(data) {
const parts = []; const parts = [];
parts.push(buildLinkSummary(hosts, unifiSwitches)); parts.push(buildLinkSummary(hosts, unifiSwitches));
parts.push(`<div class="link-collapse-bar">
<button class="lt-btn lt-btn-ghost lt-btn-sm" onclick="collapseAll()">Collapse All</button>
<button class="lt-btn lt-btn-ghost lt-btn-sm" onclick="expandAll()">Expand All</button>
</div>`);
parts.push('<div class="link-host-list">'); parts.push('<div class="link-host-list">');
for (const [hostname, ifaces] of Object.entries(hosts)) { for (const [hostname, ifaces] of Object.entries(hosts)) {
@@ -426,13 +460,13 @@ function renderLinks(data) {
.map(([iname, d]) => renderIfaceCard(iname, d)).join(''); .map(([iname, d]) => renderIfaceCard(iname, d)).join('');
const sample = Object.values(ifaces)[0] || {}; const sample = Object.values(ifaces)[0] || {};
const ip = sample.host_ip || ''; const ip = sample.host_ip || '';
const updStr = sample.updated const updStr = data.updated
? new Date(sample.updated + (sample.updated.includes('Z') ? '' : 'Z')).toLocaleTimeString() ? new Date(data.updated.replace(' UTC', 'Z').replace(' ', 'T')).toLocaleTimeString()
: ''; : '';
parts.push(` parts.push(`
<div class="link-host-panel" id="panel-${CSS.escape(hostname)}"> <div class="link-host-panel" id="panel-${CSS.escape(hostname)}">
<div class="link-host-title" onclick="togglePanel(this.closest('.link-host-panel'))"> <div class="link-host-title" data-action="toggle-panel">
<span class="link-host-name">${escHtml(hostname)}</span> <span class="link-host-name">${escHtml(hostname)}</span>
<span class="link-host-ip">${escHtml(ip)}</span> <span class="link-host-ip">${escHtml(ip)}</span>
<span class="link-host-upd">${updStr}</span> <span class="link-host-upd">${updStr}</span>
@@ -446,6 +480,17 @@ function renderLinks(data) {
parts.push('</div>'); parts.push('</div>');
document.getElementById('links-container').innerHTML = parts.join(''); document.getElementById('links-container').innerHTML = parts.join('');
restoreCollapseState(); 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() { function collapseAll() {
@@ -477,10 +522,12 @@ function checkLinksStale(updatedStr) {
if (!banner) { if (!banner) {
banner = document.createElement('div'); banner = document.createElement('div');
banner.id = 'links-stale-banner'; 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); 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 = ''; banner.style.display = '';
} else if (banner) { } else if (banner) {
banner.style.display = 'none'; banner.style.display = 'none';
@@ -510,6 +557,22 @@ async function loadLinks() {
} }
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"]');
if (toggleTitle) { togglePanel(toggleTitle.closest('.link-host-panel')); return; }
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> </script>
{% endblock %} {% endblock %}
+48 -12
View File
@@ -3,9 +3,11 @@
{% block content %} {% block content %}
<div class="g-page-header"> <div class="lt-page-header">
<h1 class="g-page-title">Alert Suppressions</h1> <div>
<p class="g-page-sub">Manage maintenance windows and per-target alert suppression rules.</p> <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> </div>
<!-- ── Create suppression ─────────────────────────────────────────── --> <!-- ── Create suppression ─────────────────────────────────────────── -->
@@ -15,11 +17,11 @@
</div> </div>
<div class="lt-card"> <div class="lt-card">
<div class="lt-card-body"> <div class="lt-card-body">
<form id="create-suppression-form" onsubmit="createSuppression(event)"> <form id="create-suppression-form">
<div class="form-row"> <div class="form-row">
<div class="lt-form-group"> <div class="lt-form-group">
<label class="lt-label" for="s-type">Target Type <span class="required">*</span></label> <label class="lt-label" for="s-type">Target Type <span class="required">*</span></label>
<select class="lt-select" id="s-type" name="target_type" onchange="onTypeChange()"> <select class="lt-select" id="s-type" name="target_type">
<option value="host">Host (all interfaces)</option> <option value="host">Host (all interfaces)</option>
<option value="interface">Specific Interface</option> <option value="interface">Specific Interface</option>
<option value="unifi_device">UniFi Device</option> <option value="unifi_device">UniFi Device</option>
@@ -83,6 +85,10 @@
</div> </div>
<div id="active-sup-wrap"> <div id="active-sup-wrap">
{% if active %} {% 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"> <div class="lt-table-wrap">
<table class="lt-table" id="active-sup-table"> <table class="lt-table" id="active-sup-table">
<caption class="lt-sr-only">Active suppression rules</caption> <caption class="lt-sr-only">Active suppression rules</caption>
@@ -95,7 +101,8 @@
<tbody> <tbody>
{% for s in active %} {% for s in active %}
<tr id="sup-row-{{ s.id }}"> <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_name or 'all' }}</td>
<td>{{ s.target_detail or '' }}</td> <td>{{ s.target_detail or '' }}</td>
<td>{{ s.reason }}</td> <td>{{ s.reason }}</td>
@@ -110,8 +117,13 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
{% else %} {% 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 %} {% endif %}
</div> </div>
</section> </section>
@@ -123,6 +135,10 @@
<span class="g-section-badge">{{ history | length }}</span> <span class="g-section-badge">{{ history | length }}</span>
</div> </div>
{% if history %} {% 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"> <div class="lt-table-wrap">
<table class="lt-table lt-table-sm"> <table class="lt-table lt-table-sm">
<caption class="lt-sr-only">Suppression history</caption> <caption class="lt-sr-only">Suppression history</caption>
@@ -154,8 +170,12 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
{% else %} {% 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 %} {% endif %}
</section> </section>
@@ -164,6 +184,11 @@
<div class="g-section-header"> <div class="g-section-header">
<h2 class="g-section-title">Available Targets</h2> <h2 class="g-section-title">Available Targets</h2>
</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">Host &amp; Interface Reference</div>
<div style="padding:12px 14px">
<div class="targets-grid"> <div class="targets-grid">
{% for name, host in snapshot.hosts.items() %} {% for name, host in snapshot.hosts.items() %}
<div class="target-card"> <div class="target-card">
@@ -179,6 +204,8 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
</div>
</div>
</section> </section>
{% endblock %} {% endblock %}
@@ -209,14 +236,15 @@
const wrap = document.getElementById('active-sup-wrap'); const wrap = document.getElementById('active-sup-wrap');
const badge = document.getElementById('active-sup-badge'); const badge = document.getElementById('active-sup-badge');
if (!rows || !rows.length) { 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'; if (badge) badge.textContent = '0';
return; return;
} }
if (badge) badge.textContent = rows.length; 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 => ` const tbody = rows.map(s => `
<tr id="sup-row-${s.id}"> <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_name || 'all')}</td>
<td>${lt.escHtml(s.target_detail || '')}</td> <td>${lt.escHtml(s.target_detail || '')}</td>
<td>${lt.escHtml(s.reason)}</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> <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(''); </tr>`).join('');
wrap.innerHTML = ` 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"> <div class="lt-table-wrap">
<table class="lt-table" id="active-sup-table"> <table class="lt-table" id="active-sup-table">
<caption class="lt-sr-only">Active suppression rules</caption> <caption class="lt-sr-only">Active suppression rules</caption>
@@ -235,6 +267,7 @@
</tr></thead> </tr></thead>
<tbody>${tbody}</tbody> <tbody>${tbody}</tbody>
</table> </table>
</div>
</div>`; </div>`;
} }
@@ -243,7 +276,7 @@
const rows = await lt.api.get('/api/suppressions'); const rows = await lt.api.get('/api/suppressions');
renderActiveRows(rows); renderActiveRows(rows);
} catch (err) { } 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'); const tbody = document.querySelector('#active-sup-table tbody');
if (tbody && !tbody.children.length) { if (tbody && !tbody.children.length) {
document.getElementById('active-sup-wrap').innerHTML = 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'; if (badge) badge.textContent = '0';
} }
showToast('Suppression removed', 'success'); showToast('Suppression removed', 'success');
@@ -294,6 +327,9 @@
} }
} }
document.getElementById('s-type')?.addEventListener('change', onTypeChange);
document.getElementById('create-suppression-form')?.addEventListener('submit', createSuppression);
document.addEventListener('click', e => { document.addEventListener('click', e => {
const pill = e.target.closest('#create-suppression-form .pill[data-duration]'); const pill = e.target.closest('#create-suppression-form .pill[data-duration]');
if (pill) { if (pill) {