diff --git a/static/app.js b/static/app.js
index aa6b526..becba3c 100644
--- a/static/app.js
+++ b/static/app.js
@@ -1,20 +1,18 @@
'use strict';
// ── Auto-redirect on auth timeout ─────────────────────────────────────
-// Intercept all fetch() calls: if the server returns 401 (auth expired),
-// reload the page so Authelia redirects to the login screen.
+// Wraps fetch so a 401 (Authelia session expired) forces a full reload.
+// lt.api uses fetch internally, so this covers all API calls too.
(function () {
const _fetch = window.fetch;
window.fetch = async function (...args) {
const resp = await _fetch(...args);
- if (resp.status === 401) {
- window.location.reload();
- }
+ if (resp.status === 401) window.location.reload();
return resp;
};
})();
-// ── Toast notifications — delegates to lt.toast from base.js ─────────
+// ── Toast notifications — thin wrapper over lt.toast ──────────────────
function showToast(msg, type = 'success') {
if (type === 'error') return lt.toast.error(msg);
if (type === 'warning') return lt.toast.warning(msg);
@@ -22,24 +20,25 @@ function showToast(msg, type = 'success') {
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 ────────────────────────────────────────────
async function refreshAll() {
try {
- const [netResp, statusResp] = await Promise.all([
- fetch('/api/network'),
- fetch('/api/status'),
+ const [net, status] = await Promise.all([
+ lt.api.get('/api/network'),
+ lt.api.get('/api/status'),
]);
- if (!netResp.ok || !statusResp.ok) return;
-
- const net = await netResp.json();
- const status = await statusResp.json();
-
updateHostGrid(net.hosts || {});
updateUnifiTable(net.unifi || []);
updateEventsTable(status.events || [], status.total_active);
updateStatusBar(status.summary || {}, status.last_check || '');
updateTopology(net.hosts || {});
-
} catch (e) {
console.warn('Refresh failed:', e);
}
@@ -57,23 +56,17 @@ function updateStatusBar(summary, lastCheck) {
const lc = document.getElementById('last-check');
if (lc && lastCheck) lc.textContent = lastCheck;
- // Update browser tab title with alert count
const critCount = summary.critical || 0;
const warnCount = summary.warning || 0;
- if (critCount) {
- document.title = `(${critCount} CRIT) GANDALF`;
- } else if (warnCount) {
- document.title = `(${warnCount} WARN) GANDALF`;
- } else {
- document.title = 'GANDALF';
- }
+ if (critCount) document.title = `(${critCount} CRIT) GANDALF`;
+ else if (warnCount) document.title = `(${warnCount} WARN) GANDALF`;
+ else document.title = 'GANDALF';
// Stale data banner: warn if last_check is older than 15 minutes
let staleBanner = document.getElementById('stale-banner');
if (lastCheck) {
- // last_check format: "2026-03-14 14:14:21 UTC"
- const checkAge = (Date.now() - new Date(lastCheck.replace(' UTC', 'Z').replace(' ', 'T'))) / 1000;
- if (checkAge > 900) { // 15 minutes
+ const checkAge = (Date.now() - new Date(_toIso(lastCheck))) / 1000;
+ if (checkAge > 900) {
if (!staleBanner) {
staleBanner = document.createElement('div');
staleBanner.id = 'stale-banner';
@@ -94,15 +87,12 @@ function updateHostGrid(hosts) {
const card = document.querySelector(`.host-card[data-host="${CSS.escape(name)}"]`);
if (!card) continue;
- // Update card border class
card.className = card.className.replace(/host-card-(up|down|degraded|unknown)/g, '');
card.classList.add(`host-card-${host.status}`);
- // Update status dot in header
const dot = card.querySelector('.host-status-dot');
if (dot) dot.className = `host-status-dot dot-${host.status}`;
- // Update interface rows
const ifaceList = card.querySelector('.iface-list');
if (ifaceList && host.interfaces && Object.keys(host.interfaces).length > 0) {
ifaceList.innerHTML = Object.entries(host.interfaces)
@@ -110,7 +100,7 @@ function updateHostGrid(hosts) {
.map(([iface, state]) => `
- ${escHtml(iface)}
+ ${lt.escHtml(iface)}
${state}
`).join('');
@@ -146,16 +136,16 @@ function updateUnifiTable(devices) {
const suppressBtn = !d.connected
? ``
: '';
return `
| ${statusText} |
- ${escHtml(d.name)} |
- ${escHtml(d.type)} |
- ${escHtml(d.model)} |
- ${escHtml(d.ip)} |
+ ${lt.escHtml(d.name)} |
+ ${lt.escHtml(d.type)} |
+ ${lt.escHtml(d.model)} |
+ ${lt.escHtml(d.ip)} |
${suppressBtn} |
`;
}).join('');
@@ -183,25 +173,25 @@ function updateEventsTable(events, totalActive) {
const ticketBase = (typeof GANDALF_CONFIG !== 'undefined' && GANDALF_CONFIG.ticket_web_url)
? GANDALF_CONFIG.ticket_web_url : 'http://t.lotusguild.org/ticket/';
const ticket = e.ticket_id
- ? `#${e.ticket_id}`
: '–';
return `
| ${e.severity} |
- ${escHtml(e.event_type.replace(/_/g,' '))} |
- ${escHtml(e.target_name)} |
- ${escHtml(e.target_detail || '–')} |
- ${escHtml((e.description||'').substring(0,60))}${(e.description||'').length>60?'…':''} |
- ${fmtRelTime(e.first_seen)} |
- ${fmtRelTime(e.last_seen)} |
+ ${lt.escHtml(e.event_type.replace(/_/g,' '))} |
+ ${lt.escHtml(e.target_name)} |
+ ${lt.escHtml(e.target_detail || '–')} |
+ ${lt.escHtml((e.description||'').substring(0,60))}${(e.description||'').length>60?'…':''} |
+ ${lt.time.ago(_toIso(e.first_seen))} |
+ ${lt.time.ago(_toIso(e.last_seen))} |
${e.consecutive_failures} |
${ticket} |
+ data-sup-type="${lt.escHtml(supType)}"
+ data-sup-name="${lt.escHtml(e.target_name)}"
+ data-sup-detail="${lt.escHtml(e.target_detail||'')}">🔕
|
`;
}).join('');
@@ -222,20 +212,19 @@ function updateEventsTable(events, totalActive) {
`;
}
-// ── Suppression modal (dashboard) ────────────────────────────────────
+// ── Suppression modal ─────────────────────────────────────────────────
function openSuppressModal(type, name, detail) {
const modal = document.getElementById('suppress-modal');
if (!modal) return;
- document.getElementById('sup-type').value = type;
- document.getElementById('sup-name').value = name;
- document.getElementById('sup-detail').value = detail;
- document.getElementById('sup-reason').value = '';
+ document.getElementById('sup-type').value = type;
+ document.getElementById('sup-name').value = name;
+ document.getElementById('sup-detail').value = detail;
+ document.getElementById('sup-reason').value = '';
document.getElementById('sup-expires').value = '';
updateSuppressForm();
- modal.classList.add('is-open');
- modal.removeAttribute('aria-hidden');
+ lt.modal.open('suppress-modal');
document.querySelectorAll('#suppress-modal .pill').forEach(p => p.classList.remove('active'));
const manualPill = document.querySelector('#suppress-modal .pill-manual');
@@ -245,10 +234,7 @@ function openSuppressModal(type, name, detail) {
}
function closeSuppressModal() {
- const modal = document.getElementById('suppress-modal');
- if (!modal) return;
- modal.classList.remove('is-open');
- modal.setAttribute('aria-hidden', 'true');
+ lt.modal.close('suppress-modal');
}
function updateSuppressForm() {
@@ -286,37 +272,38 @@ async function submitSuppress(e) {
if (type !== 'all' && !name.trim()) { showToast('Target name is required', 'error'); return; }
try {
- const resp = await fetch('/api/suppressions', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- target_type: type,
- target_name: name,
- target_detail: detail,
- reason: reason,
- expires_minutes: expires ? parseInt(expires) : null,
- }),
+ await lt.api.post('/api/suppressions', {
+ target_type: type,
+ target_name: name,
+ target_detail: detail,
+ reason,
+ expires_minutes: expires ? parseInt(expires) : null,
});
- const data = await resp.json();
- if (data.success) {
- closeSuppressModal();
- showToast('Suppression applied ✔', 'success');
- setTimeout(refreshAll, 500);
- } else {
- showToast(data.error || 'Failed to apply suppression', 'error');
- }
+ closeSuppressModal();
+ showToast('Suppression applied ✔', 'success');
+ setTimeout(refreshAll, 500);
} 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 => {
- // Close modal when clicking backdrop
- const modal = document.getElementById('suppress-modal');
- if (modal && e.target === modal) { closeSuppressModal(); return; }
+ // Refresh button
+ if (e.target.closest('[data-action="refresh"]')) {
+ 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]');
if (btn) {
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, '"');
-}
diff --git a/static/base.css b/static/base.css
index 4045b7f..c89d1ab 100644
--- a/static/base.css
+++ b/static/base.css
@@ -1114,6 +1114,13 @@ select option:checked {
.lt-row-p3 { border-left: 2px solid var(--priority-3) !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 */
.lt-data-table {
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); }
/* Chips */
-.lt-chip {
+.lt-chip, .chip {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.5rem;
@@ -1191,10 +1198,10 @@ select option:checked {
border: 1px solid currentColor;
}
-.lt-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-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-ok, .chip-ok { color: var(--accent-green); background: var(--accent-green-dim); text-shadow: var(--glow-green); }
+.lt-chip-warn, .chip-warning { color: var(--accent-amber); background: var(--accent-amber-dim); }
+.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); }
/* Generic badges */
.lt-badge {
@@ -1228,10 +1235,11 @@ select option:checked {
border-radius: 50%;
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-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-idle { background: var(--text-muted); box-shadow: none; }
+.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, .dot-down { background: var(--accent-red); box-shadow: 0 0 6px var(--accent-red), 0 0 12px var(--accent-red); }
+.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, .dot-unknown,
+.dot-initial_down { background: var(--text-muted); box-shadow: none; }
/* ----------------------------------------------------------------
diff --git a/templates/base.html b/templates/base.html
index 5173e95..3fd40b5 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -102,7 +102,12 @@
@@ -206,12 +211,8 @@
}
});
- // R key to refresh on dashboard
- document.addEventListener('keydown', function(e) {
- 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(); }
- }
+ lt.keys.on('r', function() {
+ if (typeof refreshAll === 'function') refreshAll();
});
diff --git a/templates/index.html b/templates/index.html
index c8b9d81..43d86d5 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -18,7 +18,7 @@
{{ last_check }}
-
+
@@ -386,7 +386,7 @@
@@ -437,23 +437,11 @@
{% block scripts %}
{% endblock %}