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>
This commit is contained in:
2026-04-30 21:09:56 -04:00
parent 29267c9933
commit 9d6583a08a
11 changed files with 286 additions and 83 deletions
+79 -1
View File
@@ -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."""