Integrate TDS v1.2 lt.* APIs throughout app
Lint / Python (flake8) (push) Failing after 58s
Lint / JS (eslint) (push) Successful in 7s
Security / Python Security (bandit) (push) Failing after 44s
Test / Python Tests (pytest) (push) Successful in 1m24s
Lint / Notify on failure (push) Successful in 2s
Lint / Deploy (push) Has been skipped

- app.js: replace raw fetch/escHtml/fmtRelTime with lt.api, lt.escHtml, lt.time.ago; modal open/close via lt.modal; add _toIso() for timestamp normalisation
- index.html: data-action="refresh", data-duration pills, lt.autoRefresh.start, remove local fmtRelTime
- suppressions.html: lt.api.post/delete, data-dur pill delegation
- base.html: user avatar with initials, admin badge, lt.keys.on('r') replaces manual keydown handler
- base.css: add dot-*, chip, row-state aliases so apps can use unprefixed class names

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 23:46:44 -04:00
parent bb6393e35b
commit 293edd674e
5 changed files with 123 additions and 161 deletions
+56 -91
View File
@@ -1,20 +1,18 @@
'use strict'; 'use strict';
// ── Auto-redirect on auth timeout ───────────────────────────────────── // ── Auto-redirect on auth timeout ─────────────────────────────────────
// Intercept all fetch() calls: if the server returns 401 (auth expired), // Wraps fetch so a 401 (Authelia session expired) forces a full reload.
// reload the page so Authelia redirects to the login screen. // lt.api uses fetch internally, so this covers all API calls too.
(function () { (function () {
const _fetch = window.fetch; const _fetch = window.fetch;
window.fetch = async function (...args) { window.fetch = async function (...args) {
const resp = await _fetch(...args); const resp = await _fetch(...args);
if (resp.status === 401) { if (resp.status === 401) window.location.reload();
window.location.reload();
}
return resp; return resp;
}; };
})(); })();
// ── Toast notifications — delegates to lt.toast from base.js ───────── // ── Toast notifications — thin wrapper over lt.toast ──────────────────
function showToast(msg, type = 'success') { function showToast(msg, type = 'success') {
if (type === 'error') return lt.toast.error(msg); if (type === 'error') return lt.toast.error(msg);
if (type === 'warning') return lt.toast.warning(msg); if (type === 'warning') return lt.toast.warning(msg);
@@ -22,24 +20,25 @@ function showToast(msg, type = 'success') {
return lt.toast.success(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 ──────────────────────────────────────────── // ── Dashboard auto-refresh ────────────────────────────────────────────
async function refreshAll() { async function refreshAll() {
try { try {
const [netResp, statusResp] = await Promise.all([ const [net, status] = await Promise.all([
fetch('/api/network'), lt.api.get('/api/network'),
fetch('/api/status'), lt.api.get('/api/status'),
]); ]);
if (!netResp.ok || !statusResp.ok) return;
const net = await netResp.json();
const status = await statusResp.json();
updateHostGrid(net.hosts || {}); updateHostGrid(net.hosts || {});
updateUnifiTable(net.unifi || []); updateUnifiTable(net.unifi || []);
updateEventsTable(status.events || [], status.total_active); updateEventsTable(status.events || [], status.total_active);
updateStatusBar(status.summary || {}, status.last_check || ''); updateStatusBar(status.summary || {}, status.last_check || '');
updateTopology(net.hosts || {}); updateTopology(net.hosts || {});
} catch (e) { } catch (e) {
console.warn('Refresh failed:', e); console.warn('Refresh failed:', e);
} }
@@ -57,23 +56,17 @@ function updateStatusBar(summary, lastCheck) {
const lc = document.getElementById('last-check'); const lc = document.getElementById('last-check');
if (lc && lastCheck) lc.textContent = lastCheck; if (lc && lastCheck) lc.textContent = lastCheck;
// Update browser tab title with alert count
const critCount = summary.critical || 0; const critCount = summary.critical || 0;
const warnCount = summary.warning || 0; const warnCount = summary.warning || 0;
if (critCount) { if (critCount) document.title = `(${critCount} CRIT) GANDALF`;
document.title = `(${critCount} CRIT) GANDALF`; else if (warnCount) document.title = `(${warnCount} WARN) GANDALF`;
} else if (warnCount) { else document.title = 'GANDALF';
document.title = `(${warnCount} WARN) GANDALF`;
} else {
document.title = 'GANDALF';
}
// Stale data banner: warn if last_check is older than 15 minutes // Stale data banner: warn if last_check is older than 15 minutes
let staleBanner = document.getElementById('stale-banner'); let staleBanner = document.getElementById('stale-banner');
if (lastCheck) { if (lastCheck) {
// last_check format: "2026-03-14 14:14:21 UTC" const checkAge = (Date.now() - new Date(_toIso(lastCheck))) / 1000;
const checkAge = (Date.now() - new Date(lastCheck.replace(' UTC', 'Z').replace(' ', 'T'))) / 1000; if (checkAge > 900) {
if (checkAge > 900) { // 15 minutes
if (!staleBanner) { if (!staleBanner) {
staleBanner = document.createElement('div'); staleBanner = document.createElement('div');
staleBanner.id = 'stale-banner'; staleBanner.id = 'stale-banner';
@@ -94,15 +87,12 @@ function updateHostGrid(hosts) {
const card = document.querySelector(`.host-card[data-host="${CSS.escape(name)}"]`); const card = document.querySelector(`.host-card[data-host="${CSS.escape(name)}"]`);
if (!card) continue; if (!card) continue;
// Update card border class
card.className = card.className.replace(/host-card-(up|down|degraded|unknown)/g, ''); card.className = card.className.replace(/host-card-(up|down|degraded|unknown)/g, '');
card.classList.add(`host-card-${host.status}`); card.classList.add(`host-card-${host.status}`);
// Update status dot in header
const dot = card.querySelector('.host-status-dot'); const dot = card.querySelector('.host-status-dot');
if (dot) dot.className = `host-status-dot dot-${host.status}`; if (dot) dot.className = `host-status-dot dot-${host.status}`;
// Update interface rows
const ifaceList = card.querySelector('.iface-list'); const ifaceList = card.querySelector('.iface-list');
if (ifaceList && host.interfaces && Object.keys(host.interfaces).length > 0) { if (ifaceList && host.interfaces && Object.keys(host.interfaces).length > 0) {
ifaceList.innerHTML = Object.entries(host.interfaces) ifaceList.innerHTML = Object.entries(host.interfaces)
@@ -110,7 +100,7 @@ function updateHostGrid(hosts) {
.map(([iface, state]) => ` .map(([iface, state]) => `
<div class="iface-row"> <div class="iface-row">
<span class="iface-dot dot-${state}"></span> <span class="iface-dot dot-${state}"></span>
<span class="iface-name">${escHtml(iface)}</span> <span class="iface-name">${lt.escHtml(iface)}</span>
<span class="iface-state state-${state}">${state}</span> <span class="iface-state state-${state}">${state}</span>
</div> </div>
`).join(''); `).join('');
@@ -146,16 +136,16 @@ function updateUnifiTable(devices) {
const suppressBtn = !d.connected const suppressBtn = !d.connected
? `<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress" ? `<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
data-sup-type="unifi_device" data-sup-type="unifi_device"
data-sup-name="${escHtml(d.name)}" data-sup-name="${lt.escHtml(d.name)}"
data-sup-detail="">🔕 Suppress</button>` data-sup-detail="">🔕 Suppress</button>`
: ''; : '';
return ` return `
<tr class="${statusClass}"> <tr class="${statusClass}">
<td><span class="${dotClass}"></span> ${statusText}</td> <td><span class="${dotClass}"></span> ${statusText}</td>
<td><strong>${escHtml(d.name)}</strong></td> <td><strong>${lt.escHtml(d.name)}</strong></td>
<td>${escHtml(d.type)}</td> <td>${lt.escHtml(d.type)}</td>
<td>${escHtml(d.model)}</td> <td>${lt.escHtml(d.model)}</td>
<td>${escHtml(d.ip)}</td> <td>${lt.escHtml(d.ip)}</td>
<td>${suppressBtn}</td> <td>${suppressBtn}</td>
</tr>`; </tr>`;
}).join(''); }).join('');
@@ -183,25 +173,25 @@ function updateEventsTable(events, totalActive) {
const ticketBase = (typeof GANDALF_CONFIG !== 'undefined' && GANDALF_CONFIG.ticket_web_url) const ticketBase = (typeof GANDALF_CONFIG !== 'undefined' && GANDALF_CONFIG.ticket_web_url)
? GANDALF_CONFIG.ticket_web_url : 'http://t.lotusguild.org/ticket/'; ? GANDALF_CONFIG.ticket_web_url : 'http://t.lotusguild.org/ticket/';
const ticket = e.ticket_id const ticket = e.ticket_id
? `<a href="${ticketBase}${e.ticket_id}" target="_blank" ? `<a href="${lt.escHtml(ticketBase)}${lt.escHtml(String(e.ticket_id))}" target="_blank"
class="ticket-link">#${e.ticket_id}</a>` class="ticket-link">#${e.ticket_id}</a>`
: ''; : '';
return ` return `
<tr class="row-${e.severity}"> <tr class="row-${e.severity}">
<td><span class="lt-badge badge-${e.severity}">${e.severity}</span></td> <td><span class="lt-badge badge-${e.severity}">${e.severity}</span></td>
<td>${escHtml(e.event_type.replace(/_/g,' '))}</td> <td>${lt.escHtml(e.event_type.replace(/_/g,' '))}</td>
<td><strong>${escHtml(e.target_name)}</strong></td> <td><strong>${lt.escHtml(e.target_name)}</strong></td>
<td>${escHtml(e.target_detail || '')}</td> <td>${lt.escHtml(e.target_detail || '')}</td>
<td class="desc-cell" title="${escHtml(e.description || '')}">${escHtml((e.description||'').substring(0,60))}${(e.description||'').length>60?'…':''}</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="${escHtml(e.first_seen||'')}">${fmtRelTime(e.first_seen)}</td> <td class="ts-cell" title="${lt.escHtml(e.first_seen||'')}">${lt.time.ago(_toIso(e.first_seen))}</td>
<td class="ts-cell" title="${escHtml(e.last_seen||'')}">${fmtRelTime(e.last_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>${e.consecutive_failures}</td>
<td>${ticket}</td> <td>${ticket}</td>
<td> <td>
<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress" <button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
data-sup-type="${escHtml(supType)}" data-sup-type="${lt.escHtml(supType)}"
data-sup-name="${escHtml(e.target_name)}" data-sup-name="${lt.escHtml(e.target_name)}"
data-sup-detail="${escHtml(e.target_detail||'')}">🔕</button> data-sup-detail="${lt.escHtml(e.target_detail||'')}">🔕</button>
</td> </td>
</tr>`; </tr>`;
}).join(''); }).join('');
@@ -222,7 +212,7 @@ function updateEventsTable(events, totalActive) {
</div>`; </div>`;
} }
// ── Suppression modal (dashboard) ──────────────────────────────────── // ── Suppression modal ─────────────────────────────────────────────────
function openSuppressModal(type, name, detail) { function openSuppressModal(type, name, detail) {
const modal = document.getElementById('suppress-modal'); const modal = document.getElementById('suppress-modal');
if (!modal) return; if (!modal) return;
@@ -234,8 +224,7 @@ function openSuppressModal(type, name, detail) {
document.getElementById('sup-expires').value = ''; document.getElementById('sup-expires').value = '';
updateSuppressForm(); updateSuppressForm();
modal.classList.add('is-open'); lt.modal.open('suppress-modal');
modal.removeAttribute('aria-hidden');
document.querySelectorAll('#suppress-modal .pill').forEach(p => p.classList.remove('active')); document.querySelectorAll('#suppress-modal .pill').forEach(p => p.classList.remove('active'));
const manualPill = document.querySelector('#suppress-modal .pill-manual'); const manualPill = document.querySelector('#suppress-modal .pill-manual');
@@ -245,10 +234,7 @@ function openSuppressModal(type, name, detail) {
} }
function closeSuppressModal() { function closeSuppressModal() {
const modal = document.getElementById('suppress-modal'); lt.modal.close('suppress-modal');
if (!modal) return;
modal.classList.remove('is-open');
modal.setAttribute('aria-hidden', 'true');
} }
function updateSuppressForm() { function updateSuppressForm() {
@@ -286,37 +272,38 @@ async function submitSuppress(e) {
if (type !== 'all' && !name.trim()) { showToast('Target name is required', 'error'); return; } if (type !== 'all' && !name.trim()) { showToast('Target name is required', 'error'); return; }
try { try {
const resp = await fetch('/api/suppressions', { await lt.api.post('/api/suppressions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
target_type: type, target_type: type,
target_name: name, target_name: name,
target_detail: detail, target_detail: detail,
reason: reason, reason,
expires_minutes: expires ? parseInt(expires) : null, expires_minutes: expires ? parseInt(expires) : null,
}),
}); });
const data = await resp.json();
if (data.success) {
closeSuppressModal(); closeSuppressModal();
showToast('Suppression applied ✔', 'success'); showToast('Suppression applied ✔', 'success');
setTimeout(refreshAll, 500); setTimeout(refreshAll, 500);
} else {
showToast(data.error || 'Failed to apply suppression', 'error');
}
} catch (err) { } catch (err) {
showToast('Network error', 'error'); showToast(err.message || 'Failed to apply suppression', 'error');
} }
} }
// ── Global click handler: modal backdrop + suppress button delegation // ── Global click delegation ──────────────────────────────────────────
document.addEventListener('click', e => { document.addEventListener('click', e => {
// Close modal when clicking backdrop // Refresh button
const modal = document.getElementById('suppress-modal'); if (e.target.closest('[data-action="refresh"]')) {
if (modal && e.target === modal) { closeSuppressModal(); return; } lt.autoRefresh.now();
return;
}
// Suppress button via data attributes (avoids inline onclick XSS) // 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]'); const btn = e.target.closest('.btn-suppress[data-sup-type]');
if (btn) { if (btn) {
openSuppressModal( openSuppressModal(
@@ -326,25 +313,3 @@ document.addEventListener('click', e => {
); );
} }
}); });
// ── Relative time ─────────────────────────────────────────────────────
function fmtRelTime(tsStr) {
if (!tsStr) return '';
const d = new Date(tsStr.replace(' UTC', 'Z').replace(' ', 'T'));
if (isNaN(d)) return tsStr;
const secs = Math.floor((Date.now() - d) / 1000);
if (secs < 60) return `${secs}s ago`;
if (secs < 3600) return `${Math.floor(secs/60)}m ago`;
if (secs < 86400) return `${Math.floor(secs/3600)}h ago`;
return `${Math.floor(secs/86400)}d ago`;
}
// ── Utility ───────────────────────────────────────────────────────────
function escHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
+16 -8
View File
@@ -1114,6 +1114,13 @@ select option:checked {
.lt-row-p3 { border-left: 2px solid var(--priority-3) !important; } .lt-row-p3 { border-left: 2px solid var(--priority-3) !important; }
.lt-row-p4 { border-left: 2px solid var(--priority-4) !important; } .lt-row-p4 { border-left: 2px solid var(--priority-4) !important; }
/* Row state aliases (unprefixed, compatible with monitoring apps) */
.lt-table tr.row-critical td:first-child, .lt-table tr.lt-row-critical td:first-child { border-left: 2px solid var(--accent-red); }
.lt-table tr.row-critical td { background: rgba(255,45,85,.04); }
.lt-table tr.row-warning td:first-child { border-left: 2px solid var(--accent-amber); }
.lt-table tr.row-warning td { background: rgba(255,107,0,.04); }
.lt-table tr.row-resolved td { opacity: 0.6; }
/* Compact data table */ /* Compact data table */
.lt-data-table { .lt-data-table {
width: 100%; width: 100%;
@@ -1180,7 +1187,7 @@ select option:checked {
.lt-p5 { color: var(--priority-5); background: rgba(62,96,122,0.09); border-color: rgba(62,96,122,0.30); } .lt-p5 { color: var(--priority-5); background: rgba(62,96,122,0.09); border-color: rgba(62,96,122,0.30); }
/* Chips */ /* Chips */
.lt-chip { .lt-chip, .chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 0.15rem 0.5rem; padding: 0.15rem 0.5rem;
@@ -1191,9 +1198,9 @@ select option:checked {
border: 1px solid currentColor; border: 1px solid currentColor;
} }
.lt-chip-ok { color: var(--accent-green); background: var(--accent-green-dim); text-shadow: var(--glow-green); } .lt-chip-ok, .chip-ok { color: var(--accent-green); background: var(--accent-green-dim); text-shadow: var(--glow-green); }
.lt-chip-warn { color: var(--accent-amber); background: var(--accent-amber-dim); } .lt-chip-warn, .chip-warning { color: var(--accent-amber); background: var(--accent-amber-dim); }
.lt-chip-critical { color: var(--accent-red); background: var(--accent-red-dim); text-shadow: var(--glow-red); } .lt-chip-critical, .chip-critical { color: var(--accent-red); background: var(--accent-red-dim); text-shadow: var(--glow-red); }
.lt-chip-info { color: var(--accent-cyan); background: var(--accent-cyan-dim); text-shadow: var(--glow-cyan); } .lt-chip-info { color: var(--accent-cyan); background: var(--accent-cyan-dim); text-shadow: var(--glow-cyan); }
/* Generic badges */ /* Generic badges */
@@ -1228,10 +1235,11 @@ select option:checked {
border-radius: 50%; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
} }
.lt-dot-up { background: var(--accent-green); box-shadow: 0 0 6px var(--accent-green), 0 0 14px var(--accent-green); animation: pulse-green 2.2s ease-in-out infinite; will-change: box-shadow; } .lt-dot-up, .dot-up { background: var(--accent-green); box-shadow: 0 0 6px var(--accent-green), 0 0 14px var(--accent-green); animation: pulse-green 2.2s ease-in-out infinite; will-change: box-shadow; }
.lt-dot-down { background: var(--accent-red); box-shadow: 0 0 6px var(--accent-red), 0 0 12px var(--accent-red); } .lt-dot-down, .dot-down { background: var(--accent-red); box-shadow: 0 0 6px var(--accent-red), 0 0 12px var(--accent-red); }
.lt-dot-warn { background: var(--accent-amber); box-shadow: 0 0 6px var(--accent-amber), 0 0 12px var(--accent-amber); animation: pulse-amber 2.2s ease-in-out infinite; will-change: box-shadow; } .lt-dot-warn, .dot-degraded { background: var(--accent-amber); box-shadow: 0 0 6px var(--accent-amber), 0 0 12px var(--accent-amber); animation: pulse-amber 2.2s ease-in-out infinite; will-change: box-shadow; }
.lt-dot-idle { background: var(--text-muted); box-shadow: none; } .lt-dot-idle, .dot-unknown,
.dot-initial_down { background: var(--text-muted); box-shadow: none; }
/* ---------------------------------------------------------------- /* ----------------------------------------------------------------
+8 -7
View File
@@ -102,7 +102,12 @@
</div> </div>
<div class="lt-header-right"> <div class="lt-header-right">
<span class="lt-header-user">{{ user.name or user.username }}</span> {% set _uname = user.name or user.username %}
<div class="lt-avatar" title="{{ _uname }}" aria-label="{{ _uname }}">{{ _uname[0] | upper }}</div>
<span class="lt-header-user">{{ _uname }}</span>
{% if user.groups and 'admin' in user.groups %}
<span class="lt-badge lt-badge-admin">admin</span>
{% endif %}
<button type="button" class="lt-theme-btn" id="lt-theme-btn" <button type="button" class="lt-theme-btn" id="lt-theme-btn"
aria-label="Toggle theme" title="Toggle light/dark mode">&#x2600;</button> aria-label="Toggle theme" title="Toggle light/dark mode">&#x2600;</button>
</div> </div>
@@ -206,12 +211,8 @@
} }
}); });
// R key to refresh on dashboard lt.keys.on('r', function() {
document.addEventListener('keydown', function(e) { if (typeof refreshAll === 'function') refreshAll();
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
if (e.key === 'r' || e.key === 'R') {
if (typeof refreshAll === 'function') { e.preventDefault(); refreshAll(); }
}
}); });
</script> </script>
+11 -24
View File
@@ -18,7 +18,7 @@
</div> </div>
<div class="status-meta"> <div class="status-meta">
<span class="last-check" id="last-check">{{ last_check }}</span> <span class="last-check" id="last-check">{{ last_check }}</span>
<button class="lt-btn lt-btn-ghost lt-btn-sm" onclick="refreshAll()">↻ REFRESH</button> <button class="lt-btn lt-btn-ghost lt-btn-sm" data-action="refresh">↻ REFRESH</button>
</div> </div>
</div> </div>
@@ -386,7 +386,7 @@
<div class="lt-modal"> <div class="lt-modal">
<div class="lt-modal-header"> <div class="lt-modal-header">
<h3 class="lt-modal-title" id="suppress-modal-title">Suppress Alert</h3> <h3 class="lt-modal-title" id="suppress-modal-title">Suppress Alert</h3>
<button type="button" class="lt-modal-close" onclick="closeSuppressModal()" aria-label="Close"></button> <button type="button" class="lt-modal-close" data-modal-close aria-label="Close"></button>
</div> </div>
<form id="suppress-form" onsubmit="submitSuppress(event)"> <form id="suppress-form" onsubmit="submitSuppress(event)">
<div class="lt-modal-body"> <div class="lt-modal-body">
@@ -415,18 +415,18 @@
<div class="lt-form-group" style="margin-bottom:0"> <div class="lt-form-group" style="margin-bottom:0">
<label class="lt-label">Duration</label> <label class="lt-label">Duration</label>
<div class="duration-pills"> <div class="duration-pills">
<button type="button" class="pill" onclick="setDuration(30, this)">30 min</button> <button type="button" class="pill" data-duration="30">30 min</button>
<button type="button" class="pill" onclick="setDuration(60, this)">1 hr</button> <button type="button" class="pill" data-duration="60">1 hr</button>
<button type="button" class="pill" onclick="setDuration(240, this)">4 hr</button> <button type="button" class="pill" data-duration="240">4 hr</button>
<button type="button" class="pill" onclick="setDuration(480, this)">8 hr</button> <button type="button" class="pill" data-duration="480">8 hr</button>
<button type="button" class="pill pill-manual active" onclick="setDuration(null, this)">Manual ∞</button> <button type="button" class="pill pill-manual active" data-duration="">Manual ∞</button>
</div> </div>
<input type="hidden" id="sup-expires" name="expires_minutes" value=""> <input type="hidden" id="sup-expires" name="expires_minutes" value="">
<div class="lt-field-hint" id="duration-hint">Persists until manually removed.</div> <div class="lt-field-hint" id="duration-hint">Persists until manually removed.</div>
</div> </div>
</div> </div>
<div class="lt-modal-footer"> <div class="lt-modal-footer">
<button type="button" class="lt-btn lt-btn-secondary" onclick="closeSuppressModal()">Cancel</button> <button type="button" class="lt-btn lt-btn-secondary" data-modal-close>Cancel</button>
<button type="submit" class="lt-btn lt-btn-primary">Apply</button> <button type="submit" class="lt-btn lt-btn-primary">Apply</button>
</div> </div>
</form> </form>
@@ -437,23 +437,11 @@
{% block scripts %} {% block scripts %}
<script> <script>
setInterval(refreshAll, 30000); lt.autoRefresh.start(refreshAll, 30000);
// ── Relative time display for event age cells ──────────────────
function fmtRelTime(tsStr) {
if (!tsStr) return '';
const d = new Date(tsStr.replace(' UTC', 'Z').replace(' ', 'T'));
if (isNaN(d)) return tsStr;
const secs = Math.floor((Date.now() - d) / 1000);
if (secs < 60) return `${secs}s ago`;
if (secs < 3600) return `${Math.floor(secs/60)}m ago`;
if (secs < 86400) return `${Math.floor(secs/3600)}h ago`;
return `${Math.floor(secs/86400)}d ago`;
}
function updateEventAges() { function updateEventAges() {
document.querySelectorAll('.event-age[data-ts]').forEach(el => { document.querySelectorAll('.event-age[data-ts]').forEach(el => {
el.textContent = fmtRelTime(el.dataset.ts); el.textContent = lt.time.ago(_toIso(el.dataset.ts));
}); });
} }
@@ -463,8 +451,7 @@
// ── Event duration (resolved_at - first_seen) ────────────────── // ── Event duration (resolved_at - first_seen) ──────────────────
function fmtDuration(firstTs, resolvedTs) { function fmtDuration(firstTs, resolvedTs) {
if (!firstTs || !resolvedTs) return ''; if (!firstTs || !resolvedTs) return '';
const parse = s => new Date(s.replace(' UTC', 'Z').replace(' ', 'T')); const secs = Math.floor((new Date(_toIso(resolvedTs)) - new Date(_toIso(firstTs))) / 1000);
const secs = Math.floor((parse(resolvedTs) - parse(firstTs)) / 1000);
if (secs < 0) return ''; if (secs < 0) return '';
if (secs < 60) return `${secs}s`; if (secs < 60) return `${secs}s`;
if (secs < 3600) return `${Math.floor(secs/60)}m`; if (secs < 3600) return `${Math.floor(secs/60)}m`;
+20 -19
View File
@@ -51,11 +51,11 @@
<div class="lt-form-group"> <div class="lt-form-group">
<label class="lt-label">Duration</label> <label class="lt-label">Duration</label>
<div class="duration-pills"> <div class="duration-pills">
<button type="button" class="pill" onclick="setDur(30, this)">30 min</button> <button type="button" class="pill" data-dur="30">30 min</button>
<button type="button" class="pill" onclick="setDur(60, this)">1 hr</button> <button type="button" class="pill" data-dur="60">1 hr</button>
<button type="button" class="pill" onclick="setDur(240, this)">4 hr</button> <button type="button" class="pill" data-dur="240">4 hr</button>
<button type="button" class="pill" onclick="setDur(480, this)">8 hr</button> <button type="button" class="pill" data-dur="480">8 hr</button>
<button type="button" class="pill pill-manual active" onclick="setDur(null, this)">Manual ∞</button> <button type="button" class="pill pill-manual active" data-dur="">Manual ∞</button>
</div> </div>
<input type="hidden" id="s-expires" name="expires_minutes" value=""> <input type="hidden" id="s-expires" name="expires_minutes" value="">
<div class="lt-field-hint" id="s-dur-hint">Persists until manually removed.</div> <div class="lt-field-hint" id="s-dur-hint">Persists until manually removed.</div>
@@ -207,30 +207,31 @@
reason: form.reason.value, reason: form.reason.value,
expires_minutes: form.expires_minutes.value ? parseInt(form.expires_minutes.value) : null, expires_minutes: form.expires_minutes.value ? parseInt(form.expires_minutes.value) : null,
}; };
const resp = await fetch('/api/suppressions', { try {
method: 'POST', await lt.api.post('/api/suppressions', payload);
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
const data = await resp.json();
if (data.success) {
showToast('Suppression applied', 'success'); showToast('Suppression applied', 'success');
setTimeout(() => location.reload(), 800); setTimeout(() => location.reload(), 800);
} else { } catch (err) {
showToast(data.error || 'Error', 'error'); showToast(err.message || 'Error', 'error');
} }
} }
async function removeSuppression(id) { async function removeSuppression(id) {
if (!confirm('Remove this suppression?')) return; if (!confirm('Remove this suppression?')) return;
const resp = await fetch(`/api/suppressions/${id}`, {method:'DELETE'}); try {
const data = await resp.json(); await lt.api.delete(`/api/suppressions/${id}`);
if (data.success) {
document.getElementById(`sup-row-${id}`)?.remove(); document.getElementById(`sup-row-${id}`)?.remove();
showToast('Suppression removed', 'success'); showToast('Suppression removed', 'success');
} else { } catch (err) {
showToast(data.error || 'Failed to remove suppression', 'error'); showToast(err.message || 'Failed to remove suppression', 'error');
} }
} }
document.addEventListener('click', e => {
const pill = e.target.closest('.pill[data-dur]');
if (!pill) return;
const val = pill.dataset.dur;
setDur(val ? parseInt(val) : null, pill);
});
</script> </script>
{% endblock %} {% endblock %}