feat: inspector page, link debug enhancements, security hardening

- Add /inspector page: visual model-accurate switch chassis diagrams
  (USF5P, USL8A, US24PRO, USPPDUP, USMINI), clickable port blocks
  with color coding (green=up, amber=PoE, cyan=uplink, grey=down),
  detail panel with stats/PoE/LLDP, LLDP-based path debug side-by-side

- Link Debug: port number badges (#N), LLDP neighbor line, PoE class/max,
  collapsible host/switch panels with sessionStorage persistence

- monitor.py: collect LLDP neighbor map + PoE class/max/mode per switch
  port; PulseClient uses requests.Session() for HTTP keep-alive; add
  shlex.quote() around interface names (defense-in-depth)

- Security: suppress buttons use data-* attrs + delegated click handler
  instead of inline onclick with Jinja2 variable interpolation; remove
  | safe filter from user-controlled fields in suppressions.html;
  setDuration() takes explicit el param instead of implicit event global

- db.py: thread-local connection reuse with ping(reconnect=True) to
  avoid a new TCP handshake per query

- .gitignore: add config.json (contains credentials), __pycache__

- README: full rewrite covering architecture, all 4 pages, alert logic,
  config reference, deployment, troubleshooting, security notes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 15:39:48 -05:00
parent fa7512a2c2
commit 0278dad502
12 changed files with 1548 additions and 176 deletions

View File

@@ -105,7 +105,9 @@ function updateUnifiTable(devices) {
const statusText = d.connected ? 'Online' : 'Offline';
const suppressBtn = !d.connected
? `<button class="btn-sm btn-suppress"
onclick="openSuppressModal('unifi_device','${escHtml(d.name)}','')">🔕 Suppress</button>`
data-sup-type="unifi_device"
data-sup-name="${escHtml(d.name)}"
data-sup-detail="">🔕 Suppress</button>`
: '';
return `
<tr class="${statusClass}">
@@ -149,9 +151,9 @@ function updateEventsTable(events) {
<td>${ticket}</td>
<td>
<button class="btn-sm btn-suppress"
onclick="openSuppressModal('${supType}','${escHtml(e.target_name)}','${escHtml(e.target_detail||'')}')">
🔕
</button>
data-sup-type="${escHtml(supType)}"
data-sup-name="${escHtml(e.target_name)}"
data-sup-detail="${escHtml(e.target_detail||'')}">🔕</button>
</td>
</tr>`;
}).join('');
@@ -204,12 +206,10 @@ function updateSuppressForm() {
if (detailGrp) detailGrp.style.display = (type === 'interface') ? '' : 'none';
}
function setDuration(mins) {
function setDuration(mins, el) {
document.getElementById('sup-expires').value = mins || '';
document.querySelectorAll('#suppress-modal .pill').forEach(p => p.classList.remove('active'));
event.currentTarget.classList.add('active');
if (el) el.classList.add('active');
const hint = document.getElementById('duration-hint');
if (hint) {
if (mins) {
@@ -257,10 +257,21 @@ async function submitSuppress(e) {
}
}
// ── Close modal on backdrop click ────────────────────────────────────
// ── Global click handler: modal backdrop + suppress button delegation
document.addEventListener('click', e => {
// Close modal when clicking backdrop
const modal = document.getElementById('suppress-modal');
if (modal && e.target === modal) closeSuppressModal();
if (modal && e.target === modal) { closeSuppressModal(); return; }
// Suppress button via data attributes (avoids inline onclick XSS)
const btn = e.target.closest('.btn-suppress[data-sup-type]');
if (btn) {
openSuppressModal(
btn.dataset.supType || '',
btn.dataset.supName || '',
btn.dataset.supDetail || '',
);
}
});
// ── Utility ───────────────────────────────────────────────────────────