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
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:
+67
-102
@@ -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,20 +212,19 @@ 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;
|
||||||
|
|
||||||
document.getElementById('sup-type').value = type;
|
document.getElementById('sup-type').value = type;
|
||||||
document.getElementById('sup-name').value = name;
|
document.getElementById('sup-name').value = name;
|
||||||
document.getElementById('sup-detail').value = detail;
|
document.getElementById('sup-detail').value = detail;
|
||||||
document.getElementById('sup-reason').value = '';
|
document.getElementById('sup-reason').value = '';
|
||||||
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',
|
target_type: type,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
target_name: name,
|
||||||
body: JSON.stringify({
|
target_detail: detail,
|
||||||
target_type: type,
|
reason,
|
||||||
target_name: name,
|
expires_minutes: expires ? parseInt(expires) : null,
|
||||||
target_detail: detail,
|
|
||||||
reason: reason,
|
|
||||||
expires_minutes: expires ? parseInt(expires) : null,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
closeSuppressModal();
|
||||||
if (data.success) {
|
showToast('Suppression applied ✔', 'success');
|
||||||
closeSuppressModal();
|
setTimeout(refreshAll, 500);
|
||||||
showToast('Suppression applied ✔', 'success');
|
|
||||||
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, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|||||||
+17
-9
@@ -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,10 +1198,10 @@ 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 */
|
||||||
.lt-badge {
|
.lt-badge {
|
||||||
@@ -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
@@ -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">☀</button>
|
aria-label="Toggle theme" title="Toggle light/dark mode">☀</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
@@ -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
@@ -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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user