4cb36a47a9
Lint / Python (flake8) (push) Successful in 54s
Lint / JS (eslint) (push) Successful in 7s
Security / Python Security (bandit) (push) Failing after 40s
Test / Python Tests (pytest) (push) Successful in 50s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 3s
- Four lt-stat-card widgets (Critical, Warning, Hosts, Resolved 24h) below the status bar; Critical card pulses red when count > 0 - Clicking Critical or Warning card filters the events table by severity - Events table wrapped in lt-frame with ASCII corner ornaments and lt-section-header; filter bar moved to lt-toolbar with lt-search icon - Recently Resolved table replaced with lt-timeline component - updateStatusBar() and updateHostGrid() keep stat card values live Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
581 lines
27 KiB
HTML
581 lines
27 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}Dashboard – GANDALF{% endblock %}
|
||
|
||
{% block content %}
|
||
|
||
<!-- ── Status bar ──────────────────────────────────────────────────── -->
|
||
<div class="status-bar">
|
||
<div class="status-chips">
|
||
{% 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">
|
||
<span class="lt-stat-icon" aria-hidden="true" style="color:var(--red);text-shadow:var(--glow-red)">●</span>
|
||
<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-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">
|
||
<span class="lt-stat-icon" aria-hidden="true" style="color:var(--amber)">●</span>
|
||
<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-label">Warning</span>
|
||
</div>
|
||
</div>
|
||
<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>
|
||
<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-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" aria-hidden="true" style="color:var(--green);text-shadow:var(--glow)">✔</span>
|
||
<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-label">Resolved 24h</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Network topology + host grid ───────────────────────────────── -->
|
||
<section class="g-section">
|
||
<div class="g-section-header">
|
||
<h2 class="g-section-title">Network Hosts</h2>
|
||
</div>
|
||
|
||
<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>
|
||
|
||
<!-- WAN wire: cyan → green gradient, labeled -->
|
||
<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>
|
||
<span class="topo-vc-label">WAN · 10G SFP+</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" style="background:var(--amber);opacity:.6;"></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" style="background:var(--amber);opacity:.6;"></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" style="background:var(--border-color);opacity:.5;"></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" style="max-width:860px;">
|
||
|
||
<!-- 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 '' }}"
|
||
data-host="{{ hname }}" style="min-width:80px; max-width:96px;">
|
||
<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" style="border:1px dashed var(--border-color); padding:1px 5px; font-size:.56em; color:var(--text-muted);">dashed border = off-rack</div>
|
||
</div>
|
||
|
||
</div><!-- /topo-v2 -->
|
||
</div>
|
||
|
||
<!-- Host cards -->
|
||
<div class="host-grid" id="host-grid">
|
||
{% 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>
|
||
{% if suppressed %}
|
||
<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">ping-only / no node_exporter</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=""
|
||
title="Suppress alerts for this host">
|
||
🔕 Suppress
|
||
</button>
|
||
<a href="{{ url_for('links_page') }}#{{ name }}"
|
||
class="lt-btn lt-btn-secondary lt-btn-sm" style="text-decoration:none">
|
||
↗ Links
|
||
</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-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 }}"
|
||
data-sup-detail="">
|
||
🔕 Suppress
|
||
</button>
|
||
{% endif %}
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
{% endif %}
|
||
|
||
<!-- ── Active alerts ───────────────────────────────────────────────── -->
|
||
<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">
|
||
</div>
|
||
<div class="sev-pills">
|
||
<button type="button" class="pill active" data-sev="">All</button>
|
||
<button type="button" class="pill" data-sev="critical">Critical</button>
|
||
<button type="button" class="pill" data-sev="warning">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 — <a href="/api/events?limit=1000">view all via API</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 }}">
|
||
<td><span class="lt-badge badge-{{ e.severity }}">{{ e.severity }}</span></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>
|
||
|
||
<!-- ── 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 %}
|
||
|
||
<!-- ── Quick-suppress modal ─────────────────────────────────────────── -->
|
||
<div id="suppress-modal" class="lt-modal-overlay"
|
||
role="dialog" aria-modal="true" aria-labelledby="suppress-modal-title" aria-hidden="true">
|
||
<div class="lt-modal">
|
||
<div class="lt-modal-header">
|
||
<h3 class="lt-modal-title" id="suppress-modal-title">Suppress Alert</h3>
|
||
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||
</div>
|
||
<form id="suppress-form">
|
||
<div class="lt-modal-body">
|
||
<div class="lt-form-group" style="margin-bottom:12px">
|
||
<label class="lt-label" for="sup-type">Target Type</label>
|
||
<select class="lt-select" id="sup-type" name="target_type">
|
||
<option value="host">Host (all interfaces)</option>
|
||
<option value="interface">Specific Interface</option>
|
||
<option value="unifi_device">UniFi Device</option>
|
||
<option value="all">Global Maintenance</option>
|
||
</select>
|
||
</div>
|
||
<div class="lt-form-group" id="sup-name-group" style="margin-bottom:12px">
|
||
<label class="lt-label" for="sup-name">Target Name</label>
|
||
<input type="text" class="lt-input" id="sup-name" name="target_name" placeholder="e.g. large1">
|
||
</div>
|
||
<div class="lt-form-group" id="sup-detail-group" style="margin-bottom:12px;display:none">
|
||
<label class="lt-label" for="sup-detail">Interface <span class="lt-field-hint">(interface type only)</span></label>
|
||
<input type="text" class="lt-input" id="sup-detail" name="target_detail" placeholder="e.g. enp35s0">
|
||
</div>
|
||
<div class="lt-form-group" style="margin-bottom:12px">
|
||
<label class="lt-label" for="sup-reason">Reason <span class="required">*</span></label>
|
||
<input type="text" class="lt-input" id="sup-reason" name="reason"
|
||
placeholder="e.g. Planned switch reboot" required>
|
||
</div>
|
||
<div class="lt-form-group" style="margin-bottom:0">
|
||
<label class="lt-label">Duration</label>
|
||
<div class="duration-pills">
|
||
<button type="button" class="pill" data-duration="30">30 min</button>
|
||
<button type="button" class="pill" data-duration="60">1 hr</button>
|
||
<button type="button" class="pill" data-duration="240">4 hr</button>
|
||
<button type="button" class="pill" data-duration="480">8 hr</button>
|
||
<button type="button" class="pill pill-manual active" data-duration="">Manual ∞</button>
|
||
</div>
|
||
<input type="hidden" id="sup-expires" name="expires_minutes" value="">
|
||
<div class="lt-field-hint" id="duration-hint">Persists until manually removed.</div>
|
||
</div>
|
||
</div>
|
||
<div class="lt-modal-footer">
|
||
<button type="button" class="lt-btn lt-btn-secondary" data-modal-close>Cancel</button>
|
||
<button type="submit" class="lt-btn lt-btn-primary">Apply</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
{% endblock %}
|
||
|
||
{% block scripts %}
|
||
<script>
|
||
// Start auto-refresh using saved settings interval (default 30 s)
|
||
var _savedInterval = (window.gandalfSettings && 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);
|
||
};
|
||
|
||
document.getElementById('suppress-form')?.addEventListener('submit', submitSuppress);
|
||
document.getElementById('sup-type')?.addEventListener('change', updateSuppressForm);
|
||
|
||
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'));
|
||
pill.classList.add('active');
|
||
_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 });
|
||
|
||
// 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'));
|
||
const matchPill = document.querySelector(`.sev-pills .pill[data-sev="${sev}"]`);
|
||
if (matchPill) matchPill.classList.add('active');
|
||
_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 %}
|