2026-03-01 23:03:18 -05:00
{% extends "base.html" %}
{% block title %}Dashboard – GANDALF{% endblock %}
{% block content %}
2026-03-02 12:43:11 -05:00
<!-- ── Status bar ──────────────────────────────────────────────────── -->
2026-03-01 23:03:18 -05:00
< div class = "status-bar" >
2026-05-10 23:53:17 -04:00
< div class = "status-chips" id = "status-chips" aria-live = "polite" aria-atomic = "true" >
2026-04-30 21:09:56 -04:00
{% if not daemon_ok %}
< span class = "chip chip-critical" > ⚠ MONITOR OFFLINE< / span >
{% endif %}
2026-03-01 23:03:18 -05:00
{% if summary.critical %}
2026-03-02 12:43:11 -05:00
< span class = "chip chip-critical" > ● {{ summary.critical }} CRITICAL< / span >
2026-03-01 23:03:18 -05:00
{% endif %}
{% if summary.warning %}
2026-03-02 12:43:11 -05:00
< span class = "chip chip-warning" > ● {{ summary.warning }} WARNING< / span >
2026-03-01 23:03:18 -05:00
{% endif %}
2026-04-30 21:09:56 -04:00
{% if daemon_ok and not summary.critical and not summary.warning %}
2026-03-02 12:43:11 -05:00
< span class = "chip chip-ok" > ✔ ALL SYSTEMS NOMINAL< / span >
2026-03-01 23:03:18 -05:00
{% endif %}
< / div >
< div class = "status-meta" >
2026-04-30 21:09:56 -04:00
< 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 >
2026-03-01 23:03:18 -05:00
< / div >
< / div >
2026-04-30 22:19:50 -04:00
<!-- ── 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"
2026-05-10 23:53:17 -04:00
data-stat-filter = "critical" aria-label = "{{ summary.critical or 0 }} critical alerts"
aria-controls = "events-table-wrap" >
2026-05-10 23:17:22 -04:00
< span class = "lt-stat-icon lt-text-red" aria-hidden = "true" > ●< / span >
2026-04-30 22:19:50 -04:00
< div class = "lt-stat-info" >
2026-05-10 23:17:22 -04:00
< span class = "lt-stat-value lt-text-red" id = "stat-critical-val" > {{ summary.critical or 0 }}< / span >
2026-04-30 22:19:50 -04:00
< span class = "lt-stat-label" > Critical< / span >
< / div >
< / div >
< div class = "lt-stat-card"
id = "stat-warning" role = "button" tabindex = "0"
2026-05-10 23:53:17 -04:00
data-stat-filter = "warning" aria-label = "{{ summary.warning or 0 }} warning alerts"
aria-controls = "events-table-wrap" >
2026-05-10 23:17:22 -04:00
< span class = "lt-stat-icon lt-text-amber" aria-hidden = "true" > ●< / span >
2026-04-30 22:19:50 -04:00
< div class = "lt-stat-info" >
2026-05-10 23:17:22 -04:00
< span class = "lt-stat-value lt-text-amber" id = "stat-warning-val" > {{ summary.warning or 0 }}< / span >
2026-04-30 22:19:50 -04:00
< span class = "lt-stat-label" > Warning< / span >
< / div >
< / div >
< div class = "lt-stat-card" id = "stat-hosts" aria-label = "Monitored hosts" >
2026-05-10 23:17:22 -04:00
< span class = "lt-stat-icon lt-text-cyan" aria-hidden = "true" > ⬡< / span >
2026-04-30 22:19:50 -04:00
< div class = "lt-stat-info" >
2026-05-10 23:17:22 -04:00
< span class = "lt-stat-value lt-text-cyan" id = "stat-hosts-val" > {{ snapshot.hosts | length }}< / span >
2026-04-30 22:19:50 -04:00
< 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" >
2026-05-10 23:17:22 -04:00
< span class = "lt-stat-icon lt-text-green" aria-hidden = "true" > ✔< / span >
2026-04-30 22:19:50 -04:00
< div class = "lt-stat-info" >
2026-05-10 23:17:22 -04:00
< span class = "lt-stat-value lt-text-green" id = "stat-resolved-val" > {{ recent_resolved | length }}< / span >
2026-04-30 22:19:50 -04:00
< span class = "lt-stat-label" > Resolved 24h< / span >
< / div >
< / div >
< / div >
2026-05-10 23:07:53 -04:00
<!-- ── 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"
2026-05-10 23:34:16 -04:00
placeholder = "Filter by target, type, description…" autocomplete = "off"
aria-label = "Filter active alerts" >
2026-05-10 23:07:53 -04:00
< / div >
2026-05-10 23:34:16 -04:00
< 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 >
2026-05-10 23:07:53 -04:00
< / 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 %}
2026-05-11 12:01:56 -04:00
< 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 >
2026-05-10 23:07:53 -04:00
{% 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' %}
2026-05-10 23:11:15 -04:00
< 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 >
2026-05-10 23:07:53 -04:00
< 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 >
2026-03-02 12:43:11 -05:00
<!-- ── Network topology + host grid ───────────────────────────────── -->
2026-04-18 21:01:20 -04:00
< section class = "g-section" >
< div class = "g-section-header" >
< h2 class = "g-section-title" > Network Hosts< / h2 >
2026-05-10 23:07:53 -04:00
< button type = "button" class = "topo-collapse-btn" id = "topo-toggle-btn"
aria-expanded = "true" aria-controls = "topo-collapsible-wrap" > ▴ Collapse< / button >
2026-03-02 12:43:11 -05:00
< / div >
2026-05-10 23:07:53 -04:00
< div class = "topo-collapsible" id = "topo-collapsible-wrap" >
2026-03-01 23:03:18 -05:00
< div class = "topology" id = "topology-diagram" >
2026-03-14 22:22:19 -04:00
< div class = "topo-v2" >
2026-03-14 22:06:03 -04:00
2026-03-14 22:22:19 -04:00
{%- 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 >
2026-03-02 12:43:11 -05:00
< / div >
2026-03-01 23:03:18 -05:00
< / div >
2026-03-14 22:22:19 -04:00
2026-05-10 23:13:24 -04:00
<!-- WAN wire: cyan → WAN gradient -->
2026-03-14 22:22:19 -04:00
< div class = "topo-vc" >
2026-05-10 23:13:24 -04:00
< div class = "topo-vc-wire topo-vc-wire--wan" > < / div >
2026-03-14 22:22:19 -04:00
< span class = "topo-vc-label" > WAN · 10G SFP+< / span >
2026-03-01 23:03:18 -05:00
< / div >
2026-03-14 22:06:03 -04:00
2026-03-14 22:22:19 -04:00
<!-- ══════════════════════════════════════════════════════════════
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 >
2026-03-01 23:03:18 -05:00
< / div >
< / div >
2026-03-14 22:22:19 -04:00
2026-03-14 22:42:38 -04:00
<!-- UDM - Pro → USW - Agg (10G SFP+) -->
< div class = "topo-vc" >
2026-05-10 23:13:24 -04:00
< div class = "topo-vc-wire topo-vc-wire--10g" > < / div >
2026-03-14 22:42:38 -04:00
< span class = "topo-vc-label" > 10G SFP+< / span >
2026-03-01 23:03:18 -05:00
< / div >
2026-03-14 22:06:03 -04:00
2026-03-14 22:22:19 -04:00
<!-- ══════════════════════════════════════════════════════════════
2026-03-14 22:42:38 -04:00
TIER 3: USW - Aggregation
2026-03-14 22:22:19 -04:00
══════════════════════════════════════════════════════════ -->
2026-03-14 22:42:38 -04:00
< 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 >
2026-03-14 22:35:02 -04:00
< / div >
2026-03-14 22:42:38 -04:00
< / div >
2026-03-14 22:22:19 -04:00
2026-03-14 22:42:38 -04:00
<!-- USW - Agg → Pro 24 PoE (10G trunk) -->
< div class = "topo-vc" >
2026-05-10 23:13:24 -04:00
< div class = "topo-vc-wire topo-vc-wire--10g" > < / div >
2026-03-14 22:42:38 -04:00
< span class = "topo-vc-label" > 10G trunk< / span >
< / div >
2026-03-14 22:35:02 -04:00
2026-03-14 22:42:38 -04:00
<!-- ══════════════════════════════════════════════════════════════
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 >
2026-03-14 22:35:02 -04:00
< / div >
2026-03-14 22:42:38 -04:00
<!-- Pro 24 PoE → host bus section -->
< div class = "topo-vc" >
2026-05-10 23:13:24 -04:00
< div class = "topo-vc-wire topo-vc-wire--mgmt" > < / div >
2026-03-01 23:03:18 -05:00
< / div >
2026-03-14 22:06:03 -04:00
2026-03-14 22:22:19 -04:00
<!-- ══════════════════════════════════════════════════════════════
TIER 4 connecting bus – two rails (10G green + 1G amber dashed)
showing dual - homing for all 6 servers
══════════════════════════════════════════════════════════ -->
2026-05-10 23:15:15 -04:00
< div class = "topo-bus-section" >
2026-03-14 22:06:03 -04:00
2026-03-14 22:22:19 -04:00
<!-- 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 >
2026-03-14 22:08:48 -04:00
2026-03-14 22:22:19 -04:00
<!-- ── 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" >
2026-04-29 23:37:47 -04:00
< 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 >
2026-03-14 22:22:19 -04:00
< / div >
<!-- host box -->
2026-05-10 23:13:24 -04:00
< 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 }}" >
2026-03-14 22:22:19 -04:00
< 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 >
2026-03-14 22:06:03 -04:00
< span class = "topo-badge topo-badge-{{ st }}" > {{ st if st != 'unknown' else '– ' }}< / span >
< / div >
< / div >
2026-03-14 22:22:19 -04:00
{%- endfor -%}
2026-03-01 23:03:18 -05:00
< / div >
2026-03-14 22:22:19 -04:00
< / 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 >
2026-05-10 23:09:25 -04:00
< div class = "topo-legend-item topo-legend-item--offrack" > dashed border = off-rack< / div >
2026-03-14 22:22:19 -04:00
< / div >
< / div > <!-- /topo - v2 -->
2026-03-01 23:03:18 -05:00
< / div >
<!-- Host cards -->
2026-05-10 23:07:53 -04:00
< div class = "lt-toolbar" id = "host-toolbar" >
2026-05-07 18:36:57 -04:00
< div class = "lt-toolbar-left" >
< div class = "lt-search" >
2026-05-10 23:17:22 -04:00
< input type = "search" class = "lt-input lt-search-input lt-search-input--sm" id = "host-search"
2026-05-10 23:34:16 -04:00
placeholder = "Filter hosts…" autocomplete = "off" aria-label = "Filter hosts" >
2026-05-07 18:36:57 -04:00
< / div >
< / div >
< / div >
2026-03-01 23:03:18 -05:00
< div class = "host-grid" id = "host-grid" >
2026-05-13 13:12:25 -04:00
{%- set has_global_sup = suppressions | selectattr('target_type', 'equalto', 'all') | list | length > 0 -%}
2026-03-01 23:03:18 -05:00
{% 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 >
< span class = "host-name" > {{ name }}< / span >
2026-05-13 13:12:25 -04:00
{% if suppressed or has_global_sup %}
2026-03-02 12:43:11 -05:00
< span class = "badge-suppressed" title = "Suppressed" > 🔕< / span >
2026-03-01 23:03:18 -05:00
{% endif %}
< / div >
< div class = "host-meta" >
< span class = "host-ip" > {{ host.ip }}< / span >
< span class = "host-source source-{{ host.source }}" > {{ host.source }}< / span >
2025-01-04 01:27:49 -05:00
< / div >
2026-03-01 23:03:18 -05:00
< / div >
2025-01-04 01:27:49 -05:00
2026-03-01 23:03:18 -05:00
{% 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 >
2025-02-07 20:58:23 -05:00
< / div >
2026-03-01 23:03:18 -05:00
{% endfor %}
< / div >
{% else %}
2026-03-02 12:43:11 -05:00
< div class = "host-ping-note" > ping-only / no node_exporter< / div >
2026-03-01 23:03:18 -05:00
{% endif %}
< div class = "host-actions" >
2026-04-18 21:01:20 -04:00
< button class = "lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
2026-03-03 15:39:48 -05:00
data-sup-type = "host"
data-sup-name = "{{ name }}"
data-sup-detail = ""
2026-05-10 23:34:16 -04:00
aria-label = "Suppress alerts for {{ name }}" >
2026-03-02 12:43:11 -05:00
🔕 Suppress
2026-03-01 23:03:18 -05:00
< / button >
2026-03-02 12:43:11 -05:00
< a href = "{{ url_for('links_page') }}#{{ name }}"
2026-05-10 23:07:53 -04:00
class = "lt-btn lt-btn-secondary lt-btn-sm" >
2026-03-02 12:43:11 -05:00
↗ Links
< / a >
2026-03-01 23:03:18 -05:00
< / div >
2025-01-04 00:33:04 -05:00
< / div >
2026-03-01 23:03:18 -05:00
{% else %}
2026-04-30 21:09:56 -04:00
< 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 >
2026-03-01 23:03:18 -05:00
{% endfor %}
< / div >
2026-05-10 23:07:53 -04:00
< / div > <!-- /#topo - collapsible - wrap -->
2026-03-01 23:03:18 -05:00
< / section >
2026-03-02 12:43:11 -05:00
<!-- ── UniFi devices ────────────────────────────────────────────────── -->
2026-03-01 23:03:18 -05:00
{% if snapshot.unifi %}
2026-04-18 21:01:20 -04:00
< section class = "g-section" >
< div class = "g-section-header" >
< h2 class = "g-section-title" > UniFi Devices< / h2 >
2026-03-02 12:43:11 -05:00
< / div >
2026-05-01 17:39:11 -04:00
< 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 > < strong > {{ d.name }}< / strong > < / 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 }}"
2026-05-10 23:34:16 -04:00
data-sup-detail = ""
aria-label = "Suppress alerts for {{ d.name }}" >
2026-05-01 17:39:11 -04:00
🔕 Suppress
< / button >
{% endif %}
< / td >
< / tr >
{% endfor %}
< / tbody >
< / table >
< / div >
2026-03-01 23:03:18 -05:00
< / div >
< / section >
{% endif %}
2026-03-14 21:48:40 -04:00
<!-- ── Recently Resolved (last 24h) ───────────────────────────────── -->
{% if recent_resolved %}
2026-04-18 21:01:20 -04:00
< 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 >
2026-03-14 21:48:40 -04:00
< / div >
2026-04-30 22:19:50 -04:00
< 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 %}
2026-03-14 21:48:40 -04:00
< / div >
< / section >
{% endif %}
2026-03-01 23:03:18 -05:00
{% endblock %}
{% block scripts %}
< script >
2026-04-30 21:33:02 -04:00
// Start auto-refresh using saved settings interval (default 30 s)
2026-05-13 13:19:44 -04:00
const _savedInterval = window . gandalfSettings ? . refreshInterval ? ? 30 ;
2026-04-30 21:33:02 -04:00
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 ) ;
} ;
2026-05-10 23:07:53 -04:00
// ── Topology collapse toggle ───────────────────────────────────
( function ( ) {
2026-05-10 23:37:32 -04:00
const LS _KEY = 'gandalf_topo_collapsed' ;
const btn = document . getElementById ( 'topo-toggle-btn' ) ;
const wrap = document . getElementById ( 'topo-collapsible-wrap' ) ;
2026-05-10 23:07:53 -04:00
if ( ! btn || ! wrap ) return ;
function setCollapsed ( v ) {
wrap . classList . toggle ( 'is-collapsed' , v ) ;
2026-05-10 23:53:17 -04:00
wrap . setAttribute ( 'aria-hidden' , v ? 'true' : 'false' ) ;
2026-05-10 23:07:53 -04:00
btn . setAttribute ( 'aria-expanded' , v ? 'false' : 'true' ) ;
btn . textContent = v ? '▾ Expand' : '▴ Collapse' ;
try { localStorage . setItem ( LS _KEY , v ? '1' : '0' ) ; } catch ( _ ) { }
}
2026-05-10 23:37:32 -04:00
let saved = false ;
2026-05-10 23:07:53 -04:00
try { saved = localStorage . getItem ( LS _KEY ) === '1' ; } catch ( _ ) { }
setCollapsed ( saved ) ;
btn . addEventListener ( 'click' , function ( ) {
setCollapsed ( ! wrap . classList . contains ( 'is-collapsed' ) ) ;
} ) ;
} ) ( ) ;
2026-03-14 21:46:11 -04:00
function updateEventAges ( ) {
document . querySelectorAll ( '.event-age[data-ts]' ) . forEach ( el => {
2026-04-18 23:46:44 -04:00
el . textContent = lt . time . ago ( _toIso ( el . dataset . ts ) ) ;
2026-03-14 21:46:11 -04:00
} ) ;
}
updateEventAges ( ) ;
setInterval ( updateEventAges , 60000 ) ;
2026-03-14 21:48:40 -04:00
// ── Event duration (resolved_at - first_seen) ──────────────────
function fmtDuration ( firstTs , resolvedTs ) {
if ( ! firstTs || ! resolvedTs ) return '– ' ;
2026-04-18 23:46:44 -04:00
const secs = Math . floor ( ( new Date ( _toIso ( resolvedTs ) ) - new Date ( _toIso ( firstTs ) ) ) / 1000 ) ;
2026-03-14 21:48:40 -04:00
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 ) ;
} ) ;
2026-04-19 23:35:02 -04:00
// ── 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 ;
2026-05-10 23:34:16 -04:00
document . querySelectorAll ( '.sev-pills .pill' ) . forEach ( p => {
p . classList . remove ( 'active' ) ;
p . setAttribute ( 'aria-pressed' , 'false' ) ;
} ) ;
2026-04-19 23:35:02 -04:00
pill . classList . add ( 'active' ) ;
2026-05-10 23:34:16 -04:00
pill . setAttribute ( 'aria-pressed' , 'true' ) ;
2026-04-19 23:35:02 -04:00
_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 } ) ;
2026-04-30 22:19:50 -04:00
2026-05-07 18:36:57 -04:00
// Host grid search filter
document . getElementById ( 'host-search' ) ? . addEventListener ( 'input' , function ( ) {
const q = this . value . trim ( ) . toLowerCase ( ) ;
document . querySelectorAll ( '#host-grid .host-card' ) . forEach ( card => {
const name = ( card . dataset . host || '' ) . toLowerCase ( ) ;
card . style . display = ( ! q || name . includes ( q ) ) ? '' : 'none' ;
} ) ;
} ) ;
2026-04-30 22:19:50 -04:00
// 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 ;
2026-05-10 23:34:16 -04:00
document . querySelectorAll ( '.sev-pills .pill' ) . forEach ( p => {
p . classList . remove ( 'active' ) ;
p . setAttribute ( 'aria-pressed' , 'false' ) ;
} ) ;
2026-04-30 22:19:50 -04:00
const matchPill = document . querySelector ( ` .sev-pills .pill[data-sev=" ${ sev } "] ` ) ;
2026-05-10 23:34:16 -04:00
if ( matchPill ) { matchPill . classList . add ( 'active' ) ; matchPill . setAttribute ( 'aria-pressed' , 'true' ) ; }
2026-04-30 22:19:50 -04:00
_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 ( ) ; } } ) ;
} ) ;
2026-03-01 23:03:18 -05:00
< / script >
{% endblock %}