3 Commits

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 17:39:11 -04:00
7 changed files with 116 additions and 86 deletions
+2 -1
View File
@@ -10,6 +10,7 @@ import json
import logging import logging
import os import os
import re import re
import tempfile
import threading import threading
import time import time
import uuid import uuid
@@ -458,7 +459,7 @@ def api_avatar():
# Build a safe cache filename from the username (alphanumeric + - _ .) # Build a safe cache filename from the username (alphanumeric + - _ .)
safe_name = re.sub(r'[^a-zA-Z0-9._-]', '_', username) safe_name = re.sub(r'[^a-zA-Z0-9._-]', '_', username)
cache_dir = ldap_cfg.get('cache_dir', '/tmp/gandalf_avatars') cache_dir = ldap_cfg.get('cache_dir', os.path.join(tempfile.gettempdir(), 'gandalf_avatars'))
os.makedirs(cache_dir, exist_ok=True) os.makedirs(cache_dir, exist_ok=True)
cache_file = os.path.join(cache_dir, f'user_{safe_name}.jpg') cache_file = os.path.join(cache_dir, f'user_{safe_name}.jpg')
sentinel = os.path.join(cache_dir, f'user_{safe_name}.none') sentinel = os.path.join(cache_dir, f'user_{safe_name}.none')
+7
View File
@@ -222,10 +222,17 @@ def get_status_summary() -> dict:
WHERE resolved_at IS NULL GROUP BY severity""" WHERE resolved_at IS NULL GROUP BY severity"""
) )
counts = {r['severity']: r['cnt'] for r in cur.fetchall()} counts = {r['severity']: r['cnt'] for r in cur.fetchall()}
cur.execute(
"""SELECT COUNT(*) as cnt FROM network_events
WHERE resolved_at IS NOT NULL
AND resolved_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)"""
)
resolved_24h = cur.fetchone()['cnt']
return { return {
'critical': counts.get('critical', 0), 'critical': counts.get('critical', 0),
'warning': counts.get('warning', 0), 'warning': counts.get('warning', 0),
'info': counts.get('info', 0), 'info': counts.get('info', 0),
'resolved_24h': resolved_24h,
} }
+2
View File
@@ -92,8 +92,10 @@ function updateStatusBar(summary, lastCheck, daemonOk) {
// Update stat cards // Update stat cards
const scCrit = document.getElementById('stat-critical-val'); const scCrit = document.getElementById('stat-critical-val');
const scWarn = document.getElementById('stat-warning-val'); const scWarn = document.getElementById('stat-warning-val');
const scRes = document.getElementById('stat-resolved-val');
if (scCrit) scCrit.textContent = critCount; if (scCrit) scCrit.textContent = critCount;
if (scWarn) scWarn.textContent = warnCount; if (scWarn) scWarn.textContent = warnCount;
if (scRes && summary.resolved_24h != null) scRes.textContent = summary.resolved_24h;
const statCritCard = document.getElementById('stat-critical'); const statCritCard = document.getElementById('stat-critical');
if (statCritCard) statCritCard.classList.toggle('lt-stat-card--alert', critCount > 0); if (statCritCard) statCritCard.classList.toggle('lt-stat-card--alert', critCount > 0);
-31
View File
@@ -706,38 +706,7 @@
.poe-bar-warn { background: var(--amber); } .poe-bar-warn { background: var(--amber); }
.poe-bar-crit { background: var(--red); } .poe-bar-crit { background: var(--red); }
/* UniFi section divider */
.unifi-section-header {
display: flex;
align-items: center;
gap: 12px;
margin: 24px 0 12px;
color: var(--cyan);
font-size: .75em;
letter-spacing: .1em;
}
.unifi-section-header::before,
.unifi-section-header::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(90deg, transparent, var(--cyan), transparent);
}
/* Link health summary */
.link-summary-panel {
background: var(--bg2);
border: 1px solid var(--border-color);
padding: 12px 16px;
margin-bottom: 12px;
}
.link-summary-panel.link-summary-has-alerts { border-color: var(--amber); }
.link-summary-grid { display: flex; flex-wrap: wrap; gap: 20px; align-items: flex-end; }
.link-summary-stat { min-width: 80px; }
.link-summary-stat.lss-alert .lss-label { color: var(--amber); }
.lss-label { display: block; font-size: .62em; color: var(--text-muted); text-transform: uppercase; letter-spacing: .05em; margin-bottom: 2px; }
.lss-value { font-size: 1.2em; font-weight: bold; color: var(--text); }
.lss-sub { font-size: .7em; color: var(--text-muted); font-weight: normal; }
.link-loading { padding: 20px; text-align: center; color: var(--text-muted); font-size: .8em; } .link-loading { 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; }
+22
View File
@@ -208,6 +208,14 @@
</div> </div>
<!-- Host cards --> <!-- Host cards -->
<div class="lt-toolbar" style="margin-bottom:10px" id="host-toolbar">
<div class="lt-toolbar-left">
<div class="lt-search">
<input type="search" class="lt-input lt-search-input" id="host-search"
placeholder="Filter hosts…" autocomplete="off" style="width:180px">
</div>
</div>
</div>
<div class="host-grid" id="host-grid"> <div class="host-grid" id="host-grid">
{% for name, host in snapshot.hosts.items() %} {% for name, host in snapshot.hosts.items() %}
{% set suppressed = suppressions | selectattr('target_name', 'equalto', name) | list %} {% set suppressed = suppressions | selectattr('target_name', 'equalto', name) | list %}
@@ -270,6 +278,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>
@@ -309,6 +321,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
</section> </section>
{% endif %} {% endif %}
@@ -563,6 +576,15 @@
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 });
// Host grid search filter
document.getElementById('host-search')?.addEventListener('input', function() {
const q = this.value.trim().toLowerCase();
document.querySelectorAll('#host-grid .host-card').forEach(card => {
const name = (card.dataset.host || '').toLowerCase();
card.style.display = (!q || name.includes(q)) ? '' : 'none';
});
});
// Stat card clicks — filter events table by severity // Stat card clicks — filter events table by severity
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', () => {
+27 -5
View File
@@ -13,6 +13,19 @@
</div> </div>
</div> </div>
<div class="lt-toolbar" id="links-toolbar" style="display:none">
<div class="lt-toolbar-left">
<div class="lt-search">
<input type="search" class="lt-input lt-search-input" id="links-search"
placeholder="Filter by host or switch name…" autocomplete="off">
</div>
</div>
<div class="lt-toolbar-right">
<button class="lt-btn lt-btn-ghost lt-btn-sm" data-action="collapse-all">Collapse All</button>
<button class="lt-btn lt-btn-ghost lt-btn-sm" data-action="expand-all">Expand All</button>
</div>
</div>
<div id="links-container"> <div id="links-container">
<div class="link-loading">Loading link statistics</div> <div class="link-loading">Loading link statistics</div>
</div> </div>
@@ -345,7 +358,7 @@ function renderUnifiSwitches(unifiSwitches, dataUpdated) {
</div>`; </div>`;
}).join(''); }).join('');
return `<div class="unifi-section-header">UNIFI SWITCH PORTS</div>${html}`; return `<div class="lt-divider" style="margin:20px 0 12px"><span class="lt-divider-label" style="color:var(--cyan);letter-spacing:.1em">UNIFI SWITCH PORTS</span></div>${html}`;
} }
// ── Panel collapse / expand ─────────────────────────────────────── // ── Panel collapse / expand ───────────────────────────────────────
@@ -439,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" data-action="collapse-all">Collapse All</button>
<button class="lt-btn lt-btn-ghost lt-btn-sm" data-action="expand-all">Expand All</button>
</div>`);
parts.push('<div class="link-host-list">'); parts.push('<div class="link-host-list">');
for (const [hostname, ifaces] of Object.entries(hosts)) { for (const [hostname, ifaces] of Object.entries(hosts)) {
@@ -471,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() {
@@ -552,5 +572,7 @@ document.addEventListener('click', e => {
if (e.target.closest('[data-action="collapse-all"]')) { collapseAll(); return; } if (e.target.closest('[data-action="collapse-all"]')) { collapseAll(); return; }
if (e.target.closest('[data-action="expand-all"]')) { expandAll(); return; } if (e.target.closest('[data-action="expand-all"]')) { expandAll(); return; }
}); });
document.getElementById('links-search')?.addEventListener('input', applyLinksSearch);
</script> </script>
{% endblock %} {% endblock %}
+7
View File
@@ -184,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">
@@ -199,6 +204,8 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
</div>
</div>
</section> </section>
{% endblock %} {% endblock %}