Lint / JS (eslint) (push) Successful in 21s
Lint / Python (flake8) (push) Successful in 1m20s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m0s
Test / Python Tests (pytest) (push) Successful in 1m48s
Lint / Python (flake8) (pull_request) Successful in 1m44s
Lint / JS (eslint) (pull_request) Successful in 30s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 1m42s
Test / Python Tests (pytest) (pull_request) Successful in 1m16s
The dashboard's hand-drawn diagram was out of date. /topology replaces it with the network as the switches report it, every device drawn to scale from its faceplate or cutsheet front, and one cable per physical link leaving the port it is plugged into. - topology.py builds the graph: switch-to-switch cables seen from both ends are merged, servers get one cable per NIC, the gateway's internet port goes to the modem and on to the internet, unknown neighbours are stubs, and gear with no live cable is drawn where it was last seen. - Ports named after a different device than the one plugged in are flagged. - Cables show medium, speed, state and traffic; click one for its details, hover a device to trace its path to the internet; pan, zoom and filters. - The monitor now keeps host_macs (MAC to host NIC) in link_stats. - The old dashboard diagram and its JS and CSS are removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
727 lines
30 KiB
JavaScript
727 lines
30 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 || []);
|
|
} else {
|
|
showToast('Network data unavailable', 'warning');
|
|
}
|
|
if (statusResult.status === 'fulfilled') {
|
|
const status = statusResult.value;
|
|
updateEventsTable(status.events || [], status.total_active);
|
|
updateFollowUp(status.stale_tickets || []);
|
|
updateStatusBar(status.summary || {}, status.last_check || '', status.daemon_ok,
|
|
followUpCount(status.events || [], status.stale_tickets || []),
|
|
(status.stale_tickets || []).length > 0);
|
|
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';
|
|
}
|
|
}
|
|
|
|
// Active alerts flagged stale or with a closed ticket, plus tickets left open after clearing
|
|
function followUpCount(events, staleTickets) {
|
|
const flagged = events.filter(e => e.severity !== 'info' &&
|
|
((e.stale_days !== null && e.stale_days !== undefined) || e.ticket_closed)).length;
|
|
return flagged + staleTickets.length;
|
|
}
|
|
|
|
function updateStatusBar(summary, lastCheck, daemonOk, followUps = 0, hasStaleTickets = false) {
|
|
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>');
|
|
if (followUps) {
|
|
const href = hasStaleTickets ? '#followup' : '#events-table-wrap';
|
|
chips.push(`<a class="chip chip-warning chip-followup" href="${href}">⏳ ${followUps} TO FOLLOW UP</a>`);
|
|
}
|
|
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('');
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 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 title = document.getElementById('modem-title');
|
|
|
|
if (!data.reachable) {
|
|
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 || {};
|
|
|
|
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>`
|
|
: '';
|
|
const staleBadge = (e.stale_days !== null && e.stale_days !== undefined)
|
|
? `<span class="lt-badge badge-stale" title="Active for ${Number(e.stale_days)} days with no fix yet">⏳ ${Number(e.stale_days)}d</span>`
|
|
: '';
|
|
const closedBadge = e.ticket_closed
|
|
? `<span class="lt-badge badge-ticket-closed" title="Ticket #${lt.escHtml(String(e.ticket_id))} was closed in Tinker, but this alert is still active">ticket closed</span>`
|
|
: '';
|
|
return `
|
|
<tr class="row-${e.severity}${e.is_suppressed ? ' row-suppressed' : ''}">
|
|
<td><span class="lt-badge badge-${e.severity}">${e.severity}</span>${supBadge}${staleBadge}${closedBadge}</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>`;
|
|
}
|
|
|
|
// ── Needs follow-up: tickets left open after their alert cleared ──────
|
|
function updateFollowUp(tickets) {
|
|
const section = document.getElementById('followup');
|
|
const tbody = document.querySelector('#followup-table tbody');
|
|
if (!section || !tbody) return;
|
|
section.hidden = !tickets.length;
|
|
const badge = document.getElementById('followup-badge');
|
|
if (badge) badge.textContent = tickets.length;
|
|
const ticketBase = (typeof GANDALF_CONFIG !== 'undefined' && GANDALF_CONFIG.ticket_web_url)
|
|
? GANDALF_CONFIG.ticket_web_url : 'http://t.lotusguild.org/ticket/';
|
|
tbody.innerHTML = tickets.map(t => `
|
|
<tr>
|
|
<td><a href="${lt.escHtml(ticketBase)}${lt.escHtml(String(t.ticket_id))}" target="_blank" rel="noopener"
|
|
class="ticket-link">#${lt.escHtml(String(t.ticket_id))}</a></td>
|
|
<td><strong>${lt.escHtml(t.target_name || '')}</strong></td>
|
|
<td>${lt.escHtml((t.event_type || '').replace(/_/g, ' '))}${t.target_detail
|
|
? ` <span class="lt-text-muted">${lt.escHtml(t.target_detail)}</span>` : ''}</td>
|
|
<td class="ts-cell" title="${lt.escHtml(t.cleared_at || '')}">${lt.time.ago(_toIso(t.cleared_at))}</td>
|
|
<td>${Number(t.events) || 0}</td>
|
|
</tr>`).join('');
|
|
}
|
|
|
|
// ── 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');
|
|
}
|
|
});
|
|
})();
|