Compare commits

...

4 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
5 changed files with 62 additions and 44 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(),
-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; }
+5 -4
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>
@@ -346,8 +346,9 @@
const btn = e.target.closest('[data-action]'); const btn = e.target.closest('[data-action]');
if (!btn) return; if (!btn) return;
const 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(); });
+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>
+26 -11
View File
@@ -349,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>
@@ -366,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') || '{}');
@@ -383,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');
} }
} }
} }
@@ -463,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>
@@ -498,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]))
@@ -509,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', '{}');
} }
@@ -575,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 %}