Files
gandalf/templates/index.html
T
jaredandClaude Opus 5.5 e32499b7f6
Lint / Python (flake8) (pull_request) Successful in 2m6s
Lint / JS (eslint) (pull_request) Successful in 18s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 2m11s
Test / Python Tests (pytest) (pull_request) Successful in 2m20s
Lint / JS (eslint) (push) Successful in 10s
Lint / Python (flake8) (push) Successful in 1m58s
Lint / Notify on failure (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m58s
Lint / Deploy (push) Successful in 4s
Test / Python Tests (pytest) (push) Successful in 58s
Add AT&T modem monitoring and device pages; replace Link Debug
Modem and WAN:
- Poll the BGW320 status pages read-only for light levels, PON and WAN
  state, with a WAN / Fiber panel, 7-day Rx trend, and alerts on WAN down,
  optical limits, and Rx drift
- Read WAN outages and Smart Power restarts from the UniFi system log,
  since outages can be over between 5 minute polls

Device pages:
- Add device search to the dashboard and a page per host, switch, router,
  AP, PDU, or modem
- Move all Link Debug data into expandable rows there; /links redirects
- Collect UDM ports and UniFi device details (firmware, load, WAN, outlets)
- Reorder the dashboard

Monitoring fixes:
- Alert when a configured host stops reporting: its series disappear
  rather than reporting down, so a dead host never raised an alert
- Alert on Prometheus outages instead of blanking the dashboard
- Treat a failed Pulse ping as unknown rather than host unreachable
- Stop new switch ports reporting lifetime bytes as one interval's rate
- Run database sessions in UTC: other server time zones shifted displayed
  ages and timed suppression expiries

Also log every avatar 404 reason, add a favicon, and correct the WAN link
labels (1G on Port 9, not 10G SFP+).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:40:17 -05:00

623 lines
29 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Dashboard – GANDALF{% 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 %}
</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">&#x255A;</span>
<span class="lt-frame-br">&#x255D;</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 %}
</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>
<!-- ── 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">&#x255A;</span>
<span class="lt-frame-br">&#x255D;</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 %}
<!-- ── Network topology ───────────────────────────────── -->
<section class="g-section">
<div class="g-section-header">
<h2 class="g-section-title">Network Topology</h2>
<button type="button" class="topo-collapse-btn" id="topo-toggle-btn"
aria-expanded="true" aria-controls="topo-collapsible-wrap">&#x25B4; Collapse</button>
</div>
<div class="topo-collapsible" id="topo-collapsible-wrap">
<div class="topology" id="topology-diagram">
<div class="topo-v2">
{%- set topo_h = snapshot.hosts if snapshot.hosts else {} -%}
<!-- ══════════════════════════════════════════════════════════════
TIER 1: Internet (WAN edge)
══════════════════════════════════════════════════════════ -->
<div class="topo-tier">
<div class="topo-v2-node topo-v2-internet">
<span class="topo-v2-icon">◈</span>
<span class="topo-v2-label">INTERNET</span>
<span class="topo-v2-sub">WAN uplink</span>
</div>
</div>
<!-- Fiber wire: Internet → AT&T gateway -->
<div class="topo-vc">
<div class="topo-vc-wire topo-vc-wire--wan"></div>
<span class="topo-vc-label">AT&amp;T Fiber</span>
</div>
<!-- ══════════════════════════════════════════════════════════════
TIER 1b: AT&T gateway (Rx filled in by updateModem)
══════════════════════════════════════════════════════════ -->
<div class="topo-tier">
<div class="topo-v2-node topo-v2-router" id="topo-modem">
<span class="topo-v2-icon">◈</span>
<span class="topo-v2-label">BGW320</span>
<span class="topo-v2-sub">AT&amp;T Gateway</span>
<span class="topo-v2-sub" id="topo-modem-rx">Rx –</span>
</div>
</div>
<!-- WAN wire: gateway → UDM-Pro -->
<div class="topo-vc">
<div class="topo-vc-wire topo-vc-wire--wan"></div>
<span class="topo-vc-label">WAN · Port 9 · 1G</span>
</div>
<!-- ══════════════════════════════════════════════════════════════
TIER 2: Router – UDM-Pro
══════════════════════════════════════════════════════════ -->
<div class="topo-tier">
<div class="topo-v2-node topo-v2-router">
<span class="topo-v2-icon">⬡</span>
<span class="topo-v2-label">UDM-Pro</span>
<span class="topo-v2-sub">Dream Machine Pro</span>
<span class="topo-v2-sub">RU24</span>
</div>
</div>
<!-- UDM-Pro → USW-Agg (10G SFP+) -->
<div class="topo-vc">
<div class="topo-vc-wire topo-vc-wire--10g"></div>
<span class="topo-vc-label">10G SFP+</span>
</div>
<!-- ══════════════════════════════════════════════════════════════
TIER 3: USW-Aggregation
══════════════════════════════════════════════════════════ -->
<div class="topo-tier">
<div class="topo-v2-node topo-v2-switch" id="topo-switch-agg">
<span class="topo-v2-icon">⬡</span>
<span class="topo-v2-label">USW-Agg</span>
<span class="topo-v2-sub">Aggregation · RU22</span>
<span class="topo-v2-sub">8 × 10G SFP+</span>
<span class="topo-v2-vlan">VLAN90 · 10.10.90.x/24</span>
</div>
</div>
<!-- USW-Agg → Pro 24 PoE (10G trunk) -->
<div class="topo-vc">
<div class="topo-vc-wire topo-vc-wire--10g"></div>
<span class="topo-vc-label">10G trunk</span>
</div>
<!-- ══════════════════════════════════════════════════════════════
TIER 4: Pro 24 PoE
══════════════════════════════════════════════════════════ -->
<div class="topo-tier">
<div class="topo-v2-node topo-v2-switch" id="topo-switch-poe">
<span class="topo-v2-icon">⬡</span>
<span class="topo-v2-label">Pro 24 PoE</span>
<span class="topo-v2-sub">24-Port · RU23</span>
<span class="topo-v2-sub">24 × 1G PoE</span>
<span class="topo-v2-vlan">DHCP · mgmt</span>
</div>
</div>
<!-- Pro 24 PoE → host bus section -->
<div class="topo-vc">
<div class="topo-vc-wire topo-vc-wire--mgmt"></div>
</div>
<!-- ══════════════════════════════════════════════════════════════
TIER 4 connecting bus – two rails (10G green + 1G amber dashed)
showing dual-homing for all 6 servers
══════════════════════════════════════════════════════════ -->
<div class="topo-bus-section">
<!-- 10G storage bus (Agg → VLAN90) -->
<div class="topo-bus-10g">
<span class="topo-bus-10g-label">← USW-Agg · 10G SFP+ · VLAN90 →</span>
<div class="topo-bus-10g-line"></div>
</div>
<!-- 1G management bus (PoE → DHCP) -->
<div class="topo-bus-1g">
<span class="topo-bus-1g-label">← Pro 24 PoE · 1G · DHCP mgmt →</span>
<div class="topo-bus-1g-line"></div>
</div>
<!-- ── Host nodes with drop wires ── -->
<div class="topo-v2-hosts">
{%- set all_defs = [
('compute-storage-gpu-01', 'csg-01', 'RU4–12', 'Ceph · VLAN90', False),
('compute-storage-01', 'cs-01', 'RU14–17', 'Ceph · VLAN90', False),
('storage-01', 'sto-01', 'rack', 'Ceph · VLAN90', False),
('monitor-01', 'mon-01', 'ZimaBoard', 'mgmt', False),
('monitor-02', 'mon-02', 'ZimaBoard', 'mgmt', False),
('large1', 'large1', 'off-rack', 'table', True),
] -%}
{%- for hname, hlabel, hsub, hvlan, off_rack in all_defs -%}
{%- set st = topo_h[hname].status if hname in topo_h else 'unknown' -%}
<div class="topo-v2-host-wrap">
<!-- dual-homing wires: 10G solid green + 1G dashed amber -->
<div class="topo-v2-host-wires">
<div class="topo-v2-wire-10g" data-host="{{ hname }}" title="10G SFP+ → USW-Agg"></div>
<div class="topo-v2-wire-1g" data-host="{{ hname }}" title="1G → Pro 24 PoE"></div>
</div>
<!-- host box -->
<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 }}">
<span class="topo-v2-icon">▣</span>
<span class="topo-v2-label">{{ hlabel }}</span>
<span class="topo-v2-sub">{{ hsub }}</span>
<span class="topo-v2-vlan">{{ hvlan }}</span>
<span class="topo-badge topo-badge-{{ st }}">{{ st if st != 'unknown' else '–' }}</span>
</div>
</div>
{%- endfor -%}
</div>
</div><!-- /topo-bus-section -->
<!-- ── Legend ── -->
<div class="topo-legend">
<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-1g"></span> 1G DHCP (mgmt)</div>
<div class="topo-legend-item topo-legend-item--offrack">dashed border = off-rack</div>
</div>
</div><!-- /topo-v2 -->
</div>
</div><!-- /#topo-collapsible-wrap -->
</section>
<!-- ── 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('_', ' ') }}
&nbsp;·&nbsp;
<span class="lt-badge badge-resolved">{{ e.severity }}</span>
&nbsp;·&nbsp; 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);
};
// ── Topology collapse toggle ───────────────────────────────────
(function() {
const LS_KEY = 'gandalf_topo_collapsed';
const btn = document.getElementById('topo-toggle-btn');
const wrap = document.getElementById('topo-collapsible-wrap');
if (!btn || !wrap) return;
function setCollapsed(v) {
wrap.classList.toggle('is-collapsed', v);
wrap.setAttribute('aria-hidden', v ? 'true' : 'false');
btn.setAttribute('aria-expanded', v ? 'false' : 'true');
btn.textContent = v ? '▾ Expand' : '▴ Collapse';
try { localStorage.setItem(LS_KEY, v ? '1' : '0'); } catch(_) {}
}
let saved = false;
try { saved = localStorage.getItem(LS_KEY) === '1'; } catch(_) {}
setCollapsed(saved);
btn.addEventListener('click', function() {
setCollapsed(!wrap.classList.contains('is-collapsed'));
});
})();
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();
// Topology hosts open their device page
document.getElementById('topology-diagram')?.addEventListener('click', e => {
const node = e.target.closest('.topo-host[data-host]');
if (node) window.location.href = deviceUrl(node.dataset.host);
});
// 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 %}