Lint / JS (eslint) (push) Successful in 42s
Lint / Python (flake8) (push) Successful in 1m35s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m3s
Test / Python Tests (pytest) (push) Successful in 1m5s
Lint / Python (flake8) (pull_request) Successful in 58s
Lint / JS (eslint) (pull_request) Successful in 8s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 1m1s
Test / Python Tests (pytest) (pull_request) Successful in 1m10s
A UniFi Network update on 2026-11-12 dropped the UDM's WAN, Smart Power power-cycled the modem, and Gandalf opened two tickets for the one event. - Modem and WAN reports from one check cycle open a single ticket, led by the most urgent, with the rest listed under "Also seen in this incident". A report while the modem has an open alert, or one that ended under 30 minutes ago, becomes a comment on that ticket instead. - UniFi update log entries (NETWORK_UPDATED, FIRMWARE_UPDATED_V2 and the like) add a note to modem/WAN tickets from 20 minutes before the update to 15 after, and hold new modem/WAN tickets for 15 minutes. - A Maintenance button for admins starts a global suppression for 30 min to 4 hours, with a banner on every page and End now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
713 lines
29 KiB
JavaScript
713 lines
29 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(d.url || 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 = matches[active].url || 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 || '',
|
|
);
|
|
}
|
|
});
|
|
|
|
// ── Maintenance: pause every ticket for a while (a global suppression with an expiry) ──
|
|
(function () {
|
|
const until = document.querySelector('.maint-banner .maint-until');
|
|
if (until) {
|
|
const t = new Date(until.getAttribute('datetime'));
|
|
if (!isNaN(t)) {
|
|
const sameDay = t.toDateString() === new Date().toDateString();
|
|
until.textContent = sameDay
|
|
? t.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
|
: t.toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' });
|
|
until.title = t.toLocaleString();
|
|
}
|
|
}
|
|
|
|
let minutes = 60;
|
|
document.addEventListener('click', async e => {
|
|
if (e.target.closest('[data-action="start-maintenance"]')) {
|
|
lt.modal.open('maint-modal');
|
|
document.getElementById('maint-reason')?.focus();
|
|
return;
|
|
}
|
|
const pill = e.target.closest('.maint-pill');
|
|
if (pill) {
|
|
minutes = parseInt(pill.dataset.minutes, 10);
|
|
document.querySelectorAll('.maint-pill').forEach(p => {
|
|
const on = p === pill;
|
|
p.classList.toggle('active', on);
|
|
p.setAttribute('aria-pressed', String(on));
|
|
});
|
|
return;
|
|
}
|
|
const end = e.target.closest('[data-action="end-maintenance"]');
|
|
if (end) {
|
|
end.disabled = true;
|
|
try {
|
|
await lt.api.delete(`/api/suppressions/${encodeURIComponent(end.dataset.supId)}`);
|
|
window.location.reload();
|
|
} catch (err) {
|
|
end.disabled = false;
|
|
showToast(err.message || 'Could not end maintenance', 'error');
|
|
}
|
|
}
|
|
});
|
|
|
|
document.getElementById('maint-form')?.addEventListener('submit', async e => {
|
|
e.preventDefault();
|
|
const reason = (document.getElementById('maint-reason').value || '').trim();
|
|
if (!reason) { showToast('Reason is required', 'error'); return; }
|
|
const submit = e.target.querySelector('[type="submit"]');
|
|
submit.disabled = true;
|
|
try {
|
|
await lt.api.post('/api/suppressions', {
|
|
target_type: 'all', target_name: '', reason, expires_minutes: minutes,
|
|
});
|
|
window.location.reload();
|
|
} catch (err) {
|
|
submit.disabled = false;
|
|
showToast(err.message || 'Could not start maintenance', 'error');
|
|
}
|
|
});
|
|
})();
|