Compare commits

...

6 Commits

Author SHA1 Message Date
jared 678ede4e76 refactor: replace inline onclick with data-action event delegation
Lint / Python (flake8) (push) Successful in 42s
Lint / JS (eslint) (push) Successful in 8s
Security / Python Security (bandit) (push) Successful in 1m0s
Test / Python Tests (pytest) (push) Successful in 50s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 2s
The command palette button used an inline onclick handler while every
other interactive element in base.html uses data-action + event
delegation. Now consistent: data-action="open-cmdpalette" handled in
the global footer click listener.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:45:09 -04:00
jared b51b39c3a7 a11y: keyboard-accessible panel toggles, region landmarks in inspector
Lint / Python (flake8) (push) Successful in 43s
Lint / JS (eslint) (push) Successful in 14s
Security / Python Security (bandit) (push) Successful in 45s
Test / Python Tests (pytest) (push) Successful in 50s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 3s
- Add role="button" tabindex="0" aria-expanded to .link-host-title
  in both static and JS-rendered panels (host panels + UniFi switches)
- Sync aria-expanded in togglePanel(), restoreCollapseState(),
  collapseAll(), and expandAll()
- Add keydown handler (Enter/Space) so panel headers are keyboard-operable
- Add role="region" aria-label to inspector main chassis area
- Add role="complementary" aria-label to inspector port detail panel
- Replace last inline date-parse in renderLinks() with _toIso() helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:44:23 -04:00
jared 41695a3faa security: escape user input in 403 error response to prevent XSS
Lint / Python (flake8) (push) Successful in 40s
Lint / JS (eslint) (push) Successful in 9s
Security / Python Security (bandit) (push) Successful in 1m0s
Test / Python Tests (pytest) (push) Successful in 51s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 2s
The require_auth decorator was interpolating user['username'] and the
allowed_groups list directly into HTML strings. An attacker with a
crafted username or control over group names could inject arbitrary HTML.

Use html.escape() on both values before insertion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:41:31 -04:00
jared c0e59cfa9e refactor: extract _annotate_suppressions helper, remove orphaned CSS
Lint / Python (flake8) (push) Successful in 45s
Lint / JS (eslint) (push) Successful in 8s
Security / Python Security (bandit) (push) Successful in 1m0s
Test / Python Tests (pytest) (push) Successful in 57s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 2s
- Extract identical suppression-annotation loop from index() and
  api_status() into _annotate_suppressions() helper to eliminate DRY
  violation
- Improve stuck-job error message: 'thread crash' → 'no activity for
  5 minutes' (less alarming, more accurate)
- Remove orphaned .events-filter-bar CSS class (never referenced in
  any template or JS file)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:39:52 -04:00
jared 7ab85cd055 refactor: const/let modernisation and eliminate duplicate date-parse logic
Lint / Python (flake8) (push) Successful in 47s
Lint / JS (eslint) (push) Successful in 9s
Security / Python Security (bandit) (push) Successful in 1m7s
Test / Python Tests (pytest) (push) Successful in 58s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 4s
- Replace all var declarations in base.html, index.html scripts with
  const/let (const for bindings that are never reassigned, let otherwise)
- Add _toIso() helper to links.html script block and replace the two
  inline .replace(' UTC','Z').replace(' ','T') patterns with it
- Replace var with const in links.html _linksInterval

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:37:32 -04:00
jared 68f59c49a2 a11y: aria-pressed for all pill groups, aria-label on search inputs and buttons
Lint / Python (flake8) (push) Successful in 46s
Lint / JS (eslint) (push) Successful in 10s
Security / Python Security (bandit) (push) Successful in 51s
Test / Python Tests (pytest) (push) Successful in 1m8s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 4s
- Add role="group" + aria-label to duration-pills and sev-pills containers
- Add aria-pressed to severity filter, duration, and refresh-interval pills
- Keep aria-pressed in sync with JS (setDuration, applyRefreshPillUI, modal reset)
- Add aria-label to events-search, host-search, links-search inputs
- Add aria-label to host and UniFi device suppress buttons in templates
- Replace dynamic style color strings in links.html stat cards with TDS
  utility classes (lt-text-red/green/amber) via downCls/errCls variables

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:34:16 -04:00
8 changed files with 166 additions and 124 deletions
+29 -25
View File
@@ -5,6 +5,7 @@ management UI. Authentication via Authelia forward-auth headers.
All monitoring and alerting is handled by the separate monitor.py daemon. All monitoring and alerting is handled by the separate monitor.py daemon.
""" """
import hashlib import hashlib
import html
import ipaddress import ipaddress
import json import json
import logging import logging
@@ -73,8 +74,8 @@ def _purge_old_jobs_loop():
for jid, j in list(_diag_jobs.items()): for jid, j in list(_diag_jobs.items()):
if j['status'] == 'running' and j.get('created_at', 0) < stuck_cutoff: if j['status'] == 'running' and j.get('created_at', 0) < stuck_cutoff:
j['status'] = 'done' j['status'] = 'done'
j['result'] = {'status': 'error', 'error': 'Diagnostic timed out (thread crash)'} j['result'] = {'status': 'error', 'error': 'Diagnostic abandoned — no activity for 5 minutes.'}
logger.error(f'Diagnostic job {jid} appeared stuck; marked as errored') logger.error(f'Diagnostic job {jid} stuck (no activity for 5 min); marked done/error')
_purge_thread = threading.Thread(target=_purge_old_jobs_loop, daemon=True) _purge_thread = threading.Thread(target=_purge_old_jobs_loop, daemon=True)
@@ -132,10 +133,12 @@ def require_auth(f):
) )
allowed = _config().get('auth', {}).get('allowed_groups', ['admin']) allowed = _config().get('auth', {}).get('allowed_groups', ['admin'])
if not any(g in allowed for g in user['groups']): if not any(g in allowed for g in user['groups']):
safe_user = html.escape(user['username'])
safe_groups = html.escape(', '.join(allowed))
return ( return (
f'<h1>403 Access denied</h1>' f'<h1>403 Access denied</h1>'
f'<p>Your account ({user["username"]}) is not in an allowed group ' f'<p>Your account ({safe_user}) is not in an allowed group '
f'({", ".join(allowed)}).</p>', f'({safe_groups}).</p>',
403, 403,
) )
return f(*args, **kwargs) return f(*args, **kwargs)
@@ -143,12 +146,31 @@ def require_auth(f):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Page routes # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_PAGE_LIMIT = 200 # max events returned per request _PAGE_LIMIT = 200 # max events returned per request
def _annotate_suppressions(events: list, suppressions: list) -> None:
"""Annotate each event dict in-place with an is_suppressed bool."""
for ev in events:
sup_type = (
'unifi_device' if ev.get('event_type') == 'unifi_device_offline'
else 'interface' if ev.get('event_type') == 'interface_down'
else 'host'
)
ev['is_suppressed'] = db.check_suppressed(
suppressions, sup_type,
ev.get('target_name', ''), ev.get('target_detail', '') or '',
)
# ---------------------------------------------------------------------------
# Page routes
# ---------------------------------------------------------------------------
@app.route('/') @app.route('/')
@require_auth @require_auth
def index(): def index():
@@ -160,16 +182,7 @@ def index():
last_check = db.get_state('last_check', 'Never') last_check = db.get_state('last_check', 'Never')
snapshot = json.loads(snapshot_raw) if snapshot_raw else {} snapshot = json.loads(snapshot_raw) if snapshot_raw else {}
suppressions = db.get_active_suppressions() suppressions = db.get_active_suppressions()
for ev in events: _annotate_suppressions(events, suppressions)
sup_type = (
'unifi_device' if ev.get('event_type') == 'unifi_device_offline'
else 'interface' if ev.get('event_type') == 'interface_down'
else 'host'
)
ev['is_suppressed'] = db.check_suppressed(
suppressions, sup_type,
ev.get('target_name', ''), ev.get('target_detail', '') or '',
)
recent_resolved = db.get_recent_resolved(hours=24, limit=10) recent_resolved = db.get_recent_resolved(hours=24, limit=10)
return render_template( return render_template(
'index.html', 'index.html',
@@ -225,16 +238,7 @@ def suppressions_page():
def api_status(): def api_status():
active = db.get_active_events(limit=_PAGE_LIMIT) active = db.get_active_events(limit=_PAGE_LIMIT)
suppressions = db.get_active_suppressions() suppressions = db.get_active_suppressions()
for ev in active: _annotate_suppressions(active, suppressions)
sup_type = (
'unifi_device' if ev.get('event_type') == 'unifi_device_offline'
else 'interface' if ev.get('event_type') == 'interface_down'
else 'host'
)
ev['is_suppressed'] = db.check_suppressed(
suppressions, sup_type,
ev.get('target_name', ''), ev.get('target_detail', '') or '',
)
last_check = db.get_state('last_check', 'Never') last_check = db.get_state('last_check', 'Never')
return jsonify({ return jsonify({
'summary': db.get_status_summary(), 'summary': db.get_status_summary(),
+10 -4
View File
@@ -276,9 +276,12 @@ function openSuppressModal(type, name, detail) {
updateSuppressForm(); updateSuppressForm();
lt.modal.open('suppress-modal'); lt.modal.open('suppress-modal');
document.querySelectorAll('#suppress-modal .pill').forEach(p => p.classList.remove('active')); document.querySelectorAll('#suppress-modal .pill').forEach(p => {
p.classList.remove('active');
p.setAttribute('aria-pressed', 'false');
});
const manualPill = document.querySelector('#suppress-modal .pill-manual'); const manualPill = document.querySelector('#suppress-modal .pill-manual');
if (manualPill) manualPill.classList.add('active'); if (manualPill) { manualPill.classList.add('active'); manualPill.setAttribute('aria-pressed', 'true'); }
const hint = document.getElementById('duration-hint'); const hint = document.getElementById('duration-hint');
if (hint) hint.textContent = 'Suppression will persist until manually removed.'; if (hint) hint.textContent = 'Suppression will persist until manually removed.';
} }
@@ -297,8 +300,11 @@ function updateSuppressForm() {
function setDuration(mins, el) { function setDuration(mins, el) {
document.getElementById('sup-expires').value = mins || ''; document.getElementById('sup-expires').value = mins || '';
document.querySelectorAll('#suppress-modal .pill').forEach(p => p.classList.remove('active')); document.querySelectorAll('#suppress-modal .pill').forEach(p => {
if (el) el.classList.add('active'); p.classList.remove('active');
p.setAttribute('aria-pressed', 'false');
});
if (el) { el.classList.add('active'); el.setAttribute('aria-pressed', 'true'); }
const hint = document.getElementById('duration-hint'); const hint = document.getElementById('duration-hint');
if (hint) { if (hint) {
if (mins) { if (mins) {
-2
View File
@@ -214,8 +214,6 @@
padding: 1px 7px; padding: 1px 7px;
} }
.g-section-actions { margin-left: auto; } .g-section-actions { margin-left: auto; }
.events-filter-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.events-filter-bar .lt-input-sm { width: 220px; }
.sev-pills { display: flex; gap: 4px; } .sev-pills { display: flex; gap: 4px; }
.g-page-sub { font-size: .78em; color: var(--text-muted); margin-top: 4px; } .g-page-sub { font-size: .78em; color: var(--text-muted); margin-top: 4px; }
.g-page-sub-aside { font-size: .78em; color: var(--text-muted); margin-left: 8px; } .g-page-sub-aside { font-size: .78em; color: var(--text-muted); margin-left: 8px; }
+48 -45
View File
@@ -144,9 +144,9 @@
<!-- ⌘K affordance --> <!-- ⌘K affordance -->
<button type="button" <button type="button"
class="lt-btn lt-btn-ghost lt-btn-sm lt-cmd-hint-btn" class="lt-btn lt-btn-ghost lt-btn-sm lt-cmd-hint-btn"
data-action="open-cmdpalette"
title="Command palette (Ctrl+K)" title="Command palette (Ctrl+K)"
aria-label="Open command palette" aria-label="Open command palette">&#x2315;&nbsp;K</button>
onclick="if(window.lt&&lt.cmdPalette)lt.cmdPalette.open()">&#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>
@@ -231,12 +231,12 @@
</div> </div>
<div class="lt-form-group lt-form-group--last"> <div class="lt-form-group lt-form-group--last">
<label class="lt-label">Duration</label> <label class="lt-label">Duration</label>
<div class="duration-pills"> <div class="duration-pills" role="group" aria-label="Select suppression duration">
<button type="button" class="pill" data-duration="30">30 min</button> <button type="button" class="pill" data-duration="30" aria-pressed="false">30 min</button>
<button type="button" class="pill" data-duration="60">1 hr</button> <button type="button" class="pill" data-duration="60" aria-pressed="false">1 hr</button>
<button type="button" class="pill" data-duration="240">4 hr</button> <button type="button" class="pill" data-duration="240" aria-pressed="false">4 hr</button>
<button type="button" class="pill" data-duration="480">8 hr</button> <button type="button" class="pill" data-duration="480" aria-pressed="false">8 hr</button>
<button type="button" class="pill pill-manual active" data-duration="">Manual &#x221E;</button> <button type="button" class="pill pill-manual active" data-duration="" aria-pressed="true">Manual &#x221E;</button>
</div> </div>
<input type="hidden" id="sup-expires" name="expires_minutes" value=""> <input type="hidden" id="sup-expires" name="expires_minutes" value="">
<div class="lt-field-hint" id="duration-hint">Persists until manually removed.</div> <div class="lt-field-hint" id="duration-hint">Persists until manually removed.</div>
@@ -286,12 +286,12 @@
<div class="lt-modal-body"> <div class="lt-modal-body">
<div class="lt-form-group"> <div class="lt-form-group">
<label class="lt-label">Auto-refresh interval</label> <label class="lt-label">Auto-refresh interval</label>
<div class="duration-pills" id="settings-refresh-pills"> <div class="duration-pills" id="settings-refresh-pills" role="group" aria-label="Select auto-refresh interval">
<button type="button" class="pill" data-refresh-interval="15">15 s</button> <button type="button" class="pill" data-refresh-interval="15" aria-pressed="false">15 s</button>
<button type="button" class="pill" data-refresh-interval="30">30 s</button> <button type="button" class="pill" data-refresh-interval="30" aria-pressed="false">30 s</button>
<button type="button" class="pill" data-refresh-interval="60">1 min</button> <button type="button" class="pill" data-refresh-interval="60" aria-pressed="false">1 min</button>
<button type="button" class="pill" data-refresh-interval="300">5 min</button> <button type="button" class="pill" data-refresh-interval="300" aria-pressed="false">5 min</button>
<button type="button" class="pill" data-refresh-interval="0">Off</button> <button type="button" class="pill" data-refresh-interval="0" aria-pressed="false">Off</button>
</div> </div>
<div class="lt-field-hint" id="settings-refresh-hint"></div> <div class="lt-field-hint" id="settings-refresh-hint"></div>
</div> </div>
@@ -324,7 +324,7 @@
lt.init({ bootName: 'GANDALF' }); lt.init({ bootName: 'GANDALF' });
// Theme toggle // Theme toggle
var themeBtn = document.getElementById('lt-theme-btn'); const themeBtn = document.getElementById('lt-theme-btn');
if (themeBtn) themeBtn.addEventListener('click', function() { lt.theme.toggle(); }); if (themeBtn) themeBtn.addEventListener('click', function() { lt.theme.toggle(); });
// Command palette // Command palette
@@ -343,11 +343,12 @@
// ── Global footer + key actions ─────────────────────────────────────── // ── Global footer + key actions ───────────────────────────────────────
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action]'); const btn = e.target.closest('[data-action]');
if (!btn) return; if (!btn) return;
var action = btn.getAttribute('data-action'); const action = btn.getAttribute('data-action');
if (action === 'show-keyboard-help' && window.lt) lt.modal.open('lt-keys-help'); if (action === 'open-cmdpalette' && window.lt && lt.cmdPalette) lt.cmdPalette.open();
if (action === 'open-settings' && window.lt) lt.modal.open('lt-settings-modal'); 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.autoRefresh.now(); }); lt.keys.on('r', function() { lt.autoRefresh.now(); });
@@ -366,8 +367,8 @@
// ── Settings modal ──────────────────────────────────────────────────── // ── Settings modal ────────────────────────────────────────────────────
(function() { (function() {
var LS_KEY = 'gandalf_settings'; const LS_KEY = 'gandalf_settings';
var DEFAULT = { refreshInterval: 30 }; const DEFAULT = { refreshInterval: 30 };
function loadSettings() { function loadSettings() {
try { return Object.assign({}, DEFAULT, JSON.parse(localStorage.getItem(LS_KEY) || '{}')); } try { return Object.assign({}, DEFAULT, JSON.parse(localStorage.getItem(LS_KEY) || '{}')); }
@@ -381,9 +382,11 @@
function applyRefreshPillUI(interval) { function applyRefreshPillUI(interval) {
document.querySelectorAll('#settings-refresh-pills .pill').forEach(function(p) { document.querySelectorAll('#settings-refresh-pills .pill').forEach(function(p) {
p.classList.toggle('active', parseInt(p.dataset.refreshInterval) === interval); const isActive = parseInt(p.dataset.refreshInterval) === interval;
p.classList.toggle('active', isActive);
p.setAttribute('aria-pressed', isActive ? 'true' : 'false');
}); });
var hint = document.getElementById('settings-refresh-hint'); const hint = document.getElementById('settings-refresh-hint');
if (hint) { if (hint) {
if (interval === 0) hint.textContent = 'Auto-refresh disabled.'; if (interval === 0) hint.textContent = 'Auto-refresh disabled.';
else if (interval < 60) hint.textContent = 'Refreshes every ' + interval + ' seconds.'; else if (interval < 60) hint.textContent = 'Refreshes every ' + interval + ' seconds.';
@@ -392,16 +395,16 @@
} }
// Init pill UI from saved settings // Init pill UI from saved settings
var _settings = loadSettings(); const _settings = loadSettings();
applyRefreshPillUI(_settings.refreshInterval); applyRefreshPillUI(_settings.refreshInterval);
// Expose for pages that need to read it (e.g. index.html for autoRefresh) // Expose for pages that need to read it (e.g. index.html for autoRefresh)
window.gandalfSettings = _settings; window.gandalfSettings = _settings;
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
var pill = e.target.closest('#settings-refresh-pills .pill[data-refresh-interval]'); const pill = e.target.closest('#settings-refresh-pills .pill[data-refresh-interval]');
if (!pill) return; if (!pill) return;
var interval = parseInt(pill.dataset.refreshInterval); const interval = parseInt(pill.dataset.refreshInterval);
_settings.refreshInterval = interval; _settings.refreshInterval = interval;
saveSettings(_settings); saveSettings(_settings);
applyRefreshPillUI(interval); applyRefreshPillUI(interval);
@@ -410,16 +413,16 @@
// ── Notification Bell — shows active monitoring alerts ──────────────── // ── Notification Bell — shows active monitoring alerts ────────────────
(function() { (function() {
var bell = document.getElementById('lt-notif-bell'); const bell = document.getElementById('lt-notif-bell');
var panel = document.getElementById('lt-notif-panel'); const panel = document.getElementById('lt-notif-panel');
var list = document.getElementById('lt-notif-list'); const list = document.getElementById('lt-notif-list');
var clearBtn = document.getElementById('lt-notif-clear-btn'); const clearBtn = document.getElementById('lt-notif-clear-btn');
var wrapEl = document.getElementById('lt-notif-wrap'); const wrapEl = document.getElementById('lt-notif-wrap');
if (!bell || !panel) return; if (!bell || !panel) return;
var _open = false; let _open = false;
var _lastEvents = []; let _lastEvents = [];
var LS_READ_KEY = 'gandalf_notif_read_before'; const LS_READ_KEY = 'gandalf_notif_read_before';
function getReadBefore() { function getReadBefore() {
try { return parseInt(localStorage.getItem(LS_READ_KEY) || '0'); } catch(_) { return 0; } try { return parseInt(localStorage.getItem(LS_READ_KEY) || '0'); } catch(_) { return 0; }
@@ -438,20 +441,20 @@
} }
function fmtAgo(dateStr) { function fmtAgo(dateStr) {
var diff = Math.floor((Date.now() - toMs(dateStr)) / 1000); const diff = Math.floor((Date.now() - toMs(dateStr)) / 1000);
if (diff < 60) return diff + 's ago'; if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff/60) + 'm ago'; if (diff < 3600) return Math.floor(diff/60) + 'm ago';
if (diff < 86400) return Math.floor(diff/3600) + 'h ago'; if (diff < 86400) return Math.floor(diff/3600) + 'h ago';
return Math.floor(diff/86400) + 'd ago'; return Math.floor(diff/86400) + 'd ago';
} }
var SEV_DOT = { critical: 'var(--red)', warning: 'var(--amber)' }; const SEV_DOT = { critical: 'var(--red)', warning: 'var(--amber)' };
function renderAlerts(events) { function renderAlerts(events) {
_lastEvents = events || []; _lastEvents = events || [];
var readBefore = getReadBefore(); const readBefore = getReadBefore();
var active = _lastEvents.filter(function(e) { return e.severity !== 'info'; }); const active = _lastEvents.filter(function(e) { return e.severity !== 'info'; });
var unreadCount = active.filter(function(e) { return toMs(e.last_seen) > readBefore; }).length; const unreadCount = active.filter(function(e) { return toMs(e.last_seen) > readBefore; }).length;
lt.notif.set(bell, unreadCount); lt.notif.set(bell, unreadCount);
if (!active.length) { if (!active.length) {
@@ -459,8 +462,8 @@
return; return;
} }
list.innerHTML = active.slice(0, 25).map(function(e) { list.innerHTML = active.slice(0, 25).map(function(e) {
var isUnread = toMs(e.last_seen) > readBefore; const isUnread = toMs(e.last_seen) > readBefore;
var dotColor = SEV_DOT[e.severity] || 'var(--text-muted)'; const dotColor = SEV_DOT[e.severity] || 'var(--text-muted)';
return '<div class="lt-notif-item' + (isUnread ? ' lt-notif-item--unread' : '') + '">' + return '<div class="lt-notif-item' + (isUnread ? ' lt-notif-item--unread' : '') + '">' +
'<div class="lt-notif-dot' + (isUnread ? '' : ' lt-notif-dot--read') + '" style="background:' + dotColor + '"></div>' + '<div class="lt-notif-dot' + (isUnread ? '' : ' lt-notif-dot--read') + '" style="background:' + dotColor + '"></div>' +
'<div class="lt-notif-item-body">' + '<div class="lt-notif-item-body">' +
@@ -474,14 +477,14 @@
fetch('/api/status', { credentials: 'same-origin' }) fetch('/api/status', { credentials: 'same-origin' })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
var events = data.events || []; const events = data.events || [];
if (andRender) { if (andRender) {
renderAlerts(events); renderAlerts(events);
} else { } else {
_lastEvents = events; _lastEvents = events;
var readBefore = getReadBefore(); const readBefore = getReadBefore();
var active = events.filter(function(e) { return e.severity !== 'info'; }); const active = events.filter(function(e) { return e.severity !== 'info'; });
var unread = active.filter(function(e) { return toMs(e.last_seen) > readBefore; }).length; const unread = active.filter(function(e) { return toMs(e.last_seen) > readBefore; }).length;
lt.notif.set(bell, unread); lt.notif.set(bell, unread);
} }
}) })
+25 -16
View File
@@ -73,12 +73,13 @@
<div class="lt-toolbar-left"> <div class="lt-toolbar-left">
<div class="lt-search"> <div class="lt-search">
<input type="search" class="lt-input lt-search-input" id="events-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"
aria-label="Filter active alerts">
</div> </div>
<div class="sev-pills"> <div class="sev-pills" role="group" aria-label="Filter by severity">
<button type="button" class="pill active" data-sev="">All</button> <button type="button" class="pill active" data-sev="" aria-pressed="true">All</button>
<button type="button" class="pill" data-sev="critical">Critical</button> <button type="button" class="pill" data-sev="critical" aria-pressed="false">Critical</button>
<button type="button" class="pill" data-sev="warning">Warning</button> <button type="button" class="pill" data-sev="warning" aria-pressed="false">Warning</button>
</div> </div>
</div> </div>
</div> </div>
@@ -316,7 +317,7 @@
<div class="lt-toolbar-left"> <div class="lt-toolbar-left">
<div class="lt-search"> <div class="lt-search">
<input type="search" class="lt-input lt-search-input lt-search-input--sm" id="host-search" <input type="search" class="lt-input lt-search-input lt-search-input--sm" id="host-search"
placeholder="Filter hosts…" autocomplete="off"> placeholder="Filter hosts…" autocomplete="off" aria-label="Filter hosts">
</div> </div>
</div> </div>
</div> </div>
@@ -357,7 +358,7 @@
data-sup-type="host" data-sup-type="host"
data-sup-name="{{ name }}" data-sup-name="{{ name }}"
data-sup-detail="" data-sup-detail=""
title="Suppress alerts for this host"> aria-label="Suppress alerts for {{ name }}">
🔕 Suppress 🔕 Suppress
</button> </button>
<a href="{{ url_for('links_page') }}#{{ name }}" <a href="{{ url_for('links_page') }}#{{ name }}"
@@ -416,7 +417,8 @@
<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="{{ d.name }}" data-sup-name="{{ d.name }}"
data-sup-detail=""> data-sup-detail=""
aria-label="Suppress alerts for {{ d.name }}">
🔕 Suppress 🔕 Suppress
</button> </button>
{% endif %} {% endif %}
@@ -464,7 +466,7 @@
{% block scripts %} {% block scripts %}
<script> <script>
// Start auto-refresh using saved settings interval (default 30 s) // Start auto-refresh using saved settings interval (default 30 s)
var _savedInterval = (window.gandalfSettings && window.gandalfSettings.refreshInterval) || 30; const _savedInterval = (window.gandalfSettings && window.gandalfSettings.refreshInterval) || 30;
if (_savedInterval > 0) lt.autoRefresh.start(refreshAll, _savedInterval * 1000); if (_savedInterval > 0) lt.autoRefresh.start(refreshAll, _savedInterval * 1000);
// When settings change, restart auto-refresh with new interval // When settings change, restart auto-refresh with new interval
@@ -475,9 +477,9 @@
// ── Topology collapse toggle ─────────────────────────────────── // ── Topology collapse toggle ───────────────────────────────────
(function() { (function() {
var LS_KEY = 'gandalf_topo_collapsed'; const LS_KEY = 'gandalf_topo_collapsed';
var btn = document.getElementById('topo-toggle-btn'); const btn = document.getElementById('topo-toggle-btn');
var wrap = document.getElementById('topo-collapsible-wrap'); const wrap = document.getElementById('topo-collapsible-wrap');
if (!btn || !wrap) return; if (!btn || !wrap) return;
function setCollapsed(v) { function setCollapsed(v) {
@@ -487,7 +489,7 @@
try { localStorage.setItem(LS_KEY, v ? '1' : '0'); } catch(_) {} try { localStorage.setItem(LS_KEY, v ? '1' : '0'); } catch(_) {}
} }
var saved = false; let saved = false;
try { saved = localStorage.getItem(LS_KEY) === '1'; } catch(_) {} try { saved = localStorage.getItem(LS_KEY) === '1'; } catch(_) {}
setCollapsed(saved); setCollapsed(saved);
@@ -540,8 +542,12 @@
document.querySelector('.sev-pills')?.addEventListener('click', e => { document.querySelector('.sev-pills')?.addEventListener('click', e => {
const pill = e.target.closest('.pill[data-sev]'); const pill = e.target.closest('.pill[data-sev]');
if (!pill) return; if (!pill) return;
document.querySelectorAll('.sev-pills .pill').forEach(p => p.classList.remove('active')); document.querySelectorAll('.sev-pills .pill').forEach(p => {
p.classList.remove('active');
p.setAttribute('aria-pressed', 'false');
});
pill.classList.add('active'); pill.classList.add('active');
pill.setAttribute('aria-pressed', 'true');
_filterSev = pill.dataset.sev; _filterSev = pill.dataset.sev;
applyEventsFilter(); applyEventsFilter();
}); });
@@ -563,9 +569,12 @@
document.querySelectorAll('.lt-stat-card[data-stat-filter]').forEach(card => { document.querySelectorAll('.lt-stat-card[data-stat-filter]').forEach(card => {
card.addEventListener('click', () => { card.addEventListener('click', () => {
const sev = card.dataset.statFilter; const sev = card.dataset.statFilter;
document.querySelectorAll('.sev-pills .pill').forEach(p => p.classList.remove('active')); document.querySelectorAll('.sev-pills .pill').forEach(p => {
p.classList.remove('active');
p.setAttribute('aria-pressed', 'false');
});
const matchPill = document.querySelector(`.sev-pills .pill[data-sev="${sev}"]`); const matchPill = document.querySelector(`.sev-pills .pill[data-sev="${sev}"]`);
if (matchPill) matchPill.classList.add('active'); if (matchPill) { matchPill.classList.add('active'); matchPill.setAttribute('aria-pressed', 'true'); }
_filterSev = sev; _filterSev = sev;
applyEventsFilter(); applyEventsFilter();
document.getElementById('events-table-wrap')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); document.getElementById('events-table-wrap')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+2 -2
View File
@@ -14,10 +14,10 @@
</div> </div>
<div class="inspector-layout"> <div class="inspector-layout">
<div class="inspector-main" id="inspector-main"> <div class="inspector-main" id="inspector-main" role="region" aria-label="Switch chassis diagrams">
<div class="link-loading">Loading inspector data</div> <div class="link-loading">Loading inspector data</div>
</div> </div>
<div class="inspector-panel" id="inspector-panel"> <div class="inspector-panel" id="inspector-panel" role="complementary" aria-label="Port detail panel">
<div class="inspector-panel-inner" id="inspector-panel-inner"></div> <div class="inspector-panel-inner" id="inspector-panel-inner"></div>
</div> </div>
</div> </div>
+37 -20
View File
@@ -17,7 +17,8 @@
<div class="lt-toolbar-left"> <div class="lt-toolbar-left">
<div class="lt-search"> <div class="lt-search">
<input type="search" class="lt-input lt-search-input" id="links-search" <input type="search" class="lt-input lt-search-input" id="links-search"
placeholder="Filter by host or switch name…" autocomplete="off"> placeholder="Filter by host or switch name…" autocomplete="off"
aria-label="Filter by host or switch name">
</div> </div>
</div> </div>
<div class="lt-toolbar-right"> <div class="lt-toolbar-right">
@@ -35,6 +36,7 @@
{% block scripts %} {% block scripts %}
<script> <script>
const escHtml = s => lt.escHtml(s); const escHtml = s => lt.escHtml(s);
const _toIso = s => s ? s.replace(' UTC', 'Z').replace(' ', 'T') : s;
// ── Formatting helpers ──────────────────────────────────────────── // ── Formatting helpers ────────────────────────────────────────────
function fmtRate(bytesPerSec) { function fmtRate(bytesPerSec) {
@@ -325,7 +327,7 @@ function renderPortCard(portName, d) {
function renderUnifiSwitches(unifiSwitches, dataUpdated) { function renderUnifiSwitches(unifiSwitches, dataUpdated) {
if (!unifiSwitches || !Object.keys(unifiSwitches).length) return ''; if (!unifiSwitches || !Object.keys(unifiSwitches).length) return '';
const updStr = dataUpdated const updStr = dataUpdated
? new Date(dataUpdated.replace(' UTC', 'Z').replace(' ', 'T')).toLocaleTimeString() ? new Date(_toIso(dataUpdated)).toLocaleTimeString()
: ''; : '';
const html = Object.entries(unifiSwitches).map(([swName, sw]) => { const html = Object.entries(unifiSwitches).map(([swName, sw]) => {
const ports = sw.ports || {}; const ports = sw.ports || {};
@@ -347,7 +349,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" data-action="toggle-panel"> <div class="link-host-title" data-action="toggle-panel" role="button" tabindex="0" aria-expanded="true">
<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>
@@ -364,8 +366,11 @@ function renderUnifiSwitches(unifiSwitches, dataUpdated) {
// ── Panel collapse / expand ─────────────────────────────────────── // ── Panel collapse / expand ───────────────────────────────────────
function togglePanel(panel) { function togglePanel(panel) {
panel.classList.toggle('collapsed'); panel.classList.toggle('collapsed');
const btn = panel.querySelector('.panel-toggle'); const isCollapsed = panel.classList.contains('collapsed');
if (btn) btn.textContent = panel.classList.contains('collapsed') ? '[+]' : '[]'; const btn = panel.querySelector('.panel-toggle');
const title = panel.querySelector('.link-host-title');
if (btn) btn.textContent = isCollapsed ? '[+]' : '[]';
if (title) title.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
const id = panel.id; const id = panel.id;
if (id) { if (id) {
const collapsed = JSON.parse(sessionStorage.getItem('linksCollapsed') || '{}'); const collapsed = JSON.parse(sessionStorage.getItem('linksCollapsed') || '{}');
@@ -381,8 +386,10 @@ function restoreCollapseState() {
if (!panel) continue; if (!panel) continue;
if (isCollapsed) { if (isCollapsed) {
panel.classList.add('collapsed'); panel.classList.add('collapsed');
const btn = panel.querySelector('.panel-toggle'); const btn = panel.querySelector('.panel-toggle');
if (btn) btn.textContent = '[+]'; const title = panel.querySelector('.link-host-title');
if (btn) btn.textContent = '[+]';
if (title) title.setAttribute('aria-expanded', 'false');
} }
} }
} }
@@ -407,8 +414,8 @@ function buildLinkSummary(hosts, unifiSwitches) {
} }
const allTotal = totalIfaces + swTotal; const allTotal = totalIfaces + swTotal;
const allDown = downIfaces + swDown; const allDown = downIfaces + swDown;
const downColor = allDown > 0 ? 'var(--red)' : 'var(--green)'; const downCls = allDown > 0 ? 'lt-text-red' : 'lt-text-green';
const errColor = errIfaces > 0 ? 'var(--amber)' : 'var(--green)'; const errCls = errIfaces > 0 ? 'lt-text-amber' : 'lt-text-green';
const downCardCls = allDown > 0 ? ' lt-stat-card--alert' : ''; const downCardCls = allDown > 0 ? ' lt-stat-card--alert' : '';
const poeCard = totalPoe > 0 ? ` const poeCard = totalPoe > 0 ? `
<div class="lt-stat-card"> <div class="lt-stat-card">
@@ -428,16 +435,16 @@ function buildLinkSummary(hosts, unifiSwitches) {
</div> </div>
</div> </div>
<div class="lt-stat-card${downCardCls}"> <div class="lt-stat-card${downCardCls}">
<span class="lt-stat-icon" aria-hidden="true" style="color:${downColor}"></span> <span class="lt-stat-icon ${downCls}" aria-hidden="true"></span>
<div class="lt-stat-info"> <div class="lt-stat-info">
<span class="lt-stat-value" style="color:${downColor}">${allDown}</span> <span class="lt-stat-value ${downCls}">${allDown}</span>
<span class="lt-stat-label">Ports Down</span> <span class="lt-stat-label">Ports Down</span>
</div> </div>
</div> </div>
<div class="lt-stat-card"> <div class="lt-stat-card">
<span class="lt-stat-icon" aria-hidden="true" style="color:${errColor}"></span> <span class="lt-stat-icon ${errCls}" aria-hidden="true"></span>
<div class="lt-stat-info"> <div class="lt-stat-info">
<span class="lt-stat-value" style="color:${errColor}">${errIfaces}</span> <span class="lt-stat-value ${errCls}">${errIfaces}</span>
<span class="lt-stat-label">With Errors</span> <span class="lt-stat-label">With Errors</span>
</div> </div>
</div> </div>
@@ -461,12 +468,12 @@ function renderLinks(data) {
const sample = Object.values(ifaces)[0] || {}; const sample = Object.values(ifaces)[0] || {};
const ip = sample.host_ip || ''; const ip = sample.host_ip || '';
const updStr = data.updated const updStr = data.updated
? new Date(data.updated.replace(' UTC', 'Z').replace(' ', 'T')).toLocaleTimeString() ? new Date(_toIso(data.updated)).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" data-action="toggle-panel"> <div class="link-host-title" data-action="toggle-panel" role="button" tabindex="0" aria-expanded="true">
<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>
@@ -496,8 +503,10 @@ function applyLinksSearch() {
function collapseAll() { function collapseAll() {
document.querySelectorAll('.link-host-panel').forEach(p => { document.querySelectorAll('.link-host-panel').forEach(p => {
p.classList.add('collapsed'); p.classList.add('collapsed');
const btn = p.querySelector('.panel-toggle'); const btn = p.querySelector('.panel-toggle');
if (btn) btn.textContent = '[+]'; const title = p.querySelector('.link-host-title');
if (btn) btn.textContent = '[+]';
if (title) title.setAttribute('aria-expanded', 'false');
}); });
sessionStorage.setItem('linksCollapsed', JSON.stringify( sessionStorage.setItem('linksCollapsed', JSON.stringify(
Object.fromEntries([...document.querySelectorAll('.link-host-panel')].map(p => [p.id, true])) Object.fromEntries([...document.querySelectorAll('.link-host-panel')].map(p => [p.id, true]))
@@ -507,8 +516,10 @@ function collapseAll() {
function expandAll() { function expandAll() {
document.querySelectorAll('.link-host-panel').forEach(p => { document.querySelectorAll('.link-host-panel').forEach(p => {
p.classList.remove('collapsed'); p.classList.remove('collapsed');
const btn = p.querySelector('.panel-toggle'); const btn = p.querySelector('.panel-toggle');
if (btn) btn.textContent = '[]'; const title = p.querySelector('.link-host-title');
if (btn) btn.textContent = '[]';
if (title) title.setAttribute('aria-expanded', 'true');
}); });
sessionStorage.setItem('linksCollapsed', '{}'); sessionStorage.setItem('linksCollapsed', '{}');
} }
@@ -557,7 +568,7 @@ async function loadLinks() {
} }
loadLinks(); loadLinks();
var _linksInterval = (window.gandalfSettings && window.gandalfSettings.refreshInterval) || 60; const _linksInterval = (window.gandalfSettings && window.gandalfSettings.refreshInterval) || 60;
if (_linksInterval > 0) lt.autoRefresh.start(loadLinks, Math.max(_linksInterval, 15) * 1000); if (_linksInterval > 0) lt.autoRefresh.start(loadLinks, Math.max(_linksInterval, 15) * 1000);
window.onGandalfSettingsChanged = function(s) { window.onGandalfSettingsChanged = function(s) {
@@ -573,6 +584,12 @@ document.addEventListener('click', e => {
if (e.target.closest('[data-action="expand-all"]')) { expandAll(); return; } if (e.target.closest('[data-action="expand-all"]')) { expandAll(); return; }
}); });
document.addEventListener('keydown', e => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const toggleTitle = e.target.closest('[data-action="toggle-panel"]');
if (toggleTitle) { e.preventDefault(); togglePanel(toggleTitle.closest('.link-host-panel')); }
});
document.getElementById('links-search')?.addEventListener('input', applyLinksSearch); document.getElementById('links-search')?.addEventListener('input', applyLinksSearch);
</script> </script>
{% endblock %} {% endblock %}
+15 -10
View File
@@ -58,12 +58,12 @@
<div class="form-row form-row-align"> <div class="form-row form-row-align">
<div class="lt-form-group"> <div class="lt-form-group">
<label class="lt-label">Duration</label> <label class="lt-label">Duration</label>
<div class="duration-pills"> <div class="duration-pills" role="group" aria-label="Select suppression duration">
<button type="button" class="pill" data-duration="30">30 min</button> <button type="button" class="pill" data-duration="30" aria-pressed="false">30 min</button>
<button type="button" class="pill" data-duration="60">1 hr</button> <button type="button" class="pill" data-duration="60" aria-pressed="false">1 hr</button>
<button type="button" class="pill" data-duration="240">4 hr</button> <button type="button" class="pill" data-duration="240" aria-pressed="false">4 hr</button>
<button type="button" class="pill" data-duration="480">8 hr</button> <button type="button" class="pill" data-duration="480" aria-pressed="false">8 hr</button>
<button type="button" class="pill pill-manual active" data-duration="">Manual ∞</button> <button type="button" class="pill pill-manual active" data-duration="" aria-pressed="true">Manual ∞</button>
</div> </div>
<input type="hidden" id="s-expires" name="expires_minutes" value=""> <input type="hidden" id="s-expires" name="expires_minutes" value="">
<div class="lt-field-hint" id="s-dur-hint">Persists until manually removed.</div> <div class="lt-field-hint" id="s-dur-hint">Persists until manually removed.</div>
@@ -110,7 +110,8 @@
<td class="ts-cell">{{ s.created_at }}</td> <td class="ts-cell">{{ s.created_at }}</td>
<td class="ts-cell">{% if s.expires_at %}{{ s.expires_at }}{% else %}<em>manual</em>{% endif %}</td> <td class="ts-cell">{% if s.expires_at %}{{ s.expires_at }}{% else %}<em>manual</em>{% endif %}</td>
<td> <td>
<button class="lt-btn lt-btn-danger lt-btn-sm" data-action="remove-sup" data-sup-id="{{ s.id }}">Remove</button> <button class="lt-btn lt-btn-danger lt-btn-sm" data-action="remove-sup" data-sup-id="{{ s.id }}"
aria-label="Remove suppression for {{ s.target_name or 'global' }}">Remove</button>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -221,8 +222,11 @@
function setDur(mins, el) { function setDur(mins, el) {
document.getElementById('s-expires').value = mins || ''; document.getElementById('s-expires').value = mins || '';
document.querySelectorAll('.duration-pills .pill').forEach(p => p.classList.remove('active')); document.querySelectorAll('.duration-pills .pill').forEach(p => {
if (el) el.classList.add('active'); p.classList.remove('active');
p.setAttribute('aria-pressed', 'false');
});
if (el) { el.classList.add('active'); el.setAttribute('aria-pressed', 'true'); }
const hint = document.getElementById('s-dur-hint'); const hint = document.getElementById('s-dur-hint');
if (mins) { if (mins) {
const h = Math.floor(mins/60), m = mins%60; const h = Math.floor(mins/60), m = mins%60;
@@ -251,7 +255,8 @@
<td>${lt.escHtml(s.suppressed_by)}</td> <td>${lt.escHtml(s.suppressed_by)}</td>
<td class="ts-cell">${lt.escHtml(s.created_at || '')}</td> <td class="ts-cell">${lt.escHtml(s.created_at || '')}</td>
<td class="ts-cell">${s.expires_at ? lt.escHtml(s.expires_at) : '<em>manual</em>'}</td> <td class="ts-cell">${s.expires_at ? lt.escHtml(s.expires_at) : '<em>manual</em>'}</td>
<td><button class="lt-btn lt-btn-danger lt-btn-sm" data-action="remove-sup" data-sup-id="${s.id}">Remove</button></td> <td><button class="lt-btn lt-btn-danger lt-btn-sm" data-action="remove-sup" data-sup-id="${s.id}"
aria-label="Remove suppression for ${lt.escHtml(s.target_name || 'global')}">Remove</button></td>
</tr>`).join(''); </tr>`).join('');
wrap.innerHTML = ` wrap.innerHTML = `
<div class="lt-frame"> <div class="lt-frame">