From 9d6583a08a55227bc19c60a71e358eb79d0515ac Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 30 Apr 2026 21:09:56 -0400 Subject: [PATCH] Add LDAP avatar photos, UX polish, and TDS component upgrades MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app.py | 80 ++++++++++++++++++++++++- config.json | 9 +++ monitor.py | 23 +++++--- requirements.txt | 1 + static/app.js | 16 +++-- static/style.css | 115 +++++++++++++++++++++++++++++------- templates/base.html | 8 +++ templates/index.html | 43 +++++++++----- templates/inspector.html | 22 +++---- templates/links.html | 35 +++++------ templates/suppressions.html | 17 ++++-- 11 files changed, 286 insertions(+), 83 deletions(-) diff --git a/app.py b/app.py index b1c0ebe..bfb5a18 100644 --- a/app.py +++ b/app.py @@ -8,6 +8,7 @@ import hashlib import ipaddress import json import logging +import os import re import threading import time @@ -15,7 +16,7 @@ import uuid from datetime import datetime, timezone from functools import wraps -from flask import Flask, jsonify, render_template, request +from flask import Flask, jsonify, make_response, render_template, request, send_file import db import diagnose @@ -169,6 +170,7 @@ def index(): last_check=last_check, suppressions=suppressions, recent_resolved=recent_resolved, + daemon_ok=_daemon_ok(last_check), ) @@ -442,6 +444,82 @@ def api_diagnose_poll(job_id: str): return jsonify({'status': job['status'], 'result': job.get('result')}) +@app.route('/api/avatar') +@require_auth +def api_avatar(): + """Serve the current user's LDAP avatar photo (JPEG), cached to disk.""" + username = request.headers.get('Remote-User', '').strip() + if not username: + return '', 404 + + ldap_cfg = _config().get('ldap', {}) + if not ldap_cfg.get('host') or not ldap_cfg.get('bind_dn'): + return '', 404 + + # Build a safe cache filename from the username (alphanumeric + - _ .) + safe_name = re.sub(r'[^a-zA-Z0-9._-]', '_', username) + cache_dir = ldap_cfg.get('cache_dir', '/tmp/gandalf_avatars') + os.makedirs(cache_dir, exist_ok=True) + cache_file = os.path.join(cache_dir, f'user_{safe_name}.jpg') + sentinel = os.path.join(cache_dir, f'user_{safe_name}.none') + cache_ttl = int(ldap_cfg.get('cache_ttl', 3600)) + + now = time.time() + + # Serve cached image if fresh + if os.path.exists(cache_file) and now - os.path.getmtime(cache_file) < cache_ttl: + return send_file(cache_file, mimetype='image/jpeg', + max_age=cache_ttl, conditional=True) + + # Skip LDAP if we already know this user has no avatar + if os.path.exists(sentinel) and now - os.path.getmtime(sentinel) < cache_ttl: + return '', 404 + + # Query lldap + avatar_data = None + try: + import ldap3 + server = ldap3.Server(ldap_cfg['host'], port=int(ldap_cfg.get('port', 3890))) + conn = ldap3.Connection(server, + user=ldap_cfg['bind_dn'], + password=ldap_cfg.get('bind_pw', ''), + auto_bind=True, receive_timeout=5) + safe_uid = ldap3.utils.conv.escape_filter_chars(username) + conn.search(ldap_cfg.get('user_base', 'ou=people,dc=example,dc=com'), + f'(uid={safe_uid})', attributes=['avatar']) + if conn.entries and conn.entries[0]['avatar'].value: + avatar_data = conn.entries[0]['avatar'].value + conn.unbind() + except ImportError: + logger.error('ldap3 not installed — run: pip install ldap3') + return '', 404 + except Exception as e: + logger.error(f'LDAP avatar lookup failed for {username}: {e}') + return '', 404 + + if not avatar_data or len(avatar_data) < 100: + open(sentinel, 'w').close() + return '', 404 + + # Validate JPEG magic bytes (FF D8 FF) + if isinstance(avatar_data, str): + avatar_data = avatar_data.encode('latin-1') + if avatar_data[:3] != b'\xFF\xD8\xFF': + logger.warning(f'Non-JPEG avatar data for {username}') + open(sentinel, 'w').close() + return '', 404 + + with open(cache_file, 'wb') as f: + f.write(avatar_data) + if os.path.exists(sentinel): + os.unlink(sentinel) + + resp = make_response(avatar_data) + resp.headers['Content-Type'] = 'image/jpeg' + resp.headers['Cache-Control'] = f'private, max-age={cache_ttl}' + return resp + + @app.route('/health') def health(): """Health check endpoint (no auth). Checks DB and monitor freshness.""" diff --git a/config.json b/config.json index 531995e..3aec65e 100644 --- a/config.json +++ b/config.json @@ -24,6 +24,15 @@ "url": "http://10.10.10.45/create_ticket_api.php", "api_key": "5acc5d3c647b84f7c6f59082ce4450ee772e2d1633238b960136f653d20c93af" }, + "ldap": { + "host": "10.10.10.39", + "port": 3890, + "bind_dn": "uid=jared,ou=people,dc=example,dc=com", + "bind_pw": "SJdi$P%RhB3yRoXE^PNL", + "user_base": "ou=people,dc=example,dc=com", + "cache_dir": "/tmp/gandalf_avatars", + "cache_ttl": 3600 + }, "auth": { "allowed_groups": ["admin"] }, diff --git a/monitor.py b/monitor.py index 8373691..b91895a 100644 --- a/monitor.py +++ b/monitor.py @@ -877,8 +877,12 @@ class NetworkMonitor: # ------------------------------------------------------------------ # Snapshot collection (for dashboard) # ------------------------------------------------------------------ - def _collect_snapshot(self, iface_states: Dict[str, Dict[str, bool]]) -> dict: - unifi_devices = self.unifi.get_devices() or [] + def _collect_snapshot( + self, iface_states: Dict[str, Dict[str, bool]], + unifi_devices: Optional[List[dict]] = None, + ) -> dict: + # Accept pre-fetched devices; fall back to empty list if unavailable + display_unifi = unifi_devices if unifi_devices is not None else [] hosts = {} for instance, ifaces in iface_states.items(): @@ -914,7 +918,7 @@ class NetworkMonitor: return { 'hosts': hosts, - 'unifi': unifi_devices, + 'unifi': display_unifi, 'updated': datetime.utcnow().isoformat(), } @@ -933,23 +937,24 @@ class NetworkMonitor: # 1. Fetch interface states once — shared by snapshot and alert processing iface_states = self.prom.get_interface_states() - # 2. Collect and store snapshot for dashboard - snapshot = self._collect_snapshot(iface_states) + # 2. Fetch UniFi devices once — used by both snapshot and alert processing + unifi_devices = self.unifi.get_devices() + + # 3. Collect and store snapshot for dashboard + snapshot = self._collect_snapshot(iface_states, unifi_devices) db.set_state('network_snapshot', snapshot) db.set_state('last_check', _now_utc()) - # 3. Collect link stats (ethtool + traffic metrics) + # 4. Collect link stats (ethtool + traffic metrics) try: link_data = self.link_stats.collect(self._instance_map) db.set_state('link_stats', link_data) except Exception as e: logger.error(f'Link stats collection failed: {e}', exc_info=True) - # 4. Process alerts using already-fetched interface states + # 5. Process alerts using already-fetched data suppressions = db.get_active_suppressions() self._process_interfaces(iface_states, suppressions) - - unifi_devices = self.unifi.get_devices() self._process_unifi(unifi_devices, suppressions) self._process_ping_hosts(suppressions) diff --git a/requirements.txt b/requirements.txt index e506263..9ef2819 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ flask>=2.2.0 gunicorn>=20.1.0 +ldap3>=2.9 pymysql>=1.1.0 requests>=2.31.0 urllib3>=2.0.0 diff --git a/static/app.js b/static/app.js index 55ff901..d753b12 100644 --- a/static/app.js +++ b/static/app.js @@ -33,7 +33,9 @@ function _toIso(s) { // ── Dashboard auto-refresh ──────────────────────────────────────────── async function refreshAll() { const refreshBtn = document.querySelector('[data-action="refresh"]'); + const spinner = document.getElementById('refresh-spinner'); if (refreshBtn) refreshBtn.classList.add('is-loading'); + if (spinner) spinner.style.display = ''; try { const [netResult, statusResult] = await Promise.allSettled([ lt.api.get('/api/network'), @@ -56,6 +58,7 @@ async function refreshAll() { } } finally { if (refreshBtn) refreshBtn.classList.remove('is-loading'); + if (spinner) spinner.style.display = 'none'; } } @@ -78,6 +81,13 @@ function updateStatusBar(summary, lastCheck, daemonOk) { else if (warnCount) document.title = `(${warnCount} WARN) GANDALF`; else document.title = 'GANDALF'; + const alertBadge = document.getElementById('alert-count-badge'); + if (alertBadge) { + const total = critCount + warnCount; + alertBadge.textContent = total; + alertBadge.style.display = total ? '' : 'none'; + } + // Stale data banner: warn if last_check is older than 15 minutes let staleBanner = document.getElementById('stale-banner'); if (lastCheck) { @@ -132,9 +142,7 @@ function updateTopology(hosts) { const host = hosts[name]; if (!host) return; node.className = node.className.replace(/topo-v2-status-(up|down|degraded|unknown)/g, ''); - node.className = node.className.replace(/topo-status-(up|down|degraded|unknown)/g, ''); node.classList.add(`topo-v2-status-${host.status}`); - node.classList.add(`topo-status-${host.status}`); const badge = node.querySelector('.topo-badge'); if (badge) { badge.className = `topo-badge topo-badge-${host.status}`; @@ -155,7 +163,7 @@ function updateUnifiTable(devices) { tbody.innerHTML = devices.map(d => { const statusClass = d.connected ? '' : 'row-critical'; const dotClass = d.connected ? 'dot-up' : 'dot-down'; - const statusText = d.connected ? 'Online' : 'Offline'; + const statusText = d.connected ? 'ONLINE' : 'OFFLINE'; const suppressBtn = !d.connected ? ` + {{ last_check }} + + @@ -46,7 +50,7 @@
-
+
WAN · 10G SFP+
@@ -215,7 +219,11 @@ {% else %} -

No host data yet – monitor is initializing.

+
+
+
No host data yet
+
The monitor daemon may still be starting up.
+
{% endfor %} @@ -272,9 +280,8 @@

Active Alerts

- {% if summary.critical or summary.warning %} - {{ (summary.critical or 0) + (summary.warning or 0) }} - {% endif %} + {{ (summary.critical or 0) + (summary.warning or 0) }}
Active network alerts - Sev + Sev Type Target Detail Description - First Seen - Last Seen - Failures + First Seen + Last Seen + Failures Ticket Actions @@ -341,13 +348,21 @@ {% endif %} {% else %} - No active alerts ✔ + +
+
+
No active alerts
+
+ {% endfor %}
{% else %} -

No active alerts ✔

+
+
+
No active alerts
+
{% endif %}
diff --git a/templates/inspector.html b/templates/inspector.html index 0c4cf22..c1203c4 100644 --- a/templates/inspector.html +++ b/templates/inspector.html @@ -114,7 +114,7 @@ function portBlockHtml(idx, port, swName, sfpBlock) { const speedHtml = speedTxt ? `${speedTxt}` : ''; return `
${numLabel}${speedHtml}${lldpHtml}
`; } @@ -259,7 +259,7 @@ function renderPanel(swName, idx) { const poeMaxStr = d.poe_max_power != null ? ` / max ${d.poe_max_power.toFixed(1)}W` : ''; const poeCurStr = (d.poe_power != null && d.poe_power > 0) ? ` / draw ${d.poe_power.toFixed(1)}W` : ''; poeHtml = ` -
PoE
+
PoE
Classclass ${d.poe_class}${poeMaxStr}
${d.poe_power != null ? `
Draw${d.poe_power > 0 ? `${d.poe_power.toFixed(1)}W` : '0W'}
` : ''} ${d.poe_mode ? `
Mode${escHtml(d.poe_mode)}
` : ''}`; @@ -269,16 +269,16 @@ function renderPanel(swName, idx) { let trafficHtml = ''; if (d.tx_bytes_rate != null || d.rx_bytes_rate != null) { trafficHtml = ` -
Traffic
-
TX${fmtRate(d.tx_bytes_rate)}
-
RX${fmtRate(d.rx_bytes_rate)}
`; +
Traffic
+
TX${fmtRate(d.tx_bytes_rate)}
+
RX${fmtRate(d.rx_bytes_rate)}
`; } // Errors / drops section let errHtml = ''; if (d.tx_errs_rate != null || d.rx_errs_rate != null) { errHtml = ` -
Errors / Drops
+
Errors / Drops
TX Err${fmtErrors(d.tx_errs_rate)}
RX Err${fmtErrors(d.rx_errs_rate)}
TX Drop${fmtErrors(d.tx_drops_rate)}
@@ -291,7 +291,7 @@ function renderPanel(swName, idx) { if (d.lldp && d.lldp.system_name) { const l = d.lldp; lldpHtml = ` -
LLDP Neighbor
+
LLDP Neighbor
System${escHtml(l.system_name)}
${l.port_id ? `
Port${escHtml(l.port_id)}
` : ''} ${l.port_desc ? `
Port Desc${escHtml(l.port_desc)}
` : ''} @@ -333,11 +333,11 @@ function renderPanel(swName, idx) { -
Link
+
Link
Status${upStr}
Speed${speedStr}
-
Duplex${duplexStr}
-
Auto-neg${autoneg}
+
Duplex${duplexStr}
+
Auto-neg${autoneg}
Media${escHtml(mediaStr)}
${poeHtml} @@ -392,7 +392,7 @@ function buildPathDebug(swName, swPort, serverName, ifaceName, svrData) { : ''; return ` -
Path Debug ${escHtml(connType)}
+
Path Debug · ${escHtml(connType)}
${duplexWarnHtml}${speedWarnHtml}
diff --git a/templates/links.html b/templates/links.html index d63020f..70ad320 100644 --- a/templates/links.html +++ b/templates/links.html @@ -40,6 +40,7 @@ function fmtRateBar(bytesPerSec, linkSpeedMbps) { function trafficBarClass(pct, isTx) { if (pct > 85) return 'lt-progress--red'; + if (pct > 65) return 'lt-progress--amber'; return isTx ? '' : 'lt-progress--cyan'; } @@ -152,26 +153,26 @@ function renderIfaceCard(ifaceName, d) {
- Temp + Temp ${fmtTemp(s.temp_c)}
- Voltage + Voltage ${fmtVoltage(s.voltage_v)}
- Bias + Bias ${fmtBias(s.bias_ma)}
- TX Power + TX Power ${fmtPower(s.tx_power_dbm)}
- RX Power + RX Power ${fmtPower(s.rx_power_dbm)}
@@ -179,7 +180,7 @@ function renderIfaceCard(ifaceName, d) {
${s.rx_power_dbm != null && s.tx_power_dbm != null ? `
- RX−TX Δ + RX−TX Δ ${(s.rx_power_dbm - s.tx_power_dbm).toFixed(2)} dBm
` : ''}
@@ -200,42 +201,42 @@ function renderIfaceCard(ifaceName, d) { ${isDown ? 'DOWN' : 'UP'}
- TX + TX
${fmtRate(d.tx_bytes_rate)}
- RX + RX
${fmtRate(d.rx_bytes_rate)}
@@ -431,8 +432,8 @@ function renderLinks(data) { .map(([iname, d]) => renderIfaceCard(iname, d)).join(''); const sample = Object.values(ifaces)[0] || {}; const ip = sample.host_ip || ''; - const updStr = sample.updated - ? new Date(sample.updated + (sample.updated.includes('Z') ? '' : 'Z')).toLocaleTimeString() + const updStr = data.updated + ? new Date(data.updated.replace(' UTC', 'Z').replace(' ', 'T')).toLocaleTimeString() : ''; parts.push(` diff --git a/templates/suppressions.html b/templates/suppressions.html index 1666936..2be3adb 100644 --- a/templates/suppressions.html +++ b/templates/suppressions.html @@ -112,7 +112,11 @@
{% else %} -

No active suppressions.

+
+
🔕
+
No active suppressions
+
All alerts are active. Use the form above to silence a host or interface.
+
{% endif %} @@ -156,7 +160,10 @@ {% else %} -

No suppression history yet.

+
+
📋
+
No suppression history yet
+
{% endif %} @@ -210,7 +217,7 @@ const wrap = document.getElementById('active-sup-wrap'); const badge = document.getElementById('active-sup-badge'); if (!rows || !rows.length) { - wrap.innerHTML = '

No active suppressions.

'; + wrap.innerHTML = '
🔕
No active suppressions
All alerts are active. Use the form above to silence a host or interface.
'; if (badge) badge.textContent = '0'; return; } @@ -245,7 +252,7 @@ const rows = await lt.api.get('/api/suppressions'); renderActiveRows(rows); } catch (err) { - console.warn('Failed to refresh suppressions:', err); + showToast('Failed to refresh suppressions', 'warning'); } } @@ -287,7 +294,7 @@ const tbody = document.querySelector('#active-sup-table tbody'); if (tbody && !tbody.children.length) { document.getElementById('active-sup-wrap').innerHTML = - '

No active suppressions.

'; + '
🔕
No active suppressions
All alerts are active. Use the form above to silence a host or interface.
'; if (badge) badge.textContent = '0'; } showToast('Suppression removed', 'success');