Lint / JS (eslint) (push) Successful in 7s
Lint / Python (flake8) (push) Successful in 57s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 57s
Lint / JS (eslint) (pull_request) Successful in 10s
Lint / Python (flake8) (pull_request) Successful in 51s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 1m0s
Test / Python Tests (pytest) (push) Successful in 1m3s
Test / Python Tests (pytest) (pull_request) Successful in 52s
Page titles read "Rack | GANDALF", ranges use a hyphen (U26-42, 1-100 of 300), empty cells show "-", and dash-separated phrases use a colon, comma or full stop. In the shared base.js only the on-screen strings changed (boot line, placeholders, offline banner and toast). 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();
|