Files
gandalf/static/app.js
Jared Vititoe fa7512a2c2 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>
2026-03-02 12:43:11 -05:00

275 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// ── Toast notifications ───────────────────────────────────────────────
function showToast(msg, type = 'success') {
let container = document.querySelector('.toast-container');
if (!container) {
container = document.createElement('div');
container.className = 'toast-container';
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = msg;
container.appendChild(toast);
setTimeout(() => toast.remove(), 3500);
}
// ── Dashboard auto-refresh ────────────────────────────────────────────
async function refreshAll() {
try {
const [netResp, statusResp] = await Promise.all([
fetch('/api/network'),
fetch('/api/status'),
]);
if (!netResp.ok || !statusResp.ok) return;
const net = await netResp.json();
const status = await statusResp.json();
updateHostGrid(net.hosts || {});
updateUnifiTable(net.unifi || []);
updateEventsTable(status.events || []);
updateStatusBar(status.summary || {}, status.last_check || '');
updateTopology(net.hosts || {});
} catch (e) {
console.warn('Refresh failed:', e);
}
}
function updateStatusBar(summary, lastCheck) {
const bar = document.querySelector('.status-chips');
if (!bar) return;
const chips = [];
if (summary.critical) chips.push(`<span class="chip chip-critical">● ${summary.critical} CRITICAL</span>`);
if (summary.warning) chips.push(`<span class="chip chip-warning">● ${summary.warning} WARNING</span>`);
if (!summary.critical && !summary.warning) chips.push('<span class="chip chip-ok">✔ ALL SYSTEMS NOMINAL</span>');
bar.innerHTML = chips.join('');
const lc = document.getElementById('last-check');
if (lc && lastCheck) lc.textContent = lastCheck;
}
function updateHostGrid(hosts) {
for (const [name, host] of Object.entries(hosts)) {
const card = document.querySelector(`.host-card[data-host="${CSS.escape(name)}"]`);
if (!card) continue;
// Update card border class
card.className = card.className.replace(/host-card-(up|down|degraded|unknown)/g, '');
card.classList.add(`host-card-${host.status}`);
// Update status dot in header
const dot = card.querySelector('.host-status-dot');
if (dot) dot.className = `host-status-dot dot-${host.status}`;
// Update interface rows
const ifaceList = card.querySelector('.iface-list');
if (ifaceList && host.interfaces && Object.keys(host.interfaces).length > 0) {
ifaceList.innerHTML = Object.entries(host.interfaces)
.sort(([a], [b]) => a.localeCompare(b))
.map(([iface, state]) => `
<div class="iface-row">
<span class="iface-dot dot-${state}"></span>
<span class="iface-name">${escHtml(iface)}</span>
<span class="iface-state state-${state}">${state}</span>
</div>
`).join('');
}
}
}
function updateTopology(hosts) {
document.querySelectorAll('.topo-host').forEach(node => {
const name = node.dataset.host;
const host = hosts[name];
if (!host) return;
node.className = node.className.replace(/topo-status-(up|down|degraded|unknown)/g, '');
node.classList.add(`topo-status-${host.status}`);
const badge = node.querySelector('.topo-badge');
if (badge) {
badge.className = `topo-badge topo-badge-${host.status}`;
badge.textContent = host.status;
}
});
}
function updateUnifiTable(devices) {
const tbody = document.querySelector('#unifi-table tbody');
if (!tbody || !devices.length) return;
tbody.innerHTML = devices.map(d => {
const statusClass = d.connected ? '' : 'row-critical';
const dotClass = d.connected ? 'dot-up' : 'dot-down';
const statusText = d.connected ? 'Online' : 'Offline';
const suppressBtn = !d.connected
? `<button class="btn-sm btn-suppress"
onclick="openSuppressModal('unifi_device','${escHtml(d.name)}','')">🔕 Suppress</button>`
: '';
return `
<tr class="${statusClass}">
<td><span class="${dotClass}"></span> ${statusText}</td>
<td><strong>${escHtml(d.name)}</strong></td>
<td>${escHtml(d.type)}</td>
<td>${escHtml(d.model)}</td>
<td>${escHtml(d.ip)}</td>
<td>${suppressBtn}</td>
</tr>`;
}).join('');
}
function updateEventsTable(events) {
const wrap = document.getElementById('events-table-wrap');
if (!wrap) return;
const active = events.filter(e => e.severity !== 'info');
if (!active.length) {
wrap.innerHTML = '<p class="empty-state">No active alerts ✔</p>';
return;
}
const rows = active.map(e => {
const supType = e.event_type === 'unifi_device_offline' ? 'unifi_device'
: e.event_type === 'interface_down' ? 'interface'
: 'host';
const ticket = e.ticket_id
? `<a href="http://t.lotusguild.org/ticket/${e.ticket_id}" target="_blank"
class="ticket-link">#${e.ticket_id}</a>`
: '';
return `
<tr class="row-${e.severity}">
<td><span class="badge badge-${e.severity}">${e.severity}</span></td>
<td>${escHtml(e.event_type.replace(/_/g,' '))}</td>
<td><strong>${escHtml(e.target_name)}</strong></td>
<td>${escHtml(e.target_detail || '')}</td>
<td class="desc-cell" title="${escHtml(e.description || '')}">${escHtml((e.description||'').substring(0,60))}${(e.description||'').length>60?'…':''}</td>
<td class="ts-cell">${escHtml(e.first_seen||'')}</td>
<td>${e.consecutive_failures}</td>
<td>${ticket}</td>
<td>
<button class="btn-sm btn-suppress"
onclick="openSuppressModal('${supType}','${escHtml(e.target_name)}','${escHtml(e.target_detail||'')}')">
🔕
</button>
</td>
</tr>`;
}).join('');
wrap.innerHTML = `
<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>${rows}</tbody>
</table>
</div>`;
}
// ── Suppression modal (dashboard) ────────────────────────────────────
function openSuppressModal(type, name, detail) {
const modal = document.getElementById('suppress-modal');
if (!modal) return;
document.getElementById('sup-type').value = type;
document.getElementById('sup-name').value = name;
document.getElementById('sup-detail').value = detail;
document.getElementById('sup-reason').value = '';
document.getElementById('sup-expires').value = '';
updateSuppressForm();
modal.style.display = 'flex';
document.querySelectorAll('#suppress-modal .pill').forEach(p => p.classList.remove('active'));
const manualPill = document.querySelector('#suppress-modal .pill-manual');
if (manualPill) manualPill.classList.add('active');
const hint = document.getElementById('duration-hint');
if (hint) hint.textContent = 'Suppression will persist until manually removed.';
}
function closeSuppressModal() {
const modal = document.getElementById('suppress-modal');
if (modal) modal.style.display = 'none';
}
function updateSuppressForm() {
const type = document.getElementById('sup-type').value;
const nameGrp = document.getElementById('sup-name-group');
const detailGrp = document.getElementById('sup-detail-group');
if (nameGrp) nameGrp.style.display = (type === 'all') ? 'none' : '';
if (detailGrp) detailGrp.style.display = (type === 'interface') ? '' : 'none';
}
function setDuration(mins) {
document.getElementById('sup-expires').value = mins || '';
document.querySelectorAll('#suppress-modal .pill').forEach(p => p.classList.remove('active'));
event.currentTarget.classList.add('active');
const hint = document.getElementById('duration-hint');
if (hint) {
if (mins) {
const h = Math.floor(mins / 60), m = mins % 60;
hint.textContent = `Expires in ${h ? h + 'h ' : ''}${m ? m + 'm' : ''}.`;
} else {
hint.textContent = 'Suppression will persist until manually removed.';
}
}
}
async function submitSuppress(e) {
e.preventDefault();
const type = document.getElementById('sup-type').value;
const name = document.getElementById('sup-name').value;
const detail = document.getElementById('sup-detail').value;
const reason = document.getElementById('sup-reason').value;
const expires = document.getElementById('sup-expires').value;
if (!reason.trim()) { showToast('Reason is required', 'error'); return; }
if (type !== 'all' && !name.trim()) { showToast('Target name is required', 'error'); return; }
try {
const resp = await fetch('/api/suppressions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
target_type: type,
target_name: name,
target_detail: detail,
reason: reason,
expires_minutes: expires ? parseInt(expires) : null,
}),
});
const data = await resp.json();
if (data.success) {
closeSuppressModal();
showToast('Suppression applied ✔', 'success');
setTimeout(refreshAll, 500);
} else {
showToast(data.error || 'Failed to apply suppression', 'error');
}
} catch (err) {
showToast('Network error', 'error');
}
}
// ── Close modal on backdrop click ─────────────────────────────────────
document.addEventListener('click', e => {
const modal = document.getElementById('suppress-modal');
if (modal && e.target === modal) closeSuppressModal();
});
// ── Utility ───────────────────────────────────────────────────────────
function escHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}