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
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>
651 lines
27 KiB
JavaScript
651 lines
27 KiB
JavaScript
'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 — <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 || '',
|
||
);
|
||
}
|
||
});
|