4e3d0a1f0a
Lint / Python (flake8) (push) Successful in 39s
Lint / JS (eslint) (push) Successful in 7s
Security / Python Security (bandit) (push) Successful in 1m3s
Test / Python Tests (pytest) (push) Successful in 1m5s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 3s
- updateSuppressForm() now sets required + aria-required on sup-name/sup-detail
when target type changes; sup-reason gets static aria-required="true"
- onTypeChange() in suppressions page syncs aria-required on s-name
- s-name in suppressions.html gets initial required/aria-required (default type=host)
- Duration pills in both modal and suppressions page now have descriptive
aria-label ("30 minutes", "1 hour", etc.) alongside the group aria-label
- setDuration() in app.js accepts optional {expiresId,pillSel,hintId} opts so
logic lives in one place; suppressions.html setDur() delegates to it
- Post-create form reset uses setDur() instead of manually patching DOM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
522 lines
26 KiB
HTML
522 lines
26 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en" data-theme="dark">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
|
<meta name="theme-color" content="#030508">
|
|
<meta name="robots" content="noindex, nofollow">
|
|
<title>{% block title %}GANDALF{% endblock %} — GANDALF</title>
|
|
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&family=VT323&display=swap" rel="stylesheet">
|
|
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='base.css') }}">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
|
|
|
<!-- base.js loaded in head so lt.* is available for inline scripts -->
|
|
<script src="{{ url_for('static', filename='base.js') }}"></script>
|
|
</head>
|
|
<body>
|
|
|
|
<a class="lt-skip-link" href="#main-content">Skip to main content</a>
|
|
|
|
<!-- BOOT OVERLAY -->
|
|
<div id="lt-boot" class="lt-boot-overlay" data-app-name="GANDALF" style="display:none" aria-hidden="true">
|
|
<pre id="lt-boot-text" class="lt-boot-text"></pre>
|
|
</div>
|
|
|
|
<!-- MOBILE NAV DRAWER -->
|
|
<div id="lt-nav-drawer" class="lt-nav-drawer" aria-hidden="true" role="dialog" aria-modal="true" aria-label="Navigation menu">
|
|
<div class="lt-nav-drawer-header">
|
|
<span class="lt-brand-title">GANDALF</span>
|
|
<button type="button" class="lt-nav-drawer-close" id="lt-nav-drawer-close" aria-label="Close navigation">✕</button>
|
|
</div>
|
|
<nav class="lt-nav-drawer-links" aria-label="Mobile navigation">
|
|
<a href="{{ url_for('index') }}"
|
|
class="lt-nav-drawer-link{% if request.endpoint == 'index' %} active{% endif %}"
|
|
{% if request.endpoint == 'index' %}aria-current="page"{% endif %}>Dashboard</a>
|
|
<a href="{{ url_for('links_page') }}"
|
|
class="lt-nav-drawer-link{% if request.endpoint == 'links_page' %} active{% endif %}"
|
|
{% if request.endpoint == 'links_page' %}aria-current="page"{% endif %}>Link Debug</a>
|
|
<a href="{{ url_for('inspector') }}"
|
|
class="lt-nav-drawer-link{% if request.endpoint == 'inspector' %} active{% endif %}"
|
|
{% if request.endpoint == 'inspector' %}aria-current="page"{% endif %}>Inspector</a>
|
|
{% if user.groups and 'admin' in user.groups %}
|
|
<a href="{{ url_for('suppressions_page') }}"
|
|
class="lt-nav-drawer-link{% if request.endpoint == 'suppressions_page' %} active{% endif %}"
|
|
{% if request.endpoint == 'suppressions_page' %}aria-current="page"{% endif %}>Suppressions</a>
|
|
{% endif %}
|
|
</nav>
|
|
</div>
|
|
<div id="lt-nav-overlay" class="lt-nav-drawer-overlay"></div>
|
|
|
|
<!-- PRIMARY HEADER -->
|
|
<header class="lt-header" role="banner">
|
|
<div class="lt-header-left">
|
|
|
|
<!-- Hamburger (mobile) -->
|
|
<button type="button"
|
|
class="lt-menu-btn"
|
|
id="lt-menu-btn"
|
|
data-action="open-nav-drawer"
|
|
aria-label="Open navigation menu"
|
|
aria-expanded="false"
|
|
aria-controls="lt-nav-drawer">
|
|
<span class="lt-menu-btn-bar"></span>
|
|
<span class="lt-menu-btn-bar"></span>
|
|
<span class="lt-menu-btn-bar"></span>
|
|
</button>
|
|
|
|
<!-- Brand -->
|
|
<div class="lt-brand">
|
|
<a href="{{ url_for('index') }}"
|
|
class="lt-brand-title lt-glitch"
|
|
data-text="GANDALF"
|
|
aria-label="GANDALF home">GANDALF</a>
|
|
<span class="lt-brand-subtitle">Network Monitor // LotusGuild</span>
|
|
</div>
|
|
|
|
<!-- Desktop nav -->
|
|
<nav class="lt-nav" aria-label="Main navigation">
|
|
<a href="{{ url_for('index') }}"
|
|
class="lt-nav-link{% if request.endpoint == 'index' %} active{% endif %}"
|
|
{% if request.endpoint == 'index' %}aria-current="page"{% endif %}>
|
|
Dashboard
|
|
</a>
|
|
<a href="{{ url_for('links_page') }}"
|
|
class="lt-nav-link{% if request.endpoint == 'links_page' %} active{% endif %}"
|
|
{% if request.endpoint == 'links_page' %}aria-current="page"{% endif %}>
|
|
Link Debug
|
|
</a>
|
|
<a href="{{ url_for('inspector') }}"
|
|
class="lt-nav-link{% if request.endpoint == 'inspector' %} active{% endif %}"
|
|
{% if request.endpoint == 'inspector' %}aria-current="page"{% endif %}>
|
|
Inspector
|
|
</a>
|
|
{% if user.groups and 'admin' in user.groups %}
|
|
<a href="{{ url_for('suppressions_page') }}"
|
|
class="lt-nav-link{% if request.endpoint == 'suppressions_page' %} active{% endif %}"
|
|
{% if request.endpoint == 'suppressions_page' %}aria-current="page"{% endif %}>
|
|
Suppressions
|
|
</a>
|
|
{% endif %}
|
|
</nav>
|
|
</div>
|
|
|
|
<div class="lt-header-right">
|
|
{% set _uname = user.name or user.username %}
|
|
{% set _words = _uname.split() %}
|
|
{% set _initials = (_words[0][0] ~ (_words[1][0] if _words|length > 1 else ''))|upper %}
|
|
<div class="lt-avatar lt-avatar--sm {{ _uname | avatar_color }}"
|
|
aria-hidden="true" title="{{ _uname }}">
|
|
<img src="{{ url_for('api_avatar') }}" alt="" class="lt-avatar-img">
|
|
<span class="lt-avatar-initials">{{ _initials }}</span>
|
|
</div>
|
|
<span class="lt-header-user">{{ _uname }}</span>
|
|
{% if user.groups and 'admin' in user.groups %}
|
|
<span class="lt-badge lt-badge-admin" aria-label="Administrator">ADMIN</span>
|
|
{% endif %}
|
|
|
|
<!-- Notification bell — shows active monitoring alerts -->
|
|
<div class="lt-notif-dropdown-wrap" id="lt-notif-wrap">
|
|
<button type="button"
|
|
class="lt-btn lt-btn-ghost lt-btn-sm lt-notif-bell-btn"
|
|
id="lt-notif-bell"
|
|
aria-label="Active alerts"
|
|
aria-expanded="false"
|
|
aria-controls="lt-notif-panel"
|
|
title="Active alerts">🔔</button>
|
|
<div class="lt-notif-panel" id="lt-notif-panel" aria-hidden="true" role="dialog" aria-label="Active alerts">
|
|
<div class="lt-notif-panel-header">
|
|
<span>Active Alerts</span>
|
|
<button type="button" class="lt-notif-panel-clear" id="lt-notif-clear-btn">Mark all read</button>
|
|
</div>
|
|
<div class="lt-notif-panel-list" id="lt-notif-list">
|
|
<div class="lt-notif-empty">Loading…</div>
|
|
</div>
|
|
<div class="lt-notif-panel-footer">
|
|
<a href="{{ url_for('index') }}" class="lt-btn lt-btn-ghost lt-btn-sm lt-notif-view-all">View dashboard</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ⌘K affordance -->
|
|
<button type="button"
|
|
class="lt-btn lt-btn-ghost lt-btn-sm lt-cmd-hint-btn"
|
|
data-action="open-cmdpalette"
|
|
title="Command palette (Ctrl+K)"
|
|
aria-label="Open command palette">⌕ K</button>
|
|
|
|
<button type="button" class="lt-theme-btn" id="lt-theme-btn"
|
|
aria-label="Toggle theme" title="Toggle light/dark mode">☀</button>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- COMMAND PALETTE -->
|
|
<div id="lt-cmd-overlay" class="lt-cmd-overlay" role="dialog" aria-modal="true" aria-label="Command palette" aria-hidden="true">
|
|
<div class="lt-cmd-palette" id="lt-cmd-palette">
|
|
<div class="lt-cmd-input-wrap">
|
|
<span class="lt-cmd-prompt">></span>
|
|
<input id="lt-cmd-input" class="lt-cmd-input" type="text"
|
|
placeholder="Search commands…" autocomplete="off"
|
|
spellcheck="false" aria-label="Search commands">
|
|
</div>
|
|
<div class="lt-cmd-results" id="lt-cmd-results">
|
|
<div class="lt-cmd-empty">Start typing to search…</div>
|
|
</div>
|
|
<div class="lt-cmd-footer">
|
|
<span><kbd>↑</kbd><kbd>↓</kbd> Navigate</span>
|
|
<span><kbd>Enter</kbd> Select</span>
|
|
<span><kbd>Esc</kbd> Close</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- MAIN CONTENT -->
|
|
<main class="lt-main lt-container" id="main-content">
|
|
{% block content %}{% endblock %}
|
|
</main>
|
|
|
|
<!-- FOOTER — context-sensitive per page -->
|
|
<footer class="lt-footer" role="contentinfo">
|
|
<nav class="lt-footer-hints" aria-label="Keyboard shortcuts">
|
|
{% if request.endpoint == 'index' %}
|
|
<span class="lt-footer-hint"><span class="lt-footer-key">[ R ]</span> REFRESH</span>
|
|
<span class="lt-footer-sep">|</span>
|
|
<span class="lt-footer-hint"><span class="lt-footer-key">[ S ]</span> SUPPRESS</span>
|
|
<span class="lt-footer-sep">|</span>
|
|
{% elif request.endpoint in ('links_page', 'inspector') %}
|
|
<span class="lt-footer-hint"><span class="lt-footer-key">[ R ]</span> REFRESH</span>
|
|
<span class="lt-footer-sep">|</span>
|
|
{% endif %}
|
|
<button type="button" class="lt-footer-hint" data-action="open-settings"><span class="lt-footer-key">[ * ]</span> CFG</button>
|
|
<span class="lt-footer-sep">|</span>
|
|
<button type="button" class="lt-footer-hint" data-action="show-keyboard-help"><span class="lt-footer-key">[ ? ]</span> HELP</button>
|
|
</nav>
|
|
<span>GANDALF — TDS v1.2</span>
|
|
</footer>
|
|
|
|
<!-- QUICK-SUPPRESS MODAL — available on all pages via [S] shortcut -->
|
|
<div id="suppress-modal" class="lt-modal-overlay"
|
|
role="dialog" aria-modal="true" aria-labelledby="suppress-modal-title" aria-hidden="true">
|
|
<div class="lt-modal">
|
|
<div class="lt-modal-header">
|
|
<h3 class="lt-modal-title" id="suppress-modal-title">Suppress Alert</h3>
|
|
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
|
</div>
|
|
<form id="suppress-form">
|
|
<div class="lt-modal-body">
|
|
<div class="lt-form-group">
|
|
<label class="lt-label" for="sup-type">Target Type</label>
|
|
<select class="lt-select" id="sup-type" name="target_type">
|
|
<option value="host">Host (all interfaces)</option>
|
|
<option value="interface">Specific Interface</option>
|
|
<option value="unifi_device">UniFi Device</option>
|
|
<option value="all">Global Maintenance</option>
|
|
</select>
|
|
</div>
|
|
<div class="lt-form-group" id="sup-name-group">
|
|
<label class="lt-label" for="sup-name">Target Name</label>
|
|
<input type="text" class="lt-input" id="sup-name" name="target_name" placeholder="e.g. large1">
|
|
</div>
|
|
<div class="lt-form-group" id="sup-detail-group" style="display:none">
|
|
<label class="lt-label" for="sup-detail">Interface <span class="lt-field-hint">(interface type only)</span></label>
|
|
<input type="text" class="lt-input" id="sup-detail" name="target_detail" placeholder="e.g. enp35s0">
|
|
</div>
|
|
<div class="lt-form-group">
|
|
<label class="lt-label" for="sup-reason">Reason <span class="required">*</span></label>
|
|
<input type="text" class="lt-input" id="sup-reason" name="reason"
|
|
placeholder="e.g. Planned switch reboot" required aria-required="true">
|
|
</div>
|
|
<div class="lt-form-group lt-form-group--last">
|
|
<label class="lt-label">Duration</label>
|
|
<div class="duration-pills" role="group" aria-label="Select suppression duration">
|
|
<button type="button" class="pill" data-duration="30" aria-pressed="false" aria-label="30 minutes">30 min</button>
|
|
<button type="button" class="pill" data-duration="60" aria-pressed="false" aria-label="1 hour">1 hr</button>
|
|
<button type="button" class="pill" data-duration="240" aria-pressed="false" aria-label="4 hours">4 hr</button>
|
|
<button type="button" class="pill" data-duration="480" aria-pressed="false" aria-label="8 hours">8 hr</button>
|
|
<button type="button" class="pill pill-manual active" data-duration="" aria-pressed="true" aria-label="Manual, no expiry">Manual ∞</button>
|
|
</div>
|
|
<input type="hidden" id="sup-expires" name="expires_minutes" value="">
|
|
<div class="lt-field-hint" id="duration-hint">Persists until manually removed.</div>
|
|
</div>
|
|
</div>
|
|
<div class="lt-modal-footer">
|
|
<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>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- KEYBOARD SHORTCUTS MODAL -->
|
|
<div id="lt-keys-help" class="lt-modal-overlay" aria-hidden="true">
|
|
<div class="lt-modal" role="dialog" aria-modal="true" aria-labelledby="keys-help-title">
|
|
<div class="lt-modal-header">
|
|
<span class="lt-modal-title" id="keys-help-title">Keyboard Shortcuts</span>
|
|
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
|
</div>
|
|
<div class="lt-modal-body">
|
|
<table class="lt-table">
|
|
<thead><tr><th>Shortcut</th><th>Action</th></tr></thead>
|
|
<tbody>
|
|
<tr><td>Ctrl / ⌘ + K</td><td>Command palette</td></tr>
|
|
<tr><td>R</td><td>Refresh data (Dashboard / Link Debug / Inspector)</td></tr>
|
|
<tr><td>S</td><td>Quick-suppress alert (Dashboard)</td></tr>
|
|
<tr><td>*</td><td>Open settings</td></tr>
|
|
<tr><td>?</td><td>Show this help</td></tr>
|
|
<tr><td>ESC</td><td>Close modal / panel</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="lt-modal-footer">
|
|
<button type="button" class="lt-btn" data-modal-close>Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- SETTINGS MODAL -->
|
|
<div id="lt-settings-modal" class="lt-modal-overlay" aria-hidden="true">
|
|
<div class="lt-modal" role="dialog" aria-modal="true" aria-labelledby="settings-modal-title">
|
|
<div class="lt-modal-header">
|
|
<span class="lt-modal-title" id="settings-modal-title">Settings</span>
|
|
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
|
</div>
|
|
<div class="lt-modal-body">
|
|
<div class="lt-form-group">
|
|
<label class="lt-label">Auto-refresh interval</label>
|
|
<div class="duration-pills" id="settings-refresh-pills" role="group" aria-label="Select auto-refresh interval">
|
|
<button type="button" class="pill" data-refresh-interval="15" aria-pressed="false">15 s</button>
|
|
<button type="button" class="pill" data-refresh-interval="30" aria-pressed="false">30 s</button>
|
|
<button type="button" class="pill" data-refresh-interval="60" aria-pressed="false">1 min</button>
|
|
<button type="button" class="pill" data-refresh-interval="300" aria-pressed="false">5 min</button>
|
|
<button type="button" class="pill" data-refresh-interval="0" aria-pressed="false">Off</button>
|
|
</div>
|
|
<div class="lt-field-hint" id="settings-refresh-hint"></div>
|
|
</div>
|
|
<div class="lt-divider lt-divider--compact"></div>
|
|
<div class="lt-kv-grid">
|
|
<span class="lt-kv-key">User</span>
|
|
<span class="lt-kv-val lt-kv-val--cyan">{{ user.name or user.username }}</span>
|
|
{% if user.groups %}
|
|
<span class="lt-kv-key">Groups</span>
|
|
<span class="lt-kv-val">{{ user.groups | join(', ') }}</span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
<div class="lt-modal-footer">
|
|
<button type="button" class="lt-btn" data-modal-close>Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const GANDALF_CONFIG = {
|
|
ticket_web_url: {{ config.get('ticket_api', {}).get('web_url', 'http://t.lotusguild.org/ticket/') | tojson }}
|
|
};
|
|
</script>
|
|
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
|
{% block scripts %}{% endblock %}
|
|
|
|
<script>
|
|
if (window.lt) {
|
|
lt.init({ bootName: 'GANDALF' });
|
|
|
|
// Theme toggle
|
|
const themeBtn = document.getElementById('lt-theme-btn');
|
|
if (themeBtn) themeBtn.addEventListener('click', function() { lt.theme.toggle(); });
|
|
|
|
// Command palette
|
|
lt.cmdPalette.init([
|
|
{ id: 'nav-dashboard', group: 'Navigate', icon: '~', label: 'Dashboard', action: function() { window.location.href = '/'; } },
|
|
{ id: 'nav-links', group: 'Navigate', icon: '↗', label: 'Link Debug', action: function() { window.location.href = '/links'; } },
|
|
{ id: 'nav-inspector', group: 'Navigate', icon: '⬡', label: 'Inspector', action: function() { window.location.href = '/inspector'; } },
|
|
{ id: 'nav-suppressions', group: 'Navigate', icon: '🔕', label: 'Suppressions', action: function() { window.location.href = '/suppressions'; } },
|
|
{ id: 'action-refresh', group: 'Actions', icon: '↻', label: 'Refresh Data', kbd: 'R', action: function() { lt.autoRefresh.now(); } },
|
|
{ id: 'action-suppress', group: 'Actions', icon: '🔕', label: 'New Suppression', kbd: 'S', action: function() { if (typeof openSuppressModal === 'function') openSuppressModal('host','',''); else window.location.href='/suppressions'; } },
|
|
{ id: 'help-shortcuts', group: 'Help', icon: '?', label: 'Keyboard Shortcuts', kbd: '?', action: function() { lt.modal.open('lt-keys-help'); } },
|
|
{ id: 'help-settings', group: 'Help', icon: '*', label: 'Settings', kbd: '*', action: function() { lt.modal.open('lt-settings-modal'); } },
|
|
{ id: 'help-theme', group: 'Help', icon: '☀', label: 'Toggle Theme', action: function() { lt.theme.toggle(); } },
|
|
]);
|
|
}
|
|
|
|
// ── Global footer + key actions ───────────────────────────────────────
|
|
document.addEventListener('click', function(e) {
|
|
const btn = e.target.closest('[data-action]');
|
|
if (!btn) return;
|
|
const action = btn.getAttribute('data-action');
|
|
if (action === 'open-cmdpalette' && window.lt && lt.cmdPalette) lt.cmdPalette.open();
|
|
if (action === 'show-keyboard-help' && window.lt) lt.modal.open('lt-keys-help');
|
|
if (action === 'open-settings' && window.lt) lt.modal.open('lt-settings-modal');
|
|
});
|
|
|
|
lt.keys.on('r', function() { lt.autoRefresh.now(); });
|
|
lt.keys.on('?', function() { if (window.lt) lt.modal.open('lt-keys-help'); });
|
|
lt.keys.on('*', function() { if (window.lt) lt.modal.open('lt-settings-modal'); });
|
|
lt.keys.on('s', function() {
|
|
if (typeof openSuppressModal === 'function') openSuppressModal('host', '', '');
|
|
});
|
|
|
|
// ── Avatar image error fallback ───────────────────────────────────────
|
|
document.addEventListener('error', function(e) {
|
|
if (e.target.tagName === 'IMG' && e.target.closest('.lt-avatar')) {
|
|
e.target.classList.add('lt-avatar-img-err');
|
|
}
|
|
}, true);
|
|
|
|
// ── Settings modal ────────────────────────────────────────────────────
|
|
(function() {
|
|
const LS_KEY = 'gandalf_settings';
|
|
const DEFAULT = { refreshInterval: 30 };
|
|
|
|
function loadSettings() {
|
|
try { return Object.assign({}, DEFAULT, JSON.parse(localStorage.getItem(LS_KEY) || '{}')); }
|
|
catch(_) { return Object.assign({}, DEFAULT); }
|
|
}
|
|
|
|
function saveSettings(s) {
|
|
try { localStorage.setItem(LS_KEY, JSON.stringify(s)); } catch(_) {}
|
|
if (typeof window.onGandalfSettingsChanged === 'function') window.onGandalfSettingsChanged(s);
|
|
}
|
|
|
|
function applyRefreshPillUI(interval) {
|
|
document.querySelectorAll('#settings-refresh-pills .pill').forEach(function(p) {
|
|
const isActive = parseInt(p.dataset.refreshInterval) === interval;
|
|
p.classList.toggle('active', isActive);
|
|
p.setAttribute('aria-pressed', isActive ? 'true' : 'false');
|
|
});
|
|
const hint = document.getElementById('settings-refresh-hint');
|
|
if (hint) {
|
|
if (interval === 0) hint.textContent = 'Auto-refresh disabled.';
|
|
else if (interval < 60) hint.textContent = 'Refreshes every ' + interval + ' seconds.';
|
|
else hint.textContent = 'Refreshes every ' + Math.floor(interval/60) + ' minute' + (interval > 60 ? 's' : '') + '.';
|
|
}
|
|
}
|
|
|
|
// Init pill UI from saved settings
|
|
const _settings = loadSettings();
|
|
applyRefreshPillUI(_settings.refreshInterval);
|
|
|
|
// Expose for pages that need to read it (e.g. index.html for autoRefresh)
|
|
window.gandalfSettings = _settings;
|
|
|
|
document.addEventListener('click', function(e) {
|
|
const pill = e.target.closest('#settings-refresh-pills .pill[data-refresh-interval]');
|
|
if (!pill) return;
|
|
const interval = parseInt(pill.dataset.refreshInterval);
|
|
_settings.refreshInterval = interval;
|
|
saveSettings(_settings);
|
|
applyRefreshPillUI(interval);
|
|
});
|
|
})();
|
|
|
|
// ── Notification Bell — shows active monitoring alerts ────────────────
|
|
(function() {
|
|
const bell = document.getElementById('lt-notif-bell');
|
|
const panel = document.getElementById('lt-notif-panel');
|
|
const list = document.getElementById('lt-notif-list');
|
|
const clearBtn = document.getElementById('lt-notif-clear-btn');
|
|
const wrapEl = document.getElementById('lt-notif-wrap');
|
|
if (!bell || !panel) return;
|
|
|
|
let _open = false;
|
|
let _lastEvents = [];
|
|
const LS_READ_KEY = 'gandalf_notif_read_before';
|
|
|
|
function getReadBefore() {
|
|
try { return parseInt(localStorage.getItem(LS_READ_KEY) || '0'); } catch(_) { return 0; }
|
|
}
|
|
function setReadBefore(ts) {
|
|
try { localStorage.setItem(LS_READ_KEY, String(ts)); } catch(_) {}
|
|
}
|
|
|
|
function esc(s) {
|
|
return (window.lt && lt.escHtml) ? lt.escHtml(String(s)) : String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|
|
|
|
function toMs(dateStr) {
|
|
if (!dateStr) return 0;
|
|
return new Date(dateStr.replace(' UTC','Z').replace(' ','T')).getTime();
|
|
}
|
|
|
|
function fmtAgo(dateStr) {
|
|
const diff = Math.floor((Date.now() - toMs(dateStr)) / 1000);
|
|
if (diff < 60) return diff + 's ago';
|
|
if (diff < 3600) return Math.floor(diff/60) + 'm ago';
|
|
if (diff < 86400) return Math.floor(diff/3600) + 'h ago';
|
|
return Math.floor(diff/86400) + 'd ago';
|
|
}
|
|
|
|
const SEV_DOT = { critical: 'var(--red)', warning: 'var(--amber)' };
|
|
|
|
function renderAlerts(events) {
|
|
_lastEvents = events || [];
|
|
const readBefore = getReadBefore();
|
|
const active = _lastEvents.filter(function(e) { return e.severity !== 'info'; });
|
|
const unreadCount = active.filter(function(e) { return toMs(e.last_seen) > readBefore; }).length;
|
|
lt.notif.set(bell, unreadCount);
|
|
|
|
if (!active.length) {
|
|
list.innerHTML = '<div class="lt-notif-empty">✔ No active alerts</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = active.slice(0, 25).map(function(e) {
|
|
const isUnread = toMs(e.last_seen) > readBefore;
|
|
const dotColor = SEV_DOT[e.severity] || 'var(--text-muted)';
|
|
return '<div class="lt-notif-item' + (isUnread ? ' lt-notif-item--unread' : '') + '">' +
|
|
'<div class="lt-notif-dot' + (isUnread ? '' : ' lt-notif-dot--read') + '" style="background:' + dotColor + '"></div>' +
|
|
'<div class="lt-notif-item-body">' +
|
|
'<div class="lt-notif-item-title">' + esc(e.target_name) + (e.target_detail ? ' · ' + esc(e.target_detail) : '') + '</div>' +
|
|
'<div class="lt-notif-item-time">' + esc(e.event_type.replace(/_/g,' ')) + ' · ' + fmtAgo(e.last_seen) + '</div>' +
|
|
'</div></div>';
|
|
}).join('');
|
|
}
|
|
|
|
function fetchAlerts(andRender) {
|
|
fetch('/api/status', { credentials: 'same-origin' })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
const events = data.events || [];
|
|
if (andRender) {
|
|
renderAlerts(events);
|
|
} else {
|
|
_lastEvents = events;
|
|
const readBefore = getReadBefore();
|
|
const active = events.filter(function(e) { return e.severity !== 'info'; });
|
|
const unread = active.filter(function(e) { return toMs(e.last_seen) > readBefore; }).length;
|
|
lt.notif.set(bell, unread);
|
|
}
|
|
})
|
|
.catch(function() {
|
|
if (andRender) list.innerHTML = '<div class="lt-notif-empty">Could not load</div>';
|
|
});
|
|
}
|
|
|
|
function openPanel() { _open = true; panel.removeAttribute('aria-hidden'); bell.setAttribute('aria-expanded','true'); fetchAlerts(true); }
|
|
function closePanel() { _open = false; panel.setAttribute('aria-hidden','true'); bell.setAttribute('aria-expanded','false'); }
|
|
|
|
bell.addEventListener('click', function(e) { e.stopPropagation(); _open ? closePanel() : openPanel(); });
|
|
|
|
if (clearBtn) {
|
|
clearBtn.addEventListener('click', function() {
|
|
setReadBefore(Date.now());
|
|
renderAlerts(_lastEvents);
|
|
});
|
|
}
|
|
|
|
document.addEventListener('click', function(e) { if (_open && wrapEl && !wrapEl.contains(e.target)) closePanel(); });
|
|
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && _open) closePanel(); });
|
|
|
|
// Initial badge load + poll every 60 s
|
|
fetchAlerts(false);
|
|
setInterval(function() { fetchAlerts(_open); }, 60000);
|
|
|
|
// Allow refreshAll() to also push fresh events to the bell
|
|
window.gandalfNotifUpdate = function(events) { renderAlerts(events); };
|
|
})();
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|