Lint / JS (eslint) (push) Successful in 21s
Lint / Python (flake8) (push) Successful in 1m20s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m0s
Test / Python Tests (pytest) (push) Successful in 1m48s
Lint / Python (flake8) (pull_request) Successful in 1m44s
Lint / JS (eslint) (pull_request) Successful in 30s
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 1m16s
The dashboard's hand-drawn diagram was out of date. /topology replaces it with the network as the switches report it, every device drawn to scale from its faceplate or cutsheet front, and one cable per physical link leaving the port it is plugged into. - topology.py builds the graph: switch-to-switch cables seen from both ends are merged, servers get one cable per NIC, the gateway's internet port goes to the modem and on to the internet, unknown neighbours are stubs, and gear with no live cable is drawn where it was last seen. - Ports named after a different device than the one plugged in are flagged. - Cables show medium, speed, state and traffic; click one for its details, hover a device to trace its path to the internet; pan, zoom and filters. - The monitor now keeps host_macs (MAC to host NIC) in link_stats. - The old dashboard diagram and its JS and CSS are removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
462 lines
21 KiB
HTML
462 lines
21 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Dashboard{% endblock %}
|
|
|
|
{% block content %}
|
|
|
|
<!-- ── Status bar ──────────────────────────────────────────────────── -->
|
|
<div class="status-bar">
|
|
<div class="status-chips" id="status-chips" aria-live="polite" aria-atomic="true">
|
|
{% if not daemon_ok %}
|
|
<span class="chip chip-critical">⚠ MONITOR OFFLINE</span>
|
|
{% endif %}
|
|
{% if summary.critical %}
|
|
<span class="chip chip-critical">● {{ summary.critical }} CRITICAL</span>
|
|
{% endif %}
|
|
{% if summary.warning %}
|
|
<span class="chip chip-warning">● {{ summary.warning }} WARNING</span>
|
|
{% endif %}
|
|
{% if daemon_ok and not summary.critical and not summary.warning %}
|
|
<span class="chip chip-ok">✔ ALL SYSTEMS NOMINAL</span>
|
|
{% endif %}
|
|
{% set followups = (stale_tickets | length) + (stale_alerts or 0) %}
|
|
{% if followups %}
|
|
<a class="chip chip-warning chip-followup" href="{{ '#followup' if stale_tickets else '#events-table-wrap' }}">⏳ {{ followups }} TO FOLLOW UP</a>
|
|
{% endif %}
|
|
</div>
|
|
<div class="status-meta">
|
|
<span class="last-check" id="last-check" data-tooltip="Last monitor poll timestamp" data-tooltip-pos="bottom">{{ last_check }}</span>
|
|
<span class="lt-spinner lt-spinner--sm lt-spinner--cyan" id="refresh-spinner" style="display:none"></span>
|
|
<button class="lt-btn lt-btn-ghost lt-btn-sm" data-action="refresh" data-tooltip="Refresh all data now" data-tooltip-pos="bottom">↻ REFRESH</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Stats summary cards ──────────────────────────────────────────── -->
|
|
<div class="lt-stats-grid">
|
|
<div class="lt-stat-card{% if summary.critical %} lt-stat-card--alert{% endif %}"
|
|
id="stat-critical" role="button" tabindex="0"
|
|
data-stat-filter="critical" aria-label="{{ summary.critical or 0 }} critical alerts"
|
|
aria-controls="events-table-wrap">
|
|
<span class="lt-stat-icon lt-text-red" aria-hidden="true">●</span>
|
|
<div class="lt-stat-info">
|
|
<span class="lt-stat-value lt-text-red" id="stat-critical-val">{{ summary.critical or 0 }}</span>
|
|
<span class="lt-stat-label">Critical</span>
|
|
</div>
|
|
</div>
|
|
<div class="lt-stat-card"
|
|
id="stat-warning" role="button" tabindex="0"
|
|
data-stat-filter="warning" aria-label="{{ summary.warning or 0 }} warning alerts"
|
|
aria-controls="events-table-wrap">
|
|
<span class="lt-stat-icon lt-text-amber" aria-hidden="true">●</span>
|
|
<div class="lt-stat-info">
|
|
<span class="lt-stat-value lt-text-amber" id="stat-warning-val">{{ summary.warning or 0 }}</span>
|
|
<span class="lt-stat-label">Warning</span>
|
|
</div>
|
|
</div>
|
|
<div class="lt-stat-card" id="stat-hosts" aria-label="Monitored hosts">
|
|
<span class="lt-stat-icon lt-text-cyan" aria-hidden="true">⬡</span>
|
|
<div class="lt-stat-info">
|
|
<span class="lt-stat-value lt-text-cyan" id="stat-hosts-val">{{ snapshot.hosts | length }}</span>
|
|
<span class="lt-stat-label">Hosts</span>
|
|
</div>
|
|
</div>
|
|
<div class="lt-stat-card" id="stat-resolved" aria-label="{{ recent_resolved | length }} alerts resolved in last 24 hours">
|
|
<span class="lt-stat-icon lt-text-green" aria-hidden="true">✔</span>
|
|
<div class="lt-stat-info">
|
|
<span class="lt-stat-value lt-text-green" id="stat-resolved-val">{{ recent_resolved | length }}</span>
|
|
<span class="lt-stat-label">Resolved 24h</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Device search ──────────────────────────────────────────────── -->
|
|
<section class="g-section device-search" id="device-search" role="search">
|
|
<label class="g-section-title" for="device-search-input">Find a device</label>
|
|
<div class="lt-search">
|
|
<input type="search" class="lt-input lt-search-input device-search-input" id="device-search-input"
|
|
placeholder="Hostname, IP, MAC, model, interface… ( / )" autocomplete="off" spellcheck="false"
|
|
role="combobox" aria-expanded="false" aria-controls="device-search-results" aria-autocomplete="list">
|
|
</div>
|
|
<div class="device-search-results" id="device-search-results" role="listbox" hidden></div>
|
|
</section>
|
|
|
|
<!-- ── Active alerts ─────────────────────────────── (above the fold) -->
|
|
<section class="g-section">
|
|
<div class="g-section-header">
|
|
<h2 class="g-section-title">Active Alerts</h2>
|
|
<span class="g-section-badge" id="alert-count-badge"
|
|
{% if not summary.critical and not summary.warning %}style="display:none"{% endif %}>{{ (summary.critical or 0) + (summary.warning or 0) }}</span>
|
|
</div>
|
|
<div class="lt-toolbar">
|
|
<div class="lt-toolbar-left">
|
|
<div class="lt-search">
|
|
<input type="search" class="lt-input lt-search-input" id="events-search"
|
|
placeholder="Filter by target, type, description…" autocomplete="off"
|
|
aria-label="Filter active alerts">
|
|
</div>
|
|
<div class="sev-pills" role="group" aria-label="Filter by severity">
|
|
<button type="button" class="pill active" data-sev="" aria-pressed="true">All</button>
|
|
<button type="button" class="pill" data-sev="critical" aria-pressed="false">Critical</button>
|
|
<button type="button" class="pill" data-sev="warning" aria-pressed="false">Warning</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="lt-frame">
|
|
<span class="lt-frame-bl">╚</span>
|
|
<span class="lt-frame-br">╝</span>
|
|
<div class="lt-section-header">Alert Queue</div>
|
|
<div id="events-table-wrap">
|
|
{% if events %}
|
|
{% if total_active is defined and total_active > events|length %}
|
|
<div class="pagination-notice">Showing {{ events|length }} of {{ total_active }} active alerts. Use the search box to filter, or <a href="/api/events?limit=1000" target="_blank" rel="noopener">export all as JSON</a></div>
|
|
{% endif %}
|
|
<div class="lt-table-wrap">
|
|
<table class="lt-table" id="events-table">
|
|
<caption class="lt-sr-only">Active network alerts</caption>
|
|
<thead>
|
|
<tr>
|
|
<th data-tooltip="Alert severity level" data-tooltip-pos="bottom">Sev</th>
|
|
<th>Type</th>
|
|
<th>Target</th>
|
|
<th>Detail</th>
|
|
<th>Description</th>
|
|
<th data-tooltip="When this alert was first raised" data-tooltip-pos="bottom">First Seen</th>
|
|
<th data-tooltip="Most recent check failure" data-tooltip-pos="bottom">Last Seen</th>
|
|
<th data-tooltip="Consecutive check failures since first seen" data-tooltip-pos="bottom">Failures</th>
|
|
<th>Ticket</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for e in events %}
|
|
{% if e.severity != 'info' %}
|
|
<tr class="row-{{ e.severity }}{% if e.is_suppressed %} row-suppressed{% endif %}">
|
|
<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 %}
|
|
{% if e.stale_days is not none %}<span class="lt-badge badge-stale" title="Active for {{ e.stale_days }} days with no fix yet">⏳ {{ e.stale_days }}d</span>{% endif %}
|
|
{% if e.ticket_closed %}<span class="lt-badge badge-ticket-closed" title="Ticket #{{ e.ticket_id }} was closed in Tinker, but this alert is still active">ticket closed</span>{% endif %}
|
|
</td>
|
|
<td>{{ e.event_type | replace('_', ' ') }}</td>
|
|
<td><strong>{{ e.target_name }}</strong></td>
|
|
<td>{{ e.target_detail or '-' }}</td>
|
|
<td class="desc-cell" title="{{ e.description | e }}">{{ e.description | truncate(60) }}</td>
|
|
<td class="ts-cell" title="{{ e.first_seen }}">
|
|
<span class="event-age" data-ts="{{ e.first_seen }}">{{ e.first_seen }}</span>
|
|
</td>
|
|
<td class="ts-cell" title="{{ e.last_seen }}">
|
|
<span class="event-age" data-ts="{{ e.last_seen }}">{{ e.last_seen }}</span>
|
|
</td>
|
|
<td>{{ e.consecutive_failures }}</td>
|
|
<td>
|
|
{% if e.ticket_id %}
|
|
<a href="{{ config.ticket_api.web_url }}{{ e.ticket_id }}" target="_blank"
|
|
class="ticket-link">#{{ e.ticket_id }}</a>
|
|
{% else %}-{% endif %}
|
|
</td>
|
|
<td>
|
|
<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
|
|
data-sup-type="{{ 'unifi_device' if e.event_type == 'unifi_device_offline' else 'interface' if e.event_type == 'interface_down' else 'host' }}"
|
|
data-sup-name="{{ e.target_name }}"
|
|
data-sup-detail="{{ e.target_detail or '' }}"
|
|
title="Suppress" aria-label="Suppress alert for {{ e.target_name }}">🔕</button>
|
|
</td>
|
|
</tr>
|
|
{% endif %}
|
|
{% else %}
|
|
<tr><td colspan="10">
|
|
<div class="lt-empty-state lt-empty-state--sm">
|
|
<div class="lt-empty-state-icon">✔</div>
|
|
<div class="lt-empty-state-title">No active alerts</div>
|
|
</div>
|
|
</td></tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% else %}
|
|
<div class="lt-empty-state lt-empty-state--sm">
|
|
<div class="lt-empty-state-icon">✔</div>
|
|
<div class="lt-empty-state-title">No active alerts</div>
|
|
</div>
|
|
{% endif %}
|
|
</div><!-- /#events-table-wrap -->
|
|
</div><!-- /.lt-frame -->
|
|
</section>
|
|
|
|
<!-- ── Needs follow-up: tickets left open after their alert cleared ── -->
|
|
<section class="g-section" id="followup"{% if not stale_tickets %} hidden{% endif %}>
|
|
<div class="g-section-header">
|
|
<h2 class="g-section-title">Needs Follow-up</h2>
|
|
<span class="g-section-badge" id="followup-badge">{{ stale_tickets | length }}</span>
|
|
</div>
|
|
<p class="followup-intro">These tickets are still open in Tinker, but their alert cleared over a day ago. Close them if the problem is fixed.</p>
|
|
<div class="lt-frame">
|
|
<span class="lt-frame-bl">╚</span>
|
|
<span class="lt-frame-br">╝</span>
|
|
<div class="lt-section-header">Tickets Left Open</div>
|
|
<div class="lt-table-wrap">
|
|
<table class="lt-table" id="followup-table">
|
|
<caption class="lt-sr-only">Tickets still open after their alert cleared</caption>
|
|
<thead>
|
|
<tr><th>Ticket</th><th>Target</th><th>Alert</th><th>Cleared</th><th>Alerts on ticket</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for t in stale_tickets %}
|
|
<tr>
|
|
<td><a href="{{ config.ticket_api.web_url }}{{ t.ticket_id }}" target="_blank" rel="noopener" class="ticket-link">#{{ t.ticket_id }}</a></td>
|
|
<td><strong>{{ t.target_name }}</strong></td>
|
|
<td>{{ t.event_type | replace('_', ' ') }}{% if t.target_detail %} <span class="lt-text-muted">{{ t.target_detail }}</span>{% endif %}</td>
|
|
<td class="ts-cell" title="{{ t.cleared_at }}"><span class="event-age" data-ts="{{ t.cleared_at }}">{{ t.cleared_at }}</span></td>
|
|
<td>{{ t.events }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ── WAN / fiber ──────────────────────────────────────────────────── -->
|
|
{% include "_modem_panel.html" %}
|
|
|
|
<!-- ── Hosts ────────────────────────────────────────────────────────── -->
|
|
<section class="g-section">
|
|
<div class="g-section-header">
|
|
<h2 class="g-section-title">Hosts</h2>
|
|
</div>
|
|
<div class="host-grid" id="host-grid">
|
|
{%- set has_global_sup = suppressions | selectattr('target_type', 'equalto', 'all') | list | length > 0 -%}
|
|
{% for name, host in snapshot.hosts.items() %}
|
|
{% set suppressed = suppressions | selectattr('target_name', 'equalto', name) | list %}
|
|
<div class="host-card host-card-{{ host.status }}" data-host="{{ name }}">
|
|
<div class="host-card-header">
|
|
<div class="host-name-row">
|
|
<span class="host-status-dot dot-{{ host.status }}"></span>
|
|
<a class="host-name device-link" href="{{ url_for('device_page', name=name) }}">{{ name }}</a>
|
|
{% if suppressed or has_global_sup %}
|
|
<span class="badge-suppressed" title="Suppressed">🔕</span>
|
|
{% endif %}
|
|
</div>
|
|
<div class="host-meta">
|
|
<span class="host-ip">{{ host.ip }}</span>
|
|
<span class="host-source source-{{ host.source }}">{{ host.source }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{% if host.interfaces %}
|
|
<div class="iface-list">
|
|
{% for iface, state in host.interfaces.items() | sort %}
|
|
<div class="iface-row">
|
|
<span class="iface-dot dot-{{ state }}"></span>
|
|
<span class="iface-name">{{ iface }}</span>
|
|
<span class="iface-state state-{{ state }}">{{ state }}</span>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% else %}
|
|
<div class="host-ping-note">{% if host.source == 'ping' %}ping-only / no node_exporter{% else %}no metrics from node_exporter{% endif %}</div>
|
|
{% endif %}
|
|
|
|
<div class="host-actions">
|
|
<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
|
|
data-sup-type="host"
|
|
data-sup-name="{{ name }}"
|
|
data-sup-detail=""
|
|
aria-label="Suppress alerts for {{ name }}">
|
|
🔕 Suppress
|
|
</button>
|
|
<a href="{{ url_for('device_page', name=name) }}"
|
|
class="lt-btn lt-btn-secondary lt-btn-sm">
|
|
↗ Details
|
|
</a>
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<div class="lt-empty-state lt-empty-state--sm">
|
|
<div class="lt-empty-state-icon">⌛</div>
|
|
<div class="lt-empty-state-title">No host data yet</div>
|
|
<div class="lt-empty-state-body">The monitor daemon may still be starting up.</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ── UniFi devices ────────────────────────────────────────────────── -->
|
|
{% if snapshot.unifi %}
|
|
<section class="g-section">
|
|
<div class="g-section-header">
|
|
<h2 class="g-section-title">UniFi Devices</h2>
|
|
</div>
|
|
<div class="lt-frame">
|
|
<span class="lt-frame-bl">╚</span>
|
|
<span class="lt-frame-br">╝</span>
|
|
<div class="lt-section-header">Device Inventory</div>
|
|
<div class="lt-table-wrap">
|
|
<table class="lt-table" id="unifi-table">
|
|
<caption class="lt-sr-only">UniFi network devices</caption>
|
|
<thead>
|
|
<tr>
|
|
<th>Status</th>
|
|
<th>Name</th>
|
|
<th>Type</th>
|
|
<th>Model</th>
|
|
<th>IP</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for d in snapshot.unifi %}
|
|
<tr class="{% if not d.connected %}row-critical{% endif %}">
|
|
<td>
|
|
<span class="dot-{{ 'up' if d.connected else 'down' }}"></span>
|
|
{{ 'ONLINE' if d.connected else 'OFFLINE' }}
|
|
</td>
|
|
<td><a class="device-link" href="{{ url_for('device_page', name=d.name) }}"><strong>{{ d.name }}</strong></a></td>
|
|
<td>{{ d.type }}</td>
|
|
<td>{{ d.model }}</td>
|
|
<td>{{ d.ip }}</td>
|
|
<td>
|
|
{% if not d.connected %}
|
|
<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
|
|
data-sup-type="unifi_device"
|
|
data-sup-name="{{ d.name }}"
|
|
data-sup-detail=""
|
|
aria-label="Suppress alerts for {{ d.name }}">
|
|
🔕 Suppress
|
|
</button>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
{% endif %}
|
|
|
|
<!-- ── Recently Resolved (last 24h) ───────────────────────────────── -->
|
|
{% if recent_resolved %}
|
|
<section class="g-section">
|
|
<div class="g-section-header">
|
|
<h2 class="g-section-title">Recently Resolved</h2>
|
|
<span class="g-section-badge g-section-badge-resolved">{{ recent_resolved | length }} in last 24h</span>
|
|
</div>
|
|
<div class="lt-timeline">
|
|
{% for e in recent_resolved %}
|
|
{%- set dot_cls = 'lt-timeline-item--green' if e.severity == 'info' else 'lt-timeline-item--dim' -%}
|
|
<div class="lt-timeline-item {{ dot_cls }}">
|
|
<div class="lt-timeline-meta">
|
|
<strong class="lt-timeline-actor">{{ e.target_name }}</strong>
|
|
{% if e.target_detail %}<span>· {{ e.target_detail }}</span>{% endif %}
|
|
<span class="lt-timeline-time event-age" data-ts="{{ e.resolved_at }}">{{ e.resolved_at }}</span>
|
|
</div>
|
|
<div class="lt-timeline-body">
|
|
{{ e.event_type | replace('_', ' ') }}
|
|
·
|
|
<span class="lt-badge badge-resolved">{{ e.severity }}</span>
|
|
· duration
|
|
<span class="event-duration" data-first="{{ e.first_seen }}" data-resolved="{{ e.resolved_at }}">-</span>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</section>
|
|
{% endif %}
|
|
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
// Load once even when auto-refresh is set to Off
|
|
refreshModem();
|
|
|
|
// Start auto-refresh using saved settings interval (default 30 s)
|
|
const _savedInterval = window.gandalfSettings?.refreshInterval ?? 30;
|
|
if (_savedInterval > 0) lt.autoRefresh.start(refreshAll, _savedInterval * 1000);
|
|
|
|
// When settings change, restart auto-refresh with new interval
|
|
window.onGandalfSettingsChanged = function(s) {
|
|
lt.autoRefresh.stop();
|
|
if (s.refreshInterval > 0) lt.autoRefresh.start(refreshAll, s.refreshInterval * 1000);
|
|
};
|
|
|
|
function updateEventAges() {
|
|
document.querySelectorAll('.event-age[data-ts]').forEach(el => {
|
|
el.textContent = lt.time.ago(_toIso(el.dataset.ts));
|
|
});
|
|
}
|
|
|
|
updateEventAges();
|
|
setInterval(updateEventAges, 60000);
|
|
|
|
// ── Event duration (resolved_at - first_seen) ──────────────────
|
|
function fmtDuration(firstTs, resolvedTs) {
|
|
if (!firstTs || !resolvedTs) return '-';
|
|
const secs = Math.floor((new Date(_toIso(resolvedTs)) - new Date(_toIso(firstTs))) / 1000);
|
|
if (secs < 0) return '-';
|
|
if (secs < 60) return `${secs}s`;
|
|
if (secs < 3600) return `${Math.floor(secs/60)}m`;
|
|
if (secs < 86400) return `${Math.floor(secs/3600)}h ${Math.floor((secs%3600)/60)}m`;
|
|
return `${Math.floor(secs/86400)}d`;
|
|
}
|
|
|
|
document.querySelectorAll('.event-duration[data-first][data-resolved]').forEach(el => {
|
|
el.textContent = fmtDuration(el.dataset.first, el.dataset.resolved);
|
|
});
|
|
|
|
// ── Events table filter ────────────────────────────────────────
|
|
let _filterSev = '';
|
|
|
|
function applyEventsFilter() {
|
|
const q = (document.getElementById('events-search')?.value || '').toLowerCase();
|
|
const tbody = document.querySelector('#events-table tbody');
|
|
if (!tbody) return;
|
|
tbody.querySelectorAll('tr').forEach(row => {
|
|
if (row.children.length < 3) { row.style.display = ''; return; }
|
|
const sevMatch = !_filterSev || row.classList.contains(`row-${_filterSev}`);
|
|
const textMatch = !q || row.textContent.toLowerCase().includes(q);
|
|
row.style.display = (sevMatch && textMatch) ? '' : 'none';
|
|
});
|
|
}
|
|
|
|
document.getElementById('events-search')?.addEventListener('input', applyEventsFilter);
|
|
|
|
document.querySelector('.sev-pills')?.addEventListener('click', e => {
|
|
const pill = e.target.closest('.pill[data-sev]');
|
|
if (!pill) return;
|
|
document.querySelectorAll('.sev-pills .pill').forEach(p => {
|
|
p.classList.remove('active');
|
|
p.setAttribute('aria-pressed', 'false');
|
|
});
|
|
pill.classList.add('active');
|
|
pill.setAttribute('aria-pressed', 'true');
|
|
_filterSev = pill.dataset.sev;
|
|
applyEventsFilter();
|
|
});
|
|
|
|
// Re-apply filter after dynamic table updates
|
|
new MutationObserver(applyEventsFilter)
|
|
.observe(document.getElementById('events-table-wrap'), { childList: true, subtree: true });
|
|
|
|
initDeviceSearch();
|
|
|
|
// Stat card clicks: filter events table by severity
|
|
document.querySelectorAll('.lt-stat-card[data-stat-filter]').forEach(card => {
|
|
card.addEventListener('click', () => {
|
|
const sev = card.dataset.statFilter;
|
|
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}"]`);
|
|
if (matchPill) { matchPill.classList.add('active'); matchPill.setAttribute('aria-pressed', 'true'); }
|
|
_filterSev = sev;
|
|
applyEventsFilter();
|
|
document.getElementById('events-table-wrap')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
});
|
|
card.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); card.click(); } });
|
|
});
|
|
</script>
|
|
{% endblock %}
|