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

@@ -7,24 +7,31 @@
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<nav class="navbar">
<div class="nav-brand">
<span class="nav-logo"></span>
<span class="nav-title">GANDALF</span>
<span class="nav-sub">Network Monitor</span>
<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>
</div>
<nav class="header-nav">
<a href="{{ url_for('index') }}"
class="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 %}">
Link Debug
</a>
<a href="{{ url_for('suppressions_page') }}"
class="nav-link {% if request.endpoint == 'suppressions_page' %}active{% endif %}">
Suppressions
</a>
</nav>
</div>
<div class="nav-links">
<a href="{{ url_for('index') }}" class="nav-link {% if request.endpoint == 'index' %}active{% endif %}">
Dashboard
</a>
<a href="{{ url_for('suppressions_page') }}" class="nav-link {% if request.endpoint == 'suppressions_page' %}active{% endif %}">
Suppressions
</a>
<div class="header-right">
<span class="header-user">{{ user.name or user.username }}</span>
</div>
<div class="nav-user">
<span class="nav-user-name">{{ user.name or user.username }}</span>
</div>
</nav>
</header>
<main class="main">
{% block content %}{% endblock %}

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 %}

323
templates/links.html Normal file
View File

@@ -0,0 +1,323 @@
{% extends "base.html" %}
{% block title %}Link Debug GANDALF{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Link Debug</h1>
<p class="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 every poll cycle.
<span id="links-updated" style="margin-left:10px; color:var(--text-muted); font-size:.85em;"></span>
</p>
</div>
<div id="links-container">
<div class="link-loading">Loading link statistics</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// ── Formatting helpers ────────────────────────────────────────────
function fmtRate(bytesPerSec) {
if (bytesPerSec === null || bytesPerSec === undefined) return '';
const bps = bytesPerSec * 8;
if (bps < 1e3) return bps.toFixed(0) + ' bps';
if (bps < 1e6) return (bps/1e3).toFixed(1) + ' Kbps';
if (bps < 1e9) return (bps/1e6).toFixed(2) + ' Mbps';
return (bps/1e9).toFixed(3) + ' Gbps';
}
function fmtRateBar(bytesPerSec, linkSpeedMbps) {
if (!linkSpeedMbps || linkSpeedMbps <= 0) return 0;
const mbps = (bytesPerSec * 8) / 1e6;
return Math.min(100, (mbps / linkSpeedMbps) * 100);
}
function fmtSpeed(mbps) {
if (mbps === null || mbps === undefined) return '';
if (mbps >= 1000) return (mbps/1000).toFixed(0) + ' Gbps';
return mbps + ' Mbps';
}
function fmtDuplex(d) {
if (!d) return '';
return d.charAt(0).toUpperCase() + d.slice(1);
}
function fmtTemp(c) {
if (c === null || c === undefined) return '';
return c.toFixed(1) + '°C';
}
function fmtVoltage(v) {
if (v === null || v === undefined) return '';
return v.toFixed(2) + 'V';
}
function fmtPower(dbm) {
if (dbm === null || dbm === undefined) return '';
return dbm.toFixed(2) + ' dBm';
}
function fmtBias(ma) {
if (ma === null || ma === undefined) return '';
return ma.toFixed(2) + ' 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>`;
}
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>`;
}
// Power level: returns {cls, pct} for -30..0 dBm scale
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};
}
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};
}
function tempClass(c) {
if (c === null || c === undefined) return 'val-neutral';
if (c > 80) return 'val-crit';
if (c > 60) 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';
if (v < 3.1 || v > 3.5) return 'val-warn';
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'};
}
// ── Render a single 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>') : '';
// 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(' · ') || '';
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>` : ''}
</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>
</div>
<div class="sfp-stat">
<span class="sfp-stat-label">Voltage</span>
<span class="sfp-stat-value ${vcls}">${fmtVoltage(sfp.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>
</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>
<div class="power-row">
<div class="power-track"><div class="power-fill ${tx.cls}" style="width:${tx.pct}%"></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>
<div class="power-row">
<div class="power-track"><div class="power-fill ${rx.cls}" style="width:${rx.pct}%"></div></div>
</div>
</div>
<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>
</div>
</div>`;
}
return `
<div class="link-iface-card">
<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>` : ''}
</div>
<div class="link-stats-grid">
<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>
</div>
<div class="link-stat">
<span class="link-stat-label">Auto-neg</span>
<span class="link-stat-value val-neutral">${autoneg}</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-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>
</div>
<div class="link-stat">
<span class="link-stat-label">RX Errors</span>
<span class="link-stat-value">${fmtErrors(d.rx_errs_rate)}</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>
</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>
<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>
</div>` : ''}
${sfpHtml}
</div>`;
}
// ── Render all hosts ──────────────────────────────────────────────
function renderLinks(data) {
const hosts = data.hosts || {};
if (!Object.keys(hosts).length) {
document.getElementById('links-container').innerHTML =
'<p class="empty-state">No link data collected yet. Monitor may still be initialising.</p>';
return;
}
const upd = data.updated ? `Updated: ${data.updated}` : '';
const updEl = document.getElementById('links-updated');
if (updEl) updEl.textContent = upd;
const html = Object.entries(hosts).map(([hostName, ifaces]) => {
const ifaceCards = Object.entries(ifaces)
.sort(([a],[b]) => a.localeCompare(b))
.map(([ifaceName, d]) => renderIfaceCard(ifaceName, d))
.join('');
const hostIp = ifaces[Object.keys(ifaces)[0]]?.host_ip || '';
return `
<div class="link-host-panel" id="${escHtml(hostName)}">
<div class="link-host-title">
<span class="link-host-name">${escHtml(hostName)}</span>
${hostIp ? `<span class="link-host-ip">${escHtml(hostIp)}</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 =
`<div class="link-host-list">${html}</div>`;
// Jump to anchor if URL has #hostname
if (location.hash) {
const el = document.querySelector(location.hash);
if (el) el.scrollIntoView({behavior:'smooth', block:'start'});
}
}
// ── Fetch and render ──────────────────────────────────────────────
async function loadLinks() {
try {
const resp = await fetch('/api/links');
if (!resp.ok) throw new Error('API error');
const data = await resp.json();
renderLinks(data);
} catch(e) {
document.getElementById('links-container').innerHTML =
'<p class="empty-state">Failed to load link data.</p>';
}
}
loadLinks();
setInterval(loadLinks, 60000);
</script>
{% endblock %}

View File

@@ -5,13 +5,15 @@
<div class="page-header">
<h1 class="page-title">Alert Suppressions</h1>
<p class="page-sub">Manage maintenance windows and alert suppression rules.</p>
<p class="page-sub">Manage maintenance windows and per-target alert suppression rules.</p>
</div>
<!-- ── Create suppression ─────────────────────────────────────────────── -->
<!-- ── Create suppression ─────────────────────────────────────────── -->
<section class="section">
<h2 class="section-title">Create Suppression</h2>
<div class="card form-card">
<div class="section-header">
<h2 class="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">
@@ -23,13 +25,11 @@
<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"
@@ -57,39 +57,29 @@
<button type="button" class="pill pill-manual active" onclick="setDur(null)">Manual ∞</button>
</div>
<input type="hidden" id="s-expires" name="expires_minutes" value="">
<div class="form-hint" id="s-dur-hint">
This suppression will persist until manually removed.
</div>
<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>
<button type="submit" class="btn btn-primary btn-lg">🔕 Apply Suppression</button>
</div>
</div>
</form>
</div>
</section>
<!-- ── Active suppressions ────────────────────────────────────────────── -->
<!-- ── Active suppressions ────────────────────────────────────────── -->
<section class="section">
<h2 class="section-title">
Active Suppressions
<div class="section-header">
<h2 class="section-title">Active Suppressions</h2>
<span class="section-badge">{{ active | length }}</span>
</h2>
</div>
{% if active %}
<div class="table-wrap">
<table class="data-table" id="active-sup-table">
<thead>
<tr>
<th>Type</th>
<th>Target</th>
<th>Detail</th>
<th>Reason</th>
<th>By</th>
<th>Created</th>
<th>Expires</th>
<th>Actions</th>
<th>Type</th><th>Target</th><th>Detail</th><th>Reason</th>
<th>By</th><th>Created</th><th>Expires</th><th>Actions</th>
</tr>
</thead>
<tbody>
@@ -101,14 +91,9 @@
<td>{{ s.reason }}</td>
<td>{{ s.suppressed_by }}</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>
<button class="btn-sm btn-danger"
onclick="removeSuppression({{ s.id }})">
Remove
</button>
<button class="btn-sm btn-danger" onclick="removeSuppression({{ s.id }})">Remove</button>
</td>
</tr>
{% endfor %}
@@ -120,22 +105,19 @@
{% endif %}
</section>
<!-- ── Suppression history ────────────────────────────────────────────── -->
<!-- ── Suppression history ────────────────────────────────────────── -->
<section class="section">
<h2 class="section-title">History <span class="section-badge">{{ history | length }}</span></h2>
<div class="section-header">
<h2 class="section-title">History</h2>
<span class="section-badge">{{ history | length }}</span>
</div>
{% if history %}
<div class="table-wrap">
<table class="data-table data-table-sm">
<thead>
<tr>
<th>Type</th>
<th>Target</th>
<th>Detail</th>
<th>Reason</th>
<th>By</th>
<th>Created</th>
<th>Expires</th>
<th>Active</th>
<th>Type</th><th>Target</th><th>Detail</th><th>Reason</th>
<th>By</th><th>Created</th><th>Expires</th><th>Active</th>
</tr>
</thead>
<tbody>
@@ -147,9 +129,7 @@
<td>{{ s.reason }}</td>
<td>{{ s.suppressed_by }}</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>
{% if s.active %}
<span class="badge badge-ok">Yes</span>
@@ -167,22 +147,22 @@
{% endif %}
</section>
<!-- ── Available targets reference ───────────────────────────────────── -->
<!-- ── Available targets reference ───────────────────────────────── -->
<section class="section">
<h2 class="section-title">Available Targets</h2>
<div class="section-header">
<h2 class="section-title">Available Targets</h2>
</div>
<div class="targets-grid">
{% for name, host in snapshot.hosts.items() %}
<div class="target-card">
<div class="target-name">{{ name }}</div>
<div class="target-type">Proxmox Host</div>
<div class="target-type">{{ 'Proxmox Host (prometheus)' if host.source == 'prometheus' else 'Ping-only host' }}</div>
{% if host.interfaces %}
<div class="target-ifaces">
{% for iface in host.interfaces.keys() | sort %}
<code class="iface-chip">{{ iface }}</code>
{% endfor %}
</div>
{% else %}
<div class="target-type">ping-only</div>
{% endif %}
</div>
{% endfor %}
@@ -195,11 +175,9 @@
<script>
function onTypeChange() {
const t = document.getElementById('s-type').value;
const nameGrp = document.getElementById('name-group');
const detailGrp = document.getElementById('detail-group');
nameGrp.style.display = (t === 'all') ? 'none' : '';
detailGrp.style.display = (t === 'interface') ? '' : 'none';
document.getElementById('s-name').required = (t !== 'all');
document.getElementById('name-group').style.display = (t==='all') ? 'none' : '';
document.getElementById('detail-group').style.display = (t==='interface') ? '' : 'none';
document.getElementById('s-name').required = (t!=='all');
}
function setDur(mins) {
@@ -208,10 +186,10 @@
event.target.classList.add('active');
const hint = document.getElementById('s-dur-hint');
if (mins) {
const h = Math.floor(mins / 60), m = mins % 60;
hint.textContent = `Suppression expires in ${h ? h+'h ' : ''}${m ? m+'m' : ''}.`;
const h = Math.floor(mins/60), m = mins%60;
hint.textContent = `Expires in ${h?h+'h ':''} ${m?m+'m':''}`.trim()+'.';
} else {
hint.textContent = 'This suppression will persist until manually removed.';
hint.textContent = 'Persists until manually removed.';
}
}
@@ -219,10 +197,10 @@
e.preventDefault();
const form = e.target;
const payload = {
target_type: form.target_type.value,
target_name: form.target_name ? form.target_name.value : '',
target_detail: document.getElementById('s-detail').value,
reason: form.reason.value,
target_type: form.target_type.value,
target_name: form.target_name ? form.target_name.value : '',
target_detail: document.getElementById('s-detail').value,
reason: form.reason.value,
expires_minutes: form.expires_minutes.value ? parseInt(form.expires_minutes.value) : null,
};
const resp = await fetch('/api/suppressions', {
@@ -235,13 +213,13 @@
showToast('Suppression applied', 'success');
setTimeout(() => location.reload(), 800);
} else {
showToast(data.error || 'Error applying suppression', 'error');
showToast(data.error || 'Error', 'error');
}
}
async function removeSuppression(id) {
if (!confirm('Remove this suppression?')) return;
const resp = await fetch(`/api/suppressions/${id}`, { method: 'DELETE' });
const resp = await fetch(`/api/suppressions/${id}`, {method:'DELETE'});
const data = await resp.json();
if (data.success) {
document.getElementById(`sup-row-${id}`)?.remove();