Lint / JS (eslint) (push) Successful in 6s
Lint / Python (flake8) (push) Successful in 1m56s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 2m3s
Test / Python Tests (pytest) (push) Successful in 1m0s
Lint / Python (flake8) (pull_request) Successful in 48s
Lint / JS (eslint) (pull_request) Successful in 6s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 2m7s
Test / Python Tests (pytest) (pull_request) Successful in 2m6s
/events lists every alert from the last 24 hours to 90 days, open or cleared, with how long each lasted, failed checks and its ticket. Filters (device, type, severity, state, range) live in the URL, so device pages and tickets link straight to a device's history. A "Recurring" table lists alerts that fired more than once on the same target. On phones the rows become cards. /api/events/history backs it (100 per page). The existing /api/events is unchanged. /health and the dashboard's monitor indicator now call the monitor stalled after five missed polls (at least 10 minutes) instead of a fixed 20, and /health reports the monitor's age. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
219 lines
9.5 KiB
JavaScript
219 lines
9.5 KiB
JavaScript
'use strict';
|
||
/* global deviceUrl, _toIso */
|
||
// Alert history page: renders /api/events/history with filters kept in the URL.
|
||
|
||
const esc = s => lt.escHtml(s == null ? '' : String(s));
|
||
const FILTERS = ['target', 'range', 'type', 'severity', 'status'];
|
||
const DEFAULTS = { target: '', range: '7d', type: '', severity: '', status: 'all' };
|
||
const RANGE_TEXT = { '24h': '24 hours', '7d': '7 days', '30d': '30 days', '90d': '90 days' };
|
||
|
||
let _offset = 0;
|
||
let _loadSeq = 0;
|
||
|
||
function typeText(t) {
|
||
return String(t || '').replace(/_/g, ' ');
|
||
}
|
||
|
||
function duration(seconds) {
|
||
let s = Math.max(0, Math.floor(seconds || 0));
|
||
if (s < 60) return `${s} s`;
|
||
let m = Math.floor(s / 60);
|
||
if (m < 60) return `${m} min`;
|
||
let h = Math.floor(m / 60);
|
||
m %= 60;
|
||
if (h < 48) return m ? `${h} h ${m} min` : `${h} h`;
|
||
const d = Math.floor(h / 24);
|
||
h %= 24;
|
||
return h ? `${d} d ${h} h` : `${d} d`;
|
||
}
|
||
|
||
function when(iso) {
|
||
if (!iso) return '–';
|
||
const d = new Date(_toIso(iso));
|
||
if (isNaN(d)) return esc(iso);
|
||
const text = d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||
return `<span title="${esc(d.toLocaleString())}">${esc(text)}</span>`;
|
||
}
|
||
|
||
function ticketLink(id) {
|
||
if (!id) return '<span class="val-neutral">–</span>';
|
||
const base = (typeof GANDALF_CONFIG !== 'undefined' && GANDALF_CONFIG.ticket_web_url) || '';
|
||
return base ? `<a href="${esc(base + id)}" target="_blank" rel="noopener">#${esc(id)}</a>` : `#${esc(id)}`;
|
||
}
|
||
|
||
function filters() {
|
||
const f = {};
|
||
FILTERS.forEach(k => { f[k] = document.getElementById(`ev-${k}`).value.trim(); });
|
||
return f;
|
||
}
|
||
|
||
function readUrl() {
|
||
const q = new URLSearchParams(location.search);
|
||
FILTERS.forEach(k => {
|
||
const el = document.getElementById(`ev-${k}`);
|
||
const v = q.get(k);
|
||
if (v == null) return;
|
||
if (el.tagName === 'SELECT' && ![...el.options].some(o => o.value === v)) {
|
||
// An alert type from a link may not be in the list until data arrives
|
||
if (k === 'type') el.add(new Option(typeText(v), v));
|
||
else return;
|
||
}
|
||
el.value = v;
|
||
});
|
||
}
|
||
|
||
function writeUrl(f) {
|
||
const q = new URLSearchParams();
|
||
FILTERS.forEach(k => { if (f[k] && f[k] !== DEFAULTS[k]) q.set(k, f[k]); });
|
||
const qs = q.toString();
|
||
history.replaceState(null, '', qs ? `?${qs}` : location.pathname);
|
||
document.getElementById('ev-clear').hidden = !FILTERS.some(k => k !== 'range' && f[k] && f[k] !== DEFAULTS[k]);
|
||
}
|
||
|
||
function setFilter(changes) {
|
||
Object.entries(changes).forEach(([k, v]) => {
|
||
const el = document.getElementById(`ev-${k}`);
|
||
if (el.tagName === 'SELECT' && v && ![...el.options].some(o => o.value === v)) el.add(new Option(typeText(v), v));
|
||
el.value = v;
|
||
});
|
||
_offset = 0;
|
||
load();
|
||
}
|
||
|
||
function fillTypes(types) {
|
||
const sel = document.getElementById('ev-type');
|
||
const current = sel.value;
|
||
sel.innerHTML = '<option value="">All alert types</option>' +
|
||
types.map(t => `<option value="${esc(t)}">${esc(typeText(t))}</option>`).join('');
|
||
if (current && !types.includes(current)) sel.add(new Option(typeText(current), current));
|
||
sel.value = current;
|
||
}
|
||
|
||
function renderSummary(data, f) {
|
||
const open = data.events.filter(e => !e.resolved_at).length;
|
||
const chips = [
|
||
`<span class="cs-chip"><b>${data.total}</b> alert${data.total === 1 ? '' : 's'} in the last ${esc(RANGE_TEXT[f.range] || f.range)}</span>`,
|
||
];
|
||
if (open && data.offset === 0) chips.push(`<span class="cs-chip cs-chip-bad"><b>${open}</b> open now</span>`);
|
||
Object.entries(data.by_type || {}).forEach(([t, n]) => {
|
||
const on = f.type === t;
|
||
chips.push(`<button type="button" class="cs-chip ev-type-chip${on ? ' active' : ''}" data-type="${esc(on ? '' : t)}"
|
||
aria-pressed="${on}"><b>${n}</b> ${esc(typeText(t))}</button>`);
|
||
});
|
||
document.getElementById('ev-summary').innerHTML = chips.join('');
|
||
}
|
||
|
||
function renderFlappers(rows) {
|
||
const sec = document.getElementById('ev-flappers-section');
|
||
sec.hidden = !rows.length;
|
||
if (!rows.length) return;
|
||
document.getElementById('ev-flappers').innerHTML = `<div class="lt-table-wrap"><table class="lt-table ev-cards">
|
||
<caption class="lt-sr-only">Alerts that fired more than once</caption>
|
||
<thead><tr><th>Alert</th><th>Target</th><th>Times</th><th>Total time</th><th>Last started</th><th></th></tr></thead>
|
||
<tbody>${rows.map(r => `
|
||
<tr>
|
||
<td class="ev-c-type">${esc(typeText(r.event_type))}${r.open_now ? ' <span class="lt-badge badge-critical">open</span>' : ''}</td>
|
||
<td class="ev-c-target"><a class="device-link" href="${esc(deviceUrl(r.target_name))}">${esc(r.target_name)}</a>
|
||
${r.target_detail ? `<span class="cs-port">${esc(r.target_detail)}</span>` : ''}</td>
|
||
<td data-label="Times"><strong>${r.n}</strong></td>
|
||
<td data-label="Total">${esc(duration(r.total_s))}</td>
|
||
<td class="ts-cell" data-label="Last">${when(r.last_seen)}</td>
|
||
<td class="ev-c-action"><button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-show-type="${esc(r.event_type)}"
|
||
data-show-target="${esc(r.target_name)}">Show these</button></td>
|
||
</tr>`).join('')}</tbody></table></div>`;
|
||
}
|
||
|
||
function renderTable(data) {
|
||
document.getElementById('ev-count').textContent = data.total;
|
||
const wrap = document.getElementById('ev-table');
|
||
if (!data.events.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 alerts match</div></div>';
|
||
document.getElementById('ev-pager').innerHTML = '';
|
||
return;
|
||
}
|
||
wrap.innerHTML = `<div class="lt-table-wrap"><table class="lt-table ev-table ev-cards">
|
||
<caption class="lt-sr-only">Alert history</caption>
|
||
<thead><tr><th>State</th><th>Type</th><th>Target</th><th>Started</th><th>Lasted</th>
|
||
<th title="Consecutive failed checks">Checks</th><th>Ticket</th><th>Description</th></tr></thead>
|
||
<tbody>${data.events.map(e => {
|
||
const open = !e.resolved_at;
|
||
return `<tr class="${open ? `row-${esc(e.severity)}` : ''}">
|
||
<td class="ev-c-state"><span class="lt-badge badge-${open ? esc(e.severity) : 'neutral'}">${open ? 'open' : 'cleared'}</span></td>
|
||
<td class="ev-c-type"><button type="button" class="ev-link" data-show-type="${esc(e.event_type)}"
|
||
title="Only show ${esc(typeText(e.event_type))}">${esc(typeText(e.event_type))}</button></td>
|
||
<td class="ev-c-target"><a class="device-link" href="${esc(deviceUrl(e.target_name))}">${esc(e.target_name)}</a>
|
||
${e.target_detail ? `<span class="cs-port">${esc(e.target_detail)}</span>` : ''}</td>
|
||
<td class="ts-cell" data-label="Started">${when(e.first_seen)}</td>
|
||
<td data-label="Lasted">${esc(duration(e.duration_s))}${open ? ' <span class="val-neutral">so far</span>' : ''}</td>
|
||
<td data-label="Checks">${esc(e.consecutive_failures)}</td>
|
||
<td data-label="Ticket">${ticketLink(e.ticket_id)}</td>
|
||
<td class="desc-cell ev-c-desc" title="${esc(e.description || '')}">${esc(e.description || '')}</td>
|
||
</tr>`;
|
||
}).join('')}</tbody></table></div>`;
|
||
|
||
const end = data.offset + data.events.length;
|
||
const prev = data.offset > 0;
|
||
const next = end < data.total;
|
||
document.getElementById('ev-pager').innerHTML = (prev || next) ? `
|
||
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-page="${data.offset - data.page_size}" ${prev ? '' : 'disabled'}>← Newer</button>
|
||
<span>${data.offset + 1}–${end} of ${data.total}</span>
|
||
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-page="${end}" ${next ? '' : 'disabled'}>Older →</button>` : '';
|
||
}
|
||
|
||
async function load() {
|
||
const f = filters();
|
||
writeUrl(f);
|
||
const q = new URLSearchParams();
|
||
FILTERS.forEach(k => { if (f[k]) q.set(k, f[k]); });
|
||
if (_offset) q.set('offset', _offset);
|
||
const seq = ++_loadSeq;
|
||
try {
|
||
const data = await lt.api.get(`/api/events/history?${q}`);
|
||
if (seq !== _loadSeq) return; // a newer filter change is already loading
|
||
fillTypes(data.types || []);
|
||
renderSummary(data, f);
|
||
renderFlappers(data.flappers || []);
|
||
renderTable(data);
|
||
} catch (err) {
|
||
if (seq !== _loadSeq) return;
|
||
document.getElementById('ev-table').innerHTML =
|
||
`<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Could not load alerts</div>
|
||
<div class="lt-empty-state-text">${esc(err.message || err)}</div></div>`;
|
||
}
|
||
}
|
||
|
||
let _typing = null;
|
||
document.getElementById('ev-target').addEventListener('input', () => {
|
||
clearTimeout(_typing);
|
||
_typing = setTimeout(() => { _offset = 0; load(); }, 300);
|
||
});
|
||
document.getElementById('ev-filters').addEventListener('change', e => {
|
||
if (e.target.id === 'ev-target') return;
|
||
_offset = 0;
|
||
load();
|
||
});
|
||
document.getElementById('ev-filters').addEventListener('submit', e => { e.preventDefault(); _offset = 0; load(); });
|
||
document.getElementById('ev-clear').addEventListener('click', () => {
|
||
setFilter({ target: '', type: '', severity: '', status: 'all' });
|
||
});
|
||
document.addEventListener('click', e => {
|
||
const typeChip = e.target.closest('.ev-type-chip');
|
||
if (typeChip) return setFilter({ type: typeChip.dataset.type });
|
||
const show = e.target.closest('[data-show-type]');
|
||
if (show) {
|
||
const changes = { type: show.dataset.showType };
|
||
if (show.dataset.showTarget) changes.target = show.dataset.showTarget;
|
||
return setFilter(changes);
|
||
}
|
||
const page = e.target.closest('[data-page]');
|
||
if (page && !page.disabled) {
|
||
_offset = Math.max(0, parseInt(page.dataset.page, 10) || 0);
|
||
load();
|
||
document.getElementById('ev-count').scrollIntoView({ block: 'nearest' });
|
||
}
|
||
});
|
||
|
||
readUrl();
|
||
load();
|