Restructure app to use LotusGuild Terminal Design System v1.2
Lint / Python (flake8) (push) Failing after 45s
Lint / JS (eslint) (push) Successful in 7s
Security / Python Security (bandit) (push) Failing after 1m22s
Test / Python Tests (pytest) (push) Successful in 51s
Lint / Notify on failure (push) Successful in 2s
Lint / Deploy (push) Has been skipped

Replace custom phosphor-green terminal aesthetic with the lt-* component
system from base.css/base.js. All templates now inherit the LotusGuild
multi-accent Anduril palette via variable aliases in style.css, and use
lt-header, lt-nav, lt-card, lt-table, lt-btn, lt-modal, lt-badge etc.
Custom components (topology, inspector chassis, link debug, SFP panels)
are preserved with color values updated to base.css palette variables.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 21:01:20 -04:00
parent 2c4e8fcfda
commit e8de40250a
7 changed files with 1037 additions and 2438 deletions
+7 -6
View File
@@ -78,7 +78,7 @@ function updateStatusBar(summary, lastCheck) {
staleBanner = document.createElement('div'); staleBanner = document.createElement('div');
staleBanner.id = 'stale-banner'; staleBanner.id = 'stale-banner';
staleBanner.className = 'stale-banner'; staleBanner.className = 'stale-banner';
document.querySelector('.main').prepend(staleBanner); document.querySelector('.lt-main').prepend(staleBanner);
} }
const mins = Math.floor(checkAge / 60); const mins = Math.floor(checkAge / 60);
staleBanner.textContent = `⚠ Monitoring data is stale — last check was ${mins} minute${mins !== 1 ? 's' : ''} ago. The monitor daemon may be down.`; staleBanner.textContent = `⚠ Monitoring data is stale — last check was ${mins} minute${mins !== 1 ? 's' : ''} ago. The monitor daemon may be down.`;
@@ -144,7 +144,7 @@ function updateUnifiTable(devices) {
const dotClass = d.connected ? 'dot-up' : 'dot-down'; const dotClass = d.connected ? 'dot-up' : 'dot-down';
const statusText = d.connected ? 'Online' : 'Offline'; const statusText = d.connected ? 'Online' : 'Offline';
const suppressBtn = !d.connected const suppressBtn = !d.connected
? `<button class="btn-sm btn-suppress" ? `<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
data-sup-type="unifi_device" data-sup-type="unifi_device"
data-sup-name="${escHtml(d.name)}" data-sup-name="${escHtml(d.name)}"
data-sup-detail="">🔕 Suppress</button>` data-sup-detail="">🔕 Suppress</button>`
@@ -188,7 +188,7 @@ function updateEventsTable(events, totalActive) {
: ''; : '';
return ` return `
<tr class="row-${e.severity}"> <tr class="row-${e.severity}">
<td><span class="badge badge-${e.severity}">${e.severity}</span></td> <td><span class="lt-badge badge-${e.severity}">${e.severity}</span></td>
<td>${escHtml(e.event_type.replace(/_/g,' '))}</td> <td>${escHtml(e.event_type.replace(/_/g,' '))}</td>
<td><strong>${escHtml(e.target_name)}</strong></td> <td><strong>${escHtml(e.target_name)}</strong></td>
<td>${escHtml(e.target_detail || '')}</td> <td>${escHtml(e.target_detail || '')}</td>
@@ -198,7 +198,7 @@ function updateEventsTable(events, totalActive) {
<td>${e.consecutive_failures}</td> <td>${e.consecutive_failures}</td>
<td>${ticket}</td> <td>${ticket}</td>
<td> <td>
<button class="btn-sm btn-suppress" <button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
data-sup-type="${escHtml(supType)}" data-sup-type="${escHtml(supType)}"
data-sup-name="${escHtml(e.target_name)}" data-sup-name="${escHtml(e.target_name)}"
data-sup-detail="${escHtml(e.target_detail||'')}">🔕</button> data-sup-detail="${escHtml(e.target_detail||'')}">🔕</button>
@@ -208,8 +208,9 @@ function updateEventsTable(events, totalActive) {
wrap.innerHTML = ` wrap.innerHTML = `
${countNotice} ${countNotice}
<div class="table-wrap"> <div class="lt-table-wrap">
<table class="data-table" id="events-table"> <table class="lt-table" id="events-table">
<caption class="lt-sr-only">Active network alerts</caption>
<thead> <thead>
<tr> <tr>
<th>Sev</th><th>Type</th><th>Target</th><th>Detail</th> <th>Sev</th><th>Type</th><th>Target</th><th>Detail</th>
+588 -1894
View File
File diff suppressed because it is too large Load Diff
+24 -14
View File
@@ -1,47 +1,52 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" data-theme="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#030508">
<title>{% block title %}GANDALF{% endblock %}</title> <title>{% block title %}GANDALF{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='base.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='base.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head> </head>
<body> <body>
<div id="lt-boot" class="lt-boot-overlay" data-app-name="GANDALF" style="display:none"> <div id="lt-boot" class="lt-boot-overlay" data-app-name="GANDALF" style="display:none">
<pre id="lt-boot-text" class="lt-boot-text"></pre> <pre id="lt-boot-text" class="lt-boot-text"></pre>
</div> </div>
<header class="header">
<div class="header-left"> <header class="lt-header">
<div class="header-brand"> <div class="lt-header-left">
<span class="header-title">GANDALF</span> <div class="lt-brand">
<span class="header-sub">Network Monitor // LotusGuild</span> <a href="{{ url_for('index') }}" class="lt-brand-title" style="text-decoration:none">GANDALF</a>
<span class="lt-brand-subtitle">Network Monitor // LotusGuild</span>
</div> </div>
<nav class="header-nav"> <nav class="lt-nav" aria-label="Main navigation">
<a href="{{ url_for('index') }}" <a href="{{ url_for('index') }}"
class="nav-link {% if request.endpoint == 'index' %}active{% endif %}"> class="lt-nav-link {% if request.endpoint == 'index' %}active{% endif %}">
Dashboard Dashboard
</a> </a>
<a href="{{ url_for('links_page') }}" <a href="{{ url_for('links_page') }}"
class="nav-link {% if request.endpoint == 'links_page' %}active{% endif %}"> class="lt-nav-link {% if request.endpoint == 'links_page' %}active{% endif %}">
Link Debug Link Debug
</a> </a>
<a href="{{ url_for('inspector') }}" <a href="{{ url_for('inspector') }}"
class="nav-link {% if request.endpoint == 'inspector' %}active{% endif %}"> class="lt-nav-link {% if request.endpoint == 'inspector' %}active{% endif %}">
Inspector Inspector
</a> </a>
<a href="{{ url_for('suppressions_page') }}" <a href="{{ url_for('suppressions_page') }}"
class="nav-link {% if request.endpoint == 'suppressions_page' %}active{% endif %}"> class="lt-nav-link {% if request.endpoint == 'suppressions_page' %}active{% endif %}">
Suppressions Suppressions
</a> </a>
</nav> </nav>
</div> </div>
<div class="header-right"> <div class="lt-header-right">
<span class="header-user">{{ user.name or user.username }}</span> <span class="lt-header-user">{{ user.name or user.username }}</span>
</div> </div>
</header> </header>
<main class="main"> <main class="lt-main lt-container">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
@@ -51,6 +56,11 @@
}; };
</script> </script>
<script src="{{ url_for('static', filename='base.js') }}"></script> <script src="{{ url_for('static', filename='base.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
lt.init({ bootName: 'GANDALF' });
});
</script>
<script src="{{ url_for('static', filename='app.js') }}"></script> <script src="{{ url_for('static', filename='app.js') }}"></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
+58 -52
View File
@@ -18,14 +18,14 @@
</div> </div>
<div class="status-meta"> <div class="status-meta">
<span class="last-check" id="last-check">{{ last_check }}</span> <span class="last-check" id="last-check">{{ last_check }}</span>
<button class="btn-refresh" onclick="refreshAll()">↻ REFRESH</button> <button class="lt-btn lt-btn-ghost lt-btn-sm" onclick="refreshAll()">↻ REFRESH</button>
</div> </div>
</div> </div>
<!-- ── Network topology + host grid ───────────────────────────────── --> <!-- ── Network topology + host grid ───────────────────────────────── -->
<section class="section"> <section class="g-section">
<div class="section-header"> <div class="g-section-header">
<h2 class="section-title">Network Hosts</h2> <h2 class="g-section-title">Network Hosts</h2>
</div> </div>
<div class="topology" id="topology-diagram"> <div class="topology" id="topology-diagram">
@@ -102,7 +102,7 @@
<!-- Pro 24 PoE → host bus section --> <!-- Pro 24 PoE → host bus section -->
<div class="topo-vc"> <div class="topo-vc">
<div class="topo-vc-wire" style="background:var(--border);opacity:.5;"></div> <div class="topo-vc-wire" style="background:var(--border-color);opacity:.5;"></div>
</div> </div>
<!-- ══════════════════════════════════════════════════════════════ <!-- ══════════════════════════════════════════════════════════════
@@ -161,7 +161,7 @@
<div class="topo-legend-item"><span class="topo-legend-line-wan"></span> WAN / uplink</div> <div class="topo-legend-item"><span class="topo-legend-line-wan"></span> WAN / uplink</div>
<div class="topo-legend-item"><span class="topo-legend-line-10g"></span> 10G SFP+ (Ceph / VLAN90)</div> <div class="topo-legend-item"><span class="topo-legend-line-10g"></span> 10G SFP+ (Ceph / VLAN90)</div>
<div class="topo-legend-item"><span class="topo-legend-line-1g"></span> 1G DHCP (mgmt)</div> <div class="topo-legend-item"><span class="topo-legend-line-1g"></span> 1G DHCP (mgmt)</div>
<div class="topo-legend-item" style="border:1px dashed var(--border); padding:1px 5px; font-size:.56em; color:var(--text-muted);">dashed border = off-rack</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>
</div><!-- /topo-v2 --> </div><!-- /topo-v2 -->
@@ -201,7 +201,7 @@
{% endif %} {% endif %}
<div class="host-actions"> <div class="host-actions">
<button class="btn-sm btn-suppress" <button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
data-sup-type="host" data-sup-type="host"
data-sup-name="{{ name }}" data-sup-name="{{ name }}"
data-sup-detail="" data-sup-detail=""
@@ -209,7 +209,7 @@
🔕 Suppress 🔕 Suppress
</button> </button>
<a href="{{ url_for('links_page') }}#{{ name }}" <a href="{{ url_for('links_page') }}#{{ name }}"
class="btn-sm btn-secondary" style="text-decoration:none"> class="lt-btn lt-btn-secondary lt-btn-sm" style="text-decoration:none">
↗ Links ↗ Links
</a> </a>
</div> </div>
@@ -222,12 +222,13 @@
<!-- ── UniFi devices ────────────────────────────────────────────────── --> <!-- ── UniFi devices ────────────────────────────────────────────────── -->
{% if snapshot.unifi %} {% if snapshot.unifi %}
<section class="section"> <section class="g-section">
<div class="section-header"> <div class="g-section-header">
<h2 class="section-title">UniFi Devices</h2> <h2 class="g-section-title">UniFi Devices</h2>
</div> </div>
<div class="table-wrap"> <div class="lt-table-wrap">
<table class="data-table" id="unifi-table"> <table class="lt-table" id="unifi-table">
<caption class="lt-sr-only">UniFi network devices</caption>
<thead> <thead>
<tr> <tr>
<th>Status</th> <th>Status</th>
@@ -251,7 +252,7 @@
<td>{{ d.ip }}</td> <td>{{ d.ip }}</td>
<td> <td>
{% if not d.connected %} {% if not d.connected %}
<button class="btn-sm btn-suppress" <button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
data-sup-type="unifi_device" data-sup-type="unifi_device"
data-sup-name="{{ d.name }}" data-sup-name="{{ d.name }}"
data-sup-detail=""> data-sup-detail="">
@@ -268,11 +269,11 @@
{% endif %} {% endif %}
<!-- ── Active alerts ───────────────────────────────────────────────── --> <!-- ── Active alerts ───────────────────────────────────────────────── -->
<section class="section"> <section class="g-section">
<div class="section-header"> <div class="g-section-header">
<h2 class="section-title">Active Alerts</h2> <h2 class="g-section-title">Active Alerts</h2>
{% if summary.critical or summary.warning %} {% if summary.critical or summary.warning %}
<span class="section-badge">{{ (summary.critical or 0) + (summary.warning or 0) }}</span> <span class="g-section-badge">{{ (summary.critical or 0) + (summary.warning or 0) }}</span>
{% endif %} {% endif %}
</div> </div>
<div id="events-table-wrap"> <div id="events-table-wrap">
@@ -280,8 +281,9 @@
{% if total_active is defined and total_active > events|length %} {% if total_active is defined and total_active > events|length %}
<div class="pagination-notice">Showing {{ events|length }} of {{ total_active }} active alerts &mdash; <a href="/api/events?limit=1000">view all via API</a></div> <div class="pagination-notice">Showing {{ events|length }} of {{ total_active }} active alerts &mdash; <a href="/api/events?limit=1000">view all via API</a></div>
{% endif %} {% endif %}
<div class="table-wrap"> <div class="lt-table-wrap">
<table class="data-table" id="events-table"> <table class="lt-table" id="events-table">
<caption class="lt-sr-only">Active network alerts</caption>
<thead> <thead>
<tr> <tr>
<th>Sev</th> <th>Sev</th>
@@ -300,7 +302,7 @@
{% for e in events %} {% for e in events %}
{% if e.severity != 'info' %} {% if e.severity != 'info' %}
<tr class="row-{{ e.severity }}"> <tr class="row-{{ e.severity }}">
<td><span class="badge badge-{{ e.severity }}">{{ e.severity }}</span></td> <td><span class="lt-badge badge-{{ e.severity }}">{{ e.severity }}</span></td>
<td>{{ e.event_type | replace('_', ' ') }}</td> <td>{{ e.event_type | replace('_', ' ') }}</td>
<td><strong>{{ e.target_name }}</strong></td> <td><strong>{{ e.target_name }}</strong></td>
<td>{{ e.target_detail or '' }}</td> <td>{{ e.target_detail or '' }}</td>
@@ -319,7 +321,7 @@
{% else %}{% endif %} {% else %}{% endif %}
</td> </td>
<td> <td>
<button class="btn-sm btn-suppress" <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-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-name="{{ e.target_name }}"
data-sup-detail="{{ e.target_detail or '' }}" data-sup-detail="{{ e.target_detail or '' }}"
@@ -341,13 +343,14 @@
<!-- ── Recently Resolved (last 24h) ───────────────────────────────── --> <!-- ── Recently Resolved (last 24h) ───────────────────────────────── -->
{% if recent_resolved %} {% if recent_resolved %}
<section class="section"> <section class="g-section">
<div class="section-header"> <div class="g-section-header">
<h2 class="section-title">Recently Resolved</h2> <h2 class="g-section-title">Recently Resolved</h2>
<span class="section-badge section-badge-resolved">{{ recent_resolved | length }} in last 24h</span> <span class="g-section-badge g-section-badge-resolved">{{ recent_resolved | length }} in last 24h</span>
</div> </div>
<div class="table-wrap"> <div class="lt-table-wrap">
<table class="data-table"> <table class="lt-table">
<caption class="lt-sr-only">Recently resolved alerts</caption>
<thead> <thead>
<tr> <tr>
<th>Sev</th> <th>Sev</th>
@@ -361,7 +364,7 @@
<tbody> <tbody>
{% for e in recent_resolved %} {% for e in recent_resolved %}
<tr class="row-resolved"> <tr class="row-resolved">
<td><span class="badge badge-resolved">{{ e.severity }}</span></td> <td><span class="lt-badge badge-resolved">{{ e.severity }}</span></td>
<td>{{ e.event_type | replace('_', ' ') }}</td> <td>{{ e.event_type | replace('_', ' ') }}</td>
<td><strong>{{ e.target_name }}</strong></td> <td><strong>{{ e.target_name }}</strong></td>
<td>{{ e.target_detail or '' }}</td> <td>{{ e.target_detail or '' }}</td>
@@ -378,37 +381,39 @@
{% endif %} {% endif %}
<!-- ── Quick-suppress modal ─────────────────────────────────────────── --> <!-- ── Quick-suppress modal ─────────────────────────────────────────── -->
<div id="suppress-modal" class="modal-overlay" style="display:none"> <div id="suppress-modal" class="lt-modal-backdrop" style="display:none"
<div class="modal"> role="dialog" aria-modal="true" aria-labelledby="suppress-modal-title">
<div class="modal-header"> <div class="lt-modal">
<h3>Suppress Alert</h3> <div class="lt-modal-header">
<button class="modal-close" onclick="closeSuppressModal()"></button> <h3 class="lt-modal-title" id="suppress-modal-title">Suppress Alert</h3>
<button type="button" class="lt-modal-close" onclick="closeSuppressModal()" aria-label="Close"></button>
</div> </div>
<form id="suppress-form" onsubmit="submitSuppress(event)"> <form id="suppress-form" onsubmit="submitSuppress(event)">
<div class="form-group" style="margin-bottom:10px"> <div class="lt-modal-body">
<label>Target Type</label> <div class="lt-form-group" style="margin-bottom:12px">
<select id="sup-type" name="target_type" onchange="updateSuppressForm()"> <label class="lt-label" for="sup-type">Target Type</label>
<select class="lt-select" id="sup-type" name="target_type" onchange="updateSuppressForm()">
<option value="host">Host (all interfaces)</option> <option value="host">Host (all interfaces)</option>
<option value="interface">Specific Interface</option> <option value="interface">Specific Interface</option>
<option value="unifi_device">UniFi Device</option> <option value="unifi_device">UniFi Device</option>
<option value="all">Global Maintenance</option> <option value="all">Global Maintenance</option>
</select> </select>
</div> </div>
<div class="form-group" id="sup-name-group" style="margin-bottom:10px"> <div class="lt-form-group" id="sup-name-group" style="margin-bottom:12px">
<label>Target Name</label> <label class="lt-label" for="sup-name">Target Name</label>
<input type="text" id="sup-name" name="target_name" placeholder="e.g. large1"> <input type="text" class="lt-input" id="sup-name" name="target_name" placeholder="e.g. large1">
</div> </div>
<div class="form-group" id="sup-detail-group" style="margin-bottom:10px;display:none"> <div class="lt-form-group" id="sup-detail-group" style="margin-bottom:12px;display:none">
<label>Interface <span class="form-hint">(interface type only)</span></label> <label class="lt-label" for="sup-detail">Interface <span class="lt-field-hint">(interface type only)</span></label>
<input type="text" id="sup-detail" name="target_detail" placeholder="e.g. enp35s0"> <input type="text" class="lt-input" id="sup-detail" name="target_detail" placeholder="e.g. enp35s0">
</div> </div>
<div class="form-group" style="margin-bottom:10px"> <div class="lt-form-group" style="margin-bottom:12px">
<label>Reason <span class="required">*</span></label> <label class="lt-label" for="sup-reason">Reason <span class="required">*</span></label>
<input type="text" id="sup-reason" name="reason" <input type="text" class="lt-input" id="sup-reason" name="reason"
placeholder="e.g. Planned switch reboot" required> placeholder="e.g. Planned switch reboot" required>
</div> </div>
<div class="form-group" style="margin-bottom:0"> <div class="lt-form-group" style="margin-bottom:0">
<label>Duration</label> <label class="lt-label">Duration</label>
<div class="duration-pills"> <div class="duration-pills">
<button type="button" class="pill" onclick="setDuration(30, this)">30 min</button> <button type="button" class="pill" onclick="setDuration(30, this)">30 min</button>
<button type="button" class="pill" onclick="setDuration(60, this)">1 hr</button> <button type="button" class="pill" onclick="setDuration(60, this)">1 hr</button>
@@ -417,11 +422,12 @@
<button type="button" class="pill pill-manual active" onclick="setDuration(null, this)">Manual ∞</button> <button type="button" class="pill pill-manual active" onclick="setDuration(null, this)">Manual ∞</button>
</div> </div>
<input type="hidden" id="sup-expires" name="expires_minutes" value=""> <input type="hidden" id="sup-expires" name="expires_minutes" value="">
<div class="form-hint" id="duration-hint">Persists until manually removed.</div> <div class="lt-field-hint" id="duration-hint">Persists until manually removed.</div>
</div> </div>
<div class="modal-actions"> </div>
<button type="button" class="btn btn-secondary" onclick="closeSuppressModal()">Cancel</button> <div class="lt-modal-footer">
<button type="submit" class="btn btn-primary">Apply</button> <button type="button" class="lt-btn lt-btn-secondary" onclick="closeSuppressModal()">Cancel</button>
<button type="submit" class="lt-btn lt-btn-primary">Apply</button>
</div> </div>
</form> </form>
</div> </div>
+3 -3
View File
@@ -3,9 +3,9 @@
{% block content %} {% block content %}
<div class="page-header"> <div class="g-page-header">
<h1 class="page-title">Network Inspector</h1> <h1 class="g-page-title">Network Inspector</h1>
<p class="page-sub"> <p class="g-page-sub">
Visual switch chassis diagrams. Click a port to see detailed stats and LLDP path debug. Visual switch chassis diagrams. Click a port to see detailed stats and LLDP path debug.
<span id="inspector-updated" style="margin-left:10px; color:var(--text-muted); font-size:.85em;"></span> <span id="inspector-updated" style="margin-left:10px; color:var(--text-muted); font-size:.85em;"></span>
</p> </p>
+221 -337
View File
@@ -3,9 +3,9 @@
{% block content %} {% block content %}
<div class="page-header"> <div class="g-page-header">
<h1 class="page-title">Link Debug</h1> <h1 class="g-page-title">Link Debug</h1>
<p class="page-sub"> <p class="g-page-sub">
Per-interface stats: speed, duplex, SFP optical levels, TX/RX rates, errors, and carrier changes. Per-interface stats: speed, duplex, SFP optical levels, TX/RX rates, errors, and carrier changes.
Data collected via Prometheus node_exporter + SSH ethtool (servers) and UniFi API (switches) every poll cycle. Data collected via Prometheus node_exporter + SSH ethtool (servers) and UniFi API (switches) every poll cycle.
<span id="links-updated" style="margin-left:10px; color:var(--text-muted); font-size:.85em;"></span> <span id="links-updated" style="margin-left:10px; color:var(--text-muted); font-size:.85em;"></span>
@@ -69,44 +69,34 @@ function fmtBias(ma) {
function fmtErrors(rate) { function fmtErrors(rate) {
if (rate === null || rate === undefined) return ''; if (rate === null || rate === undefined) return '';
if (rate < 0.001) return '<span class="counter-zero">0 /s</span>'; if (rate < 0.001) return '<span class="val-good">0 /s</span>';
return `<span class="counter-nonzero">${rate.toFixed(3)} /s</span>`; return `<span class="val-crit">${rate.toFixed(3)} /s</span>`;
} }
function fmtCarrier(n) { function fmtCarrier(n) {
if (n === null || n === undefined) return ''; if (n === null || n === undefined) return '';
const v = parseInt(n); if (n === 0) return '<span class="counter-zero">0</span>';
if (v <= 2) return `<span class="val-good">${v}</span>`; return `<span class="counter-nonzero">${n}</span>`;
if (v <= 10) return `<span class="val-warn">${v}</span>`;
return `<span class="val-crit">${v}</span>`;
} }
// Power level: returns {cls, pct} for -30..0 dBm scale // ── SFP/DOM value classification ─────────────────────────────────
function rxPowerClass(dbm) { function rxPowerClass(dbm) {
if (dbm === null || dbm === undefined) return {cls:'power-ok', pct:0}; if (dbm === null || dbm === undefined) return 'val-neutral';
const pct = Math.max(0, Math.min(100, (dbm + 30) / 30 * 100)); if (dbm < -15) return 'val-crit';
let cls = 'power-ok'; if (dbm < -10) return 'val-warn';
if (dbm < -25 || dbm > 0) cls = 'power-crit'; return 'val-good';
else if (dbm < -20) cls = 'power-warn';
return {cls, pct};
} }
function txPowerClass(dbm) { function txPowerClass(dbm) {
if (dbm === null || dbm === undefined) return {cls:'power-ok', pct:0}; if (dbm === null || dbm === undefined) return 'val-neutral';
const pct = Math.max(0, Math.min(100, (dbm + 20) / 20 * 100)); if (dbm < -5) return 'val-crit';
let cls = 'power-ok'; return 'val-good';
if (dbm < -15 || dbm > 2) cls = 'power-crit';
else if (dbm < -10) cls = 'power-warn';
return {cls, pct};
} }
function tempClass(c) { function tempClass(c) {
if (c === null || c === undefined) return 'val-neutral'; if (c === null || c === undefined) return 'val-neutral';
if (c > 80) return 'val-crit'; if (c > 80) return 'val-crit';
if (c > 60) return 'val-warn'; if (c > 70) return 'val-warn';
return 'val-good'; return 'val-good';
} }
function voltageClass(v) { function voltageClass(v) {
if (v === null || v === undefined) return 'val-neutral'; if (v === null || v === undefined) return 'val-neutral';
if (v < 3.0 || v > 3.6) return 'val-crit'; if (v < 3.0 || v > 3.6) return 'val-crit';
@@ -114,319 +104,254 @@ function voltageClass(v) {
return 'val-good'; return 'val-good';
} }
function portTypeLabel(pt) {
if (!pt) return {label:'', cls:''};
const u = pt.toUpperCase();
if (u.includes('FIBRE') || u.includes('FIBER') || u.includes('SFP'))
return {label: pt, cls: 'type-fibre'};
if (u.includes('DA') || u.includes('DIRECT'))
return {label: pt, cls: 'type-da'};
return {label: pt, cls: 'type-copper'};
}
// ── Error alert badge ─────────────────────────────────────────────
function errorBadges(d) { function errorBadges(d) {
const badges = []; const badges = [];
if ((d.tx_errs_rate || 0) > 0.01 || (d.rx_errs_rate || 0) > 0.01) if ((d.tx_errors_per_sec || 0) > 0 || (d.rx_errors_per_sec || 0) > 0)
badges.push('<span class="link-alert-badge">ERR</span>'); badges.push('<span class="link-alert-badge">ERR</span>');
if ((d.tx_drops_rate || 0) > 0.1 || (d.rx_drops_rate || 0) > 0.1) if ((d.tx_drops_per_sec || 0) > 0 || (d.rx_drops_per_sec || 0) > 0)
badges.push('<span class="link-alert-badge link-alert-amber">DROP</span>'); badges.push('<span class="link-alert-badge link-alert-amber">DROP</span>');
if ((d.carrier_changes || 0) > 10) if ((d.carrier_changes || 0) > 3)
badges.push('<span class="link-alert-badge link-alert-amber">FLAP</span>'); badges.push('<span class="link-alert-badge link-alert-amber">FLAP</span>');
return badges.join(''); return badges.join('');
} }
// ── Render a single interface card ──────────────────────────────── // ── Render a single server interface card ─────────────────────────
function renderIfaceCard(ifaceName, d) { function renderIfaceCard(ifaceName, d) {
const speed = fmtSpeed(d.speed_mbps); const isDown = d.link_detected === false || d.admin_status === 'down';
const duplex = fmtDuplex(d.duplex); const mediaTag = d.media_type === 'fibre' ? 'type-fibre'
const ptype = portTypeLabel(d.port_type); : d.media_type === 'da' ? 'type-da'
const autoneg= d.auto_neg !== undefined ? (d.auto_neg ? 'On' : 'Off') : ''; : 'type-copper';
const linkDet= d.link_detected !== undefined ? (d.link_detected ? '<span class="val-good">Yes</span>' : '<span class="val-crit">No</span>') : ''; const mediaLabel = d.media_type || '';
const speedStr = d.speed_mbps ? fmtSpeed(d.speed_mbps) : '';
const txPct = fmtRateBar(d.tx_bytes_per_sec, d.speed_mbps);
const rxPct = fmtRateBar(d.rx_bytes_per_sec, d.speed_mbps);
// Traffic bars
const txRate = d.tx_bytes_rate;
const rxRate = d.rx_bytes_rate;
const txPct = fmtRateBar(txRate, d.speed_mbps);
const rxPct = fmtRateBar(rxRate, d.speed_mbps);
const txStr = fmtRate(txRate);
const rxStr = fmtRate(rxRate);
// SFP / optical section
let sfpHtml = ''; let sfpHtml = '';
const sfp = d.sfp; if (d.sfp && Object.keys(d.sfp).length > 0) {
if (sfp && Object.keys(sfp).length > 0) { const s = d.sfp;
const tx = txPowerClass(sfp.tx_power_dbm); const rxClass = rxPowerClass(s.rx_power_dbm);
const rx = rxPowerClass(sfp.rx_power_dbm); const txClass = txPowerClass(s.tx_power_dbm);
const tcls = tempClass(sfp.temp_c); const tmpClass = tempClass(s.temp_c);
const vcls = voltageClass(sfp.voltage_v); const vClass = voltageClass(s.voltage_v);
const rxPct2 = s.rx_power_dbm != null ? Math.min(100, Math.max(0, (s.rx_power_dbm + 20) / 15 * 100)) : 0;
const vendorStr = [sfp.vendor, sfp.part_no].filter(Boolean).join(' / ') || ''; const txPct2 = s.tx_power_dbm != null ? Math.min(100, Math.max(0, (s.tx_power_dbm + 10) / 8 * 100)) : 0;
const sfpTypeStr= [sfp.sfp_type, sfp.connector, sfp.wavelength_nm ? sfp.wavelength_nm + 'nm' : ''].filter(Boolean).join(' · ') || '';
sfpHtml = ` sfpHtml = `
<div class="sfp-panel"> <div class="sfp-panel">
<div class="sfp-vendor-row"> <div class="sfp-vendor-row">
<span>${escHtml(vendorStr)}</span> ${s.vendor ? `<span>${escHtml(s.vendor)}</span>` : ''}
${sfpTypeStr ? `<span style="margin-left:8px;color:var(--text-muted)">${escHtml(sfpTypeStr)}</span>` : ''} ${s.part_number ? ` / <span>${escHtml(s.part_number)}</span>` : ''}
</div> </div>
<div class="sfp-grid"> <div class="sfp-grid">
<div class="sfp-stat"> <div class="sfp-stat">
<span class="sfp-stat-label">Temp</span> <span class="sfp-stat-label">Temp</span>
<span class="sfp-stat-value ${tcls}">${fmtTemp(sfp.temp_c)}</span> <span class="sfp-stat-value ${tmpClass}">${fmtTemp(s.temp_c)}</span>
</div> </div>
<div class="sfp-stat"> <div class="sfp-stat">
<span class="sfp-stat-label">Voltage</span> <span class="sfp-stat-label">Voltage</span>
<span class="sfp-stat-value ${vcls}">${fmtVoltage(sfp.voltage_v)}</span> <span class="sfp-stat-value ${vClass}">${fmtVoltage(s.voltage_v)}</span>
</div> </div>
<div class="sfp-stat"> <div class="sfp-stat">
<span class="sfp-stat-label">Bias</span> <span class="sfp-stat-label">Bias</span>
<span class="sfp-stat-value">${fmtBias(sfp.bias_ma)}</span> <span class="sfp-stat-value">${fmtBias(s.bias_ma)}</span>
</div> </div>
<div class="sfp-stat"> <div class="sfp-stat">
<span class="sfp-stat-label">TX Power</span> <span class="sfp-stat-label">TX Power</span>
<span class="sfp-stat-value ${tx.cls === 'power-ok' ? 'val-good' : tx.cls === 'power-warn' ? 'val-warn' : 'val-crit'}">${fmtPower(sfp.tx_power_dbm)}</span> <span class="sfp-stat-value ${txClass}">${fmtPower(s.tx_power_dbm)}</span>
<div class="power-row"> <div class="power-row">
<div class="power-track"><div class="power-fill ${tx.cls}" style="width:${tx.pct}%"></div></div> <div class="power-track"><div class="power-fill ${txClass === 'val-good' ? 'power-ok' : txClass === 'val-warn' ? 'power-warn' : 'power-crit'}" style="width:${txPct2}%"></div></div>
</div> </div>
</div> </div>
<div class="sfp-stat"> <div class="sfp-stat">
<span class="sfp-stat-label">RX Power</span> <span class="sfp-stat-label">RX Power</span>
<span class="sfp-stat-value ${rx.cls === 'power-ok' ? 'val-good' : rx.cls === 'power-warn' ? 'val-warn' : 'val-crit'}">${fmtPower(sfp.rx_power_dbm)}</span> <span class="sfp-stat-value ${rxClass}">${fmtPower(s.rx_power_dbm)}</span>
<div class="power-row"> <div class="power-row">
<div class="power-track"><div class="power-fill ${rx.cls}" style="width:${rx.pct}%"></div></div> <div class="power-track"><div class="power-fill ${rxClass === 'val-good' ? 'power-ok' : rxClass === 'val-warn' ? 'power-warn' : 'power-crit'}" style="width:${rxPct2}%"></div></div>
</div> </div>
</div> </div>
${s.rx_power_dbm != null && s.tx_power_dbm != null ? `
<div class="sfp-stat"> <div class="sfp-stat">
<span class="sfp-stat-label">RX TX</span> <span class="sfp-stat-label">RXTX Δ</span>
<span class="sfp-stat-value ${sfp.rx_power_dbm !== undefined && sfp.tx_power_dbm !== undefined ? (Math.abs(sfp.rx_power_dbm - sfp.tx_power_dbm) > 8 ? 'val-warn' : 'val-neutral') : 'val-neutral'}"> <span class="sfp-stat-value">${(s.rx_power_dbm - s.tx_power_dbm).toFixed(2)} dBm</span>
${sfp.rx_power_dbm !== undefined && sfp.tx_power_dbm !== undefined </div>` : ''}
? (sfp.rx_power_dbm - sfp.tx_power_dbm).toFixed(2) + ' dBm'
: ''}
</span>
</div>
</div> </div>
</div>`; </div>`;
} }
return ` return `
<div class="link-iface-card"> <div class="link-iface-card ${isDown ? 'port-down' : ''}">
<div class="link-iface-header"> <div class="link-iface-header">
<span class="link-iface-name">${escHtml(ifaceName)}</span> <span class="link-iface-name">${escHtml(ifaceName)}</span>
${speed !== '' ? `<span class="link-iface-speed">${speed}</span>` : ''} <span class="link-iface-speed">${speedStr}</span>
${ptype.label !== '' ? `<span class="link-iface-type ${ptype.cls}">${escHtml(ptype.label)}</span>` : ''} <span class="link-iface-type ${mediaTag}">${escHtml(mediaLabel)}</span>
${errorBadges(d)} ${errorBadges(d)}
</div> </div>
<div class="link-stats-grid"> <div class="link-stats-grid">
<div class="link-stat">
<span class="link-stat-label">Link</span>
<span class="link-stat-value ${isDown ? 'val-crit' : 'val-good'}">${isDown ? 'DOWN' : 'UP'}</span>
</div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">Duplex</span> <span class="link-stat-label">Duplex</span>
<span class="link-stat-value ${duplex==='Full'?'val-good':duplex==='Half'?'val-crit':'val-neutral'}">${duplex}</span> <span class="link-stat-value">${fmtDuplex(d.duplex)}</span>
</div> </div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">Auto-neg</span> <span class="link-stat-label">Auto-neg</span>
<span class="link-stat-value val-neutral">${autoneg}</span> <span class="link-stat-value">${d.auto_negotiation == null ? '' : d.auto_negotiation ? 'On' : 'Off'}</span>
</div> </div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">Link Det.</span> <span class="link-stat-label">Carrier Δ</span>
<span class="link-stat-value">${linkDet}</span>
</div>
<div class="link-stat">
<span class="link-stat-label">Carrier Chg</span>
<span class="link-stat-value">${fmtCarrier(d.carrier_changes)}</span> <span class="link-stat-value">${fmtCarrier(d.carrier_changes)}</span>
</div> </div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">TX Errors</span> <span class="link-stat-label">TX Err/s</span>
<span class="link-stat-value">${fmtErrors(d.tx_errs_rate)}</span> <span class="link-stat-value">${fmtErrors(d.tx_errors_per_sec)}</span>
</div> </div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">RX Errors</span> <span class="link-stat-label">RX Err/s</span>
<span class="link-stat-value">${fmtErrors(d.rx_errs_rate)}</span> <span class="link-stat-value">${fmtErrors(d.rx_errors_per_sec)}</span>
</div> </div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">TX Drops</span> <span class="link-stat-label">TX Drop/s</span>
<span class="link-stat-value">${fmtErrors(d.tx_drops_rate)}</span> <span class="link-stat-value">${fmtErrors(d.tx_drops_per_sec)}</span>
</div> </div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">RX Drops</span> <span class="link-stat-label">RX Drop/s</span>
<span class="link-stat-value">${fmtErrors(d.rx_drops_rate)}</span> <span class="link-stat-value">${fmtErrors(d.rx_drops_per_sec)}</span>
</div> </div>
</div> </div>
${(txRate !== undefined || rxRate !== undefined) ? `
<div class="traffic-section"> <div class="traffic-section">
<div class="traffic-row"> <div class="traffic-row">
<span class="traffic-label">TX</span> <span class="traffic-label">TX</span>
<div class="traffic-bar-track"> <div class="traffic-bar-track"><div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div></div>
<div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div> <span class="traffic-value">${fmtRate(d.tx_bytes_per_sec)}</span>
</div>
<span class="traffic-value">${txStr}</span>
</div> </div>
<div class="traffic-row"> <div class="traffic-row">
<span class="traffic-label">RX</span> <span class="traffic-label">RX</span>
<div class="traffic-bar-track"> <div class="traffic-bar-track"><div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div></div>
<div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div> <span class="traffic-value">${fmtRate(d.rx_bytes_per_sec)}</span>
</div> </div>
<span class="traffic-value">${rxStr}</span>
</div> </div>
</div>` : ''}
${sfpHtml} ${sfpHtml}
</div>`; </div>`;
} }
// ── Render a single UniFi switch port card ──────────────────────── // ── Render a single UniFi switch port card ────────────────────────
function renderPortCard(portName, d) { function renderPortCard(portName, d) {
const up = d.up; const isDown = !d.up;
const speed = up ? fmtSpeed(d.speed_mbps) : 'DOWN'; const speedStr = d.speed_mbps ? fmtSpeed(d.speed_mbps) : '';
const duplex = d.full_duplex ? 'Full' : (up ? 'Half' : ''); const txPct = fmtRateBar(d.tx_bytes_per_sec, d.speed_mbps);
const media = d.media || ''; const rxPct = fmtRateBar(d.rx_bytes_per_sec, d.speed_mbps);
const numBadge = d.port_idx != null ? `<span class="port-badge port-badge-num">#${d.port_idx}</span>` : '';
const uplinkBadge = d.is_uplink const uplinkBadge = d.is_uplink ? `<span class="port-badge port-badge-uplink">UPLINK</span>` : '';
? '<span class="port-badge port-badge-uplink">UPLINK</span>' : ''; const poeBadge = d.poe_power_w ? `<span class="port-badge port-badge-poe">PoE ${d.poe_power_w.toFixed(1)}W</span>` : '';
const poeBadge = (d.poe_power != null && d.poe_power > 0) const lldpLine = d.lldp ? `<div class="port-lldp">→ ${escHtml(d.lldp.system_name || '')} (${escHtml(d.lldp.port_id || '')})</div>` : '';
? `<span class="port-badge port-badge-poe">PoE ${d.poe_power.toFixed(1)}W</span>` : ''; const poeLine = d.poe_class ? `<div class="port-poe-info">PoE ${escHtml(d.poe_class)} · max ${d.poe_power_w_max != null ? d.poe_power_w_max.toFixed(1)+'W' : ''}</div>` : '';
const numBadge = d.port_idx
? `<span class="port-badge port-badge-num">#${d.port_idx}</span>` : '';
const lldpHtml = (d.lldp && d.lldp.system_name)
? `<div class="port-lldp">→ ${escHtml(d.lldp.system_name)}${d.lldp.port_id ? ' (' + escHtml(d.lldp.port_id) + ')' : ''}</div>` : '';
let poeMaxHtml = '';
if (d.poe_class != null) {
const poeDraw = d.poe_power || 0;
const poeMax = d.poe_max_power || 0;
const poePct = poeMax > 0 ? Math.min(100, (poeDraw / poeMax) * 100) : 0;
const poeBarCls = poePct >= 80 ? 'poe-bar-crit' : poePct >= 60 ? 'poe-bar-warn' : 'poe-bar-ok';
const poeMode = d.poe_mode ? ` · ${escHtml(d.poe_mode)}` : '';
poeMaxHtml = `<div class="port-poe-info">
PoE class ${d.poe_class}${poeMax > 0 ? ` · ${poeDraw.toFixed(1)}W / ${poeMax.toFixed(1)}W max${poeMode}` : poeMode}
${poeMax > 0 ? `<div class="poe-bar-track"><div class="poe-bar-fill ${poeBarCls}" style="width:${poePct.toFixed(1)}%"></div></div>` : ''}
</div>`;
}
const txRate = d.tx_bytes_rate;
const rxRate = d.rx_bytes_rate;
const txPct = fmtRateBar(txRate, d.speed_mbps);
const rxPct = fmtRateBar(rxRate, d.speed_mbps);
const txStr = fmtRate(txRate);
const rxStr = fmtRate(rxRate);
return ` return `
<div class="link-iface-card${up ? '' : ' port-down'}"> <div class="link-iface-card ${isDown ? 'port-down' : ''}">
<div class="link-iface-header"> <div class="link-iface-header">
<span class="link-iface-name">${escHtml(portName)}</span> <span class="link-iface-name">${numBadge} ${escHtml(portName)}</span>
<span class="link-iface-speed ${up ? '' : 'val-crit'}">${speed}</span> <span class="link-iface-speed">${speedStr}</span>
${numBadge}${uplinkBadge}${poeBadge} ${uplinkBadge}${poeBadge}
${media ? `<span class="link-iface-type type-${media.toLowerCase().includes('sfp') ? 'fibre' : 'copper'}">${escHtml(media)}</span>` : ''}
${errorBadges(d)} ${errorBadges(d)}
</div> </div>
${lldpHtml}${poeMaxHtml} ${lldpLine}${poeLine}
<div class="link-stats-grid"> <div class="link-stats-grid">
<div class="link-stat">
<span class="link-stat-label">Link</span>
<span class="link-stat-value ${isDown ? 'val-crit' : 'val-good'}">${isDown ? 'DOWN' : 'UP'}</span>
</div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">Duplex</span> <span class="link-stat-label">Duplex</span>
<span class="link-stat-value ${duplex==='Full'?'val-good':duplex==='Half'?'val-crit':'val-neutral'}">${duplex}</span> <span class="link-stat-value">${fmtDuplex(d.duplex)}</span>
</div> </div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">Auto-neg</span> <span class="link-stat-label">Auto-neg</span>
<span class="link-stat-value val-neutral">${d.autoneg ? 'On' : 'Off'}</span> <span class="link-stat-value">${d.auto_negotiation == null ? '' : d.auto_negotiation ? 'On' : 'Off'}</span>
</div> </div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">TX Errors</span> <span class="link-stat-label">TX Err/s</span>
<span class="link-stat-value">${fmtErrors(d.tx_errs_rate)}</span> <span class="link-stat-value">${fmtErrors(d.tx_errors_per_sec)}</span>
</div> </div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">RX Errors</span> <span class="link-stat-label">RX Err/s</span>
<span class="link-stat-value">${fmtErrors(d.rx_errs_rate)}</span> <span class="link-stat-value">${fmtErrors(d.rx_errors_per_sec)}</span>
</div> </div>
<div class="link-stat"> <div class="link-stat">
<span class="link-stat-label">TX Drops</span> <span class="link-stat-label">TX Drop/s</span>
<span class="link-stat-value">${fmtErrors(d.tx_drops_rate)}</span> <span class="link-stat-value">${fmtErrors(d.tx_drops_per_sec)}</span>
</div>
<div class="link-stat">
<span class="link-stat-label">RX Drops</span>
<span class="link-stat-value">${fmtErrors(d.rx_drops_rate)}</span>
</div> </div>
</div> </div>
${(up && (txRate != null || rxRate != null)) ? `
<div class="traffic-section"> <div class="traffic-section">
<div class="traffic-row"> <div class="traffic-row">
<span class="traffic-label">TX</span> <span class="traffic-label">TX</span>
<div class="traffic-bar-track"> <div class="traffic-bar-track"><div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div></div>
<div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div> <span class="traffic-value">${fmtRate(d.tx_bytes_per_sec)}</span>
</div>
<span class="traffic-value">${txStr}</span>
</div> </div>
<div class="traffic-row"> <div class="traffic-row">
<span class="traffic-label">RX</span> <span class="traffic-label">RX</span>
<div class="traffic-bar-track"> <div class="traffic-bar-track"><div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div></div>
<div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div> <span class="traffic-value">${fmtRate(d.rx_bytes_per_sec)}</span>
</div> </div>
<span class="traffic-value">${rxStr}</span>
</div> </div>
</div>` : ''}
</div>`; </div>`;
} }
// ── Render UniFi switches section ───────────────────────────────── // ── Render all UniFi switches ─────────────────────────────────────
function renderUnifiSwitches(unifiSwitches) { function renderUnifiSwitches(unifiSwitches) {
if (!unifiSwitches || !Object.keys(unifiSwitches).length) return ''; if (!unifiSwitches || !Object.keys(unifiSwitches).length) return '';
const html = Object.entries(unifiSwitches).map(([swName, sw]) => {
const panels = Object.entries(unifiSwitches).map(([swName, sw]) => {
const ports = sw.ports || {}; const ports = sw.ports || {};
const allPorts= Object.entries(ports) const portCards = Object.entries(ports)
.sort(([,a],[,b]) => (a.port_idx||0) - (b.port_idx||0)); .sort(([,a],[,b]) => (a.port_idx||0) - (b.port_idx||0))
const upCount = allPorts.filter(([,d]) => d.up).length; .map(([pname, d]) => renderPortCard(pname, d)).join('');
const downCount = allPorts.length - upCount; const updStr = sw.updated ? new Date(sw.updated + (sw.updated.includes('Z') ? '' : 'Z')).toLocaleTimeString() : '';
const poeLoad = sw.poe_total_w != null ? ` · PoE ${sw.poe_total_w.toFixed(1)}W` : '';
const portCards = allPorts // PoE utilisation bar
.map(([pname, d]) => renderPortCard(pname, d)) let poebar = '';
.join(''); if (sw.poe_total_w != null && sw.poe_max_w) {
const pct = Math.min(100, (sw.poe_total_w / sw.poe_max_w) * 100);
const meta = [ const cls = pct > 80 ? 'poe-bar-crit' : pct > 60 ? 'poe-bar-warn' : 'poe-bar-ok';
sw.model, poebar = `<div class="poe-bar-track"><div class="poe-bar-fill ${cls}" style="width:${pct}%"></div></div>`;
`${upCount} up`, }
downCount ? `${downCount} down` : '',
].filter(Boolean).join(' · ');
return ` return `
<div class="link-host-panel" id="unifi-${escHtml(swName)}"> <div class="link-host-panel" id="panel-${CSS.escape(swName)}">
<div class="link-host-title" onclick="togglePanel(this.closest('.link-host-panel'))"> <div class="link-host-title" onclick="togglePanel(this.closest('.link-host-panel'))">
<span class="link-host-name">${escHtml(swName)}</span> <span class="link-host-name">${escHtml(swName)}</span>
${sw.ip ? `<span class="link-host-ip">${escHtml(sw.ip)}</span>` : ''} <span class="link-host-ip">${escHtml(sw.ip || '')}</span>
<span class="link-host-upd">${escHtml(meta)}</span> <span class="link-host-upd">${updStr}${poeLoad}</span>
<span class="panel-toggle" title="Collapse / expand">[]</span> ${poebar}
</div> <span class="panel-toggle">[]</span>
<div class="link-ifaces-grid">
${portCards || '<div class="link-no-data">No port data available.</div>'}
</div> </div>
<div class="link-ifaces-grid">${portCards}</div>
</div>`; </div>`;
}).join(''); }).join('');
return ` return `<div class="unifi-section-header">UNIFI SWITCH PORTS</div>${html}`;
<div class="unifi-section-header">UniFi Switches</div>
<div class="link-host-list">${panels}</div>`;
} }
// ── Collapse / expand panels ─────────────────────────────────────── // ── Panel collapse / expand ───────────────────────────────────────
function togglePanel(panel) { function togglePanel(panel) {
panel.classList.toggle('collapsed'); panel.classList.toggle('collapsed');
const btn = panel.querySelector('.panel-toggle'); const btn = panel.querySelector('.panel-toggle');
if (btn) btn.textContent = panel.classList.contains('collapsed') ? '[+]' : '[]'; if (btn) btn.textContent = panel.classList.contains('collapsed') ? '[+]' : '[]';
const id = panel.id; const id = panel.id;
if (id) { if (id) {
const saved = JSON.parse(localStorage.getItem('gandalfCollapsed') || '{}'); const collapsed = JSON.parse(sessionStorage.getItem('linksCollapsed') || '{}');
saved[id] = panel.classList.contains('collapsed'); collapsed[id] = panel.classList.contains('collapsed');
localStorage.setItem('gandalfCollapsed', JSON.stringify(saved)); sessionStorage.setItem('linksCollapsed', JSON.stringify(collapsed));
} }
} }
function restoreCollapseState() { function restoreCollapseState() {
const saved = JSON.parse(localStorage.getItem('gandalfCollapsed') || '{}'); const collapsed = JSON.parse(sessionStorage.getItem('linksCollapsed') || '{}');
for (const [id, collapsed] of Object.entries(saved)) { for (const [id, isCollapsed] of Object.entries(collapsed)) {
if (!collapsed) continue;
const panel = document.getElementById(id); const panel = document.getElementById(id);
if (panel) { if (!panel) continue;
if (isCollapsed) {
panel.classList.add('collapsed'); panel.classList.add('collapsed');
const btn = panel.querySelector('.panel-toggle'); const btn = panel.querySelector('.panel-toggle');
if (btn) btn.textContent = '[+]'; if (btn) btn.textContent = '[+]';
@@ -434,189 +359,148 @@ function restoreCollapseState() {
} }
} }
function collapseAll() { // ── Build summary stats header ────────────────────────────────────
document.querySelectorAll('.link-host-panel').forEach(panel => {
panel.classList.add('collapsed');
const btn = panel.querySelector('.panel-toggle');
if (btn) btn.textContent = '[+]';
const id = panel.id;
if (id) {
const saved = JSON.parse(localStorage.getItem('gandalfCollapsed') || '{}');
saved[id] = true;
localStorage.setItem('gandalfCollapsed', JSON.stringify(saved));
}
});
}
function expandAll() {
document.querySelectorAll('.link-host-panel').forEach(panel => {
panel.classList.remove('collapsed');
const btn = panel.querySelector('.panel-toggle');
if (btn) btn.textContent = '[]';
const id = panel.id;
if (id) {
const saved = JSON.parse(localStorage.getItem('gandalfCollapsed') || '{}');
saved[id] = false;
localStorage.setItem('gandalfCollapsed', JSON.stringify(saved));
}
});
}
// ── Link health summary ───────────────────────────────────────────
function buildLinkSummary(hosts, unifiSwitches) { function buildLinkSummary(hosts, unifiSwitches) {
let svrTotal = 0, svrErrors = 0, svrFlap = 0; let totalIfaces = 0, downIfaces = 0, errIfaces = 0, totalPoe = 0;
let swTotal = 0, swUp = 0, swDown = 0, swErrors = 0; for (const ifaces of Object.values(hosts || {})) {
let poeDrawW = 0, poeMaxW = 0;
for (const ifaces of Object.values(hosts)) {
for (const d of Object.values(ifaces)) { for (const d of Object.values(ifaces)) {
svrTotal++; totalIfaces++;
if ((d.tx_errs_rate || 0) > 0.01 || (d.rx_errs_rate || 0) > 0.01) svrErrors++; if (d.link_detected === false) downIfaces++;
if ((d.carrier_changes || 0) > 10) svrFlap++; if ((d.tx_errors_per_sec || 0) > 0 || (d.rx_errors_per_sec || 0) > 0) errIfaces++;
} }
} }
for (const sw of Object.values(unifiSwitches || {})) { for (const sw of Object.values(unifiSwitches || {})) {
for (const d of Object.values(sw.ports || {})) { totalPoe += sw.poe_total_w || 0;
swTotal++;
if (d.up) swUp++; else swDown++;
if ((d.tx_errs_rate || 0) > 0.01 || (d.rx_errs_rate || 0) > 0.01) swErrors++;
if (d.poe_power != null) poeDrawW += d.poe_power;
if (d.poe_max_power != null) poeMaxW += d.poe_max_power;
} }
} const hasAlerts = downIfaces > 0 || errIfaces > 0;
const poePct = poeMaxW > 0 ? (poeDrawW / poeMaxW * 100) : null;
const poeBarCls = poePct >= 80 ? 'poe-bar-crit' : poePct >= 60 ? 'poe-bar-warn' : 'poe-bar-ok';
const totalErrors = svrErrors + swErrors;
const hasAlerts = totalErrors > 0 || svrFlap > 0 || swDown > 0;
return ` return `
<div class="link-summary-panel ${hasAlerts ? 'link-summary-has-alerts' : ''}"> <div class="link-summary-panel ${hasAlerts ? 'link-summary-has-alerts' : ''}">
<div class="link-summary-grid"> <div class="link-summary-grid">
<div class="link-summary-stat"> <div class="link-summary-stat">
<span class="lss-label">Server Ifaces</span> <span class="lss-label">Total Interfaces</span>
<span class="lss-value">${svrTotal}</span> <span class="lss-value">${totalIfaces}</span>
</div> </div>
${svrErrors > 0 ? `<div class="link-summary-stat lss-alert"> <div class="link-summary-stat ${downIfaces ? 'lss-alert' : ''}">
<span class="lss-label">Iface Errors</span> <span class="lss-label">Interfaces Down</span>
<span class="lss-value val-crit">${svrErrors}</span> <span class="lss-value ${downIfaces ? 'val-crit' : 'val-good'}">${downIfaces}</span>
</div>` : ''} </div>
${svrFlap > 0 ? `<div class="link-summary-stat lss-alert"> <div class="link-summary-stat ${errIfaces ? 'lss-alert' : ''}">
<span class="lss-label">Flapping</span> <span class="lss-label">With Errors</span>
<span class="lss-value val-warn">${svrFlap}</span> <span class="lss-value ${errIfaces ? 'val-warn' : 'val-good'}">${errIfaces}</span>
</div>` : ''} </div>
${swTotal > 0 ? `<div class="link-summary-stat"> ${totalPoe > 0 ? `
<span class="lss-label">Switch Ports</span> <div class="link-summary-stat">
<span class="lss-value">${swUp}<span class="lss-sub">/${swTotal}</span></span>
</div>` : ''}
${swDown > 0 ? `<div class="link-summary-stat lss-alert">
<span class="lss-label">Ports Down</span>
<span class="lss-value val-crit">${swDown}</span>
</div>` : ''}
${swErrors > 0 ? `<div class="link-summary-stat lss-alert">
<span class="lss-label">Port Errors</span>
<span class="lss-value val-crit">${swErrors}</span>
</div>` : ''}
${poePct !== null ? `<div class="link-summary-stat">
<span class="lss-label">PoE Load</span> <span class="lss-label">PoE Load</span>
<span class="lss-value ${poeBarCls === 'poe-bar-crit' ? 'val-crit' : poeBarCls === 'poe-bar-warn' ? 'val-warn' : 'val-good'}">${poeDrawW.toFixed(0)}W<span class="lss-sub">/${poeMaxW.toFixed(0)}W</span></span> <span class="lss-value">${totalPoe.toFixed(1)} <span class="lss-sub">W</span></span>
<div class="poe-bar-track" style="margin-top:3px"><div class="poe-bar-fill ${poeBarCls}" style="width:${poePct.toFixed(1)}%"></div></div>
</div>` : ''}
${totalErrors === 0 && svrFlap === 0 && swDown === 0 ? `<div class="link-summary-stat">
<span class="lss-label">Status</span>
<span class="lss-value val-good">All OK ✔</span>
</div>` : ''} </div>` : ''}
</div> </div>
</div>`; </div>`;
} }
// ── Render all hosts ────────────────────────────────────────────── // ── Main render ───────────────────────────────────────────────────
function renderLinks(data) { function renderLinks(data) {
const hosts = data.hosts || {}; const hosts = data.hosts || {};
const unifi = data.unifi_switches || {}; const unifiSwitches = data.unifi_switches || {};
const parts = [];
if (!Object.keys(hosts).length && !Object.keys(unifi).length) { parts.push(buildLinkSummary(hosts, unifiSwitches));
document.getElementById('links-container').innerHTML = parts.push(`<div class="link-collapse-bar">
'<p class="empty-state">No link data collected yet. Monitor may still be initialising.</p>'; <button class="lt-btn lt-btn-ghost lt-btn-sm" onclick="collapseAll()">Collapse All</button>
return; <button class="lt-btn lt-btn-ghost lt-btn-sm" onclick="expandAll()">Expand All</button>
} </div>`);
parts.push('<div class="link-host-list">');
const upd = data.updated ? `Updated: ${data.updated}` : ''; for (const [hostname, ifaces] of Object.entries(hosts)) {
const updEl = document.getElementById('links-updated');
if (updEl) updEl.textContent = upd;
const serverHtml = Object.entries(hosts).map(([hostName, ifaces]) => {
const ifaceCards = Object.entries(ifaces) const ifaceCards = Object.entries(ifaces)
.sort(([a],[b]) => a.localeCompare(b)) .sort(([a],[b]) => a.localeCompare(b))
.map(([ifaceName, d]) => renderIfaceCard(ifaceName, d)) .map(([iname, d]) => renderIfaceCard(iname, d)).join('');
.join(''); const sample = Object.values(ifaces)[0] || {};
const ip = sample.host_ip || '';
const updStr = sample.updated
? new Date(sample.updated + (sample.updated.includes('Z') ? '' : 'Z')).toLocaleTimeString()
: '';
const hostIp = ifaces[Object.keys(ifaces)[0]]?.host_ip || ''; parts.push(`
return ` <div class="link-host-panel" id="panel-${CSS.escape(hostname)}">
<div class="link-host-panel" id="${escHtml(hostName)}">
<div class="link-host-title" onclick="togglePanel(this.closest('.link-host-panel'))"> <div class="link-host-title" onclick="togglePanel(this.closest('.link-host-panel'))">
<span class="link-host-name">${escHtml(hostName)}</span> <span class="link-host-name">${escHtml(hostname)}</span>
${hostIp ? `<span class="link-host-ip">${escHtml(hostIp)}</span>` : ''} <span class="link-host-ip">${escHtml(ip)}</span>
<span class="panel-toggle" title="Collapse / expand">[]</span> <span class="link-host-upd">${updStr}</span>
<span class="panel-toggle">[]</span>
</div> </div>
<div class="link-ifaces-grid"> <div class="link-ifaces-grid">${ifaceCards}</div>
${ifaceCards || '<div class="link-no-data">No interface data available.</div>'} </div>`);
</div> }
</div>`;
}).join('');
document.getElementById('links-container').innerHTML =
buildLinkSummary(hosts, unifi) +
`<div class="link-collapse-bar">
<button class="btn btn-secondary btn-sm" onclick="collapseAll()">Collapse all</button>
<button class="btn btn-secondary btn-sm" onclick="expandAll()">Expand all</button>
</div>` +
`<div class="link-host-list">${serverHtml}</div>` +
renderUnifiSwitches(unifi);
parts.push(renderUnifiSwitches(unifiSwitches));
parts.push('</div>');
document.getElementById('links-container').innerHTML = parts.join('');
restoreCollapseState(); restoreCollapseState();
// Jump to anchor if URL has #hostname
if (location.hash) {
const el = document.querySelector(location.hash);
if (el) {
if (el.classList.contains('collapsed')) togglePanel(el);
el.scrollIntoView({behavior:'smooth', block:'start'});
}
}
} }
// ── Stale data check ───────────────────────────────────────────── function collapseAll() {
document.querySelectorAll('.link-host-panel').forEach(p => {
p.classList.add('collapsed');
const btn = p.querySelector('.panel-toggle');
if (btn) btn.textContent = '[+]';
});
sessionStorage.setItem('linksCollapsed', JSON.stringify(
Object.fromEntries([...document.querySelectorAll('.link-host-panel')].map(p => [p.id, true]))
));
}
function expandAll() {
document.querySelectorAll('.link-host-panel').forEach(p => {
p.classList.remove('collapsed');
const btn = p.querySelector('.panel-toggle');
if (btn) btn.textContent = '[]';
});
sessionStorage.setItem('linksCollapsed', '{}');
}
// ── Stale data warning ────────────────────────────────────────────
function checkLinksStale(updatedStr) { function checkLinksStale(updatedStr) {
let banner = document.getElementById('links-stale-banner');
if (!updatedStr) return; if (!updatedStr) return;
const ageMs = Date.now() - new Date(updatedStr.replace(' UTC', 'Z').replace(' ', 'T')); const age = (Date.now() - new Date(updatedStr + (updatedStr.includes('Z') ? '' : 'Z'))) / 1000;
if (ageMs > 120000) { // >2 minutes let banner = document.getElementById('links-stale-banner');
if (age > 120) {
if (!banner) { if (!banner) {
banner = document.createElement('div'); banner = document.createElement('div');
banner.id = 'links-stale-banner'; banner.id = 'links-stale-banner';
banner.className = 'stale-banner'; banner.className = 'stale-banner';
document.getElementById('links-container').insertAdjacentElement('beforebegin', banner); document.getElementById('links-container').prepend(banner);
} }
const mins = Math.floor(ageMs / 60000); banner.textContent = `⚠ Link data may be stale — last updated ${Math.floor(age/60)}m ago.`;
banner.textContent = `⚠ Link data is stale — last update was ${mins} minute${mins !== 1 ? 's' : ''} ago.`;
banner.style.display = ''; banner.style.display = '';
} else if (banner) { } else if (banner) {
banner.style.display = 'none'; banner.style.display = 'none';
} }
} }
// ── Fetch and render ────────────────────────────────────────────── // ── Fetch + render ────────────────────────────────────────────────
async function loadLinks() { async function loadLinks() {
try { try {
const resp = await fetch('/api/links'); const resp = await fetch('/api/links');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); if (!resp.ok) {
document.getElementById('links-container').innerHTML =
'<div class="error-state">Failed to load link statistics.</div>';
return;
}
const data = await resp.json(); const data = await resp.json();
if (!data.hosts && !data.unifi_switches) {
document.getElementById('links-container').innerHTML =
'<div class="link-no-data">No link data yet — monitor has not completed a full cycle.</div>';
return;
}
const updEl = document.getElementById('links-updated');
if (updEl && data.updated) {
updEl.textContent = 'Updated: ' + new Date(data.updated + (data.updated.includes('Z') ? '' : 'Z')).toLocaleTimeString();
}
renderLinks(data); renderLinks(data);
checkLinksStale(data.updated); checkLinksStale(data.updated);
} catch (e) { } catch (e) {
document.getElementById('links-container').innerHTML = document.getElementById('links-container').innerHTML =
`<div class="error-state"><p>Failed to load link data: ${escHtml(e.message)}</p></div>`; '<div class="error-state">Network error loading link statistics.</div>';
} }
} }
+47 -43
View File
@@ -3,52 +3,53 @@
{% block content %} {% block content %}
<div class="page-header"> <div class="g-page-header">
<h1 class="page-title">Alert Suppressions</h1> <h1 class="g-page-title">Alert Suppressions</h1>
<p class="page-sub">Manage maintenance windows and per-target alert suppression rules.</p> <p class="g-page-sub">Manage maintenance windows and per-target alert suppression rules.</p>
</div> </div>
<!-- ── Create suppression ─────────────────────────────────────────── --> <!-- ── Create suppression ─────────────────────────────────────────── -->
<section class="section"> <section class="g-section">
<div class="section-header"> <div class="g-section-header">
<h2 class="section-title">Create Suppression</h2> <h2 class="g-section-title">Create Suppression</h2>
</div> </div>
<div class="form-card"> <div class="lt-card">
<div class="lt-card-body">
<form id="create-suppression-form" onsubmit="createSuppression(event)"> <form id="create-suppression-form" onsubmit="createSuppression(event)">
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="lt-form-group">
<label for="s-type">Target Type <span class="required">*</span></label> <label class="lt-label" for="s-type">Target Type <span class="required">*</span></label>
<select id="s-type" name="target_type" onchange="onTypeChange()"> <select class="lt-select" id="s-type" name="target_type" onchange="onTypeChange()">
<option value="host">Host (all interfaces)</option> <option value="host">Host (all interfaces)</option>
<option value="interface">Specific Interface</option> <option value="interface">Specific Interface</option>
<option value="unifi_device">UniFi Device</option> <option value="unifi_device">UniFi Device</option>
<option value="all">Global (suppress everything)</option> <option value="all">Global (suppress everything)</option>
</select> </select>
</div> </div>
<div class="form-group" id="name-group"> <div class="lt-form-group" id="name-group">
<label for="s-name">Target Name <span class="required">*</span></label> <label class="lt-label" for="s-name">Target Name <span class="required">*</span></label>
<input type="text" id="s-name" name="target_name" <input type="text" class="lt-input" id="s-name" name="target_name"
placeholder="hostname or device name" autocomplete="off"> placeholder="hostname or device name" autocomplete="off">
</div> </div>
<div class="form-group" id="detail-group" style="display:none"> <div class="lt-form-group" id="detail-group" style="display:none">
<label for="s-detail">Interface Name</label> <label class="lt-label" for="s-detail">Interface Name</label>
<input type="text" id="s-detail" name="target_detail" <input type="text" class="lt-input" id="s-detail" name="target_detail"
placeholder="e.g. enp35s0 or bond0" autocomplete="off"> placeholder="e.g. enp35s0 or bond0" autocomplete="off">
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group form-group-wide"> <div class="lt-form-group form-group-wide">
<label for="s-reason">Reason <span class="required">*</span></label> <label class="lt-label" for="s-reason">Reason <span class="required">*</span></label>
<input type="text" id="s-reason" name="reason" <input type="text" class="lt-input" id="s-reason" name="reason"
placeholder="e.g. Planned switch maintenance, replacing SFP on large1/enp43s0" placeholder="e.g. Planned switch maintenance, replacing SFP on large1/enp43s0"
required> required>
</div> </div>
</div> </div>
<div class="form-row form-row-align"> <div class="form-row form-row-align">
<div class="form-group"> <div class="lt-form-group">
<label>Duration</label> <label class="lt-label">Duration</label>
<div class="duration-pills"> <div class="duration-pills">
<button type="button" class="pill" onclick="setDur(30, this)">30 min</button> <button type="button" class="pill" onclick="setDur(30, this)">30 min</button>
<button type="button" class="pill" onclick="setDur(60, this)">1 hr</button> <button type="button" class="pill" onclick="setDur(60, this)">1 hr</button>
@@ -57,25 +58,27 @@
<button type="button" class="pill pill-manual active" onclick="setDur(null, this)">Manual ∞</button> <button type="button" class="pill pill-manual active" onclick="setDur(null, this)">Manual ∞</button>
</div> </div>
<input type="hidden" id="s-expires" name="expires_minutes" value=""> <input type="hidden" id="s-expires" name="expires_minutes" value="">
<div class="form-hint" id="s-dur-hint">Persists until manually removed.</div> <div class="lt-field-hint" id="s-dur-hint">Persists until manually removed.</div>
</div> </div>
<div class="form-group form-group-submit"> <div class="lt-form-group form-group-submit">
<button type="submit" class="btn btn-primary btn-lg">🔕 Apply Suppression</button> <button type="submit" class="lt-btn lt-btn-primary lt-btn-lg">🔕 Apply Suppression</button>
</div> </div>
</div> </div>
</form> </form>
</div> </div>
</div>
</section> </section>
<!-- ── Active suppressions ────────────────────────────────────────── --> <!-- ── Active suppressions ────────────────────────────────────────── -->
<section class="section"> <section class="g-section">
<div class="section-header"> <div class="g-section-header">
<h2 class="section-title">Active Suppressions</h2> <h2 class="g-section-title">Active Suppressions</h2>
<span class="section-badge">{{ active | length }}</span> <span class="g-section-badge">{{ active | length }}</span>
</div> </div>
{% if active %} {% if active %}
<div class="table-wrap"> <div class="lt-table-wrap">
<table class="data-table" id="active-sup-table"> <table class="lt-table" id="active-sup-table">
<caption class="lt-sr-only">Active suppression rules</caption>
<thead> <thead>
<tr> <tr>
<th>Type</th><th>Target</th><th>Detail</th><th>Reason</th> <th>Type</th><th>Target</th><th>Detail</th><th>Reason</th>
@@ -85,7 +88,7 @@
<tbody> <tbody>
{% for s in active %} {% for s in active %}
<tr id="sup-row-{{ s.id }}"> <tr id="sup-row-{{ s.id }}">
<td><span class="badge badge-info">{{ s.target_type }}</span></td> <td><span class="lt-badge badge-info">{{ s.target_type }}</span></td>
<td>{{ s.target_name or 'all' }}</td> <td>{{ s.target_name or 'all' }}</td>
<td>{{ s.target_detail or '' }}</td> <td>{{ s.target_detail or '' }}</td>
<td>{{ s.reason }}</td> <td>{{ s.reason }}</td>
@@ -93,7 +96,7 @@
<td class="ts-cell">{{ s.created_at }}</td> <td class="ts-cell">{{ s.created_at }}</td>
<td class="ts-cell">{% if s.expires_at %}{{ s.expires_at }}{% else %}<em>manual</em>{% endif %}</td> <td class="ts-cell">{% if s.expires_at %}{{ s.expires_at }}{% else %}<em>manual</em>{% endif %}</td>
<td> <td>
<button class="btn-sm btn-danger" onclick="removeSuppression({{ s.id }})">Remove</button> <button class="lt-btn lt-btn-danger lt-btn-sm" onclick="removeSuppression({{ s.id }})">Remove</button>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -106,14 +109,15 @@
</section> </section>
<!-- ── Suppression history ────────────────────────────────────────── --> <!-- ── Suppression history ────────────────────────────────────────── -->
<section class="section"> <section class="g-section">
<div class="section-header"> <div class="g-section-header">
<h2 class="section-title">History</h2> <h2 class="g-section-title">History</h2>
<span class="section-badge">{{ history | length }}</span> <span class="g-section-badge">{{ history | length }}</span>
</div> </div>
{% if history %} {% if history %}
<div class="table-wrap"> <div class="lt-table-wrap">
<table class="data-table data-table-sm"> <table class="lt-table lt-table-sm">
<caption class="lt-sr-only">Suppression history</caption>
<thead> <thead>
<tr> <tr>
<th>Type</th><th>Target</th><th>Detail</th><th>Reason</th> <th>Type</th><th>Target</th><th>Detail</th><th>Reason</th>
@@ -132,9 +136,9 @@
<td class="ts-cell">{% if s.expires_at %}{{ s.expires_at }}{% else %}<em>manual</em>{% endif %}</td> <td class="ts-cell">{% if s.expires_at %}{{ s.expires_at }}{% else %}<em>manual</em>{% endif %}</td>
<td> <td>
{% if s.active %} {% if s.active %}
<span class="badge badge-ok">Yes</span> <span class="lt-badge badge-ok">Yes</span>
{% else %} {% else %}
<span class="badge badge-neutral">No</span> <span class="lt-badge badge-neutral">No</span>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@@ -148,9 +152,9 @@
</section> </section>
<!-- ── Available targets reference ───────────────────────────────── --> <!-- ── Available targets reference ───────────────────────────────── -->
<section class="section"> <section class="g-section">
<div class="section-header"> <div class="g-section-header">
<h2 class="section-title">Available Targets</h2> <h2 class="g-section-title">Available Targets</h2>
</div> </div>
<div class="targets-grid"> <div class="targets-grid">
{% for name, host in snapshot.hosts.items() %} {% for name, host in snapshot.hosts.items() %}