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:
+7
-6
@@ -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
File diff suppressed because it is too large
Load Diff
+24
-14
@@ -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
@@ -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 — <a href="/api/events?limit=1000">view all via API</a></div>
|
<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 %}
|
{% 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,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
@@ -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">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'}">
|
<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
@@ -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() %}
|
||||||
|
|||||||
Reference in New Issue
Block a user