Lint / JS (eslint) (push) Successful in 7s
Lint / Python (flake8) (push) Successful in 50s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 50s
Test / Python Tests (pytest) (push) Successful in 1m2s
Lint / Python (flake8) (pull_request) Successful in 52s
Lint / JS (eslint) (pull_request) Successful in 7s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 1m42s
Test / Python Tests (pytest) (pull_request) Successful in 2m2s
The rack front is drawn from the rack units on cutsheet items. Switches with a faceplate use it, with live port state; patch panels get a generic panel with port labels; everything else is a labelled plate. Each documented port shows its cutsheet state, and hovering a port lights up every port on the same cable run (through patch panels) and names the cables. Clicking opens the switch port or the cable. Overlapping items and items past the top of the rack are listed instead of drawn over another; where two claim the same top unit, the first one recorded wins. rack.units sets the height (default 42). /api/rack builds on the cutsheet view; cutsheet.rack_layout and cutsheet.port_marks hold the placement and per-port logic. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
533 lines
26 KiB
HTML
533 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">
|
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%23030508'/%3E%3Cpath d='M8 2l5 3v6l-5 3-5-3V5z' fill='none' stroke='%2300e5ff' stroke-width='1.5'/%3E%3C/svg%3E">
|
|
<title>{% block title %}Network Monitor{% 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('events_page') }}"
|
|
class="lt-nav-drawer-link{% if request.endpoint == 'events_page' %} active{% endif %}"
|
|
{% if request.endpoint == 'events_page' %}aria-current="page"{% endif %}>History</a>
|
|
<a href="{{ url_for('cutsheet_page') }}"
|
|
class="lt-nav-drawer-link{% if request.endpoint == 'cutsheet_page' %} active{% endif %}"
|
|
{% if request.endpoint == 'cutsheet_page' %}aria-current="page"{% endif %}>Cutsheet</a>
|
|
<a href="{{ url_for('rack_page') }}"
|
|
class="lt-nav-drawer-link{% if request.endpoint == 'rack_page' %} active{% endif %}"
|
|
{% if request.endpoint == 'rack_page' %}aria-current="page"{% endif %}>Rack</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('events_page') }}"
|
|
class="lt-nav-link{% if request.endpoint == 'events_page' %} active{% endif %}"
|
|
{% if request.endpoint == 'events_page' %}aria-current="page"{% endif %}>
|
|
History
|
|
</a>
|
|
<a href="{{ url_for('cutsheet_page') }}"
|
|
class="lt-nav-link{% if request.endpoint == 'cutsheet_page' %} active{% endif %}"
|
|
{% if request.endpoint == 'cutsheet_page' %}aria-current="page"{% endif %}>
|
|
Cutsheet
|
|
</a>
|
|
<a href="{{ url_for('rack_page') }}"
|
|
class="lt-nav-link{% if request.endpoint == 'rack_page' %} active{% endif %}"
|
|
{% if request.endpoint == 'rack_page' %}aria-current="page"{% endif %}>
|
|
Rack
|
|
</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 == 'device_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>
|
|
<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 / Device)</td></tr>
|
|
<tr><td>/</td><td>Search devices (Dashboard)</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-history', group: 'Navigate', icon: '⏱', label: 'Alert History', action: function() { window.location.href = '/events'; } },
|
|
{ id: 'nav-cutsheet', group: 'Navigate', icon: '🔌', label: 'Cutsheet', action: function() { window.location.href = '/cutsheet'; } },
|
|
{ id: 'nav-rack', group: 'Navigate', icon: '🗄', label: 'Rack', action: function() { window.location.href = '/rack'; } },
|
|
{ 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>
|