Compare commits

...

14 Commits

Author SHA1 Message Date
jared 678ede4e76 refactor: replace inline onclick with data-action event delegation
Lint / Python (flake8) (push) Successful in 42s
Lint / JS (eslint) (push) Successful in 8s
Security / Python Security (bandit) (push) Successful in 1m0s
Test / Python Tests (pytest) (push) Successful in 50s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 2s
The command palette button used an inline onclick handler while every
other interactive element in base.html uses data-action + event
delegation. Now consistent: data-action="open-cmdpalette" handled in
the global footer click listener.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:45:09 -04:00
jared b51b39c3a7 a11y: keyboard-accessible panel toggles, region landmarks in inspector
Lint / Python (flake8) (push) Successful in 43s
Lint / JS (eslint) (push) Successful in 14s
Security / Python Security (bandit) (push) Successful in 45s
Test / Python Tests (pytest) (push) Successful in 50s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 3s
- Add role="button" tabindex="0" aria-expanded to .link-host-title
  in both static and JS-rendered panels (host panels + UniFi switches)
- Sync aria-expanded in togglePanel(), restoreCollapseState(),
  collapseAll(), and expandAll()
- Add keydown handler (Enter/Space) so panel headers are keyboard-operable
- Add role="region" aria-label to inspector main chassis area
- Add role="complementary" aria-label to inspector port detail panel
- Replace last inline date-parse in renderLinks() with _toIso() helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:44:23 -04:00
jared 41695a3faa security: escape user input in 403 error response to prevent XSS
Lint / Python (flake8) (push) Successful in 40s
Lint / JS (eslint) (push) Successful in 9s
Security / Python Security (bandit) (push) Successful in 1m0s
Test / Python Tests (pytest) (push) Successful in 51s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 2s
The require_auth decorator was interpolating user['username'] and the
allowed_groups list directly into HTML strings. An attacker with a
crafted username or control over group names could inject arbitrary HTML.

Use html.escape() on both values before insertion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:41:31 -04:00
jared c0e59cfa9e refactor: extract _annotate_suppressions helper, remove orphaned CSS
Lint / Python (flake8) (push) Successful in 45s
Lint / JS (eslint) (push) Successful in 8s
Security / Python Security (bandit) (push) Successful in 1m0s
Test / Python Tests (pytest) (push) Successful in 57s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 2s
- Extract identical suppression-annotation loop from index() and
  api_status() into _annotate_suppressions() helper to eliminate DRY
  violation
- Improve stuck-job error message: 'thread crash' → 'no activity for
  5 minutes' (less alarming, more accurate)
- Remove orphaned .events-filter-bar CSS class (never referenced in
  any template or JS file)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:39:52 -04:00
jared 7ab85cd055 refactor: const/let modernisation and eliminate duplicate date-parse logic
Lint / Python (flake8) (push) Successful in 47s
Lint / JS (eslint) (push) Successful in 9s
Security / Python Security (bandit) (push) Successful in 1m7s
Test / Python Tests (pytest) (push) Successful in 58s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 4s
- Replace all var declarations in base.html, index.html scripts with
  const/let (const for bindings that are never reassigned, let otherwise)
- Add _toIso() helper to links.html script block and replace the two
  inline .replace(' UTC','Z').replace(' ','T') patterns with it
- Replace var with const in links.html _linksInterval

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:37:32 -04:00
jared 68f59c49a2 a11y: aria-pressed for all pill groups, aria-label on search inputs and buttons
Lint / Python (flake8) (push) Successful in 46s
Lint / JS (eslint) (push) Successful in 10s
Security / Python Security (bandit) (push) Successful in 51s
Test / Python Tests (pytest) (push) Successful in 1m8s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 4s
- Add role="group" + aria-label to duration-pills and sev-pills containers
- Add aria-pressed to severity filter, duration, and refresh-interval pills
- Keep aria-pressed in sync with JS (setDuration, applyRefreshPillUI, modal reset)
- Add aria-label to events-search, host-search, links-search inputs
- Add aria-label to host and UniFi device suppress buttons in templates
- Replace dynamic style color strings in links.html stat cards with TDS
  utility classes (lt-text-red/green/amber) via downCls/errCls variables

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:34:16 -04:00
jared a3c0818fef Fix: inspector empty states and diagnostic button accessibility
Lint / Python (flake8) (push) Successful in 57s
Lint / JS (eslint) (push) Successful in 7s
Security / Python Security (bandit) (push) Successful in 45s
Test / Python Tests (pytest) (push) Successful in 1m13s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 3s
- Replace .empty-state (removed class) with TDS lt-empty-state--sm in
  both error branches of renderInspector() and loadInspector()
- Diagnostic run button: add aria-label, apply lt-btn TDS classes for
  consistent styling instead of custom btn-diag-only styling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:21:27 -04:00
jared 4dd7fc16f3 CSS: migrate links.html static inline styles to classes
Lint / Python (flake8) (push) Successful in 41s
Lint / JS (eslint) (push) Successful in 7s
Security / Python Security (bandit) (push) Successful in 40s
Test / Python Tests (pytest) (push) Successful in 48s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 4s
- lt-divider--unifi / lt-divider-label--unifi: replace hardcoded margin
  and cyan label color on the UniFi switch section divider
- lt-text-amber / lt-text-cyan on stat card icons and values (matches
  same migration done in index.html)
- lt-stats-grid--mb: margin-bottom:16px on the summary stats grid
- g-page-sub-aside: replaces margin-left:8px on the updated timestamp
  span in links and inspector page subtitle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:19:32 -04:00
jared 0b33589106 CSS: extract notification panel inline styles to classes
Lint / Python (flake8) (push) Successful in 40s
Lint / JS (eslint) (push) Successful in 8s
Security / Python Security (bandit) (push) Successful in 1m7s
Test / Python Tests (pytest) (push) Successful in 1m42s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 3s
- lt-notif-empty: replaces all hardcoded padding/font/color/align on
  the empty-state and loading/error text in the notification bell panel
- lt-notif-view-all: replaces width/text-align/display/font-size inline
  style on the 'View dashboard' footer link
- lt-notif-dot: moves border-radius:50%;margin-top from inline style
  (only background color remains inline, which is dynamic per-severity)
- Initial 'Loading…' text in the panel HTML uses lt-notif-empty

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:18:33 -04:00
jared ca4bcef26c CSS: replace remaining inline color/size styles with TDS utilities
Lint / Python (flake8) (push) Successful in 57s
Lint / JS (eslint) (push) Successful in 7s
Security / Python Security (bandit) (push) Successful in 53s
Test / Python Tests (pytest) (push) Successful in 1m13s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 9s
- Stat card icons and values: style="color:var(--red)" etc replaced with
  lt-text-red, lt-text-amber, lt-text-cyan, lt-text-green (defined in
  base.css with both color and glow-shadow)
- Host search input: style="width:180px" extracted to .lt-search-input--sm
- base.html: suppress modal form groups use lt-form-group--last for last
  item (already committed); lt-divider--compact applied to settings divider

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:17:22 -04:00
jared 15120a280f CSS: remove remaining fixable inline styles across templates
Lint / Python (flake8) (push) Successful in 48s
Lint / JS (eslint) (push) Successful in 7s
Security / Python Security (bandit) (push) Successful in 42s
Test / Python Tests (pytest) (push) Successful in 54s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 3s
- Suppress modal form groups: strip margin-bottom:12px (lt-form-group
  already has margin-bottom in TDS); use lt-form-group--last for the
  final group where zero margin is needed
- Keyboard shortcuts table: remove width:100% (lt-table is already full-
  width in base.css)
- Settings divider: replace style=margin override with .lt-divider--compact
- Topology bus section: move max-width:860px into .topo-bus-section rule

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:15:15 -04:00
jared 906869f425 CSS: convert all topology inline styles to modifier classes
Lint / Python (flake8) (push) Successful in 41s
Lint / JS (eslint) (push) Successful in 9s
Security / Python Security (bandit) (push) Successful in 1m0s
Test / Python Tests (pytest) (push) Successful in 55s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 4s
Replace style= attributes on topology wire/node elements with semantic
modifier classes:
- topo-vc-wire--wan, --10g, --mgmt (wire colour semantics in CSS)
- topo-v2-host--bus (bus-section node size constraint)
- topo-legend-item--offrack already done in prior commit

Zero inline styles remain in the topology section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:13:24 -04:00
jared c027b5422a Feature: show suppression status on active alert rows
Lint / Python (flake8) (push) Successful in 45s
Lint / JS (eslint) (push) Successful in 7s
Security / Python Security (bandit) (push) Successful in 47s
Test / Python Tests (pytest) (push) Successful in 1m11s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 3s
Active events now carry an is_suppressed boolean (added in api_status()
and the index() route via check_suppressed() against the pre-loaded
suppression list). The events table renders a muted '🔕 sup' badge next
to the severity and dims the entire row (.row-suppressed) so operators
can immediately see which firing alerts are silenced.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:11:15 -04:00
jared d3e8191f26 Cleanup: strip redundant inline styles, add CSS classes
Lint / Python (flake8) (push) Successful in 41s
Lint / JS (eslint) (push) Successful in 7s
Security / Python Security (bandit) (push) Successful in 44s
Test / Python Tests (pytest) (push) Successful in 52s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 3s
- Remove style="margin-top:4px" from .g-page-sub in all three secondary
  pages (the value is already defined in .g-page-sub rule in style.css)
- suppressions.html: replace inline style="padding:12px 14px" with TDS
  lt-section-body class
- index.html topology legend: replace inline dashed-border style with
  .topo-legend-item--offrack modifier class in style.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:09:25 -04:00
8 changed files with 269 additions and 153 deletions
+30 -5
View File
@@ -5,6 +5,7 @@ management UI. Authentication via Authelia forward-auth headers.
All monitoring and alerting is handled by the separate monitor.py daemon. All monitoring and alerting is handled by the separate monitor.py daemon.
""" """
import hashlib import hashlib
import html
import ipaddress import ipaddress
import json import json
import logging import logging
@@ -73,8 +74,8 @@ def _purge_old_jobs_loop():
for jid, j in list(_diag_jobs.items()): for jid, j in list(_diag_jobs.items()):
if j['status'] == 'running' and j.get('created_at', 0) < stuck_cutoff: if j['status'] == 'running' and j.get('created_at', 0) < stuck_cutoff:
j['status'] = 'done' j['status'] = 'done'
j['result'] = {'status': 'error', 'error': 'Diagnostic timed out (thread crash)'} j['result'] = {'status': 'error', 'error': 'Diagnostic abandoned — no activity for 5 minutes.'}
logger.error(f'Diagnostic job {jid} appeared stuck; marked as errored') logger.error(f'Diagnostic job {jid} stuck (no activity for 5 min); marked done/error')
_purge_thread = threading.Thread(target=_purge_old_jobs_loop, daemon=True) _purge_thread = threading.Thread(target=_purge_old_jobs_loop, daemon=True)
@@ -132,10 +133,12 @@ def require_auth(f):
) )
allowed = _config().get('auth', {}).get('allowed_groups', ['admin']) allowed = _config().get('auth', {}).get('allowed_groups', ['admin'])
if not any(g in allowed for g in user['groups']): if not any(g in allowed for g in user['groups']):
safe_user = html.escape(user['username'])
safe_groups = html.escape(', '.join(allowed))
return ( return (
f'<h1>403 Access denied</h1>' f'<h1>403 Access denied</h1>'
f'<p>Your account ({user["username"]}) is not in an allowed group ' f'<p>Your account ({safe_user}) is not in an allowed group '
f'({", ".join(allowed)}).</p>', f'({safe_groups}).</p>',
403, 403,
) )
return f(*args, **kwargs) return f(*args, **kwargs)
@@ -143,12 +146,31 @@ def require_auth(f):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Page routes # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_PAGE_LIMIT = 200 # max events returned per request _PAGE_LIMIT = 200 # max events returned per request
def _annotate_suppressions(events: list, suppressions: list) -> None:
"""Annotate each event dict in-place with an is_suppressed bool."""
for ev in events:
sup_type = (
'unifi_device' if ev.get('event_type') == 'unifi_device_offline'
else 'interface' if ev.get('event_type') == 'interface_down'
else 'host'
)
ev['is_suppressed'] = db.check_suppressed(
suppressions, sup_type,
ev.get('target_name', ''), ev.get('target_detail', '') or '',
)
# ---------------------------------------------------------------------------
# Page routes
# ---------------------------------------------------------------------------
@app.route('/') @app.route('/')
@require_auth @require_auth
def index(): def index():
@@ -160,6 +182,7 @@ def index():
last_check = db.get_state('last_check', 'Never') last_check = db.get_state('last_check', 'Never')
snapshot = json.loads(snapshot_raw) if snapshot_raw else {} snapshot = json.loads(snapshot_raw) if snapshot_raw else {}
suppressions = db.get_active_suppressions() suppressions = db.get_active_suppressions()
_annotate_suppressions(events, suppressions)
recent_resolved = db.get_recent_resolved(hours=24, limit=10) recent_resolved = db.get_recent_resolved(hours=24, limit=10)
return render_template( return render_template(
'index.html', 'index.html',
@@ -214,6 +237,8 @@ def suppressions_page():
@require_auth @require_auth
def api_status(): def api_status():
active = db.get_active_events(limit=_PAGE_LIMIT) active = db.get_active_events(limit=_PAGE_LIMIT)
suppressions = db.get_active_suppressions()
_annotate_suppressions(active, suppressions)
last_check = db.get_state('last_check', 'Never') last_check = db.get_state('last_check', 'Never')
return jsonify({ return jsonify({
'summary': db.get_status_summary(), 'summary': db.get_status_summary(),
+15 -6
View File
@@ -222,9 +222,12 @@ function updateEventsTable(events, totalActive) {
? `<a href="${lt.escHtml(ticketBase)}${lt.escHtml(String(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>` class="ticket-link">#${e.ticket_id}</a>`
: ''; : '';
const supBadge = e.is_suppressed
? `<span class="lt-badge badge-suppressed" title="Alert suppressed">🔕 sup</span>`
: '';
return ` return `
<tr class="row-${e.severity}"> <tr class="row-${e.severity}${e.is_suppressed ? ' row-suppressed' : ''}">
<td><span class="lt-badge badge-${e.severity}">${e.severity}</span></td> <td><span class="lt-badge badge-${e.severity}">${e.severity}</span>${supBadge}</td>
<td>${lt.escHtml(e.event_type.replace(/_/g,' '))}</td> <td>${lt.escHtml(e.event_type.replace(/_/g,' '))}</td>
<td><strong>${lt.escHtml(e.target_name)}</strong></td> <td><strong>${lt.escHtml(e.target_name)}</strong></td>
<td>${lt.escHtml(e.target_detail || '')}</td> <td>${lt.escHtml(e.target_detail || '')}</td>
@@ -273,9 +276,12 @@ function openSuppressModal(type, name, detail) {
updateSuppressForm(); updateSuppressForm();
lt.modal.open('suppress-modal'); lt.modal.open('suppress-modal');
document.querySelectorAll('#suppress-modal .pill').forEach(p => p.classList.remove('active')); document.querySelectorAll('#suppress-modal .pill').forEach(p => {
p.classList.remove('active');
p.setAttribute('aria-pressed', 'false');
});
const manualPill = document.querySelector('#suppress-modal .pill-manual'); const manualPill = document.querySelector('#suppress-modal .pill-manual');
if (manualPill) manualPill.classList.add('active'); if (manualPill) { manualPill.classList.add('active'); manualPill.setAttribute('aria-pressed', 'true'); }
const hint = document.getElementById('duration-hint'); const hint = document.getElementById('duration-hint');
if (hint) hint.textContent = 'Suppression will persist until manually removed.'; if (hint) hint.textContent = 'Suppression will persist until manually removed.';
} }
@@ -294,8 +300,11 @@ function updateSuppressForm() {
function setDuration(mins, el) { function setDuration(mins, el) {
document.getElementById('sup-expires').value = mins || ''; document.getElementById('sup-expires').value = mins || '';
document.querySelectorAll('#suppress-modal .pill').forEach(p => p.classList.remove('active')); document.querySelectorAll('#suppress-modal .pill').forEach(p => {
if (el) el.classList.add('active'); p.classList.remove('active');
p.setAttribute('aria-pressed', 'false');
});
if (el) { el.classList.add('active'); el.setAttribute('aria-pressed', 'true'); }
const hint = document.getElementById('duration-hint'); const hint = document.getElementById('duration-hint');
if (hint) { if (hint) {
if (mins) { if (mins) {
+45 -2
View File
@@ -118,6 +118,39 @@
padding: 0.2rem 0.45rem; padding: 0.2rem 0.45rem;
} }
/* ── Form group modifiers ────────────────────────────────────────── */
.lt-form-group--last { margin-bottom: 0; }
/* ── Search input size variant ───────────────────────────────────── */
.lt-search-input--sm { width: 180px; }
/* ── Notification panel helpers ──────────────────────────────────── */
.lt-notif-empty {
padding: 1rem;
font-size: 0.75rem;
color: var(--text-muted);
text-align: center;
}
.lt-notif-view-all {
width: 100%;
text-align: center;
display: block;
font-size: 0.72rem;
}
.lt-notif-dot {
border-radius: 50%;
margin-top: 4px;
flex-shrink: 0;
}
/* ── Divider variants ────────────────────────────────────────────── */
.lt-divider--compact { margin: 1rem 0 0.75rem; }
.lt-divider--unifi { margin: 20px 0 12px; }
.lt-divider-label--unifi { color: var(--cyan); letter-spacing: .1em; }
/* ── Stats grid spacing variant ──────────────────────────────────── */
.lt-stats-grid--mb { margin-bottom: 16px; }
/* ── Topology section collapse toggle ────────────────────────────── */ /* ── Topology section collapse toggle ────────────────────────────── */
.topo-collapse-btn { .topo-collapse-btn {
margin-left: auto; margin-left: auto;
@@ -181,10 +214,9 @@
padding: 1px 7px; padding: 1px 7px;
} }
.g-section-actions { margin-left: auto; } .g-section-actions { margin-left: auto; }
.events-filter-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.events-filter-bar .lt-input-sm { width: 220px; }
.sev-pills { display: flex; gap: 4px; } .sev-pills { display: flex; gap: 4px; }
.g-page-sub { font-size: .78em; color: var(--text-muted); margin-top: 4px; } .g-page-sub { font-size: .78em; color: var(--text-muted); margin-top: 4px; }
.g-page-sub-aside { font-size: .78em; color: var(--text-muted); margin-left: 8px; }
/* ── Badge severity color variants (used with lt-badge) ───────────── */ /* ── Badge severity color variants (used with lt-badge) ───────────── */
.badge-critical { color: var(--red); border-color: var(--red); text-shadow: var(--glow-red); } .badge-critical { color: var(--red); border-color: var(--red); text-shadow: var(--glow-red); }
@@ -203,6 +235,8 @@
.lt-table tr.row-warning td { background: rgba(255,107,0,.04); } .lt-table tr.row-warning td { background: rgba(255,107,0,.04); }
.lt-table tr.row-warning td:first-child { border-left: 2px solid var(--orange); } .lt-table tr.row-warning td:first-child { border-left: 2px solid var(--orange); }
.lt-table tr.row-resolved td { opacity: .65; } .lt-table tr.row-resolved td { opacity: .65; }
.lt-table tr.row-suppressed td { opacity: .6; }
.lt-table tr.row-suppressed td:first-child{ border-left-color: var(--text-muted) !important; }
/* ── Table size modifier ─────────────────────────────────────────── */ /* ── Table size modifier ─────────────────────────────────────────── */
.lt-table-sm th, .lt-table-sm th,
@@ -408,6 +442,9 @@
background: linear-gradient(to bottom, var(--cyan), var(--green)); background: linear-gradient(to bottom, var(--cyan), var(--green));
opacity: .7; opacity: .7;
} }
.topo-vc-wire--wan { background: linear-gradient(to bottom, var(--cyan), rgba(0,212,255,.3)); opacity: .7; }
.topo-vc-wire--10g { background: var(--amber); opacity: .6; }
.topo-vc-wire--mgmt { background: var(--border-color); opacity: .5; }
/* Blurred copy of the wire for a soft glow halo */ /* Blurred copy of the wire for a soft glow halo */
.topo-vc-wire::before { .topo-vc-wire::before {
content: ''; content: '';
@@ -465,6 +502,7 @@
.topo-v2-sub { font-size: .58em; color: var(--text-muted); letter-spacing: .02em; } .topo-v2-sub { font-size: .58em; color: var(--text-muted); letter-spacing: .02em; }
.topo-v2-vlan { font-size: .54em; color: var(--cyan); opacity: .75; } .topo-v2-vlan { font-size: .54em; color: var(--cyan); opacity: .75; }
.topo-v2-host--bus { min-width: 80px; max-width: 96px; }
.topo-v2-internet { border-color: var(--cyan); color: var(--cyan); text-shadow: var(--glow-cyan); box-shadow: 0 0 12px rgba(0,212,255,.12); } .topo-v2-internet { border-color: var(--cyan); color: var(--cyan); text-shadow: var(--glow-cyan); box-shadow: 0 0 12px rgba(0,212,255,.12); }
.topo-v2-router { border-color: var(--cyan); color: var(--cyan); text-shadow: var(--glow-cyan); box-shadow: 0 0 12px rgba(0,212,255,.14); } .topo-v2-router { border-color: var(--cyan); color: var(--cyan); text-shadow: var(--glow-cyan); box-shadow: 0 0 12px rgba(0,212,255,.14); }
.topo-v2-switch { border-color: var(--amber); color: var(--amber); text-shadow: var(--glow-amber); box-shadow: 0 0 12px rgba(255,179,0,.12); } .topo-v2-switch { border-color: var(--amber); color: var(--amber); text-shadow: var(--glow-amber); box-shadow: 0 0 12px rgba(255,179,0,.12); }
@@ -514,6 +552,7 @@
/* Bus rails */ /* Bus rails */
.topo-bus-section { .topo-bus-section {
width: 100%; width: 100%;
max-width: 860px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
@@ -594,6 +633,10 @@
color: var(--text-muted); color: var(--text-muted);
font-family: var(--font); font-family: var(--font);
} }
.topo-legend-item--offrack {
border: 1px dashed var(--border-color);
padding: 1px 5px;
}
.topo-legend-line-10g { width: 24px; height: 2px; background: var(--green); display: inline-block; box-shadow: 0 0 4px rgba(0,255,136,.5); } .topo-legend-line-10g { width: 24px; height: 2px; background: var(--green); display: inline-block; box-shadow: 0 0 4px rgba(0,255,136,.5); }
.topo-legend-line-1g { width: 24px; height: 0; border-top: 2px dashed var(--amber); display: inline-block; } .topo-legend-line-1g { width: 24px; height: 0; border-top: 2px dashed var(--amber); display: inline-block; }
.topo-legend-line-wan { width: 24px; height: 2px; background: linear-gradient(to right, var(--cyan), var(--green)); display: inline-block; } .topo-legend-line-wan { width: 24px; height: 2px; background: linear-gradient(to right, var(--cyan), var(--green)); display: inline-block; }
+60 -57
View File
@@ -133,10 +133,10 @@
<button type="button" class="lt-notif-panel-clear" id="lt-notif-clear-btn">Mark all read</button> <button type="button" class="lt-notif-panel-clear" id="lt-notif-clear-btn">Mark all read</button>
</div> </div>
<div class="lt-notif-panel-list" id="lt-notif-list"> <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&hellip;</div> <div class="lt-notif-empty">Loading&hellip;</div>
</div> </div>
<div class="lt-notif-panel-footer"> <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> <a href="{{ url_for('index') }}" class="lt-btn lt-btn-ghost lt-btn-sm lt-notif-view-all">View dashboard</a>
</div> </div>
</div> </div>
</div> </div>
@@ -144,9 +144,9 @@
<!-- ⌘K affordance --> <!-- ⌘K affordance -->
<button type="button" <button type="button"
class="lt-btn lt-btn-ghost lt-btn-sm lt-cmd-hint-btn" class="lt-btn lt-btn-ghost lt-btn-sm lt-cmd-hint-btn"
data-action="open-cmdpalette"
title="Command palette (Ctrl+K)" title="Command palette (Ctrl+K)"
aria-label="Open command palette" aria-label="Open command palette">&#x2315;&nbsp;K</button>
onclick="if(window.lt&&lt.cmdPalette)lt.cmdPalette.open()">&#x2315;&nbsp;K</button>
<button type="button" class="lt-theme-btn" id="lt-theme-btn" <button type="button" class="lt-theme-btn" id="lt-theme-btn"
aria-label="Toggle theme" title="Toggle light/dark mode">&#x2600;</button> aria-label="Toggle theme" title="Toggle light/dark mode">&#x2600;</button>
@@ -207,7 +207,7 @@
</div> </div>
<form id="suppress-form"> <form id="suppress-form">
<div class="lt-modal-body"> <div class="lt-modal-body">
<div class="lt-form-group" style="margin-bottom:12px"> <div class="lt-form-group">
<label class="lt-label" for="sup-type">Target Type</label> <label class="lt-label" for="sup-type">Target Type</label>
<select class="lt-select" id="sup-type" name="target_type"> <select class="lt-select" id="sup-type" name="target_type">
<option value="host">Host (all interfaces)</option> <option value="host">Host (all interfaces)</option>
@@ -216,27 +216,27 @@
<option value="all">Global Maintenance</option> <option value="all">Global Maintenance</option>
</select> </select>
</div> </div>
<div class="lt-form-group" id="sup-name-group" style="margin-bottom:12px"> <div class="lt-form-group" id="sup-name-group">
<label class="lt-label" for="sup-name">Target Name</label> <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"> <input type="text" class="lt-input" id="sup-name" name="target_name" placeholder="e.g. large1">
</div> </div>
<div class="lt-form-group" id="sup-detail-group" style="margin-bottom:12px;display:none"> <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> <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"> <input type="text" class="lt-input" id="sup-detail" name="target_detail" placeholder="e.g. enp35s0">
</div> </div>
<div class="lt-form-group" style="margin-bottom:12px"> <div class="lt-form-group">
<label class="lt-label" for="sup-reason">Reason <span class="required">*</span></label> <label class="lt-label" for="sup-reason">Reason <span class="required">*</span></label>
<input type="text" class="lt-input" id="sup-reason" name="reason" <input type="text" class="lt-input" id="sup-reason" name="reason"
placeholder="e.g. Planned switch reboot" required> placeholder="e.g. Planned switch reboot" required>
</div> </div>
<div class="lt-form-group" style="margin-bottom:0"> <div class="lt-form-group lt-form-group--last">
<label class="lt-label">Duration</label> <label class="lt-label">Duration</label>
<div class="duration-pills"> <div class="duration-pills" role="group" aria-label="Select suppression duration">
<button type="button" class="pill" data-duration="30">30 min</button> <button type="button" class="pill" data-duration="30" aria-pressed="false">30 min</button>
<button type="button" class="pill" data-duration="60">1 hr</button> <button type="button" class="pill" data-duration="60" aria-pressed="false">1 hr</button>
<button type="button" class="pill" data-duration="240">4 hr</button> <button type="button" class="pill" data-duration="240" aria-pressed="false">4 hr</button>
<button type="button" class="pill" data-duration="480">8 hr</button> <button type="button" class="pill" data-duration="480" aria-pressed="false">8 hr</button>
<button type="button" class="pill pill-manual active" data-duration="">Manual &#x221E;</button> <button type="button" class="pill pill-manual active" data-duration="" aria-pressed="true">Manual &#x221E;</button>
</div> </div>
<input type="hidden" id="sup-expires" name="expires_minutes" value=""> <input type="hidden" id="sup-expires" name="expires_minutes" value="">
<div class="lt-field-hint" id="duration-hint">Persists until manually removed.</div> <div class="lt-field-hint" id="duration-hint">Persists until manually removed.</div>
@@ -258,7 +258,7 @@
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button> <button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div> </div>
<div class="lt-modal-body"> <div class="lt-modal-body">
<table class="lt-table" style="width:100%"> <table class="lt-table">
<thead><tr><th>Shortcut</th><th>Action</th></tr></thead> <thead><tr><th>Shortcut</th><th>Action</th></tr></thead>
<tbody> <tbody>
<tr><td>Ctrl / &#x2318; + K</td><td>Command palette</td></tr> <tr><td>Ctrl / &#x2318; + K</td><td>Command palette</td></tr>
@@ -286,16 +286,16 @@
<div class="lt-modal-body"> <div class="lt-modal-body">
<div class="lt-form-group"> <div class="lt-form-group">
<label class="lt-label">Auto-refresh interval</label> <label class="lt-label">Auto-refresh interval</label>
<div class="duration-pills" id="settings-refresh-pills"> <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">15 s</button> <button type="button" class="pill" data-refresh-interval="15" aria-pressed="false">15 s</button>
<button type="button" class="pill" data-refresh-interval="30">30 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">1 min</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">5 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">Off</button> <button type="button" class="pill" data-refresh-interval="0" aria-pressed="false">Off</button>
</div> </div>
<div class="lt-field-hint" id="settings-refresh-hint"></div> <div class="lt-field-hint" id="settings-refresh-hint"></div>
</div> </div>
<div class="lt-divider" style="margin:1rem 0 0.75rem"></div> <div class="lt-divider lt-divider--compact"></div>
<div class="lt-kv-grid"> <div class="lt-kv-grid">
<span class="lt-kv-key">User</span> <span class="lt-kv-key">User</span>
<span class="lt-kv-val lt-kv-val--cyan">{{ user.name or user.username }}</span> <span class="lt-kv-val lt-kv-val--cyan">{{ user.name or user.username }}</span>
@@ -324,7 +324,7 @@
lt.init({ bootName: 'GANDALF' }); lt.init({ bootName: 'GANDALF' });
// Theme toggle // Theme toggle
var themeBtn = document.getElementById('lt-theme-btn'); const themeBtn = document.getElementById('lt-theme-btn');
if (themeBtn) themeBtn.addEventListener('click', function() { lt.theme.toggle(); }); if (themeBtn) themeBtn.addEventListener('click', function() { lt.theme.toggle(); });
// Command palette // Command palette
@@ -343,11 +343,12 @@
// ── Global footer + key actions ─────────────────────────────────────── // ── Global footer + key actions ───────────────────────────────────────
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action]'); const btn = e.target.closest('[data-action]');
if (!btn) return; if (!btn) return;
var action = btn.getAttribute('data-action'); const action = btn.getAttribute('data-action');
if (action === 'show-keyboard-help' && window.lt) lt.modal.open('lt-keys-help'); if (action === 'open-cmdpalette' && window.lt && lt.cmdPalette) lt.cmdPalette.open();
if (action === 'open-settings' && window.lt) lt.modal.open('lt-settings-modal'); 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('r', function() { lt.autoRefresh.now(); });
@@ -366,8 +367,8 @@
// ── Settings modal ──────────────────────────────────────────────────── // ── Settings modal ────────────────────────────────────────────────────
(function() { (function() {
var LS_KEY = 'gandalf_settings'; const LS_KEY = 'gandalf_settings';
var DEFAULT = { refreshInterval: 30 }; const DEFAULT = { refreshInterval: 30 };
function loadSettings() { function loadSettings() {
try { return Object.assign({}, DEFAULT, JSON.parse(localStorage.getItem(LS_KEY) || '{}')); } try { return Object.assign({}, DEFAULT, JSON.parse(localStorage.getItem(LS_KEY) || '{}')); }
@@ -381,9 +382,11 @@
function applyRefreshPillUI(interval) { function applyRefreshPillUI(interval) {
document.querySelectorAll('#settings-refresh-pills .pill').forEach(function(p) { document.querySelectorAll('#settings-refresh-pills .pill').forEach(function(p) {
p.classList.toggle('active', parseInt(p.dataset.refreshInterval) === interval); const isActive = parseInt(p.dataset.refreshInterval) === interval;
p.classList.toggle('active', isActive);
p.setAttribute('aria-pressed', isActive ? 'true' : 'false');
}); });
var hint = document.getElementById('settings-refresh-hint'); const hint = document.getElementById('settings-refresh-hint');
if (hint) { if (hint) {
if (interval === 0) hint.textContent = 'Auto-refresh disabled.'; if (interval === 0) hint.textContent = 'Auto-refresh disabled.';
else if (interval < 60) hint.textContent = 'Refreshes every ' + interval + ' seconds.'; else if (interval < 60) hint.textContent = 'Refreshes every ' + interval + ' seconds.';
@@ -392,16 +395,16 @@
} }
// Init pill UI from saved settings // Init pill UI from saved settings
var _settings = loadSettings(); const _settings = loadSettings();
applyRefreshPillUI(_settings.refreshInterval); applyRefreshPillUI(_settings.refreshInterval);
// Expose for pages that need to read it (e.g. index.html for autoRefresh) // Expose for pages that need to read it (e.g. index.html for autoRefresh)
window.gandalfSettings = _settings; window.gandalfSettings = _settings;
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
var pill = e.target.closest('#settings-refresh-pills .pill[data-refresh-interval]'); const pill = e.target.closest('#settings-refresh-pills .pill[data-refresh-interval]');
if (!pill) return; if (!pill) return;
var interval = parseInt(pill.dataset.refreshInterval); const interval = parseInt(pill.dataset.refreshInterval);
_settings.refreshInterval = interval; _settings.refreshInterval = interval;
saveSettings(_settings); saveSettings(_settings);
applyRefreshPillUI(interval); applyRefreshPillUI(interval);
@@ -410,16 +413,16 @@
// ── Notification Bell — shows active monitoring alerts ──────────────── // ── Notification Bell — shows active monitoring alerts ────────────────
(function() { (function() {
var bell = document.getElementById('lt-notif-bell'); const bell = document.getElementById('lt-notif-bell');
var panel = document.getElementById('lt-notif-panel'); const panel = document.getElementById('lt-notif-panel');
var list = document.getElementById('lt-notif-list'); const list = document.getElementById('lt-notif-list');
var clearBtn = document.getElementById('lt-notif-clear-btn'); const clearBtn = document.getElementById('lt-notif-clear-btn');
var wrapEl = document.getElementById('lt-notif-wrap'); const wrapEl = document.getElementById('lt-notif-wrap');
if (!bell || !panel) return; if (!bell || !panel) return;
var _open = false; let _open = false;
var _lastEvents = []; let _lastEvents = [];
var LS_READ_KEY = 'gandalf_notif_read_before'; const LS_READ_KEY = 'gandalf_notif_read_before';
function getReadBefore() { function getReadBefore() {
try { return parseInt(localStorage.getItem(LS_READ_KEY) || '0'); } catch(_) { return 0; } try { return parseInt(localStorage.getItem(LS_READ_KEY) || '0'); } catch(_) { return 0; }
@@ -438,31 +441,31 @@
} }
function fmtAgo(dateStr) { function fmtAgo(dateStr) {
var diff = Math.floor((Date.now() - toMs(dateStr)) / 1000); const diff = Math.floor((Date.now() - toMs(dateStr)) / 1000);
if (diff < 60) return diff + 's ago'; if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff/60) + 'm ago'; if (diff < 3600) return Math.floor(diff/60) + 'm ago';
if (diff < 86400) return Math.floor(diff/3600) + 'h ago'; if (diff < 86400) return Math.floor(diff/3600) + 'h ago';
return Math.floor(diff/86400) + 'd ago'; return Math.floor(diff/86400) + 'd ago';
} }
var SEV_DOT = { critical: 'var(--red)', warning: 'var(--amber)' }; const SEV_DOT = { critical: 'var(--red)', warning: 'var(--amber)' };
function renderAlerts(events) { function renderAlerts(events) {
_lastEvents = events || []; _lastEvents = events || [];
var readBefore = getReadBefore(); const readBefore = getReadBefore();
var active = _lastEvents.filter(function(e) { return e.severity !== 'info'; }); const active = _lastEvents.filter(function(e) { return e.severity !== 'info'; });
var unreadCount = active.filter(function(e) { return toMs(e.last_seen) > readBefore; }).length; const unreadCount = active.filter(function(e) { return toMs(e.last_seen) > readBefore; }).length;
lt.notif.set(bell, unreadCount); lt.notif.set(bell, unreadCount);
if (!active.length) { if (!active.length) {
list.innerHTML = '<div style="padding:1rem;font-size:0.75rem;color:var(--text-muted);text-align:center">&#x2714; No active alerts</div>'; list.innerHTML = '<div class="lt-notif-empty">&#x2714; No active alerts</div>';
return; return;
} }
list.innerHTML = active.slice(0, 25).map(function(e) { list.innerHTML = active.slice(0, 25).map(function(e) {
var isUnread = toMs(e.last_seen) > readBefore; const isUnread = toMs(e.last_seen) > readBefore;
var dotColor = SEV_DOT[e.severity] || 'var(--text-muted)'; const dotColor = SEV_DOT[e.severity] || 'var(--text-muted)';
return '<div class="lt-notif-item' + (isUnread ? ' lt-notif-item--unread' : '') + '">' + 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-dot' + (isUnread ? '' : ' lt-notif-dot--read') + '" style="background:' + dotColor + '"></div>' +
'<div class="lt-notif-item-body">' + '<div class="lt-notif-item-body">' +
'<div class="lt-notif-item-title">' + esc(e.target_name) + (e.target_detail ? ' &middot; ' + esc(e.target_detail) : '') + '</div>' + '<div class="lt-notif-item-title">' + esc(e.target_name) + (e.target_detail ? ' &middot; ' + esc(e.target_detail) : '') + '</div>' +
'<div class="lt-notif-item-time">' + esc(e.event_type.replace(/_/g,' ')) + ' &middot; ' + fmtAgo(e.last_seen) + '</div>' + '<div class="lt-notif-item-time">' + esc(e.event_type.replace(/_/g,' ')) + ' &middot; ' + fmtAgo(e.last_seen) + '</div>' +
@@ -474,19 +477,19 @@
fetch('/api/status', { credentials: 'same-origin' }) fetch('/api/status', { credentials: 'same-origin' })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
var events = data.events || []; const events = data.events || [];
if (andRender) { if (andRender) {
renderAlerts(events); renderAlerts(events);
} else { } else {
_lastEvents = events; _lastEvents = events;
var readBefore = getReadBefore(); const readBefore = getReadBefore();
var active = events.filter(function(e) { return e.severity !== 'info'; }); const active = events.filter(function(e) { return e.severity !== 'info'; });
var unread = active.filter(function(e) { return toMs(e.last_seen) > readBefore; }).length; const unread = active.filter(function(e) { return toMs(e.last_seen) > readBefore; }).length;
lt.notif.set(bell, unread); lt.notif.set(bell, unread);
} }
}) })
.catch(function() { .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>'; if (andRender) list.innerHTML = '<div class="lt-notif-empty">Could not load</div>';
}); });
} }
+48 -36
View File
@@ -31,32 +31,32 @@
<div class="lt-stat-card{% if summary.critical %} lt-stat-card--alert{% endif %}" <div class="lt-stat-card{% if summary.critical %} lt-stat-card--alert{% endif %}"
id="stat-critical" role="button" tabindex="0" id="stat-critical" role="button" tabindex="0"
data-stat-filter="critical" aria-label="{{ summary.critical or 0 }} critical alerts"> data-stat-filter="critical" aria-label="{{ summary.critical or 0 }} critical alerts">
<span class="lt-stat-icon" aria-hidden="true" style="color:var(--red);text-shadow:var(--glow-red)"></span> <span class="lt-stat-icon lt-text-red" aria-hidden="true"></span>
<div class="lt-stat-info"> <div class="lt-stat-info">
<span class="lt-stat-value" id="stat-critical-val" style="color:var(--red)">{{ summary.critical or 0 }}</span> <span class="lt-stat-value lt-text-red" id="stat-critical-val">{{ summary.critical or 0 }}</span>
<span class="lt-stat-label">Critical</span> <span class="lt-stat-label">Critical</span>
</div> </div>
</div> </div>
<div class="lt-stat-card" <div class="lt-stat-card"
id="stat-warning" role="button" tabindex="0" id="stat-warning" role="button" tabindex="0"
data-stat-filter="warning" aria-label="{{ summary.warning or 0 }} warning alerts"> data-stat-filter="warning" aria-label="{{ summary.warning or 0 }} warning alerts">
<span class="lt-stat-icon" aria-hidden="true" style="color:var(--amber)"></span> <span class="lt-stat-icon lt-text-amber" aria-hidden="true"></span>
<div class="lt-stat-info"> <div class="lt-stat-info">
<span class="lt-stat-value" id="stat-warning-val" style="color:var(--amber)">{{ summary.warning or 0 }}</span> <span class="lt-stat-value lt-text-amber" id="stat-warning-val">{{ summary.warning or 0 }}</span>
<span class="lt-stat-label">Warning</span> <span class="lt-stat-label">Warning</span>
</div> </div>
</div> </div>
<div class="lt-stat-card" id="stat-hosts" aria-label="Monitored hosts"> <div class="lt-stat-card" id="stat-hosts" aria-label="Monitored hosts">
<span class="lt-stat-icon" aria-hidden="true" style="color:var(--cyan)"></span> <span class="lt-stat-icon lt-text-cyan" aria-hidden="true"></span>
<div class="lt-stat-info"> <div class="lt-stat-info">
<span class="lt-stat-value" id="stat-hosts-val" style="color:var(--cyan)">{{ snapshot.hosts | length }}</span> <span class="lt-stat-value lt-text-cyan" id="stat-hosts-val">{{ snapshot.hosts | length }}</span>
<span class="lt-stat-label">Hosts</span> <span class="lt-stat-label">Hosts</span>
</div> </div>
</div> </div>
<div class="lt-stat-card" id="stat-resolved" aria-label="{{ recent_resolved | length }} alerts resolved in last 24 hours"> <div class="lt-stat-card" id="stat-resolved" aria-label="{{ recent_resolved | length }} alerts resolved in last 24 hours">
<span class="lt-stat-icon" aria-hidden="true" style="color:var(--green);text-shadow:var(--glow)"></span> <span class="lt-stat-icon lt-text-green" aria-hidden="true"></span>
<div class="lt-stat-info"> <div class="lt-stat-info">
<span class="lt-stat-value" id="stat-resolved-val" style="color:var(--green)">{{ recent_resolved | length }}</span> <span class="lt-stat-value lt-text-green" id="stat-resolved-val">{{ recent_resolved | length }}</span>
<span class="lt-stat-label">Resolved 24h</span> <span class="lt-stat-label">Resolved 24h</span>
</div> </div>
</div> </div>
@@ -73,12 +73,13 @@
<div class="lt-toolbar-left"> <div class="lt-toolbar-left">
<div class="lt-search"> <div class="lt-search">
<input type="search" class="lt-input lt-search-input" id="events-search" <input type="search" class="lt-input lt-search-input" id="events-search"
placeholder="Filter by target, type, description…" autocomplete="off"> placeholder="Filter by target, type, description…" autocomplete="off"
aria-label="Filter active alerts">
</div> </div>
<div class="sev-pills"> <div class="sev-pills" role="group" aria-label="Filter by severity">
<button type="button" class="pill active" data-sev="">All</button> <button type="button" class="pill active" data-sev="" aria-pressed="true">All</button>
<button type="button" class="pill" data-sev="critical">Critical</button> <button type="button" class="pill" data-sev="critical" aria-pressed="false">Critical</button>
<button type="button" class="pill" data-sev="warning">Warning</button> <button type="button" class="pill" data-sev="warning" aria-pressed="false">Warning</button>
</div> </div>
</div> </div>
</div> </div>
@@ -111,8 +112,11 @@
<tbody> <tbody>
{% for e in events %} {% for e in events %}
{% if e.severity != 'info' %} {% if e.severity != 'info' %}
<tr class="row-{{ e.severity }}"> <tr class="row-{{ e.severity }}{% if e.is_suppressed %} row-suppressed{% endif %}">
<td><span class="lt-badge badge-{{ e.severity }}">{{ e.severity }}</span></td> <td>
<span class="lt-badge badge-{{ e.severity }}">{{ e.severity }}</span>
{% if e.is_suppressed %}<span class="lt-badge badge-suppressed" title="Alert suppressed">🔕 sup</span>{% endif %}
</td>
<td>{{ e.event_type | replace('_', ' ') }}</td> <td>{{ e.event_type | replace('_', ' ') }}</td>
<td><strong>{{ e.target_name }}</strong></td> <td><strong>{{ e.target_name }}</strong></td>
<td>{{ e.target_detail or '' }}</td> <td>{{ e.target_detail or '' }}</td>
@@ -185,9 +189,9 @@
</div> </div>
</div> </div>
<!-- WAN wire: cyan → green gradient, labeled --> <!-- WAN wire: cyan → WAN gradient -->
<div class="topo-vc"> <div class="topo-vc">
<div class="topo-vc-wire" style="background:linear-gradient(to bottom,var(--cyan),rgba(0,212,255,.3)); opacity:.7;"></div> <div class="topo-vc-wire topo-vc-wire--wan"></div>
<span class="topo-vc-label">WAN · 10G SFP+</span> <span class="topo-vc-label">WAN · 10G SFP+</span>
</div> </div>
@@ -205,7 +209,7 @@
<!-- UDM-Pro → USW-Agg (10G SFP+) --> <!-- UDM-Pro → USW-Agg (10G SFP+) -->
<div class="topo-vc"> <div class="topo-vc">
<div class="topo-vc-wire" style="background:var(--amber);opacity:.6;"></div> <div class="topo-vc-wire topo-vc-wire--10g"></div>
<span class="topo-vc-label">10G SFP+</span> <span class="topo-vc-label">10G SFP+</span>
</div> </div>
@@ -224,7 +228,7 @@
<!-- USW-Agg → Pro 24 PoE (10G trunk) --> <!-- USW-Agg → Pro 24 PoE (10G trunk) -->
<div class="topo-vc"> <div class="topo-vc">
<div class="topo-vc-wire" style="background:var(--amber);opacity:.6;"></div> <div class="topo-vc-wire topo-vc-wire--10g"></div>
<span class="topo-vc-label">10G trunk</span> <span class="topo-vc-label">10G trunk</span>
</div> </div>
@@ -243,14 +247,14 @@
<!-- Pro 24 PoE → host bus section --> <!-- Pro 24 PoE → host bus section -->
<div class="topo-vc"> <div class="topo-vc">
<div class="topo-vc-wire" style="background:var(--border-color);opacity:.5;"></div> <div class="topo-vc-wire topo-vc-wire--mgmt"></div>
</div> </div>
<!-- ══════════════════════════════════════════════════════════════ <!-- ══════════════════════════════════════════════════════════════
TIER 4 connecting bus two rails (10G green + 1G amber dashed) TIER 4 connecting bus two rails (10G green + 1G amber dashed)
showing dual-homing for all 6 servers showing dual-homing for all 6 servers
══════════════════════════════════════════════════════════ --> ══════════════════════════════════════════════════════════ -->
<div class="topo-bus-section" style="max-width:860px;"> <div class="topo-bus-section">
<!-- 10G storage bus (Agg → VLAN90) --> <!-- 10G storage bus (Agg → VLAN90) -->
<div class="topo-bus-10g"> <div class="topo-bus-10g">
@@ -283,8 +287,8 @@
<div class="topo-v2-wire-1g" data-host="{{ hname }}" title="1G → Pro 24 PoE"></div> <div class="topo-v2-wire-1g" data-host="{{ hname }}" title="1G → Pro 24 PoE"></div>
</div> </div>
<!-- host box --> <!-- host box -->
<div class="topo-v2-node topo-v2-host topo-host topo-v2-status-{{ st }}{{ ' topo-v2-offrack' if off_rack else '' }}" <div class="topo-v2-node topo-v2-host topo-host topo-v2-status-{{ st }}{{ ' topo-v2-offrack' if off_rack else '' }} topo-v2-host--bus"
data-host="{{ hname }}" style="min-width:80px; max-width:96px;"> data-host="{{ hname }}">
<span class="topo-v2-icon"></span> <span class="topo-v2-icon"></span>
<span class="topo-v2-label">{{ hlabel }}</span> <span class="topo-v2-label">{{ hlabel }}</span>
<span class="topo-v2-sub">{{ hsub }}</span> <span class="topo-v2-sub">{{ hsub }}</span>
@@ -302,7 +306,7 @@
<div class="topo-legend-item"><span class="topo-legend-line-wan"></span> WAN / uplink</div> <div class="topo-legend-item"><span class="topo-legend-line-wan"></span> WAN / uplink</div>
<div class="topo-legend-item"><span class="topo-legend-line-10g"></span> 10G SFP+ (Ceph / VLAN90)</div> <div class="topo-legend-item"><span class="topo-legend-line-10g"></span> 10G SFP+ (Ceph / VLAN90)</div>
<div class="topo-legend-item"><span class="topo-legend-line-1g"></span> 1G DHCP (mgmt)</div> <div class="topo-legend-item"><span class="topo-legend-line-1g"></span> 1G DHCP (mgmt)</div>
<div class="topo-legend-item" style="border:1px dashed var(--border-color); padding:1px 5px; font-size:.56em; color:var(--text-muted);">dashed border = off-rack</div> <div class="topo-legend-item topo-legend-item--offrack">dashed border = off-rack</div>
</div> </div>
</div><!-- /topo-v2 --> </div><!-- /topo-v2 -->
@@ -312,8 +316,8 @@
<div class="lt-toolbar" id="host-toolbar"> <div class="lt-toolbar" id="host-toolbar">
<div class="lt-toolbar-left"> <div class="lt-toolbar-left">
<div class="lt-search"> <div class="lt-search">
<input type="search" class="lt-input lt-search-input" id="host-search" <input type="search" class="lt-input lt-search-input lt-search-input--sm" id="host-search"
placeholder="Filter hosts…" autocomplete="off" style="width:180px"> placeholder="Filter hosts…" autocomplete="off" aria-label="Filter hosts">
</div> </div>
</div> </div>
</div> </div>
@@ -354,7 +358,7 @@
data-sup-type="host" data-sup-type="host"
data-sup-name="{{ name }}" data-sup-name="{{ name }}"
data-sup-detail="" data-sup-detail=""
title="Suppress alerts for this host"> aria-label="Suppress alerts for {{ name }}">
🔕 Suppress 🔕 Suppress
</button> </button>
<a href="{{ url_for('links_page') }}#{{ name }}" <a href="{{ url_for('links_page') }}#{{ name }}"
@@ -413,7 +417,8 @@
<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress" <button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
data-sup-type="unifi_device" data-sup-type="unifi_device"
data-sup-name="{{ d.name }}" data-sup-name="{{ d.name }}"
data-sup-detail=""> data-sup-detail=""
aria-label="Suppress alerts for {{ d.name }}">
🔕 Suppress 🔕 Suppress
</button> </button>
{% endif %} {% endif %}
@@ -461,7 +466,7 @@
{% block scripts %} {% block scripts %}
<script> <script>
// Start auto-refresh using saved settings interval (default 30 s) // Start auto-refresh using saved settings interval (default 30 s)
var _savedInterval = (window.gandalfSettings && window.gandalfSettings.refreshInterval) || 30; const _savedInterval = (window.gandalfSettings && window.gandalfSettings.refreshInterval) || 30;
if (_savedInterval > 0) lt.autoRefresh.start(refreshAll, _savedInterval * 1000); if (_savedInterval > 0) lt.autoRefresh.start(refreshAll, _savedInterval * 1000);
// When settings change, restart auto-refresh with new interval // When settings change, restart auto-refresh with new interval
@@ -472,9 +477,9 @@
// ── Topology collapse toggle ─────────────────────────────────── // ── Topology collapse toggle ───────────────────────────────────
(function() { (function() {
var LS_KEY = 'gandalf_topo_collapsed'; const LS_KEY = 'gandalf_topo_collapsed';
var btn = document.getElementById('topo-toggle-btn'); const btn = document.getElementById('topo-toggle-btn');
var wrap = document.getElementById('topo-collapsible-wrap'); const wrap = document.getElementById('topo-collapsible-wrap');
if (!btn || !wrap) return; if (!btn || !wrap) return;
function setCollapsed(v) { function setCollapsed(v) {
@@ -484,7 +489,7 @@
try { localStorage.setItem(LS_KEY, v ? '1' : '0'); } catch(_) {} try { localStorage.setItem(LS_KEY, v ? '1' : '0'); } catch(_) {}
} }
var saved = false; let saved = false;
try { saved = localStorage.getItem(LS_KEY) === '1'; } catch(_) {} try { saved = localStorage.getItem(LS_KEY) === '1'; } catch(_) {}
setCollapsed(saved); setCollapsed(saved);
@@ -537,8 +542,12 @@
document.querySelector('.sev-pills')?.addEventListener('click', e => { document.querySelector('.sev-pills')?.addEventListener('click', e => {
const pill = e.target.closest('.pill[data-sev]'); const pill = e.target.closest('.pill[data-sev]');
if (!pill) return; if (!pill) return;
document.querySelectorAll('.sev-pills .pill').forEach(p => p.classList.remove('active')); document.querySelectorAll('.sev-pills .pill').forEach(p => {
p.classList.remove('active');
p.setAttribute('aria-pressed', 'false');
});
pill.classList.add('active'); pill.classList.add('active');
pill.setAttribute('aria-pressed', 'true');
_filterSev = pill.dataset.sev; _filterSev = pill.dataset.sev;
applyEventsFilter(); applyEventsFilter();
}); });
@@ -560,9 +569,12 @@
document.querySelectorAll('.lt-stat-card[data-stat-filter]').forEach(card => { document.querySelectorAll('.lt-stat-card[data-stat-filter]').forEach(card => {
card.addEventListener('click', () => { card.addEventListener('click', () => {
const sev = card.dataset.statFilter; const sev = card.dataset.statFilter;
document.querySelectorAll('.sev-pills .pill').forEach(p => p.classList.remove('active')); document.querySelectorAll('.sev-pills .pill').forEach(p => {
p.classList.remove('active');
p.setAttribute('aria-pressed', 'false');
});
const matchPill = document.querySelector(`.sev-pills .pill[data-sev="${sev}"]`); const matchPill = document.querySelector(`.sev-pills .pill[data-sev="${sev}"]`);
if (matchPill) matchPill.classList.add('active'); if (matchPill) { matchPill.classList.add('active'); matchPill.setAttribute('aria-pressed', 'true'); }
_filterSev = sev; _filterSev = sev;
applyEventsFilter(); applyEventsFilter();
document.getElementById('events-table-wrap')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); document.getElementById('events-table-wrap')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+9 -7
View File
@@ -6,18 +6,18 @@
<div class="lt-page-header"> <div class="lt-page-header">
<div> <div>
<h1 class="lt-page-title">Network Inspector</h1> <h1 class="lt-page-title">Network Inspector</h1>
<p class="g-page-sub" style="margin-top:4px"> <p class="g-page-sub">
Visual switch chassis diagrams. Click a port to see detailed stats and LLDP path debug. Visual switch chassis diagrams. Click a port to see detailed stats and LLDP path debug.
<span id="inspector-updated" style="margin-left:8px"></span> <span id="inspector-updated" class="g-page-sub-aside"></span>
</p> </p>
</div> </div>
</div> </div>
<div class="inspector-layout"> <div class="inspector-layout">
<div class="inspector-main" id="inspector-main"> <div class="inspector-main" id="inspector-main" role="region" aria-label="Switch chassis diagrams">
<div class="link-loading">Loading inspector data</div> <div class="link-loading">Loading inspector data</div>
</div> </div>
<div class="inspector-panel" id="inspector-panel"> <div class="inspector-panel" id="inspector-panel" role="complementary" aria-label="Port detail panel">
<div class="inspector-panel-inner" id="inspector-panel-inner"></div> <div class="inspector-panel-inner" id="inspector-panel-inner"></div>
</div> </div>
</div> </div>
@@ -320,7 +320,9 @@ function renderPanel(swName, idx) {
_apiData.hosts && _apiData.hosts[d.lldp.system_name]); _apiData.hosts && _apiData.hosts[d.lldp.system_name]);
const diagHtml = hasDiagTarget ? ` const diagHtml = hasDiagTarget ? `
<div class="diag-bar"> <div class="diag-bar">
<button class="btn-diag" data-action="run-diagnostic" data-sw="${escHtml(swName)}" data-idx="${idx}">Run Link Diagnostics</button> <button class="btn-diag lt-btn lt-btn-secondary lt-btn-sm" data-action="run-diagnostic"
data-sw="${escHtml(swName)}" data-idx="${idx}"
aria-label="Run link diagnostics for port ${idx} on ${escHtml(swName)}">Run Diagnostics</button>
<span class="diag-status" id="diag-status"></span> <span class="diag-status" id="diag-status"></span>
</div> </div>
<div class="diag-results" id="diag-results"></div>` : ''; <div class="diag-results" id="diag-results"></div>` : '';
@@ -433,7 +435,7 @@ function renderInspector(data) {
} }
if (!Object.keys(switches).length) { if (!Object.keys(switches).length) {
main.innerHTML = '<p class="empty-state">No switch data available. Monitor may still be initialising.</p>'; main.innerHTML = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-icon"></div><div class="lt-empty-state-title">No switch data available</div><div class="lt-empty-state-body">Monitor may still be initialising.</div></div>';
return; return;
} }
@@ -460,7 +462,7 @@ async function loadInspector() {
renderInspector(data); renderInspector(data);
} catch (e) { } catch (e) {
document.getElementById('inspector-main').innerHTML = document.getElementById('inspector-main').innerHTML =
'<p class="empty-state">Failed to load inspector data.</p>'; '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-icon"></div><div class="lt-empty-state-title">Failed to load inspector data</div></div>';
lt.toast.error('Failed to load inspector data'); lt.toast.error('Failed to load inspector data');
} }
} }
+45 -28
View File
@@ -6,9 +6,9 @@
<div class="lt-page-header"> <div class="lt-page-header">
<div> <div>
<h1 class="lt-page-title">Link Debug</h1> <h1 class="lt-page-title">Link Debug</h1>
<p class="g-page-sub" style="margin-top:4px"> <p class="g-page-sub">
Per-interface stats: speed, duplex, SFP optical levels, TX/RX rates, errors, and carrier changes. Per-interface stats: speed, duplex, SFP optical levels, TX/RX rates, errors, and carrier changes.
<span id="links-updated" style="margin-left:8px"></span> <span id="links-updated" class="g-page-sub-aside"></span>
</p> </p>
</div> </div>
</div> </div>
@@ -17,7 +17,8 @@
<div class="lt-toolbar-left"> <div class="lt-toolbar-left">
<div class="lt-search"> <div class="lt-search">
<input type="search" class="lt-input lt-search-input" id="links-search" <input type="search" class="lt-input lt-search-input" id="links-search"
placeholder="Filter by host or switch name…" autocomplete="off"> placeholder="Filter by host or switch name…" autocomplete="off"
aria-label="Filter by host or switch name">
</div> </div>
</div> </div>
<div class="lt-toolbar-right"> <div class="lt-toolbar-right">
@@ -35,6 +36,7 @@
{% block scripts %} {% block scripts %}
<script> <script>
const escHtml = s => lt.escHtml(s); const escHtml = s => lt.escHtml(s);
const _toIso = s => s ? s.replace(' UTC', 'Z').replace(' ', 'T') : s;
// ── Formatting helpers ──────────────────────────────────────────── // ── Formatting helpers ────────────────────────────────────────────
function fmtRate(bytesPerSec) { function fmtRate(bytesPerSec) {
@@ -325,7 +327,7 @@ function renderPortCard(portName, d) {
function renderUnifiSwitches(unifiSwitches, dataUpdated) { function renderUnifiSwitches(unifiSwitches, dataUpdated) {
if (!unifiSwitches || !Object.keys(unifiSwitches).length) return ''; if (!unifiSwitches || !Object.keys(unifiSwitches).length) return '';
const updStr = dataUpdated const updStr = dataUpdated
? new Date(dataUpdated.replace(' UTC', 'Z').replace(' ', 'T')).toLocaleTimeString() ? new Date(_toIso(dataUpdated)).toLocaleTimeString()
: ''; : '';
const html = Object.entries(unifiSwitches).map(([swName, sw]) => { const html = Object.entries(unifiSwitches).map(([swName, sw]) => {
const ports = sw.ports || {}; const ports = sw.ports || {};
@@ -347,7 +349,7 @@ function renderUnifiSwitches(unifiSwitches, dataUpdated) {
return ` return `
<div class="link-host-panel" id="panel-${CSS.escape(swName)}"> <div class="link-host-panel" id="panel-${CSS.escape(swName)}">
<div class="link-host-title" data-action="toggle-panel"> <div class="link-host-title" data-action="toggle-panel" role="button" tabindex="0" aria-expanded="true">
<span class="link-host-name">${escHtml(swName)}</span> <span class="link-host-name">${escHtml(swName)}</span>
<span class="link-host-ip">${escHtml(sw.ip || '')}</span> <span class="link-host-ip">${escHtml(sw.ip || '')}</span>
<span class="link-host-upd">${escHtml(sw.model || '')}${updStr ? ' · ' + updStr : ''}${poeLoad}</span> <span class="link-host-upd">${escHtml(sw.model || '')}${updStr ? ' · ' + updStr : ''}${poeLoad}</span>
@@ -358,14 +360,17 @@ function renderUnifiSwitches(unifiSwitches, dataUpdated) {
</div>`; </div>`;
}).join(''); }).join('');
return `<div class="lt-divider" style="margin:20px 0 12px"><span class="lt-divider-label" style="color:var(--cyan);letter-spacing:.1em">UNIFI SWITCH PORTS</span></div>${html}`; return `<div class="lt-divider lt-divider--unifi"><span class="lt-divider-label lt-divider-label--unifi">UNIFI SWITCH PORTS</span></div>${html}`;
} }
// ── Panel collapse / expand ─────────────────────────────────────── // ── Panel collapse / expand ───────────────────────────────────────
function togglePanel(panel) { function togglePanel(panel) {
panel.classList.toggle('collapsed'); panel.classList.toggle('collapsed');
const btn = panel.querySelector('.panel-toggle'); const isCollapsed = panel.classList.contains('collapsed');
if (btn) btn.textContent = panel.classList.contains('collapsed') ? '[+]' : '[]'; const btn = panel.querySelector('.panel-toggle');
const title = panel.querySelector('.link-host-title');
if (btn) btn.textContent = isCollapsed ? '[+]' : '[]';
if (title) title.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
const id = panel.id; const id = panel.id;
if (id) { if (id) {
const collapsed = JSON.parse(sessionStorage.getItem('linksCollapsed') || '{}'); const collapsed = JSON.parse(sessionStorage.getItem('linksCollapsed') || '{}');
@@ -381,8 +386,10 @@ function restoreCollapseState() {
if (!panel) continue; if (!panel) continue;
if (isCollapsed) { if (isCollapsed) {
panel.classList.add('collapsed'); panel.classList.add('collapsed');
const btn = panel.querySelector('.panel-toggle'); const btn = panel.querySelector('.panel-toggle');
if (btn) btn.textContent = '[+]'; const title = panel.querySelector('.link-host-title');
if (btn) btn.textContent = '[+]';
if (title) title.setAttribute('aria-expanded', 'false');
} }
} }
} }
@@ -407,37 +414,37 @@ function buildLinkSummary(hosts, unifiSwitches) {
} }
const allTotal = totalIfaces + swTotal; const allTotal = totalIfaces + swTotal;
const allDown = downIfaces + swDown; const allDown = downIfaces + swDown;
const downColor = allDown > 0 ? 'var(--red)' : 'var(--green)'; const downCls = allDown > 0 ? 'lt-text-red' : 'lt-text-green';
const errColor = errIfaces > 0 ? 'var(--amber)' : 'var(--green)'; const errCls = errIfaces > 0 ? 'lt-text-amber' : 'lt-text-green';
const downCardCls = allDown > 0 ? ' lt-stat-card--alert' : ''; const downCardCls = allDown > 0 ? ' lt-stat-card--alert' : '';
const poeCard = totalPoe > 0 ? ` const poeCard = totalPoe > 0 ? `
<div class="lt-stat-card"> <div class="lt-stat-card">
<span class="lt-stat-icon" aria-hidden="true" style="color:var(--amber)"></span> <span class="lt-stat-icon lt-text-amber" aria-hidden="true"></span>
<div class="lt-stat-info"> <div class="lt-stat-info">
<span class="lt-stat-value" style="color:var(--amber)">${totalPoe.toFixed(1)}</span> <span class="lt-stat-value lt-text-amber">${totalPoe.toFixed(1)}</span>
<span class="lt-stat-label">PoE Load (W)</span> <span class="lt-stat-label">PoE Load (W)</span>
</div> </div>
</div>` : ''; </div>` : '';
return ` return `
<div class="lt-stats-grid" style="margin-bottom:16px"> <div class="lt-stats-grid lt-stats-grid--mb">
<div class="lt-stat-card"> <div class="lt-stat-card">
<span class="lt-stat-icon" aria-hidden="true" style="color:var(--cyan)"></span> <span class="lt-stat-icon lt-text-cyan" aria-hidden="true"></span>
<div class="lt-stat-info"> <div class="lt-stat-info">
<span class="lt-stat-value" style="color:var(--cyan)">${allTotal}</span> <span class="lt-stat-value lt-text-cyan">${allTotal}</span>
<span class="lt-stat-label">Interfaces</span> <span class="lt-stat-label">Interfaces</span>
</div> </div>
</div> </div>
<div class="lt-stat-card${downCardCls}"> <div class="lt-stat-card${downCardCls}">
<span class="lt-stat-icon" aria-hidden="true" style="color:${downColor}"></span> <span class="lt-stat-icon ${downCls}" aria-hidden="true"></span>
<div class="lt-stat-info"> <div class="lt-stat-info">
<span class="lt-stat-value" style="color:${downColor}">${allDown}</span> <span class="lt-stat-value ${downCls}">${allDown}</span>
<span class="lt-stat-label">Ports Down</span> <span class="lt-stat-label">Ports Down</span>
</div> </div>
</div> </div>
<div class="lt-stat-card"> <div class="lt-stat-card">
<span class="lt-stat-icon" aria-hidden="true" style="color:${errColor}"></span> <span class="lt-stat-icon ${errCls}" aria-hidden="true"></span>
<div class="lt-stat-info"> <div class="lt-stat-info">
<span class="lt-stat-value" style="color:${errColor}">${errIfaces}</span> <span class="lt-stat-value ${errCls}">${errIfaces}</span>
<span class="lt-stat-label">With Errors</span> <span class="lt-stat-label">With Errors</span>
</div> </div>
</div> </div>
@@ -461,12 +468,12 @@ function renderLinks(data) {
const sample = Object.values(ifaces)[0] || {}; const sample = Object.values(ifaces)[0] || {};
const ip = sample.host_ip || ''; const ip = sample.host_ip || '';
const updStr = data.updated const updStr = data.updated
? new Date(data.updated.replace(' UTC', 'Z').replace(' ', 'T')).toLocaleTimeString() ? new Date(_toIso(data.updated)).toLocaleTimeString()
: ''; : '';
parts.push(` parts.push(`
<div class="link-host-panel" id="panel-${CSS.escape(hostname)}"> <div class="link-host-panel" id="panel-${CSS.escape(hostname)}">
<div class="link-host-title" data-action="toggle-panel"> <div class="link-host-title" data-action="toggle-panel" role="button" tabindex="0" aria-expanded="true">
<span class="link-host-name">${escHtml(hostname)}</span> <span class="link-host-name">${escHtml(hostname)}</span>
<span class="link-host-ip">${escHtml(ip)}</span> <span class="link-host-ip">${escHtml(ip)}</span>
<span class="link-host-upd">${updStr}</span> <span class="link-host-upd">${updStr}</span>
@@ -496,8 +503,10 @@ function applyLinksSearch() {
function collapseAll() { function collapseAll() {
document.querySelectorAll('.link-host-panel').forEach(p => { document.querySelectorAll('.link-host-panel').forEach(p => {
p.classList.add('collapsed'); p.classList.add('collapsed');
const btn = p.querySelector('.panel-toggle'); const btn = p.querySelector('.panel-toggle');
if (btn) btn.textContent = '[+]'; const title = p.querySelector('.link-host-title');
if (btn) btn.textContent = '[+]';
if (title) title.setAttribute('aria-expanded', 'false');
}); });
sessionStorage.setItem('linksCollapsed', JSON.stringify( sessionStorage.setItem('linksCollapsed', JSON.stringify(
Object.fromEntries([...document.querySelectorAll('.link-host-panel')].map(p => [p.id, true])) Object.fromEntries([...document.querySelectorAll('.link-host-panel')].map(p => [p.id, true]))
@@ -507,8 +516,10 @@ function collapseAll() {
function expandAll() { function expandAll() {
document.querySelectorAll('.link-host-panel').forEach(p => { document.querySelectorAll('.link-host-panel').forEach(p => {
p.classList.remove('collapsed'); p.classList.remove('collapsed');
const btn = p.querySelector('.panel-toggle'); const btn = p.querySelector('.panel-toggle');
if (btn) btn.textContent = '[]'; const title = p.querySelector('.link-host-title');
if (btn) btn.textContent = '[]';
if (title) title.setAttribute('aria-expanded', 'true');
}); });
sessionStorage.setItem('linksCollapsed', '{}'); sessionStorage.setItem('linksCollapsed', '{}');
} }
@@ -557,7 +568,7 @@ async function loadLinks() {
} }
loadLinks(); loadLinks();
var _linksInterval = (window.gandalfSettings && window.gandalfSettings.refreshInterval) || 60; const _linksInterval = (window.gandalfSettings && window.gandalfSettings.refreshInterval) || 60;
if (_linksInterval > 0) lt.autoRefresh.start(loadLinks, Math.max(_linksInterval, 15) * 1000); if (_linksInterval > 0) lt.autoRefresh.start(loadLinks, Math.max(_linksInterval, 15) * 1000);
window.onGandalfSettingsChanged = function(s) { window.onGandalfSettingsChanged = function(s) {
@@ -573,6 +584,12 @@ document.addEventListener('click', e => {
if (e.target.closest('[data-action="expand-all"]')) { expandAll(); return; } if (e.target.closest('[data-action="expand-all"]')) { expandAll(); return; }
}); });
document.addEventListener('keydown', e => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const toggleTitle = e.target.closest('[data-action="toggle-panel"]');
if (toggleTitle) { e.preventDefault(); togglePanel(toggleTitle.closest('.link-host-panel')); }
});
document.getElementById('links-search')?.addEventListener('input', applyLinksSearch); document.getElementById('links-search')?.addEventListener('input', applyLinksSearch);
</script> </script>
{% endblock %} {% endblock %}
+17 -12
View File
@@ -6,7 +6,7 @@
<div class="lt-page-header"> <div class="lt-page-header">
<div> <div>
<h1 class="lt-page-title">Alert Suppressions</h1> <h1 class="lt-page-title">Alert Suppressions</h1>
<p class="g-page-sub" style="margin-top:4px">Manage maintenance windows and per-target alert suppression rules.</p> <p class="g-page-sub">Manage maintenance windows and per-target alert suppression rules.</p>
</div> </div>
</div> </div>
@@ -58,12 +58,12 @@
<div class="form-row form-row-align"> <div class="form-row form-row-align">
<div class="lt-form-group"> <div class="lt-form-group">
<label class="lt-label">Duration</label> <label class="lt-label">Duration</label>
<div class="duration-pills"> <div class="duration-pills" role="group" aria-label="Select suppression duration">
<button type="button" class="pill" data-duration="30">30 min</button> <button type="button" class="pill" data-duration="30" aria-pressed="false">30 min</button>
<button type="button" class="pill" data-duration="60">1 hr</button> <button type="button" class="pill" data-duration="60" aria-pressed="false">1 hr</button>
<button type="button" class="pill" data-duration="240">4 hr</button> <button type="button" class="pill" data-duration="240" aria-pressed="false">4 hr</button>
<button type="button" class="pill" data-duration="480">8 hr</button> <button type="button" class="pill" data-duration="480" aria-pressed="false">8 hr</button>
<button type="button" class="pill pill-manual active" data-duration="">Manual ∞</button> <button type="button" class="pill pill-manual active" data-duration="" aria-pressed="true">Manual ∞</button>
</div> </div>
<input type="hidden" id="s-expires" name="expires_minutes" value=""> <input type="hidden" id="s-expires" name="expires_minutes" value="">
<div class="lt-field-hint" id="s-dur-hint">Persists until manually removed.</div> <div class="lt-field-hint" id="s-dur-hint">Persists until manually removed.</div>
@@ -110,7 +110,8 @@
<td class="ts-cell">{{ s.created_at }}</td> <td class="ts-cell">{{ s.created_at }}</td>
<td class="ts-cell">{% if s.expires_at %}{{ s.expires_at }}{% else %}<em>manual</em>{% endif %}</td> <td class="ts-cell">{% if s.expires_at %}{{ s.expires_at }}{% else %}<em>manual</em>{% endif %}</td>
<td> <td>
<button class="lt-btn lt-btn-danger lt-btn-sm" data-action="remove-sup" data-sup-id="{{ s.id }}">Remove</button> <button class="lt-btn lt-btn-danger lt-btn-sm" data-action="remove-sup" data-sup-id="{{ s.id }}"
aria-label="Remove suppression for {{ s.target_name or 'global' }}">Remove</button>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -188,7 +189,7 @@
<span class="lt-frame-bl">&#x255A;</span> <span class="lt-frame-bl">&#x255A;</span>
<span class="lt-frame-br">&#x255D;</span> <span class="lt-frame-br">&#x255D;</span>
<div class="lt-section-header">Host &amp; Interface Reference</div> <div class="lt-section-header">Host &amp; Interface Reference</div>
<div style="padding:12px 14px"> <div class="lt-section-body">
<div class="targets-grid"> <div class="targets-grid">
{% for name, host in snapshot.hosts.items() %} {% for name, host in snapshot.hosts.items() %}
<div class="target-card"> <div class="target-card">
@@ -221,8 +222,11 @@
function setDur(mins, el) { function setDur(mins, el) {
document.getElementById('s-expires').value = mins || ''; document.getElementById('s-expires').value = mins || '';
document.querySelectorAll('.duration-pills .pill').forEach(p => p.classList.remove('active')); document.querySelectorAll('.duration-pills .pill').forEach(p => {
if (el) el.classList.add('active'); p.classList.remove('active');
p.setAttribute('aria-pressed', 'false');
});
if (el) { el.classList.add('active'); el.setAttribute('aria-pressed', 'true'); }
const hint = document.getElementById('s-dur-hint'); const hint = document.getElementById('s-dur-hint');
if (mins) { if (mins) {
const h = Math.floor(mins/60), m = mins%60; const h = Math.floor(mins/60), m = mins%60;
@@ -251,7 +255,8 @@
<td>${lt.escHtml(s.suppressed_by)}</td> <td>${lt.escHtml(s.suppressed_by)}</td>
<td class="ts-cell">${lt.escHtml(s.created_at || '')}</td> <td class="ts-cell">${lt.escHtml(s.created_at || '')}</td>
<td class="ts-cell">${s.expires_at ? lt.escHtml(s.expires_at) : '<em>manual</em>'}</td> <td class="ts-cell">${s.expires_at ? lt.escHtml(s.expires_at) : '<em>manual</em>'}</td>
<td><button class="lt-btn lt-btn-danger lt-btn-sm" data-action="remove-sup" data-sup-id="${s.id}">Remove</button></td> <td><button class="lt-btn lt-btn-danger lt-btn-sm" data-action="remove-sup" data-sup-id="${s.id}"
aria-label="Remove suppression for ${lt.escHtml(s.target_name || 'global')}">Remove</button></td>
</tr>`).join(''); </tr>`).join('');
wrap.innerHTML = ` wrap.innerHTML = `
<div class="lt-frame"> <div class="lt-frame">