Add notification bell, settings modal, and context-sensitive footer
Lint / Python (flake8) (push) Successful in 40s
Lint / JS (eslint) (push) Successful in 9s
Security / Python Security (bandit) (push) Failing after 1m11s
Test / Python Tests (pytest) (push) Successful in 1m3s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 2s
Lint / Python (flake8) (push) Successful in 40s
Lint / JS (eslint) (push) Successful in 9s
Security / Python Security (bandit) (push) Failing after 1m11s
Test / Python Tests (pytest) (push) Successful in 1m3s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 2s
- Notification bell in header polls /api/status and shows active alerts with severity-colored dots; badge counts unread items via localStorage - Settings modal ([ * ] CFG) controls auto-refresh interval (15s/30s/1m/5m/off) persisted to localStorage and wired into lt.autoRefresh on all pages - Context-sensitive footer hints: Dashboard shows REFRESH + SUPPRESS, Link Debug shows REFRESH, all pages show CFG + HELP - Added S key (quick suppress) and * key (settings) shortcuts - ⌘K affordance button added to header-right - R key now uses lt.autoRefresh.now() so it works on any page - refreshAll() pushes fresh events to notification bell on each poll Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,7 @@ async function refreshAll() {
|
||||
const status = statusResult.value;
|
||||
updateEventsTable(status.events || [], status.total_active);
|
||||
updateStatusBar(status.summary || {}, status.last_check || '', status.daemon_ok);
|
||||
if (typeof window.gandalfNotifUpdate === 'function') window.gandalfNotifUpdate(status.events || []);
|
||||
} else {
|
||||
showToast('Status data unavailable', 'warning');
|
||||
}
|
||||
|
||||
+254
-20
@@ -140,6 +140,38 @@
|
||||
{% 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 style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Loading…</div>
|
||||
</div>
|
||||
<div class="lt-notif-panel-footer">
|
||||
<a href="{{ url_for('index') }}" class="lt-btn lt-btn-ghost lt-btn-sm" style="width:100%;text-align:center;display:block;font-size:0.72rem">View dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ⌘K affordance -->
|
||||
<button type="button"
|
||||
class="lt-btn lt-btn-ghost lt-btn-sm"
|
||||
title="Command palette (Ctrl+K)"
|
||||
aria-label="Open command palette"
|
||||
onclick="if(window.lt&<.cmdPalette)lt.cmdPalette.open()"
|
||||
style="font-size:0.65rem;opacity:0.55;letter-spacing:0.03em;padding:0.2rem 0.45rem">⌕ K</button>
|
||||
|
||||
<button type="button" class="lt-theme-btn" id="lt-theme-btn"
|
||||
aria-label="Toggle theme" title="Toggle light/dark mode">☀</button>
|
||||
</div>
|
||||
@@ -170,12 +202,19 @@
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<!-- FOOTER — context-sensitive per page -->
|
||||
<footer class="lt-footer" role="contentinfo">
|
||||
<nav class="lt-footer-hints" aria-label="Keyboard shortcuts">
|
||||
<span class="lt-footer-hint"><span class="lt-footer-key">[ Ctrl+K ]</span> SEARCH</span>
|
||||
<span class="lt-footer-sep">|</span>
|
||||
<span class="lt-footer-hint"><span class="lt-footer-key">[ R ]</span> REFRESH</span>
|
||||
{% 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 == 'links_page' %}
|
||||
<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>
|
||||
@@ -194,9 +233,11 @@
|
||||
<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 dashboard data</td></tr>
|
||||
<tr><td>R</td><td>Refresh data (Dashboard / Link Debug)</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</td></tr>
|
||||
<tr><td>ESC</td><td>Close modal / panel</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -206,6 +247,45 @@
|
||||
</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">
|
||||
<button type="button" class="pill" data-refresh-interval="15">15 s</button>
|
||||
<button type="button" class="pill" data-refresh-interval="30">30 s</button>
|
||||
<button type="button" class="pill" data-refresh-interval="60">1 min</button>
|
||||
<button type="button" class="pill" data-refresh-interval="300">5 min</button>
|
||||
<button type="button" class="pill" data-refresh-interval="0">Off</button>
|
||||
</div>
|
||||
<div class="lt-field-hint" id="settings-refresh-hint"></div>
|
||||
</div>
|
||||
<div class="lt-divider" style="margin:1rem 0 0.75rem"></div>
|
||||
<div class="lt-kv-grid">
|
||||
<div class="lt-kv-row">
|
||||
<span class="lt-kv-label">User</span>
|
||||
<span class="lt-kv-value lt-text-cyan">{{ user.name or user.username }}</span>
|
||||
</div>
|
||||
{% if user.groups %}
|
||||
<div class="lt-kv-row">
|
||||
<span class="lt-kv-label">Groups</span>
|
||||
<span class="lt-kv-value">{{ user.groups | join(', ') }}</span>
|
||||
</div>
|
||||
{% 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/') }}"
|
||||
@@ -224,35 +304,189 @@
|
||||
|
||||
// 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: 'help-shortcuts', group: 'Help', icon: '?', label: 'Keyboard Shortcuts', action: function() { lt.modal.open('lt-keys-help'); } },
|
||||
{ id: 'help-theme', group: 'Help', icon: '*', label: 'Toggle Theme', action: function() { lt.theme.toggle(); } },
|
||||
{ id: 'action-refresh', group: 'Actions', icon: '↻', label: 'Refresh Data', kbd: 'R', action: function() { if (typeof refreshAll === 'function') refreshAll(); } },
|
||||
{ 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(); } },
|
||||
]);
|
||||
}
|
||||
|
||||
// Footer hint actions
|
||||
// ── Global footer + key actions ───────────────────────────────────────
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
if (btn.getAttribute('data-action') === 'show-keyboard-help' && window.lt) {
|
||||
lt.modal.open('lt-keys-help');
|
||||
}
|
||||
var action = btn.getAttribute('data-action');
|
||||
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() {
|
||||
if (typeof refreshAll === 'function') refreshAll();
|
||||
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 — hide broken img, reveal initials beneath
|
||||
// ── 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() {
|
||||
var LS_KEY = 'gandalf_settings';
|
||||
var 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) {
|
||||
p.classList.toggle('active', parseInt(p.dataset.refreshInterval) === interval);
|
||||
});
|
||||
var 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
|
||||
var _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) {
|
||||
var pill = e.target.closest('#settings-refresh-pills .pill[data-refresh-interval]');
|
||||
if (!pill) return;
|
||||
var interval = parseInt(pill.dataset.refreshInterval);
|
||||
_settings.refreshInterval = interval;
|
||||
saveSettings(_settings);
|
||||
applyRefreshPillUI(interval);
|
||||
});
|
||||
})();
|
||||
|
||||
// ── Notification Bell — shows active monitoring alerts ────────────────
|
||||
(function() {
|
||||
var bell = document.getElementById('lt-notif-bell');
|
||||
var panel = document.getElementById('lt-notif-panel');
|
||||
var list = document.getElementById('lt-notif-list');
|
||||
var clearBtn = document.getElementById('lt-notif-clear-btn');
|
||||
var wrapEl = document.getElementById('lt-notif-wrap');
|
||||
if (!bell || !panel) return;
|
||||
|
||||
var _open = false;
|
||||
var _lastEvents = [];
|
||||
var 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) {
|
||||
var 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';
|
||||
}
|
||||
|
||||
var SEV_DOT = { critical: 'var(--red)', warning: 'var(--amber)' };
|
||||
|
||||
function renderAlerts(events) {
|
||||
_lastEvents = events || [];
|
||||
var readBefore = getReadBefore();
|
||||
var active = _lastEvents.filter(function(e) { return e.severity !== 'info'; });
|
||||
var unreadCount = active.filter(function(e) { return toMs(e.last_seen) > readBefore; }).length;
|
||||
lt.notif.set(bell, unreadCount);
|
||||
|
||||
if (!active.length) {
|
||||
list.innerHTML = '<div style="padding:1rem;font-size:0.75rem;color:var(--text-muted);text-align:center">✔ No active alerts</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = active.slice(0, 25).map(function(e) {
|
||||
var isUnread = toMs(e.last_seen) > readBefore;
|
||||
var 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 + ';border-radius:50%;margin-top:4px"></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) {
|
||||
var events = data.events || [];
|
||||
if (andRender) {
|
||||
renderAlerts(events);
|
||||
} else {
|
||||
_lastEvents = events;
|
||||
var readBefore = getReadBefore();
|
||||
var active = events.filter(function(e) { return e.severity !== 'info'; });
|
||||
var unread = active.filter(function(e) { return toMs(e.last_seen) > readBefore; }).length;
|
||||
lt.notif.set(bell, unread);
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
if (andRender) list.innerHTML = '<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">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>
|
||||
|
||||
+10
-1
@@ -463,7 +463,16 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
lt.autoRefresh.start(refreshAll, 30000);
|
||||
// Start auto-refresh using saved settings interval (default 30 s)
|
||||
var _savedInterval = (window.gandalfSettings && window.gandalfSettings.refreshInterval) || 30;
|
||||
if (_savedInterval > 0) lt.autoRefresh.start(refreshAll, _savedInterval * 1000);
|
||||
|
||||
// When settings change, restart auto-refresh with new interval
|
||||
window.onGandalfSettingsChanged = function(s) {
|
||||
lt.autoRefresh.stop();
|
||||
if (s.refreshInterval > 0) lt.autoRefresh.start(refreshAll, s.refreshInterval * 1000);
|
||||
};
|
||||
|
||||
document.getElementById('suppress-form')?.addEventListener('submit', submitSuppress);
|
||||
document.getElementById('sup-type')?.addEventListener('change', updateSuppressForm);
|
||||
|
||||
|
||||
@@ -518,7 +518,13 @@ async function loadLinks() {
|
||||
}
|
||||
|
||||
loadLinks();
|
||||
lt.autoRefresh.start(loadLinks, 60000);
|
||||
var _linksInterval = (window.gandalfSettings && window.gandalfSettings.refreshInterval) || 60;
|
||||
if (_linksInterval > 0) lt.autoRefresh.start(loadLinks, Math.max(_linksInterval, 15) * 1000);
|
||||
|
||||
window.onGandalfSettingsChanged = function(s) {
|
||||
lt.autoRefresh.stop();
|
||||
if (s.refreshInterval > 0) lt.autoRefresh.start(loadLinks, Math.max(s.refreshInterval, 15) * 1000);
|
||||
};
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
const toggleTitle = e.target.closest('[data-action="toggle-panel"]');
|
||||
|
||||
Reference in New Issue
Block a user