feat: terminal aesthetic rewrite + link debug page

- Full dark terminal aesthetic (Pulse/TinkerTickets style):
  - #0a0a0a background, #00ff41 green, #ffb000 amber, #00ffff cyan
  - CRT scanline overlay, phosphor glow, ASCII corner pseudoelements
  - Bracket-notation badges [CRITICAL], monospace font throughout
  - style.css, base.html, index.html, suppressions.html all rewritten

- New Link Debug page (/links, /api/links):
  - Per-host, per-interface cards with speed/duplex/port type/auto-neg
  - Traffic bars (TX cyan, RX green) with rate labels
  - Error/drop counters, carrier change history
  - SFP/DOM optical panel: vendor, temp, voltage, bias, TX/RX power dBm bars
  - RX-TX delta shown; color-coded warn/crit thresholds
  - Auto-refresh every 60s, anchor-jump to #hostname

- LinkStatsCollector in monitor.py:
  - SSHes to each host (one connection, all ifaces batched)
  - Parses ethtool + ethtool -m (SFP DOM) output
  - Merges with Prometheus traffic/error/carrier metrics
  - Stores as link_stats in monitor_state table

- config.json: added ssh section for ethtool collection
- app.js: terminal chip style consistency (uppercase, ● bullet)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 12:43:11 -05:00
parent 4356af1d84
commit fa7512a2c2
9 changed files with 1443 additions and 748 deletions

View File

@@ -3,33 +3,37 @@
{% block content %}
<!-- ── Status bar ─────────────────────────────────────────────────────── -->
<!-- ── Status bar ──────────────────────────────────────────────────── -->
<div class="status-bar">
<div class="status-chips">
{% if summary.critical %}
<span class="chip chip-critical"> {{ summary.critical }} Critical</span>
<span class="chip chip-critical"> {{ summary.critical }} CRITICAL</span>
{% endif %}
{% if summary.warning %}
<span class="chip chip-warning"> {{ summary.warning }} Warning</span>
<span class="chip chip-warning"> {{ summary.warning }} WARNING</span>
{% endif %}
{% if not summary.critical and not summary.warning %}
<span class="chip chip-ok">✔ All systems nominal</span>
<span class="chip chip-ok">✔ ALL SYSTEMS NOMINAL</span>
{% endif %}
</div>
<div class="status-meta">
<span class="last-check" id="last-check">Last check: {{ last_check }}</span>
<button class="btn-refresh" onclick="refreshAll()">↻ Refresh</button>
<span class="last-check" id="last-check">{{ last_check }}</span>
<button class="btn-refresh" onclick="refreshAll()">↻ REFRESH</button>
</div>
</div>
<!-- ── Network topology + host grid ──────────────────────────────────── -->
<!-- ── Network topology + host grid ───────────────────────────────── -->
<section class="section">
<h2 class="section-title">Network Hosts</h2>
<div class="section-header">
<h2 class="section-title">Network Hosts</h2>
</div>
<!-- Simple topology diagram -->
<div class="topology" id="topology-diagram">
<div class="topo-row topo-row-internet">
<div class="topo-node topo-internet">🌐 Internet</div>
<div class="topo-node topo-internet">
<span class="topo-icon"></span>
<span class="topo-label">Internet</span>
</div>
</div>
<div class="topo-connectors single">
<div class="topo-line"></div>
@@ -87,7 +91,7 @@
<span class="host-status-dot dot-{{ host.status }}"></span>
<span class="host-name">{{ name }}</span>
{% if suppressed %}
<span class="badge badge-suppressed" title="Suppressed">🔕</span>
<span class="badge-suppressed" title="Suppressed">🔕</span>
{% endif %}
</div>
<div class="host-meta">
@@ -107,15 +111,19 @@
{% endfor %}
</div>
{% else %}
<div class="host-ping-note">Monitored via ping only</div>
<div class="host-ping-note">ping-only / no node_exporter</div>
{% endif %}
<div class="host-actions">
<button class="btn-sm btn-suppress"
onclick="openSuppressModal('host', '{{ name }}', '')"
title="Suppress alerts for this host">
🔕 Suppress Host
🔕 Suppress
</button>
<a href="{{ url_for('links_page') }}#{{ name }}"
class="btn-sm btn-secondary" style="text-decoration:none">
↗ Links
</a>
</div>
</div>
{% else %}
@@ -124,10 +132,12 @@
</div>
</section>
<!-- ── UniFi devices ──────────────────────────────────────────────────── -->
<!-- ── UniFi devices ────────────────────────────────────────────────── -->
{% if snapshot.unifi %}
<section class="section">
<h2 class="section-title">UniFi Devices</h2>
<div class="section-header">
<h2 class="section-title">UniFi Devices</h2>
</div>
<div class="table-wrap">
<table class="data-table" id="unifi-table">
<thead>
@@ -145,7 +155,7 @@
<tr class="{% if not d.connected %}row-critical{% endif %}">
<td>
<span class="dot-{{ 'up' if d.connected else 'down' }}"></span>
{{ 'Online' if d.connected else 'Offline' }}
{{ 'ONLINE' if d.connected else 'OFFLINE' }}
</td>
<td><strong>{{ d.name }}</strong></td>
<td>{{ d.type }}</td>
@@ -167,70 +177,68 @@
</section>
{% endif %}
<!-- ── Active alerts ─────────────────────────────────────────────────── -->
<!-- ── Active alerts ───────────────────────────────────────────────── -->
<section class="section">
<h2 class="section-title">
Active Alerts
<div class="section-header">
<h2 class="section-title">Active Alerts</h2>
{% if summary.critical or summary.warning %}
<span class="section-badge badge-critical">{{ (summary.critical or 0) + (summary.warning or 0) }} open</span>
<span class="section-badge">{{ (summary.critical or 0) + (summary.warning or 0) }}</span>
{% endif %}
</h2>
<div class="table-wrap" id="events-table-wrap">
</div>
<div id="events-table-wrap">
{% if events %}
<table class="data-table" id="events-table">
<thead>
<tr>
<th>Severity</th>
<th>Type</th>
<th>Target</th>
<th>Detail</th>
<th>Description</th>
<th>First Seen</th>
<th>Failures</th>
<th>Ticket</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for e in events %}
{% if e.severity != 'info' %}
<tr class="row-{{ e.severity }}">
<td><span class="badge badge-{{ e.severity }}">{{ e.severity }}</span></td>
<td>{{ e.event_type | replace('_', ' ') }}</td>
<td><strong>{{ e.target_name }}</strong></td>
<td>{{ e.target_detail or '' }}</td>
<td class="desc-cell" title="{{ e.description }}">{{ e.description | truncate(60) }}</td>
<td class="ts-cell">{{ e.first_seen }}</td>
<td>{{ e.consecutive_failures }}</td>
<td>
{% if e.ticket_id %}
<a href="http://t.lotusguild.org/ticket/{{ e.ticket_id }}" target="_blank"
class="ticket-link">#{{ e.ticket_id }}</a>
{% else %}{% endif %}
</td>
<td>
<button class="btn-sm btn-suppress"
onclick="openSuppressModal('{{ 'unifi_device' if e.event_type == 'unifi_device_offline' else 'interface' if e.event_type == 'interface_down' else 'host' }}', '{{ e.target_name }}', '{{ e.target_detail or '' }}')"
title="Suppress this alert">
🔕
</button>
</td>
</tr>
{% endif %}
{% else %}
<tr class="empty-row">
<td colspan="9" class="empty-state">No active alerts ✔</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="table-wrap">
<table class="data-table" id="events-table">
<thead>
<tr>
<th>Sev</th>
<th>Type</th>
<th>Target</th>
<th>Detail</th>
<th>Description</th>
<th>First Seen</th>
<th>Failures</th>
<th>Ticket</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for e in events %}
{% if e.severity != 'info' %}
<tr class="row-{{ e.severity }}">
<td><span class="badge badge-{{ e.severity }}">{{ e.severity }}</span></td>
<td>{{ e.event_type | replace('_', ' ') }}</td>
<td><strong>{{ e.target_name }}</strong></td>
<td>{{ e.target_detail or '' }}</td>
<td class="desc-cell" title="{{ e.description }}">{{ e.description | truncate(60) }}</td>
<td class="ts-cell">{{ e.first_seen }}</td>
<td>{{ e.consecutive_failures }}</td>
<td>
{% if e.ticket_id %}
<a href="http://t.lotusguild.org/ticket/{{ e.ticket_id }}" target="_blank"
class="ticket-link">#{{ e.ticket_id }}</a>
{% else %}{% endif %}
</td>
<td>
<button class="btn-sm btn-suppress"
onclick="openSuppressModal('{{ 'unifi_device' if e.event_type == 'unifi_device_offline' else 'interface' if e.event_type == 'interface_down' else 'host' }}', '{{ e.target_name }}', '{{ e.target_detail or '' }}')"
title="Suppress">🔕</button>
</td>
</tr>
{% endif %}
{% else %}
<tr><td colspan="9" class="empty-state">No active alerts ✔</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty-state">No active alerts ✔</p>
{% endif %}
</div>
</section>
<!-- ── Quick-suppress modal ───────────────────────────────────────────── -->
<!-- ── Quick-suppress modal ─────────────────────────────────────────── -->
<div id="suppress-modal" class="modal-overlay" style="display:none">
<div class="modal">
<div class="modal-header">
@@ -238,42 +246,43 @@
<button class="modal-close" onclick="closeSuppressModal()"></button>
</div>
<form id="suppress-form" onsubmit="submitSuppress(event)">
<div class="form-group">
<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">Everything (global maintenance)</option>
<option value="all">Global Maintenance</option>
</select>
</div>
<div class="form-group" id="sup-name-group">
<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">
<label>Interface Name <span class="form-hint">(for interface type)</span></label>
<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">
<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>
<input type="text" id="sup-reason" name="reason"
placeholder="e.g. Planned switch reboot" required>
</div>
<div class="form-group">
<div class="form-group" style="margin-bottom:0">
<label>Duration</label>
<div class="duration-pills">
<button type="button" class="pill" onclick="setDuration(30)">30 min</button>
<button type="button" class="pill" onclick="setDuration(60)">1 hr</button>
<button type="button" class="pill" onclick="setDuration(240)">4 hr</button>
<button type="button" class="pill" onclick="setDuration(480)">8 hr</button>
<button type="button" class="pill pill-manual active" onclick="setDuration(null)">Manual</button>
<button type="button" class="pill pill-manual active" onclick="setDuration(null)">Manual</button>
</div>
<input type="hidden" id="sup-expires" name="expires_minutes" value="">
<div class="form-hint" id="duration-hint">Suppression will persist until manually removed.</div>
<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 Suppression</button>
<button type="submit" class="btn btn-primary">Apply</button>
</div>
</form>
</div>
@@ -283,7 +292,6 @@
{% block scripts %}
<script>
// Auto-refresh every 30 seconds
setInterval(refreshAll, 30000);
</script>
{% endblock %}