Files
gandalf/static/app.js
T
jaredandClaude Opus 5.5 e32499b7f6
Lint / Python (flake8) (pull_request) Successful in 2m6s
Lint / JS (eslint) (pull_request) Successful in 18s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 2m11s
Test / Python Tests (pytest) (pull_request) Successful in 2m20s
Lint / JS (eslint) (push) Successful in 10s
Lint / Python (flake8) (push) Successful in 1m58s
Lint / Notify on failure (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m58s
Lint / Deploy (push) Successful in 4s
Test / Python Tests (pytest) (push) Successful in 58s
Add AT&T modem monitoring and device pages; replace Link Debug
Modem and WAN:
- Poll the BGW320 status pages read-only for light levels, PON and WAN
  state, with a WAN / Fiber panel, 7-day Rx trend, and alerts on WAN down,
  optical limits, and Rx drift
- Read WAN outages and Smart Power restarts from the UniFi system log,
  since outages can be over between 5 minute polls

Device pages:
- Add device search to the dashboard and a page per host, switch, router,
  AP, PDU, or modem
- Move all Link Debug data into expandable rows there; /links redirects
- Collect UDM ports and UniFi device details (firmware, load, WAN, outlets)
- Reorder the dashboard

Monitoring fixes:
- Alert when a configured host stops reporting: its series disappear
  rather than reporting down, so a dead host never raised an alert
- Alert on Prometheus outages instead of blanking the dashboard
- Treat a failed Pulse ping as unknown rather than host unreachable
- Stop new switch ports reporting lifetime bytes as one interval's rate
- Run database sessions in UTC: other server time zones shifted displayed
  ages and timed suppression expiries

Also log every avatar 404 reason, add a favicon, and correct the WAN link
labels (1G on Port 9, not 10G SFP+).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:40:17 -05:00

651 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* exported initDeviceSearch */
/* global DEVICE_NAME */
// ── Auto-redirect on auth timeout ─────────────────────────────────────
// Wraps fetch so a 401 (Authelia session expired) forces a full reload.
// lt.api uses fetch internally, so this covers all API calls too.
(function () {
const _fetch = window.fetch;
window.fetch = async function (...args) {
const resp = await _fetch(...args);
if (resp.status === 401) {
window.location.reload();
throw new Error('Session expired — reloading');
}
return resp;
};
})();
// ── Toast notifications — thin wrapper over lt.toast ──────────────────
function showToast(msg, type = 'success') {
if (type === 'error') return lt.toast.error(msg);
if (type === 'warning') return lt.toast.warning(msg);
if (type === 'info') return lt.toast.info(msg);
return lt.toast.success(msg);
}
// ── Normalise UTC timestamp string for Date() parsing ─────────────────
// Server returns "2026-03-14 14:14:21 UTC"; Date() needs ISO 8601.
function _toIso(s) {
if (!s) return s;
return s.replace(' UTC', 'Z').replace(' ', 'T');
}
// ── 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'),
lt.api.get('/api/status'),
refreshModem(),
]);
if (netResult.status === 'fulfilled') {
const net = netResult.value;
updateHostGrid(net.hosts || {});
updateUnifiTable(net.unifi || []);
updateTopology(net.hosts || {});
} else {
showToast('Network data unavailable', 'warning');
}
if (statusResult.status === 'fulfilled') {
const status = statusResult.value;
updateEventsTable(status.events || [], status.total_active);
updateStatusBar(status.summary || {}, status.last_check || '', status.daemon_ok);
if (typeof window.gandalfNotifUpdate === 'function') window.gandalfNotifUpdate(status.events || []);
} else {
showToast('Status data unavailable', 'warning');
}
} finally {
if (refreshBtn) refreshBtn.classList.remove('is-loading');
if (spinner) spinner.style.display = 'none';
}
}
function updateStatusBar(summary, lastCheck, daemonOk) {
const bar = document.querySelector('.status-chips');
if (!bar) return;
const chips = [];
if (daemonOk === false) chips.push('<span class="chip chip-critical">⚠ MONITOR OFFLINE</span>');
if (summary.critical) chips.push(`<span class="chip chip-critical">● ${summary.critical} CRITICAL</span>`);
if (summary.warning) chips.push(`<span class="chip chip-warning">● ${summary.warning} WARNING</span>`);
if (!summary.critical && !summary.warning && daemonOk !== false) chips.push('<span class="chip chip-ok">✔ ALL SYSTEMS NOMINAL</span>');
bar.innerHTML = chips.join('');
const lc = document.getElementById('last-check');
if (lc && lastCheck) lc.textContent = lastCheck;
const critCount = summary.critical || 0;
const warnCount = summary.warning || 0;
if (critCount) document.title = `(${critCount} CRIT) GANDALF`;
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';
}
// Update stat cards
const scCrit = document.getElementById('stat-critical-val');
const scWarn = document.getElementById('stat-warning-val');
const scRes = document.getElementById('stat-resolved-val');
if (scCrit) scCrit.textContent = critCount;
if (scWarn) scWarn.textContent = warnCount;
if (scRes && summary.resolved_24h !== null && summary.resolved_24h !== undefined) scRes.textContent = summary.resolved_24h;
const statCritCard = document.getElementById('stat-critical');
if (statCritCard) statCritCard.classList.toggle('lt-stat-card--alert', critCount > 0);
// Stale data banner: warn if last_check is older than 15 minutes
let staleBanner = document.getElementById('stale-banner');
if (lastCheck) {
const checkAge = (Date.now() - new Date(_toIso(lastCheck))) / 1000;
if (checkAge > 900) {
if (!staleBanner) {
staleBanner = document.createElement('div');
staleBanner.id = 'stale-banner';
staleBanner.className = 'lt-alert lt-alert--warning';
staleBanner.innerHTML = '<span class="lt-alert-icon">⚠</span><div class="lt-alert-body"><div class="lt-alert-msg"></div></div>';
document.querySelector('.lt-main').prepend(staleBanner);
}
const mins = Math.floor(checkAge / 60);
staleBanner.querySelector('.lt-alert-msg').textContent =
`Monitoring data is stale — last check was ${mins} minute${mins !== 1 ? 's' : ''} ago. The monitor daemon may be down.`;
staleBanner.style.display = '';
} else if (staleBanner) {
staleBanner.style.display = 'none';
}
}
}
function updateHostGrid(hosts) {
const scHosts = document.getElementById('stat-hosts-val');
if (scHosts) scHosts.textContent = Object.keys(hosts).length;
for (const [name, host] of Object.entries(hosts)) {
const card = document.querySelector(`.host-card[data-host="${CSS.escape(name)}"]`);
if (!card) continue;
card.className = card.className.replace(/host-card-(up|down|degraded|unknown)/g, '');
card.classList.add(`host-card-${host.status}`);
const dot = card.querySelector('.host-status-dot');
if (dot) dot.className = `host-status-dot dot-${host.status}`;
const ifaceList = card.querySelector('.iface-list');
if (ifaceList && host.interfaces && Object.keys(host.interfaces).length > 0) {
ifaceList.innerHTML = Object.entries(host.interfaces)
.sort(([a], [b]) => a.localeCompare(b))
.map(([iface, state]) => `
<div class="iface-row">
<span class="iface-dot dot-${state}"></span>
<span class="iface-name">${lt.escHtml(iface)}</span>
<span class="iface-state state-${state}">${state}</span>
</div>
`).join('');
}
}
}
function updateTopology(hosts) {
document.querySelectorAll('.topo-host').forEach(node => {
const name = node.dataset.host;
const host = hosts[name];
if (!host) return;
node.className = node.className.replace(/topo-v2-status-(up|down|degraded|unknown)/g, '');
node.classList.add(`topo-v2-status-${host.status}`);
const badge = node.querySelector('.topo-badge');
if (badge) {
badge.className = `topo-badge topo-badge-${host.status}`;
badge.textContent = host.status;
}
// Animate the 10G drop-wire red+dashed when host is down
document.querySelectorAll(`.topo-v2-wire-10g[data-host="${CSS.escape(name)}"]`).forEach(wire => {
wire.classList.toggle('wire-down', host.status === 'down');
});
});
}
// ── Device search ─────────────────────────────────────────────────────
function deviceUrl(name) {
return `/device/${encodeURIComponent(name)}`;
}
// All words must match somewhere; the device's name matching ranks first.
function rankDevices(devices, query) {
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (!words.length) return [];
const scored = [];
for (const d of devices) {
if (!words.every(w => d.search.includes(w))) continue;
const name = d.name.toLowerCase();
let score = 3;
if (name === words[0]) score = 0;
else if (name.startsWith(words[0])) score = 1;
else if (name.includes(words[0]) || (d.ip || '').startsWith(words[0])) score = 2;
scored.push({ d, score });
}
return scored
.sort((a, b) => a.score - b.score || a.d.name.localeCompare(b.d.name))
.slice(0, 12)
.map(s => s.d);
}
function initDeviceSearch() {
const input = document.getElementById('device-search-input');
const results = document.getElementById('device-search-results');
if (!input || !results) return;
let devices = [];
let matches = [];
let active = 0;
async function loadDevices() {
try {
devices = (await lt.api.get('/api/devices')).devices || [];
} catch (err) {
console.warn('Device list unavailable', err);
}
}
function close() {
results.hidden = true;
input.setAttribute('aria-expanded', 'false');
input.removeAttribute('aria-activedescendant');
}
function render() {
matches = rankDevices(devices, input.value);
if (!input.value.trim()) { close(); return; }
active = Math.min(active, Math.max(matches.length - 1, 0));
results.hidden = false;
input.setAttribute('aria-expanded', 'true');
if (!matches.length) {
results.innerHTML = '<div class="device-search-empty">No devices match.</div>';
input.removeAttribute('aria-activedescendant');
return;
}
results.innerHTML = matches.map((d, i) => `
<a class="device-search-row${i === active ? ' is-active' : ''}" id="device-opt-${i}" role="option"
aria-selected="${i === active}" href="${lt.escHtml(deviceUrl(d.name))}">
<span class="dot-${lt.escHtml(d.status)}"></span>
<span class="device-search-name">${lt.escHtml(d.name)}</span>
<span class="device-search-kind">${lt.escHtml(d.kind)}</span>
<span class="device-search-meta">${lt.escHtml(d.ip || '')}</span>
<span class="device-search-meta">${lt.escHtml(d.model || '')}</span>
</a>`).join('');
input.setAttribute('aria-activedescendant', `device-opt-${active}`);
}
input.addEventListener('input', () => { active = 0; render(); });
input.addEventListener('focus', () => { loadDevices().then(render); });
input.addEventListener('keydown', e => {
if (e.key === 'Escape') { close(); return; }
if (!matches.length) return;
if (e.key === 'ArrowDown') { e.preventDefault(); active = (active + 1) % matches.length; render(); }
if (e.key === 'ArrowUp') { e.preventDefault(); active = (active - 1 + matches.length) % matches.length; render(); }
if (e.key === 'Enter') { e.preventDefault(); window.location.href = deviceUrl(matches[active].name); }
});
document.addEventListener('click', e => {
if (!e.target.closest('#device-search')) close();
});
document.addEventListener('keydown', e => {
const typing = e.target.closest('input, textarea, select, [contenteditable]');
if (e.key === '/' && !typing && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
input.focus();
}
});
loadDevices();
}
// ── WAN modem / fiber panel ───────────────────────────────────────────
async function refreshModem() {
try {
updateModem(await lt.api.get('/api/modem'));
} catch (err) {
console.warn('Modem data unavailable', err);
}
}
function _modemUptime(seconds) {
if (seconds === null || seconds === undefined) return '–';
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
return d ? `${d}d ${h}h` : `${h}h ${m}m`;
}
function _modemRate(bytesPerSec) {
if (bytesPerSec === null || bytesPerSec === undefined) return '–';
const mbps = bytesPerSec * 8 / 1e6;
return mbps >= 1000 ? `${(mbps / 1000).toFixed(2)} Gbps` : `${mbps.toFixed(1)} Mbps`;
}
function _modemStat(label, value, cls, note) {
return `
<div class="modem-stat">
<span class="modem-stat-label">${lt.escHtml(label)}</span>
<span class="modem-stat-value ${cls || ''}">${lt.escHtml(String(value))}</span>
${note ? `<span class="modem-stat-note">${lt.escHtml(note)}</span>` : ''}
</div>`;
}
function _modemOptic(label, metric, status, unit) {
if (!metric || metric.value === null || metric.value === undefined) return _modemStat(label, '–');
const cls = { alarm: 'lt-text-red', warning: 'lt-text-amber', ok: 'lt-text-green' }[status] || '';
return _modemStat(label, `${metric.value} ${unit}`, cls, `warn ${metric.warn_low} to ${metric.warn_high}`);
}
function _modemChart(history) {
const points = (history || []).filter(p => p.rx_avg !== null && p.rx_avg !== undefined);
if (points.length < 2) {
return '<div class="modem-chart-empty">Rx power trend appears after a few polls.</div>';
}
const W = 600, H = 70, PAD = 4;
const values = points.map(p => p.rx_avg);
const low = Math.min(...values) - 0.5, high = Math.max(...values) + 0.5;
const t0 = points[0].ts, tSpan = (points[points.length - 1].ts - t0) || 1;
const coord = p => `${((p.ts - t0) / tSpan * W).toFixed(1)},` +
`${(PAD + (high - p.rx_avg) / (high - low) * (H - 2 * PAD)).toFixed(1)}`;
// Break the line where samples are missing rather than drawing across an outage
const gaps = points.slice(1).map((p, i) => p.ts - points[i].ts).sort((a, b) => a - b);
const maxGap = gaps[Math.floor(gaps.length / 2)] * 3;
const segments = [[]];
points.forEach((p, i) => {
if (i && p.ts - points[i - 1].ts > maxGap) segments.push([]);
segments[segments.length - 1].push(coord(p));
});
const lines = segments
.map(s => `<polyline points="${s.join(' ')}" fill="none" vector-effect="non-scaling-stroke"/>`)
.join('');
const summary = `range ${Math.min(...values)} to ${Math.max(...values)} dBm`;
return `
<div class="modem-chart">
<div class="modem-chart-title">Rx power, last 7 days <span>${lt.escHtml(summary)}</span></div>
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img"
aria-label="Rx power trend, ${lt.escHtml(summary)}">${lines}</svg>
</div>`;
}
function _modemWhen(epochSeconds) {
return new Date(epochSeconds * 1000).toLocaleString([], { dateStyle: 'short', timeStyle: 'short' });
}
function _modemLastRestart(history) {
const restarts = (history && history.restarts) || [];
if (!restarts.length) return '';
const last = restarts[restarts.length - 1];
return `restarted ${_modemWhen(last.at)}${last.by ? ` by ${last.by}` : ''}`;
}
function _modemDuration(seconds) {
if (seconds < 60) return `${Math.round(seconds)}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
}
function _modemOutages(history) {
const outages = ((history && history.outages) || []).slice(-5).reverse();
if (!outages.length) return '';
const rows = outages.map(o => {
const length = o.restored_at === null || o.restored_at === undefined
? '<span class="lt-text-red">ongoing</span>'
: lt.escHtml(_modemDuration(o.restored_at - o.failed_at));
return `<li><span>${lt.escHtml(_modemWhen(o.failed_at))}</span><span>${length}</span></li>`;
}).join('');
return `
<div class="modem-outages">
<div class="modem-chart-title">Recent WAN outages</div>
<ul>${rows}</ul>
</div>`;
}
function updateModem(data) {
const section = document.getElementById('modem-section');
const body = document.getElementById('modem-body');
if (!section || !body) return;
section.hidden = !data.configured;
if (!data.configured) return;
// On the dashboard, link to the modem's page. On that page, it would link to itself.
const detailsLink = document.getElementById('modem-device-link');
if (detailsLink && typeof DEVICE_NAME === 'undefined' && data.name) {
detailsLink.href = deviceUrl(data.name);
detailsLink.hidden = false;
}
const rxLabel = document.getElementById('topo-modem-rx');
const topo = document.getElementById('topo-modem');
const title = document.getElementById('modem-title');
if (!data.reachable) {
if (rxLabel) rxLabel.textContent = 'unreachable';
if (topo) topo.classList.add('topo-v2-status-down');
body.innerHTML = `
<div class="modem-error">Modem status unavailable: ${lt.escHtml(data.error || 'unknown error')}
<span class="modem-stat-note">Last attempt ${lt.escHtml(data.updated || '–')}</span></div>
${_modemChart(data.history)}`;
return;
}
const problems = data.wan_problems || [];
const optics = data.optics || {};
const status = data.optics_status || {};
const counters = data.counters || {};
const rates = data.rates || {};
const rx = optics.rx_power ? optics.rx_power.value : null;
if (topo) topo.classList.toggle('topo-v2-status-down', problems.length > 0);
if (rxLabel) rxLabel.textContent = (rx === null || rx === undefined) ? 'Rx –' : `Rx ${rx} dBm`;
if (title) {
title.textContent = [data.model || 'AT&T Gateway', data.firmware && `fw ${data.firmware}`]
.filter(Boolean).join(' · ');
}
const errs = (counters.rx_errors || 0) + (counters.tx_errors || 0);
const drops = (counters.rx_drops || 0) + (counters.tx_drops || 0);
body.innerHTML = `
${problems.length ? `<div class="modem-error">WAN down: ${lt.escHtml(problems.join('; '))}</div>` : ''}
<div class="modem-stats">
${_modemStat('WAN', problems.length ? 'DOWN' : 'UP', problems.length ? 'lt-text-red' : 'lt-text-green',
data.wan_ip || '')}
${_modemStat('PON', data.pon_state || '–', '', data.wavelength || '')}
${_modemOptic('Rx power', optics.rx_power, status.rx_power, 'dBm')}
${_modemOptic('Tx power', optics.tx_power, status.tx_power, 'dBm')}
${_modemOptic('Optics temp', optics.temperature, status.temperature, '°C')}
${_modemStat('Throughput', `↓ ${_modemRate(rates.rx_bytes)}`, '', `↑ ${_modemRate(rates.tx_bytes)}`)}
${_modemStat('Errors / drops', `${errs} / ${drops}`, errs ? 'lt-text-amber' : '', 'since modem reboot')}
${_modemStat('Uptime', _modemUptime(data.uptime_s), '', _modemLastRestart(data.wan_history))}
</div>
${_modemChart(data.history)}
${_modemOutages(data.wan_history)}
<div class="modem-stat-note modem-updated">Updated ${lt.escHtml(data.updated || '–')}</div>`;
}
function updateUnifiTable(devices) {
const tbody = document.querySelector('#unifi-table tbody');
if (!tbody || !devices.length) return;
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 suppressBtn = !d.connected
? `<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
data-sup-type="unifi_device"
data-sup-name="${lt.escHtml(d.name)}"
data-sup-detail=""
aria-label="Suppress alerts for ${lt.escHtml(d.name)}">🔕 Suppress</button>`
: '';
return `
<tr class="${statusClass}">
<td><span class="${dotClass}"></span> ${statusText}</td>
<td><a class="device-link" href="${lt.escHtml(deviceUrl(d.name))}"><strong>${lt.escHtml(d.name)}</strong></a></td>
<td>${lt.escHtml(d.type)}</td>
<td>${lt.escHtml(d.model)}</td>
<td>${lt.escHtml(d.ip)}</td>
<td>${suppressBtn}</td>
</tr>`;
}).join('');
}
function updateEventsTable(events, totalActive) {
const wrap = document.getElementById('events-table-wrap');
if (!wrap) return;
const active = (events || []).filter(e => e.severity !== 'info');
if (!active.length) {
wrap.innerHTML = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-icon">✔</div><div class="lt-empty-state-title">No active alerts</div></div>';
return;
}
const truncated = totalActive !== null && totalActive !== undefined && totalActive > active.length;
const countNotice = truncated
? `<div class="pagination-notice">Showing ${active.length} of ${totalActive} active alerts &mdash; <a href="/api/events?limit=1000">view all via API</a></div>`
: '';
const rows = active.map(e => {
const supType = e.event_type === 'unifi_device_offline' ? 'unifi_device'
: e.event_type === 'interface_down' ? 'interface'
: 'host';
const ticketBase = (typeof GANDALF_CONFIG !== 'undefined' && GANDALF_CONFIG.ticket_web_url)
? GANDALF_CONFIG.ticket_web_url : 'http://t.lotusguild.org/ticket/';
const ticket = e.ticket_id
? `<a href="${lt.escHtml(ticketBase)}${lt.escHtml(String(e.ticket_id))}" target="_blank"
class="ticket-link">#${lt.escHtml(String(e.ticket_id))}</a>`
: '–';
const supBadge = e.is_suppressed
? `<span class="lt-badge badge-suppressed" title="Alert suppressed">🔕 sup</span>`
: '';
return `
<tr class="row-${e.severity}${e.is_suppressed ? ' row-suppressed' : ''}">
<td><span class="lt-badge badge-${e.severity}">${e.severity}</span>${supBadge}</td>
<td>${lt.escHtml(e.event_type.replace(/_/g,' '))}</td>
<td><strong>${lt.escHtml(e.target_name)}</strong></td>
<td>${lt.escHtml(e.target_detail || '–')}</td>
<td class="desc-cell" title="${lt.escHtml(e.description || '')}">${lt.escHtml((e.description||'').substring(0,60))}${(e.description||'').length>60?'…':''}</td>
<td class="ts-cell" title="${lt.escHtml(e.first_seen||'')}">${lt.time.ago(_toIso(e.first_seen))}</td>
<td class="ts-cell" title="${lt.escHtml(e.last_seen||'')}">${lt.time.ago(_toIso(e.last_seen))}</td>
<td>${e.consecutive_failures}</td>
<td>${ticket}</td>
<td>
<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
data-sup-type="${lt.escHtml(supType)}"
data-sup-name="${lt.escHtml(e.target_name)}"
data-sup-detail="${lt.escHtml(e.target_detail||'')}"
aria-label="Suppress alert for ${lt.escHtml(e.target_name)}">🔕</button>
</td>
</tr>`;
}).join('');
wrap.innerHTML = `
${countNotice}
<div class="lt-table-wrap">
<table class="lt-table" id="events-table">
<caption class="lt-sr-only">Active network alerts</caption>
<thead>
<tr>
<th>Sev</th><th>Type</th><th>Target</th><th>Detail</th>
<th>Description</th><th>First Seen</th><th>Last Seen</th><th>Failures</th><th>Ticket</th><th>Actions</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
// ── Suppression modal ─────────────────────────────────────────────────
function openSuppressModal(type, name, detail) {
const modal = document.getElementById('suppress-modal');
if (!modal) return;
document.getElementById('sup-type').value = type;
document.getElementById('sup-name').value = name;
document.getElementById('sup-detail').value = detail;
document.getElementById('sup-reason').value = '';
document.getElementById('sup-expires').value = '';
updateSuppressForm();
lt.modal.open('suppress-modal');
document.querySelectorAll('#suppress-modal .pill').forEach(p => {
p.classList.remove('active');
p.setAttribute('aria-pressed', 'false');
});
const manualPill = document.querySelector('#suppress-modal .pill-manual');
if (manualPill) { manualPill.classList.add('active'); manualPill.setAttribute('aria-pressed', 'true'); }
const hint = document.getElementById('duration-hint');
if (hint) hint.textContent = 'Suppression will persist until manually removed.';
}
function closeSuppressModal() {
lt.modal.close('suppress-modal');
}
function updateSuppressForm() {
const type = document.getElementById('sup-type').value;
const nameGrp = document.getElementById('sup-name-group');
const detailGrp = document.getElementById('sup-detail-group');
const nameInput = document.getElementById('sup-name');
const detailInput = document.getElementById('sup-detail');
if (nameGrp) nameGrp.style.display = (type === 'all') ? 'none' : '';
if (detailGrp) detailGrp.style.display = (type === 'interface') ? '' : 'none';
if (nameInput) {
const req = (type !== 'all');
nameInput.required = req;
nameInput.setAttribute('aria-required', String(req));
}
if (detailInput) {
const req = (type === 'interface');
detailInput.required = req;
detailInput.setAttribute('aria-required', String(req));
}
}
function setDuration(mins, el, opts) {
const o = opts || {};
const expiresEl = document.getElementById(o.expiresId || 'sup-expires');
const pillSel = o.pillSel || '#suppress-modal .pill';
const hint = document.getElementById(o.hintId || 'duration-hint');
if (expiresEl) expiresEl.value = mins || '';
document.querySelectorAll(pillSel).forEach(p => {
p.classList.remove('active');
p.setAttribute('aria-pressed', 'false');
});
if (el) { el.classList.add('active'); el.setAttribute('aria-pressed', 'true'); }
if (hint) {
if (mins) {
const h = Math.floor(mins / 60), m = mins % 60;
hint.textContent = `Expires in ${h ? h + 'h ' : ''}${m ? m + 'm' : ''}.`;
} else {
hint.textContent = 'Suppression will persist until manually removed.';
}
}
}
async function submitSuppress(e) {
e.preventDefault();
const type = document.getElementById('sup-type').value;
const name = document.getElementById('sup-name').value;
const detail = document.getElementById('sup-detail').value;
const reason = document.getElementById('sup-reason').value;
const expires = document.getElementById('sup-expires').value;
if (!reason.trim()) { showToast('Reason is required', 'error'); return; }
if (type !== 'all' && !name.trim()) { showToast('Target name is required', 'error'); return; }
try {
await lt.api.post('/api/suppressions', {
target_type: type,
target_name: name,
target_detail: detail,
reason,
expires_minutes: expires ? parseInt(expires) : null,
});
closeSuppressModal();
showToast('Suppression applied ✔', 'success');
setTimeout(refreshAll, 500);
} catch (err) {
showToast(err.message || 'Failed to apply suppression', 'error');
}
}
// ── Suppress form – wired here so the modal works from any page ──────
document.getElementById('suppress-form')?.addEventListener('submit', submitSuppress);
document.getElementById('sup-type')?.addEventListener('change', updateSuppressForm);
// ── Global click delegation ───────────────────────────────────────────
document.addEventListener('click', e => {
// Refresh button
if (e.target.closest('[data-action="refresh"]')) {
lt.autoRefresh.now();
return;
}
// Duration pills (data-duration="" = manual/forever)
const pill = e.target.closest('.pill[data-duration]');
if (pill) {
const val = pill.dataset.duration;
setDuration(val ? parseInt(val) : null, pill);
return;
}
// Suppress buttons
const btn = e.target.closest('.btn-suppress[data-sup-type]');
if (btn) {
openSuppressModal(
btn.dataset.supType || '',
btn.dataset.supName || '',
btn.dataset.supDetail || '',
);
}
});