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';
|
||||
|
||||
// ── 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]) => `
|
||||
<div class="iface-row">
|
||||
<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>
|
||||
</div>
|
||||
`).join('');
|
||||
@@ -146,16 +136,16 @@ function updateUnifiTable(devices) {
|
||||
const suppressBtn = !d.connected
|
||||
? `<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
|
||||
data-sup-type="unifi_device"
|
||||
data-sup-name="${escHtml(d.name)}"
|
||||
data-sup-name="${lt.escHtml(d.name)}"
|
||||
data-sup-detail="">🔕 Suppress</button>`
|
||||
: '';
|
||||
return `
|
||||
<tr class="${statusClass}">
|
||||
<td><span class="${dotClass}"></span> ${statusText}</td>
|
||||
<td><strong>${escHtml(d.name)}</strong></td>
|
||||
<td>${escHtml(d.type)}</td>
|
||||
<td>${escHtml(d.model)}</td>
|
||||
<td>${escHtml(d.ip)}</td>
|
||||
<td><strong>${lt.escHtml(d.name)}</strong></td>
|
||||
<td>${lt.escHtml(d.type)}</td>
|
||||
<td>${lt.escHtml(d.model)}</td>
|
||||
<td>${lt.escHtml(d.ip)}</td>
|
||||
<td>${suppressBtn}</td>
|
||||
</tr>`;
|
||||
}).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
|
||||
? `<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>`
|
||||
: '–';
|
||||
return `
|
||||
<tr class="row-${e.severity}">
|
||||
<td><span class="lt-badge badge-${e.severity}">${e.severity}</span></td>
|
||||
<td>${escHtml(e.event_type.replace(/_/g,' '))}</td>
|
||||
<td><strong>${escHtml(e.target_name)}</strong></td>
|
||||
<td>${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="ts-cell" title="${escHtml(e.first_seen||'')}">${fmtRelTime(e.first_seen)}</td>
|
||||
<td class="ts-cell" title="${escHtml(e.last_seen||'')}">${fmtRelTime(e.last_seen)}</td>
|
||||
<td>${lt.escHtml(e.event_type.replace(/_/g,' '))}</td>
|
||||
<td><strong>${lt.escHtml(e.target_name)}</strong></td>
|
||||
<td>${lt.escHtml(e.target_detail || '–')}</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="${lt.escHtml(e.first_seen||'')}">${lt.time.ago(_toIso(e.first_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>${ticket}</td>
|
||||
<td>
|
||||
<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
|
||||
data-sup-type="${escHtml(supType)}"
|
||||
data-sup-name="${escHtml(e.target_name)}"
|
||||
data-sup-detail="${escHtml(e.target_detail||'')}">🔕</button>
|
||||
data-sup-type="${lt.escHtml(supType)}"
|
||||
data-sup-name="${lt.escHtml(e.target_name)}"
|
||||
data-sup-detail="${lt.escHtml(e.target_detail||'')}">🔕</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
@@ -222,20 +212,19 @@ function updateEventsTable(events, totalActive) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── 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, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user