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
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:
+24
-14
@@ -1,47 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="theme-color" content="#030508">
|
||||
<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='style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<div class="header-brand">
|
||||
<span class="header-title">GANDALF</span>
|
||||
<span class="header-sub">Network Monitor // LotusGuild</span>
|
||||
|
||||
<header class="lt-header">
|
||||
<div class="lt-header-left">
|
||||
<div class="lt-brand">
|
||||
<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>
|
||||
<nav class="header-nav">
|
||||
<nav class="lt-nav" aria-label="Main navigation">
|
||||
<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
|
||||
</a>
|
||||
<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
|
||||
</a>
|
||||
<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
|
||||
</a>
|
||||
<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
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="header-user">{{ user.name or user.username }}</span>
|
||||
<div class="lt-header-right">
|
||||
<span class="lt-header-user">{{ user.name or user.username }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<main class="lt-main lt-container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
@@ -51,6 +56,11 @@
|
||||
};
|
||||
</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>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
|
||||
+75
-69
@@ -18,14 +18,14 @@
|
||||
</div>
|
||||
<div class="status-meta">
|
||||
<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>
|
||||
|
||||
<!-- ── Network topology + host grid ───────────────────────────────── -->
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Network Hosts</h2>
|
||||
<section class="g-section">
|
||||
<div class="g-section-header">
|
||||
<h2 class="g-section-title">Network Hosts</h2>
|
||||
</div>
|
||||
|
||||
<div class="topology" id="topology-diagram">
|
||||
@@ -102,7 +102,7 @@
|
||||
|
||||
<!-- Pro 24 PoE → host bus section -->
|
||||
<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>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
@@ -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-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); 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><!-- /topo-v2 -->
|
||||
@@ -201,7 +201,7 @@
|
||||
{% endif %}
|
||||
|
||||
<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-name="{{ name }}"
|
||||
data-sup-detail=""
|
||||
@@ -209,7 +209,7 @@
|
||||
🔕 Suppress
|
||||
</button>
|
||||
<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
|
||||
</a>
|
||||
</div>
|
||||
@@ -222,12 +222,13 @@
|
||||
|
||||
<!-- ── UniFi devices ────────────────────────────────────────────────── -->
|
||||
{% if snapshot.unifi %}
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">UniFi Devices</h2>
|
||||
<section class="g-section">
|
||||
<div class="g-section-header">
|
||||
<h2 class="g-section-title">UniFi Devices</h2>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="unifi-table">
|
||||
<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>
|
||||
@@ -251,7 +252,7 @@
|
||||
<td>{{ d.ip }}</td>
|
||||
<td>
|
||||
{% 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-name="{{ d.name }}"
|
||||
data-sup-detail="">
|
||||
@@ -268,11 +269,11 @@
|
||||
{% endif %}
|
||||
|
||||
<!-- ── Active alerts ───────────────────────────────────────────────── -->
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Active Alerts</h2>
|
||||
<section class="g-section">
|
||||
<div class="g-section-header">
|
||||
<h2 class="g-section-title">Active Alerts</h2>
|
||||
{% 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 %}
|
||||
</div>
|
||||
<div id="events-table-wrap">
|
||||
@@ -280,8 +281,9 @@
|
||||
{% 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="table-wrap">
|
||||
<table class="data-table" id="events-table">
|
||||
<div class="lt-table-wrap">
|
||||
<table class="lt-table" id="events-table">
|
||||
<caption class="lt-sr-only">Active network alerts</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sev</th>
|
||||
@@ -300,7 +302,7 @@
|
||||
{% for e in events %}
|
||||
{% if e.severity != 'info' %}
|
||||
<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><strong>{{ e.target_name }}</strong></td>
|
||||
<td>{{ e.target_detail or '–' }}</td>
|
||||
@@ -319,7 +321,7 @@
|
||||
{% else %}–{% endif %}
|
||||
</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-name="{{ e.target_name }}"
|
||||
data-sup-detail="{{ e.target_detail or '' }}"
|
||||
@@ -341,13 +343,14 @@
|
||||
|
||||
<!-- ── Recently Resolved (last 24h) ───────────────────────────────── -->
|
||||
{% if recent_resolved %}
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Recently Resolved</h2>
|
||||
<span class="section-badge section-badge-resolved">{{ recent_resolved | length }} in last 24h</span>
|
||||
<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="table-wrap">
|
||||
<table class="data-table">
|
||||
<div class="lt-table-wrap">
|
||||
<table class="lt-table">
|
||||
<caption class="lt-sr-only">Recently resolved alerts</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sev</th>
|
||||
@@ -361,7 +364,7 @@
|
||||
<tbody>
|
||||
{% for e in recent_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><strong>{{ e.target_name }}</strong></td>
|
||||
<td>{{ e.target_detail or '–' }}</td>
|
||||
@@ -378,50 +381,53 @@
|
||||
{% endif %}
|
||||
|
||||
<!-- ── Quick-suppress modal ─────────────────────────────────────────── -->
|
||||
<div id="suppress-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Suppress Alert</h3>
|
||||
<button class="modal-close" onclick="closeSuppressModal()">✕</button>
|
||||
<div id="suppress-modal" class="lt-modal-backdrop" style="display:none"
|
||||
role="dialog" aria-modal="true" aria-labelledby="suppress-modal-title">
|
||||
<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" onclick="closeSuppressModal()" aria-label="Close">✕</button>
|
||||
</div>
|
||||
<form id="suppress-form" onsubmit="submitSuppress(event)">
|
||||
<div class="form-group" style="margin-bottom:10px">
|
||||
<label>Target Type</label>
|
||||
<select id="sup-type" name="target_type" onchange="updateSuppressForm()">
|
||||
<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="form-group" id="sup-name-group" style="margin-bottom:10px">
|
||||
<label>Target Name</label>
|
||||
<input type="text" id="sup-name" name="target_name" placeholder="e.g. large1">
|
||||
</div>
|
||||
<div class="form-group" id="sup-detail-group" style="margin-bottom:10px;display:none">
|
||||
<label>Interface <span class="form-hint">(interface type only)</span></label>
|
||||
<input type="text" id="sup-detail" name="target_detail" placeholder="e.g. enp35s0">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:10px">
|
||||
<label>Reason <span class="required">*</span></label>
|
||||
<input type="text" id="sup-reason" name="reason"
|
||||
placeholder="e.g. Planned switch reboot" required>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:0">
|
||||
<label>Duration</label>
|
||||
<div class="duration-pills">
|
||||
<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(240, this)">4 hr</button>
|
||||
<button type="button" class="pill" onclick="setDuration(480, this)">8 hr</button>
|
||||
<button type="button" class="pill pill-manual active" onclick="setDuration(null, this)">Manual ∞</button>
|
||||
<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" onchange="updateSuppressForm()">
|
||||
<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" 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(240, this)">4 hr</button>
|
||||
<button type="button" class="pill" onclick="setDuration(480, this)">8 hr</button>
|
||||
<button type="button" class="pill pill-manual active" onclick="setDuration(null, this)">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>
|
||||
<input type="hidden" id="sup-expires" name="expires_minutes" value="">
|
||||
<div class="form-hint" id="duration-hint">Persists until manually removed.</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeSuppressModal()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Apply</button>
|
||||
<div class="lt-modal-footer">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Network Inspector</h1>
|
||||
<p class="page-sub">
|
||||
<div class="g-page-header">
|
||||
<h1 class="g-page-title">Network Inspector</h1>
|
||||
<p class="g-page-sub">
|
||||
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>
|
||||
</p>
|
||||
|
||||
+230
-346
@@ -3,9 +3,9 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Link Debug</h1>
|
||||
<p class="page-sub">
|
||||
<div class="g-page-header">
|
||||
<h1 class="g-page-title">Link Debug</h1>
|
||||
<p class="g-page-sub">
|
||||
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.
|
||||
<span id="links-updated" style="margin-left:10px; color:var(--text-muted); font-size:.85em;"></span>
|
||||
@@ -49,12 +49,12 @@ function fmtDuplex(d) {
|
||||
|
||||
function fmtTemp(c) {
|
||||
if (c === null || c === undefined) return '–';
|
||||
return c.toFixed(1) + '°C';
|
||||
return c.toFixed(1) + ' °C';
|
||||
}
|
||||
|
||||
function fmtVoltage(v) {
|
||||
if (v === null || v === undefined) return '–';
|
||||
return v.toFixed(2) + 'V';
|
||||
return v.toFixed(2) + ' V';
|
||||
}
|
||||
|
||||
function fmtPower(dbm) {
|
||||
@@ -69,44 +69,34 @@ function fmtBias(ma) {
|
||||
|
||||
function fmtErrors(rate) {
|
||||
if (rate === null || rate === undefined) return '–';
|
||||
if (rate < 0.001) return '<span class="counter-zero">0 /s</span>';
|
||||
return `<span class="counter-nonzero">${rate.toFixed(3)} /s</span>`;
|
||||
if (rate < 0.001) return '<span class="val-good">0 /s</span>';
|
||||
return `<span class="val-crit">${rate.toFixed(3)} /s</span>`;
|
||||
}
|
||||
|
||||
function fmtCarrier(n) {
|
||||
if (n === null || n === undefined) return '–';
|
||||
const v = parseInt(n);
|
||||
if (v <= 2) return `<span class="val-good">${v}</span>`;
|
||||
if (v <= 10) return `<span class="val-warn">${v}</span>`;
|
||||
return `<span class="val-crit">${v}</span>`;
|
||||
if (n === 0) return '<span class="counter-zero">0</span>';
|
||||
return `<span class="counter-nonzero">${n}</span>`;
|
||||
}
|
||||
|
||||
// Power level: returns {cls, pct} for -30..0 dBm scale
|
||||
// ── SFP/DOM value classification ─────────────────────────────────
|
||||
function rxPowerClass(dbm) {
|
||||
if (dbm === null || dbm === undefined) return {cls:'power-ok', pct:0};
|
||||
const pct = Math.max(0, Math.min(100, (dbm + 30) / 30 * 100));
|
||||
let cls = 'power-ok';
|
||||
if (dbm < -25 || dbm > 0) cls = 'power-crit';
|
||||
else if (dbm < -20) cls = 'power-warn';
|
||||
return {cls, pct};
|
||||
if (dbm === null || dbm === undefined) return 'val-neutral';
|
||||
if (dbm < -15) return 'val-crit';
|
||||
if (dbm < -10) return 'val-warn';
|
||||
return 'val-good';
|
||||
}
|
||||
|
||||
function txPowerClass(dbm) {
|
||||
if (dbm === null || dbm === undefined) return {cls:'power-ok', pct:0};
|
||||
const pct = Math.max(0, Math.min(100, (dbm + 20) / 20 * 100));
|
||||
let cls = 'power-ok';
|
||||
if (dbm < -15 || dbm > 2) cls = 'power-crit';
|
||||
else if (dbm < -10) cls = 'power-warn';
|
||||
return {cls, pct};
|
||||
if (dbm === null || dbm === undefined) return 'val-neutral';
|
||||
if (dbm < -5) return 'val-crit';
|
||||
return 'val-good';
|
||||
}
|
||||
|
||||
function tempClass(c) {
|
||||
if (c === null || c === undefined) return 'val-neutral';
|
||||
if (c > 80) return 'val-crit';
|
||||
if (c > 60) return 'val-warn';
|
||||
if (c > 70) return 'val-warn';
|
||||
return 'val-good';
|
||||
}
|
||||
|
||||
function voltageClass(v) {
|
||||
if (v === null || v === undefined) return 'val-neutral';
|
||||
if (v < 3.0 || v > 3.6) return 'val-crit';
|
||||
@@ -114,319 +104,254 @@ function voltageClass(v) {
|
||||
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) {
|
||||
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>');
|
||||
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>');
|
||||
if ((d.carrier_changes || 0) > 10)
|
||||
if ((d.carrier_changes || 0) > 3)
|
||||
badges.push('<span class="link-alert-badge link-alert-amber">FLAP</span>');
|
||||
return badges.join('');
|
||||
}
|
||||
|
||||
// ── Render a single interface card ────────────────────────────────
|
||||
// ── Render a single server interface card ─────────────────────────
|
||||
function renderIfaceCard(ifaceName, d) {
|
||||
const speed = fmtSpeed(d.speed_mbps);
|
||||
const duplex = fmtDuplex(d.duplex);
|
||||
const ptype = portTypeLabel(d.port_type);
|
||||
const autoneg= d.auto_neg !== undefined ? (d.auto_neg ? 'On' : 'Off') : '–';
|
||||
const linkDet= d.link_detected !== undefined ? (d.link_detected ? '<span class="val-good">Yes</span>' : '<span class="val-crit">No</span>') : '–';
|
||||
const isDown = d.link_detected === false || d.admin_status === 'down';
|
||||
const mediaTag = d.media_type === 'fibre' ? 'type-fibre'
|
||||
: d.media_type === 'da' ? 'type-da'
|
||||
: 'type-copper';
|
||||
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 = '';
|
||||
const sfp = d.sfp;
|
||||
if (sfp && Object.keys(sfp).length > 0) {
|
||||
const tx = txPowerClass(sfp.tx_power_dbm);
|
||||
const rx = rxPowerClass(sfp.rx_power_dbm);
|
||||
const tcls = tempClass(sfp.temp_c);
|
||||
const vcls = voltageClass(sfp.voltage_v);
|
||||
|
||||
const vendorStr = [sfp.vendor, sfp.part_no].filter(Boolean).join(' / ') || '–';
|
||||
const sfpTypeStr= [sfp.sfp_type, sfp.connector, sfp.wavelength_nm ? sfp.wavelength_nm + 'nm' : ''].filter(Boolean).join(' · ') || '';
|
||||
if (d.sfp && Object.keys(d.sfp).length > 0) {
|
||||
const s = d.sfp;
|
||||
const rxClass = rxPowerClass(s.rx_power_dbm);
|
||||
const txClass = txPowerClass(s.tx_power_dbm);
|
||||
const tmpClass = tempClass(s.temp_c);
|
||||
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 txPct2 = s.tx_power_dbm != null ? Math.min(100, Math.max(0, (s.tx_power_dbm + 10) / 8 * 100)) : 0;
|
||||
|
||||
sfpHtml = `
|
||||
<div class="sfp-panel">
|
||||
<div class="sfp-vendor-row">
|
||||
<span>${escHtml(vendorStr)}</span>
|
||||
${sfpTypeStr ? `<span style="margin-left:8px;color:var(--text-muted)">${escHtml(sfpTypeStr)}</span>` : ''}
|
||||
${s.vendor ? `<span>${escHtml(s.vendor)}</span>` : ''}
|
||||
${s.part_number ? ` / <span>${escHtml(s.part_number)}</span>` : ''}
|
||||
</div>
|
||||
<div class="sfp-grid">
|
||||
<div class="sfp-stat">
|
||||
<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 class="sfp-stat">
|
||||
<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 class="sfp-stat">
|
||||
<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 class="sfp-stat">
|
||||
<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-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 class="sfp-stat">
|
||||
<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-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>
|
||||
${s.rx_power_dbm != null && s.tx_power_dbm != null ? `
|
||||
<div class="sfp-stat">
|
||||
<span class="sfp-stat-label">RX – TX</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'}">
|
||||
${sfp.rx_power_dbm !== undefined && sfp.tx_power_dbm !== undefined
|
||||
? (sfp.rx_power_dbm - sfp.tx_power_dbm).toFixed(2) + ' dBm'
|
||||
: '–'}
|
||||
</span>
|
||||
</div>
|
||||
<span class="sfp-stat-label">RX−TX Δ</span>
|
||||
<span class="sfp-stat-value">${(s.rx_power_dbm - s.tx_power_dbm).toFixed(2)} dBm</span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="link-iface-card">
|
||||
<div class="link-iface-card ${isDown ? 'port-down' : ''}">
|
||||
<div class="link-iface-header">
|
||||
<span class="link-iface-name">${escHtml(ifaceName)}</span>
|
||||
${speed !== '–' ? `<span class="link-iface-speed">${speed}</span>` : ''}
|
||||
${ptype.label !== '–' ? `<span class="link-iface-type ${ptype.cls}">${escHtml(ptype.label)}</span>` : ''}
|
||||
<span class="link-iface-speed">${speedStr}</span>
|
||||
<span class="link-iface-type ${mediaTag}">${escHtml(mediaLabel)}</span>
|
||||
${errorBadges(d)}
|
||||
</div>
|
||||
<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">
|
||||
<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 class="link-stat">
|
||||
<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 class="link-stat">
|
||||
<span class="link-stat-label">Link Det.</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-label">Carrier Δ</span>
|
||||
<span class="link-stat-value">${fmtCarrier(d.carrier_changes)}</span>
|
||||
</div>
|
||||
<div class="link-stat">
|
||||
<span class="link-stat-label">TX Errors</span>
|
||||
<span class="link-stat-value">${fmtErrors(d.tx_errs_rate)}</span>
|
||||
<span class="link-stat-label">TX Err/s</span>
|
||||
<span class="link-stat-value">${fmtErrors(d.tx_errors_per_sec)}</span>
|
||||
</div>
|
||||
<div class="link-stat">
|
||||
<span class="link-stat-label">RX Errors</span>
|
||||
<span class="link-stat-value">${fmtErrors(d.rx_errs_rate)}</span>
|
||||
<span class="link-stat-label">RX Err/s</span>
|
||||
<span class="link-stat-value">${fmtErrors(d.rx_errors_per_sec)}</span>
|
||||
</div>
|
||||
<div class="link-stat">
|
||||
<span class="link-stat-label">TX Drops</span>
|
||||
<span class="link-stat-value">${fmtErrors(d.tx_drops_rate)}</span>
|
||||
<span class="link-stat-label">TX Drop/s</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>
|
||||
<span class="link-stat-label">RX Drop/s</span>
|
||||
<span class="link-stat-value">${fmtErrors(d.rx_drops_per_sec)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${(txRate !== undefined || rxRate !== undefined) ? `
|
||||
<div class="traffic-section">
|
||||
<div class="traffic-row">
|
||||
<span class="traffic-label">TX</span>
|
||||
<div class="traffic-bar-track">
|
||||
<div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div>
|
||||
</div>
|
||||
<span class="traffic-value">${txStr}</span>
|
||||
<div class="traffic-bar-track"><div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div></div>
|
||||
<span class="traffic-value">${fmtRate(d.tx_bytes_per_sec)}</span>
|
||||
</div>
|
||||
<div class="traffic-row">
|
||||
<span class="traffic-label">RX</span>
|
||||
<div class="traffic-bar-track">
|
||||
<div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div>
|
||||
</div>
|
||||
<span class="traffic-value">${rxStr}</span>
|
||||
<div class="traffic-bar-track"><div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div></div>
|
||||
<span class="traffic-value">${fmtRate(d.rx_bytes_per_sec)}</span>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
</div>
|
||||
${sfpHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Render a single UniFi switch port card ────────────────────────
|
||||
function renderPortCard(portName, d) {
|
||||
const up = d.up;
|
||||
const speed = up ? fmtSpeed(d.speed_mbps) : 'DOWN';
|
||||
const duplex = d.full_duplex ? 'Full' : (up ? 'Half' : '–');
|
||||
const media = d.media || '';
|
||||
|
||||
const uplinkBadge = d.is_uplink
|
||||
? '<span class="port-badge port-badge-uplink">UPLINK</span>' : '';
|
||||
const poeBadge = (d.poe_power != null && d.poe_power > 0)
|
||||
? `<span class="port-badge port-badge-poe">PoE ${d.poe_power.toFixed(1)}W</span>` : '';
|
||||
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);
|
||||
const isDown = !d.up;
|
||||
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);
|
||||
const numBadge = d.port_idx != null ? `<span class="port-badge port-badge-num">#${d.port_idx}</span>` : '';
|
||||
const uplinkBadge = d.is_uplink ? `<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 lldpLine = d.lldp ? `<div class="port-lldp">→ ${escHtml(d.lldp.system_name || '')} (${escHtml(d.lldp.port_id || '')})</div>` : '';
|
||||
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>` : '';
|
||||
|
||||
return `
|
||||
<div class="link-iface-card${up ? '' : ' port-down'}">
|
||||
<div class="link-iface-card ${isDown ? 'port-down' : ''}">
|
||||
<div class="link-iface-header">
|
||||
<span class="link-iface-name">${escHtml(portName)}</span>
|
||||
<span class="link-iface-speed ${up ? '' : 'val-crit'}">${speed}</span>
|
||||
${numBadge}${uplinkBadge}${poeBadge}
|
||||
${media ? `<span class="link-iface-type type-${media.toLowerCase().includes('sfp') ? 'fibre' : 'copper'}">${escHtml(media)}</span>` : ''}
|
||||
<span class="link-iface-name">${numBadge} ${escHtml(portName)}</span>
|
||||
<span class="link-iface-speed">${speedStr}</span>
|
||||
${uplinkBadge}${poeBadge}
|
||||
${errorBadges(d)}
|
||||
</div>
|
||||
${lldpHtml}${poeMaxHtml}
|
||||
${lldpLine}${poeLine}
|
||||
<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">
|
||||
<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 class="link-stat">
|
||||
<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 class="link-stat">
|
||||
<span class="link-stat-label">TX Errors</span>
|
||||
<span class="link-stat-value">${fmtErrors(d.tx_errs_rate)}</span>
|
||||
<span class="link-stat-label">TX Err/s</span>
|
||||
<span class="link-stat-value">${fmtErrors(d.tx_errors_per_sec)}</span>
|
||||
</div>
|
||||
<div class="link-stat">
|
||||
<span class="link-stat-label">RX Errors</span>
|
||||
<span class="link-stat-value">${fmtErrors(d.rx_errs_rate)}</span>
|
||||
<span class="link-stat-label">RX Err/s</span>
|
||||
<span class="link-stat-value">${fmtErrors(d.rx_errors_per_sec)}</span>
|
||||
</div>
|
||||
<div class="link-stat">
|
||||
<span class="link-stat-label">TX Drops</span>
|
||||
<span class="link-stat-value">${fmtErrors(d.tx_drops_rate)}</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>
|
||||
<span class="link-stat-label">TX Drop/s</span>
|
||||
<span class="link-stat-value">${fmtErrors(d.tx_drops_per_sec)}</span>
|
||||
</div>
|
||||
</div>
|
||||
${(up && (txRate != null || rxRate != null)) ? `
|
||||
<div class="traffic-section">
|
||||
<div class="traffic-row">
|
||||
<span class="traffic-label">TX</span>
|
||||
<div class="traffic-bar-track">
|
||||
<div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div>
|
||||
</div>
|
||||
<span class="traffic-value">${txStr}</span>
|
||||
<div class="traffic-bar-track"><div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div></div>
|
||||
<span class="traffic-value">${fmtRate(d.tx_bytes_per_sec)}</span>
|
||||
</div>
|
||||
<div class="traffic-row">
|
||||
<span class="traffic-label">RX</span>
|
||||
<div class="traffic-bar-track">
|
||||
<div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div>
|
||||
</div>
|
||||
<span class="traffic-value">${rxStr}</span>
|
||||
<div class="traffic-bar-track"><div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div></div>
|
||||
<span class="traffic-value">${fmtRate(d.rx_bytes_per_sec)}</span>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Render UniFi switches section ─────────────────────────────────
|
||||
// ── Render all UniFi switches ─────────────────────────────────────
|
||||
function renderUnifiSwitches(unifiSwitches) {
|
||||
if (!unifiSwitches || !Object.keys(unifiSwitches).length) return '';
|
||||
const html = Object.entries(unifiSwitches).map(([swName, sw]) => {
|
||||
const ports = sw.ports || {};
|
||||
const portCards = Object.entries(ports)
|
||||
.sort(([,a],[,b]) => (a.port_idx||0) - (b.port_idx||0))
|
||||
.map(([pname, d]) => renderPortCard(pname, d)).join('');
|
||||
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 panels = Object.entries(unifiSwitches).map(([swName, sw]) => {
|
||||
const ports = sw.ports || {};
|
||||
const allPorts= Object.entries(ports)
|
||||
.sort(([,a],[,b]) => (a.port_idx||0) - (b.port_idx||0));
|
||||
const upCount = allPorts.filter(([,d]) => d.up).length;
|
||||
const downCount = allPorts.length - upCount;
|
||||
|
||||
const portCards = allPorts
|
||||
.map(([pname, d]) => renderPortCard(pname, d))
|
||||
.join('');
|
||||
|
||||
const meta = [
|
||||
sw.model,
|
||||
`${upCount} up`,
|
||||
downCount ? `${downCount} down` : '',
|
||||
].filter(Boolean).join(' · ');
|
||||
// PoE utilisation bar
|
||||
let poebar = '';
|
||||
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 cls = pct > 80 ? 'poe-bar-crit' : pct > 60 ? 'poe-bar-warn' : 'poe-bar-ok';
|
||||
poebar = `<div class="poe-bar-track"><div class="poe-bar-fill ${cls}" style="width:${pct}%"></div></div>`;
|
||||
}
|
||||
|
||||
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'))">
|
||||
<span class="link-host-name">${escHtml(swName)}</span>
|
||||
${sw.ip ? `<span class="link-host-ip">${escHtml(sw.ip)}</span>` : ''}
|
||||
<span class="link-host-upd">${escHtml(meta)}</span>
|
||||
<span class="panel-toggle" title="Collapse / expand">[–]</span>
|
||||
</div>
|
||||
<div class="link-ifaces-grid">
|
||||
${portCards || '<div class="link-no-data">No port data available.</div>'}
|
||||
<span class="link-host-ip">${escHtml(sw.ip || '')}</span>
|
||||
<span class="link-host-upd">${updStr}${poeLoad}</span>
|
||||
${poebar}
|
||||
<span class="panel-toggle">[–]</span>
|
||||
</div>
|
||||
<div class="link-ifaces-grid">${portCards}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="unifi-section-header">UniFi Switches</div>
|
||||
<div class="link-host-list">${panels}</div>`;
|
||||
return `<div class="unifi-section-header">UNIFI SWITCH PORTS</div>${html}`;
|
||||
}
|
||||
|
||||
// ── Collapse / expand panels ───────────────────────────────────────
|
||||
// ── Panel collapse / expand ───────────────────────────────────────
|
||||
function togglePanel(panel) {
|
||||
panel.classList.toggle('collapsed');
|
||||
const btn = panel.querySelector('.panel-toggle');
|
||||
if (btn) btn.textContent = panel.classList.contains('collapsed') ? '[+]' : '[–]';
|
||||
const id = panel.id;
|
||||
if (id) {
|
||||
const saved = JSON.parse(localStorage.getItem('gandalfCollapsed') || '{}');
|
||||
saved[id] = panel.classList.contains('collapsed');
|
||||
localStorage.setItem('gandalfCollapsed', JSON.stringify(saved));
|
||||
const collapsed = JSON.parse(sessionStorage.getItem('linksCollapsed') || '{}');
|
||||
collapsed[id] = panel.classList.contains('collapsed');
|
||||
sessionStorage.setItem('linksCollapsed', JSON.stringify(collapsed));
|
||||
}
|
||||
}
|
||||
|
||||
function restoreCollapseState() {
|
||||
const saved = JSON.parse(localStorage.getItem('gandalfCollapsed') || '{}');
|
||||
for (const [id, collapsed] of Object.entries(saved)) {
|
||||
if (!collapsed) continue;
|
||||
const collapsed = JSON.parse(sessionStorage.getItem('linksCollapsed') || '{}');
|
||||
for (const [id, isCollapsed] of Object.entries(collapsed)) {
|
||||
const panel = document.getElementById(id);
|
||||
if (panel) {
|
||||
if (!panel) continue;
|
||||
if (isCollapsed) {
|
||||
panel.classList.add('collapsed');
|
||||
const btn = panel.querySelector('.panel-toggle');
|
||||
if (btn) btn.textContent = '[+]';
|
||||
@@ -434,189 +359,148 @@ function restoreCollapseState() {
|
||||
}
|
||||
}
|
||||
|
||||
function collapseAll() {
|
||||
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 ───────────────────────────────────────────
|
||||
// ── Build summary stats header ────────────────────────────────────
|
||||
function buildLinkSummary(hosts, unifiSwitches) {
|
||||
let svrTotal = 0, svrErrors = 0, svrFlap = 0;
|
||||
let swTotal = 0, swUp = 0, swDown = 0, swErrors = 0;
|
||||
let poeDrawW = 0, poeMaxW = 0;
|
||||
|
||||
for (const ifaces of Object.values(hosts)) {
|
||||
let totalIfaces = 0, downIfaces = 0, errIfaces = 0, totalPoe = 0;
|
||||
for (const ifaces of Object.values(hosts || {})) {
|
||||
for (const d of Object.values(ifaces)) {
|
||||
svrTotal++;
|
||||
if ((d.tx_errs_rate || 0) > 0.01 || (d.rx_errs_rate || 0) > 0.01) svrErrors++;
|
||||
if ((d.carrier_changes || 0) > 10) svrFlap++;
|
||||
totalIfaces++;
|
||||
if (d.link_detected === false) downIfaces++;
|
||||
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 d of Object.values(sw.ports || {})) {
|
||||
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;
|
||||
}
|
||||
totalPoe += sw.poe_total_w || 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;
|
||||
|
||||
const hasAlerts = downIfaces > 0 || errIfaces > 0;
|
||||
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-stat">
|
||||
<span class="lss-label">Server Ifaces</span>
|
||||
<span class="lss-value">${svrTotal}</span>
|
||||
<span class="lss-label">Total Interfaces</span>
|
||||
<span class="lss-value">${totalIfaces}</span>
|
||||
</div>
|
||||
${svrErrors > 0 ? `<div class="link-summary-stat lss-alert">
|
||||
<span class="lss-label">Iface Errors</span>
|
||||
<span class="lss-value val-crit">${svrErrors}</span>
|
||||
</div>` : ''}
|
||||
${svrFlap > 0 ? `<div class="link-summary-stat lss-alert">
|
||||
<span class="lss-label">Flapping</span>
|
||||
<span class="lss-value val-warn">${svrFlap}</span>
|
||||
</div>` : ''}
|
||||
${swTotal > 0 ? `<div class="link-summary-stat">
|
||||
<span class="lss-label">Switch Ports</span>
|
||||
<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">
|
||||
<div class="link-summary-stat ${downIfaces ? 'lss-alert' : ''}">
|
||||
<span class="lss-label">Interfaces Down</span>
|
||||
<span class="lss-value ${downIfaces ? 'val-crit' : 'val-good'}">${downIfaces}</span>
|
||||
</div>
|
||||
<div class="link-summary-stat ${errIfaces ? 'lss-alert' : ''}">
|
||||
<span class="lss-label">With Errors</span>
|
||||
<span class="lss-value ${errIfaces ? 'val-warn' : 'val-good'}">${errIfaces}</span>
|
||||
</div>
|
||||
${totalPoe > 0 ? `
|
||||
<div class="link-summary-stat">
|
||||
<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>
|
||||
<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>
|
||||
<span class="lss-value">${totalPoe.toFixed(1)} <span class="lss-sub">W</span></span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Render all hosts ──────────────────────────────────────────────
|
||||
// ── Main render ───────────────────────────────────────────────────
|
||||
function renderLinks(data) {
|
||||
const hosts = data.hosts || {};
|
||||
const unifi = data.unifi_switches || {};
|
||||
const hosts = data.hosts || {};
|
||||
const unifiSwitches = data.unifi_switches || {};
|
||||
const parts = [];
|
||||
|
||||
if (!Object.keys(hosts).length && !Object.keys(unifi).length) {
|
||||
document.getElementById('links-container').innerHTML =
|
||||
'<p class="empty-state">No link data collected yet. Monitor may still be initialising.</p>';
|
||||
return;
|
||||
}
|
||||
parts.push(buildLinkSummary(hosts, unifiSwitches));
|
||||
parts.push(`<div class="link-collapse-bar">
|
||||
<button class="lt-btn lt-btn-ghost lt-btn-sm" onclick="collapseAll()">Collapse All</button>
|
||||
<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}` : '';
|
||||
const updEl = document.getElementById('links-updated');
|
||||
if (updEl) updEl.textContent = upd;
|
||||
|
||||
const serverHtml = Object.entries(hosts).map(([hostName, ifaces]) => {
|
||||
for (const [hostname, ifaces] of Object.entries(hosts)) {
|
||||
const ifaceCards = Object.entries(ifaces)
|
||||
.sort(([a],[b]) => a.localeCompare(b))
|
||||
.map(([ifaceName, d]) => renderIfaceCard(ifaceName, d))
|
||||
.join('');
|
||||
.map(([iname, d]) => renderIfaceCard(iname, d)).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 || '';
|
||||
return `
|
||||
<div class="link-host-panel" id="${escHtml(hostName)}">
|
||||
parts.push(`
|
||||
<div class="link-host-panel" id="panel-${CSS.escape(hostname)}">
|
||||
<div class="link-host-title" onclick="togglePanel(this.closest('.link-host-panel'))">
|
||||
<span class="link-host-name">${escHtml(hostName)}</span>
|
||||
${hostIp ? `<span class="link-host-ip">${escHtml(hostIp)}</span>` : ''}
|
||||
<span class="panel-toggle" title="Collapse / expand">[–]</span>
|
||||
<span class="link-host-name">${escHtml(hostname)}</span>
|
||||
<span class="link-host-ip">${escHtml(ip)}</span>
|
||||
<span class="link-host-upd">${updStr}</span>
|
||||
<span class="panel-toggle">[–]</span>
|
||||
</div>
|
||||
<div class="link-ifaces-grid">
|
||||
${ifaceCards || '<div class="link-no-data">No interface data available.</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);
|
||||
|
||||
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'});
|
||||
}
|
||||
<div class="link-ifaces-grid">${ifaceCards}</div>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
parts.push(renderUnifiSwitches(unifiSwitches));
|
||||
parts.push('</div>');
|
||||
document.getElementById('links-container').innerHTML = parts.join('');
|
||||
restoreCollapseState();
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
let banner = document.getElementById('links-stale-banner');
|
||||
if (!updatedStr) return;
|
||||
const ageMs = Date.now() - new Date(updatedStr.replace(' UTC', 'Z').replace(' ', 'T'));
|
||||
if (ageMs > 120000) { // >2 minutes
|
||||
const age = (Date.now() - new Date(updatedStr + (updatedStr.includes('Z') ? '' : 'Z'))) / 1000;
|
||||
let banner = document.getElementById('links-stale-banner');
|
||||
if (age > 120) {
|
||||
if (!banner) {
|
||||
banner = document.createElement('div');
|
||||
banner.id = 'links-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 is stale — last update was ${mins} minute${mins !== 1 ? 's' : ''} ago.`;
|
||||
banner.textContent = `⚠ Link data may be stale — last updated ${Math.floor(age/60)}m ago.`;
|
||||
banner.style.display = '';
|
||||
} else if (banner) {
|
||||
banner.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fetch and render ──────────────────────────────────────────────
|
||||
// ── Fetch + render ────────────────────────────────────────────────
|
||||
async function loadLinks() {
|
||||
try {
|
||||
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();
|
||||
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);
|
||||
checkLinksStale(data.updated);
|
||||
} catch(e) {
|
||||
} catch (e) {
|
||||
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>';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+77
-73
@@ -3,79 +3,82 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Alert Suppressions</h1>
|
||||
<p class="page-sub">Manage maintenance windows and per-target alert suppression rules.</p>
|
||||
<div class="g-page-header">
|
||||
<h1 class="g-page-title">Alert Suppressions</h1>
|
||||
<p class="g-page-sub">Manage maintenance windows and per-target alert suppression rules.</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Create suppression ─────────────────────────────────────────── -->
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Create Suppression</h2>
|
||||
<section class="g-section">
|
||||
<div class="g-section-header">
|
||||
<h2 class="g-section-title">Create Suppression</h2>
|
||||
</div>
|
||||
<div class="form-card">
|
||||
<form id="create-suppression-form" onsubmit="createSuppression(event)">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="s-type">Target Type <span class="required">*</span></label>
|
||||
<select id="s-type" name="target_type" onchange="onTypeChange()">
|
||||
<option value="host">Host (all interfaces)</option>
|
||||
<option value="interface">Specific Interface</option>
|
||||
<option value="unifi_device">UniFi Device</option>
|
||||
<option value="all">Global (suppress everything)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="name-group">
|
||||
<label for="s-name">Target Name <span class="required">*</span></label>
|
||||
<input type="text" id="s-name" name="target_name"
|
||||
placeholder="hostname or device name" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group" id="detail-group" style="display:none">
|
||||
<label for="s-detail">Interface Name</label>
|
||||
<input type="text" id="s-detail" name="target_detail"
|
||||
placeholder="e.g. enp35s0 or bond0" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group form-group-wide">
|
||||
<label for="s-reason">Reason <span class="required">*</span></label>
|
||||
<input type="text" id="s-reason" name="reason"
|
||||
placeholder="e.g. Planned switch maintenance, replacing SFP on large1/enp43s0"
|
||||
required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row form-row-align">
|
||||
<div class="form-group">
|
||||
<label>Duration</label>
|
||||
<div class="duration-pills">
|
||||
<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(240, this)">4 hr</button>
|
||||
<button type="button" class="pill" onclick="setDur(480, this)">8 hr</button>
|
||||
<button type="button" class="pill pill-manual active" onclick="setDur(null, this)">Manual ∞</button>
|
||||
<div class="lt-card">
|
||||
<div class="lt-card-body">
|
||||
<form id="create-suppression-form" onsubmit="createSuppression(event)">
|
||||
<div class="form-row">
|
||||
<div class="lt-form-group">
|
||||
<label class="lt-label" for="s-type">Target Type <span class="required">*</span></label>
|
||||
<select class="lt-select" id="s-type" name="target_type" onchange="onTypeChange()">
|
||||
<option value="host">Host (all interfaces)</option>
|
||||
<option value="interface">Specific Interface</option>
|
||||
<option value="unifi_device">UniFi Device</option>
|
||||
<option value="all">Global (suppress everything)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="lt-form-group" id="name-group">
|
||||
<label class="lt-label" for="s-name">Target Name <span class="required">*</span></label>
|
||||
<input type="text" class="lt-input" id="s-name" name="target_name"
|
||||
placeholder="hostname or device name" autocomplete="off">
|
||||
</div>
|
||||
<div class="lt-form-group" id="detail-group" style="display:none">
|
||||
<label class="lt-label" for="s-detail">Interface Name</label>
|
||||
<input type="text" class="lt-input" id="s-detail" name="target_detail"
|
||||
placeholder="e.g. enp35s0 or bond0" autocomplete="off">
|
||||
</div>
|
||||
<input type="hidden" id="s-expires" name="expires_minutes" value="">
|
||||
<div class="form-hint" id="s-dur-hint">Persists until manually removed.</div>
|
||||
</div>
|
||||
<div class="form-group form-group-submit">
|
||||
<button type="submit" class="btn btn-primary btn-lg">🔕 Apply Suppression</button>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="lt-form-group form-group-wide">
|
||||
<label class="lt-label" for="s-reason">Reason <span class="required">*</span></label>
|
||||
<input type="text" class="lt-input" id="s-reason" name="reason"
|
||||
placeholder="e.g. Planned switch maintenance, replacing SFP on large1/enp43s0"
|
||||
required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="form-row form-row-align">
|
||||
<div class="lt-form-group">
|
||||
<label class="lt-label">Duration</label>
|
||||
<div class="duration-pills">
|
||||
<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(240, this)">4 hr</button>
|
||||
<button type="button" class="pill" onclick="setDur(480, this)">8 hr</button>
|
||||
<button type="button" class="pill pill-manual active" onclick="setDur(null, this)">Manual ∞</button>
|
||||
</div>
|
||||
<input type="hidden" id="s-expires" name="expires_minutes" value="">
|
||||
<div class="lt-field-hint" id="s-dur-hint">Persists until manually removed.</div>
|
||||
</div>
|
||||
<div class="lt-form-group form-group-submit">
|
||||
<button type="submit" class="lt-btn lt-btn-primary lt-btn-lg">🔕 Apply Suppression</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Active suppressions ────────────────────────────────────────── -->
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Active Suppressions</h2>
|
||||
<span class="section-badge">{{ active | length }}</span>
|
||||
<section class="g-section">
|
||||
<div class="g-section-header">
|
||||
<h2 class="g-section-title">Active Suppressions</h2>
|
||||
<span class="g-section-badge">{{ active | length }}</span>
|
||||
</div>
|
||||
{% if active %}
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="active-sup-table">
|
||||
<div class="lt-table-wrap">
|
||||
<table class="lt-table" id="active-sup-table">
|
||||
<caption class="lt-sr-only">Active suppression rules</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th><th>Target</th><th>Detail</th><th>Reason</th>
|
||||
@@ -85,7 +88,7 @@
|
||||
<tbody>
|
||||
{% for s in active %}
|
||||
<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_detail or '–' }}</td>
|
||||
<td>{{ s.reason }}</td>
|
||||
@@ -93,7 +96,7 @@
|
||||
<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>
|
||||
<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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -106,14 +109,15 @@
|
||||
</section>
|
||||
|
||||
<!-- ── Suppression history ────────────────────────────────────────── -->
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">History</h2>
|
||||
<span class="section-badge">{{ history | length }}</span>
|
||||
<section class="g-section">
|
||||
<div class="g-section-header">
|
||||
<h2 class="g-section-title">History</h2>
|
||||
<span class="g-section-badge">{{ history | length }}</span>
|
||||
</div>
|
||||
{% if history %}
|
||||
<div class="table-wrap">
|
||||
<table class="data-table data-table-sm">
|
||||
<div class="lt-table-wrap">
|
||||
<table class="lt-table lt-table-sm">
|
||||
<caption class="lt-sr-only">Suppression history</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
{% if s.active %}
|
||||
<span class="badge badge-ok">Yes</span>
|
||||
<span class="lt-badge badge-ok">Yes</span>
|
||||
{% else %}
|
||||
<span class="badge badge-neutral">No</span>
|
||||
<span class="lt-badge badge-neutral">No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -148,9 +152,9 @@
|
||||
</section>
|
||||
|
||||
<!-- ── Available targets reference ───────────────────────────────── -->
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Available Targets</h2>
|
||||
<section class="g-section">
|
||||
<div class="g-section-header">
|
||||
<h2 class="g-section-title">Available Targets</h2>
|
||||
</div>
|
||||
<div class="targets-grid">
|
||||
{% for name, host in snapshot.hosts.items() %}
|
||||
|
||||
Reference in New Issue
Block a user