Files

219 lines
9.5 KiB
JavaScript
Raw Permalink Normal View History

'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();