Add AT&T modem monitoring and device pages; replace Link Debug
Lint / Python (flake8) (pull_request) Successful in 2m6s
Lint / JS (eslint) (pull_request) Successful in 18s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 2m11s
Test / Python Tests (pytest) (pull_request) Successful in 2m20s
Lint / JS (eslint) (push) Successful in 10s
Lint / Python (flake8) (push) Successful in 1m58s
Lint / Notify on failure (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m58s
Lint / Deploy (push) Successful in 4s
Test / Python Tests (pytest) (push) Successful in 58s
Lint / Python (flake8) (pull_request) Successful in 2m6s
Lint / JS (eslint) (pull_request) Successful in 18s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 2m11s
Test / Python Tests (pytest) (pull_request) Successful in 2m20s
Lint / JS (eslint) (push) Successful in 10s
Lint / Python (flake8) (push) Successful in 1m58s
Lint / Notify on failure (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m58s
Lint / Deploy (push) Successful in 4s
Test / Python Tests (pytest) (push) Successful in 58s
Modem and WAN: - Poll the BGW320 status pages read-only for light levels, PON and WAN state, with a WAN / Fiber panel, 7-day Rx trend, and alerts on WAN down, optical limits, and Rx drift - Read WAN outages and Smart Power restarts from the UniFi system log, since outages can be over between 5 minute polls Device pages: - Add device search to the dashboard and a page per host, switch, router, AP, PDU, or modem - Move all Link Debug data into expandable rows there; /links redirects - Collect UDM ports and UniFi device details (firmware, load, WAN, outlets) - Reorder the dashboard Monitoring fixes: - Alert when a configured host stops reporting: its series disappear rather than reporting down, so a dead host never raised an alert - Alert on Prometheus outages instead of blanking the dashboard - Treat a failed Pulse ping as unknown rather than host unreachable - Stop new switch ports reporting lifetime bytes as one interval's rate - Run database sessions in UTC: other server time zones shifted displayed ages and timed suppression expiries Also log every avatar 404 reason, add a favicon, and correct the WAN link labels (1G on Port 9, not 10G SFP+). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #4.
This commit is contained in:
+261
-1
@@ -1,4 +1,6 @@
|
||||
'use strict';
|
||||
/* exported initDeviceSearch */
|
||||
/* global DEVICE_NAME */
|
||||
|
||||
// ── Auto-redirect on auth timeout ─────────────────────────────────────
|
||||
// Wraps fetch so a 401 (Authelia session expired) forces a full reload.
|
||||
@@ -40,6 +42,7 @@ async function refreshAll() {
|
||||
const [netResult, statusResult] = await Promise.allSettled([
|
||||
lt.api.get('/api/network'),
|
||||
lt.api.get('/api/status'),
|
||||
refreshModem(),
|
||||
]);
|
||||
if (netResult.status === 'fulfilled') {
|
||||
const net = netResult.value;
|
||||
@@ -170,6 +173,263 @@ function updateTopology(hosts) {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Device search ─────────────────────────────────────────────────────
|
||||
function deviceUrl(name) {
|
||||
return `/device/${encodeURIComponent(name)}`;
|
||||
}
|
||||
|
||||
// All words must match somewhere; the device's name matching ranks first.
|
||||
function rankDevices(devices, query) {
|
||||
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (!words.length) return [];
|
||||
const scored = [];
|
||||
for (const d of devices) {
|
||||
if (!words.every(w => d.search.includes(w))) continue;
|
||||
const name = d.name.toLowerCase();
|
||||
let score = 3;
|
||||
if (name === words[0]) score = 0;
|
||||
else if (name.startsWith(words[0])) score = 1;
|
||||
else if (name.includes(words[0]) || (d.ip || '').startsWith(words[0])) score = 2;
|
||||
scored.push({ d, score });
|
||||
}
|
||||
return scored
|
||||
.sort((a, b) => a.score - b.score || a.d.name.localeCompare(b.d.name))
|
||||
.slice(0, 12)
|
||||
.map(s => s.d);
|
||||
}
|
||||
|
||||
function initDeviceSearch() {
|
||||
const input = document.getElementById('device-search-input');
|
||||
const results = document.getElementById('device-search-results');
|
||||
if (!input || !results) return;
|
||||
let devices = [];
|
||||
let matches = [];
|
||||
let active = 0;
|
||||
|
||||
async function loadDevices() {
|
||||
try {
|
||||
devices = (await lt.api.get('/api/devices')).devices || [];
|
||||
} catch (err) {
|
||||
console.warn('Device list unavailable', err);
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
results.hidden = true;
|
||||
input.setAttribute('aria-expanded', 'false');
|
||||
input.removeAttribute('aria-activedescendant');
|
||||
}
|
||||
|
||||
function render() {
|
||||
matches = rankDevices(devices, input.value);
|
||||
if (!input.value.trim()) { close(); return; }
|
||||
active = Math.min(active, Math.max(matches.length - 1, 0));
|
||||
results.hidden = false;
|
||||
input.setAttribute('aria-expanded', 'true');
|
||||
if (!matches.length) {
|
||||
results.innerHTML = '<div class="device-search-empty">No devices match.</div>';
|
||||
input.removeAttribute('aria-activedescendant');
|
||||
return;
|
||||
}
|
||||
results.innerHTML = matches.map((d, i) => `
|
||||
<a class="device-search-row${i === active ? ' is-active' : ''}" id="device-opt-${i}" role="option"
|
||||
aria-selected="${i === active}" href="${lt.escHtml(deviceUrl(d.name))}">
|
||||
<span class="dot-${lt.escHtml(d.status)}"></span>
|
||||
<span class="device-search-name">${lt.escHtml(d.name)}</span>
|
||||
<span class="device-search-kind">${lt.escHtml(d.kind)}</span>
|
||||
<span class="device-search-meta">${lt.escHtml(d.ip || '')}</span>
|
||||
<span class="device-search-meta">${lt.escHtml(d.model || '')}</span>
|
||||
</a>`).join('');
|
||||
input.setAttribute('aria-activedescendant', `device-opt-${active}`);
|
||||
}
|
||||
|
||||
input.addEventListener('input', () => { active = 0; render(); });
|
||||
input.addEventListener('focus', () => { loadDevices().then(render); });
|
||||
input.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') { close(); return; }
|
||||
if (!matches.length) return;
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); active = (active + 1) % matches.length; render(); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); active = (active - 1 + matches.length) % matches.length; render(); }
|
||||
if (e.key === 'Enter') { e.preventDefault(); window.location.href = deviceUrl(matches[active].name); }
|
||||
});
|
||||
document.addEventListener('click', e => {
|
||||
if (!e.target.closest('#device-search')) close();
|
||||
});
|
||||
document.addEventListener('keydown', e => {
|
||||
const typing = e.target.closest('input, textarea, select, [contenteditable]');
|
||||
if (e.key === '/' && !typing && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
loadDevices();
|
||||
}
|
||||
|
||||
// ── WAN modem / fiber panel ───────────────────────────────────────────
|
||||
async function refreshModem() {
|
||||
try {
|
||||
updateModem(await lt.api.get('/api/modem'));
|
||||
} catch (err) {
|
||||
console.warn('Modem data unavailable', err);
|
||||
}
|
||||
}
|
||||
|
||||
function _modemUptime(seconds) {
|
||||
if (seconds === null || seconds === undefined) return '–';
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return d ? `${d}d ${h}h` : `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
function _modemRate(bytesPerSec) {
|
||||
if (bytesPerSec === null || bytesPerSec === undefined) return '–';
|
||||
const mbps = bytesPerSec * 8 / 1e6;
|
||||
return mbps >= 1000 ? `${(mbps / 1000).toFixed(2)} Gbps` : `${mbps.toFixed(1)} Mbps`;
|
||||
}
|
||||
|
||||
function _modemStat(label, value, cls, note) {
|
||||
return `
|
||||
<div class="modem-stat">
|
||||
<span class="modem-stat-label">${lt.escHtml(label)}</span>
|
||||
<span class="modem-stat-value ${cls || ''}">${lt.escHtml(String(value))}</span>
|
||||
${note ? `<span class="modem-stat-note">${lt.escHtml(note)}</span>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _modemOptic(label, metric, status, unit) {
|
||||
if (!metric || metric.value === null || metric.value === undefined) return _modemStat(label, '–');
|
||||
const cls = { alarm: 'lt-text-red', warning: 'lt-text-amber', ok: 'lt-text-green' }[status] || '';
|
||||
return _modemStat(label, `${metric.value} ${unit}`, cls, `warn ${metric.warn_low} to ${metric.warn_high}`);
|
||||
}
|
||||
|
||||
function _modemChart(history) {
|
||||
const points = (history || []).filter(p => p.rx_avg !== null && p.rx_avg !== undefined);
|
||||
if (points.length < 2) {
|
||||
return '<div class="modem-chart-empty">Rx power trend appears after a few polls.</div>';
|
||||
}
|
||||
const W = 600, H = 70, PAD = 4;
|
||||
const values = points.map(p => p.rx_avg);
|
||||
const low = Math.min(...values) - 0.5, high = Math.max(...values) + 0.5;
|
||||
const t0 = points[0].ts, tSpan = (points[points.length - 1].ts - t0) || 1;
|
||||
const coord = p => `${((p.ts - t0) / tSpan * W).toFixed(1)},` +
|
||||
`${(PAD + (high - p.rx_avg) / (high - low) * (H - 2 * PAD)).toFixed(1)}`;
|
||||
|
||||
// Break the line where samples are missing rather than drawing across an outage
|
||||
const gaps = points.slice(1).map((p, i) => p.ts - points[i].ts).sort((a, b) => a - b);
|
||||
const maxGap = gaps[Math.floor(gaps.length / 2)] * 3;
|
||||
const segments = [[]];
|
||||
points.forEach((p, i) => {
|
||||
if (i && p.ts - points[i - 1].ts > maxGap) segments.push([]);
|
||||
segments[segments.length - 1].push(coord(p));
|
||||
});
|
||||
const lines = segments
|
||||
.map(s => `<polyline points="${s.join(' ')}" fill="none" vector-effect="non-scaling-stroke"/>`)
|
||||
.join('');
|
||||
const summary = `range ${Math.min(...values)} to ${Math.max(...values)} dBm`;
|
||||
return `
|
||||
<div class="modem-chart">
|
||||
<div class="modem-chart-title">Rx power, last 7 days <span>${lt.escHtml(summary)}</span></div>
|
||||
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img"
|
||||
aria-label="Rx power trend, ${lt.escHtml(summary)}">${lines}</svg>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _modemWhen(epochSeconds) {
|
||||
return new Date(epochSeconds * 1000).toLocaleString([], { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
function _modemLastRestart(history) {
|
||||
const restarts = (history && history.restarts) || [];
|
||||
if (!restarts.length) return '';
|
||||
const last = restarts[restarts.length - 1];
|
||||
return `restarted ${_modemWhen(last.at)}${last.by ? ` by ${last.by}` : ''}`;
|
||||
}
|
||||
|
||||
function _modemDuration(seconds) {
|
||||
if (seconds < 60) return `${Math.round(seconds)}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
|
||||
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
||||
}
|
||||
|
||||
function _modemOutages(history) {
|
||||
const outages = ((history && history.outages) || []).slice(-5).reverse();
|
||||
if (!outages.length) return '';
|
||||
const rows = outages.map(o => {
|
||||
const length = o.restored_at === null || o.restored_at === undefined
|
||||
? '<span class="lt-text-red">ongoing</span>'
|
||||
: lt.escHtml(_modemDuration(o.restored_at - o.failed_at));
|
||||
return `<li><span>${lt.escHtml(_modemWhen(o.failed_at))}</span><span>${length}</span></li>`;
|
||||
}).join('');
|
||||
return `
|
||||
<div class="modem-outages">
|
||||
<div class="modem-chart-title">Recent WAN outages</div>
|
||||
<ul>${rows}</ul>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function updateModem(data) {
|
||||
const section = document.getElementById('modem-section');
|
||||
const body = document.getElementById('modem-body');
|
||||
if (!section || !body) return;
|
||||
section.hidden = !data.configured;
|
||||
if (!data.configured) return;
|
||||
|
||||
// On the dashboard, link to the modem's page. On that page, it would link to itself.
|
||||
const detailsLink = document.getElementById('modem-device-link');
|
||||
if (detailsLink && typeof DEVICE_NAME === 'undefined' && data.name) {
|
||||
detailsLink.href = deviceUrl(data.name);
|
||||
detailsLink.hidden = false;
|
||||
}
|
||||
|
||||
const rxLabel = document.getElementById('topo-modem-rx');
|
||||
const topo = document.getElementById('topo-modem');
|
||||
const title = document.getElementById('modem-title');
|
||||
|
||||
if (!data.reachable) {
|
||||
if (rxLabel) rxLabel.textContent = 'unreachable';
|
||||
if (topo) topo.classList.add('topo-v2-status-down');
|
||||
body.innerHTML = `
|
||||
<div class="modem-error">Modem status unavailable: ${lt.escHtml(data.error || 'unknown error')}
|
||||
<span class="modem-stat-note">Last attempt ${lt.escHtml(data.updated || '–')}</span></div>
|
||||
${_modemChart(data.history)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const problems = data.wan_problems || [];
|
||||
const optics = data.optics || {};
|
||||
const status = data.optics_status || {};
|
||||
const counters = data.counters || {};
|
||||
const rates = data.rates || {};
|
||||
const rx = optics.rx_power ? optics.rx_power.value : null;
|
||||
|
||||
if (topo) topo.classList.toggle('topo-v2-status-down', problems.length > 0);
|
||||
if (rxLabel) rxLabel.textContent = (rx === null || rx === undefined) ? 'Rx –' : `Rx ${rx} dBm`;
|
||||
if (title) {
|
||||
title.textContent = [data.model || 'AT&T Gateway', data.firmware && `fw ${data.firmware}`]
|
||||
.filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
const errs = (counters.rx_errors || 0) + (counters.tx_errors || 0);
|
||||
const drops = (counters.rx_drops || 0) + (counters.tx_drops || 0);
|
||||
body.innerHTML = `
|
||||
${problems.length ? `<div class="modem-error">WAN down: ${lt.escHtml(problems.join('; '))}</div>` : ''}
|
||||
<div class="modem-stats">
|
||||
${_modemStat('WAN', problems.length ? 'DOWN' : 'UP', problems.length ? 'lt-text-red' : 'lt-text-green',
|
||||
data.wan_ip || '')}
|
||||
${_modemStat('PON', data.pon_state || '–', '', data.wavelength || '')}
|
||||
${_modemOptic('Rx power', optics.rx_power, status.rx_power, 'dBm')}
|
||||
${_modemOptic('Tx power', optics.tx_power, status.tx_power, 'dBm')}
|
||||
${_modemOptic('Optics temp', optics.temperature, status.temperature, '°C')}
|
||||
${_modemStat('Throughput', `↓ ${_modemRate(rates.rx_bytes)}`, '', `↑ ${_modemRate(rates.tx_bytes)}`)}
|
||||
${_modemStat('Errors / drops', `${errs} / ${drops}`, errs ? 'lt-text-amber' : '', 'since modem reboot')}
|
||||
${_modemStat('Uptime', _modemUptime(data.uptime_s), '', _modemLastRestart(data.wan_history))}
|
||||
</div>
|
||||
${_modemChart(data.history)}
|
||||
${_modemOutages(data.wan_history)}
|
||||
<div class="modem-stat-note modem-updated">Updated ${lt.escHtml(data.updated || '–')}</div>`;
|
||||
}
|
||||
|
||||
function updateUnifiTable(devices) {
|
||||
const tbody = document.querySelector('#unifi-table tbody');
|
||||
if (!tbody || !devices.length) return;
|
||||
@@ -188,7 +448,7 @@ function updateUnifiTable(devices) {
|
||||
return `
|
||||
<tr class="${statusClass}">
|
||||
<td><span class="${dotClass}"></span> ${statusText}</td>
|
||||
<td><strong>${lt.escHtml(d.name)}</strong></td>
|
||||
<td><a class="device-link" href="${lt.escHtml(deviceUrl(d.name))}"><strong>${lt.escHtml(d.name)}</strong></a></td>
|
||||
<td>${lt.escHtml(d.type)}</td>
|
||||
<td>${lt.escHtml(d.model)}</td>
|
||||
<td>${lt.escHtml(d.ip)}</td>
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
'use strict';
|
||||
/* global DEVICE_NAME, deviceUrl, refreshModem, _toIso */
|
||||
|
||||
// Per-device page: overview, alerts, and a compact interface/port table whose
|
||||
// rows expand to show everything the old Link Debug page showed.
|
||||
|
||||
const esc = s => lt.escHtml(String(s === null || s === undefined ? '' : s));
|
||||
|
||||
const _openRows = new Set(); // survives auto-refresh
|
||||
let _filter = 'all';
|
||||
let _query = '';
|
||||
let _device = null;
|
||||
|
||||
// ── Formatting ───────────────────────────────────────────────────────
|
||||
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 fmtSpeed(mbps) {
|
||||
if (!mbps) return '–';
|
||||
return mbps >= 1000 ? (mbps / 1000).toFixed(0) + ' Gbps' : mbps + ' Mbps';
|
||||
}
|
||||
|
||||
function fmtErrors(rate) {
|
||||
if (rate === null || rate === undefined) return '–';
|
||||
if (rate < 0.001) return '<span class="val-good">0 /s</span>';
|
||||
return `<span class="val-crit">${rate.toFixed(3)} /s</span>`;
|
||||
}
|
||||
|
||||
function fmtUnit(value, unit, digits) {
|
||||
return value === null || value === undefined ? '–' : `${value.toFixed(digits)} ${unit}`;
|
||||
}
|
||||
|
||||
function fmtUptime(seconds) {
|
||||
if (seconds === null || seconds === undefined) return '–';
|
||||
const d = Math.floor(seconds / 86400), h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return d ? `${d}d ${h}h` : `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
function ratePct(bytesPerSec, mbps) {
|
||||
if (!mbps || !bytesPerSec) return 0;
|
||||
return Math.min(100, (bytesPerSec * 8 / 1e6 / mbps) * 100);
|
||||
}
|
||||
|
||||
function barClass(pct, isTx) {
|
||||
if (pct > 85) return 'lt-progress--red';
|
||||
if (pct > 65) return 'lt-progress--amber';
|
||||
return isTx ? '' : 'lt-progress--cyan';
|
||||
}
|
||||
|
||||
// ── SFP/DOM value classification ─────────────────────────────────────
|
||||
function rxClass(dbm) {
|
||||
if (dbm === null || dbm === undefined) return 'val-neutral';
|
||||
if (dbm < -15) return 'val-crit';
|
||||
return dbm < -10 ? 'val-warn' : 'val-good';
|
||||
}
|
||||
function txClass(dbm) {
|
||||
if (dbm === null || dbm === undefined) return 'val-neutral';
|
||||
return dbm < -5 ? 'val-crit' : 'val-good';
|
||||
}
|
||||
function tempClass(c) {
|
||||
if (c === null || c === undefined) return 'val-neutral';
|
||||
if (c > 80) return 'val-crit';
|
||||
return c > 70 ? 'val-warn' : 'val-good';
|
||||
}
|
||||
function voltClass(v) {
|
||||
if (v === null || v === undefined) return 'val-neutral';
|
||||
if (v < 3.0 || v > 3.6) return 'val-crit';
|
||||
return (v < 3.1 || v > 3.5) ? 'val-warn' : 'val-good';
|
||||
}
|
||||
|
||||
function powerFill(cls) {
|
||||
return cls === 'val-good' ? 'power-ok' : cls === 'val-warn' ? 'power-warn' : 'power-crit';
|
||||
}
|
||||
|
||||
function badges(d) {
|
||||
const out = [];
|
||||
if ((d.tx_errs_rate || 0) > 0.001 || (d.rx_errs_rate || 0) > 0.001) out.push('<span class="link-alert-badge">ERR</span>');
|
||||
if ((d.tx_drops_rate || 0) > 0.001 || (d.rx_drops_rate || 0) > 0.001) out.push('<span class="link-alert-badge link-alert-amber">DROP</span>');
|
||||
if ((d.carrier_changes || 0) > 3) out.push('<span class="link-alert-badge link-alert-amber">FLAP</span>');
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
function stat(label, valueHtml, tooltip) {
|
||||
const tip = tooltip ? ` data-tooltip="${esc(tooltip)}"` : '';
|
||||
return `<div class="link-stat"><span class="link-stat-label"${tip}>${esc(label)}</span>` +
|
||||
`<span class="link-stat-value">${valueHtml}</span></div>`;
|
||||
}
|
||||
|
||||
function trafficBars(d) {
|
||||
const tx = ratePct(d.tx_bytes_rate, d.speed_mbps), rx = ratePct(d.rx_bytes_rate, d.speed_mbps);
|
||||
const row = (label, pct, rate, isTx) => `
|
||||
<div class="traffic-row">
|
||||
<span class="traffic-label">${label}</span>
|
||||
<div class="lt-progress ${barClass(pct, isTx)}"><div class="lt-progress-bar" style="width:${pct}%"></div></div>
|
||||
<span class="traffic-value">${fmtRate(rate)}</span>
|
||||
</div>`;
|
||||
return `<div class="traffic-section">${row('TX', tx, d.tx_bytes_rate, true)}${row('RX', rx, d.rx_bytes_rate, false)}</div>`;
|
||||
}
|
||||
|
||||
function sfpPanel(s) {
|
||||
if (!s || !Object.keys(s).length) return '';
|
||||
const rx = rxClass(s.rx_power_dbm), tx = txClass(s.tx_power_dbm);
|
||||
const has = v => v !== null && v !== undefined;
|
||||
const rxPct = has(s.rx_power_dbm) ? Math.min(100, Math.max(0, (s.rx_power_dbm + 20) / 15 * 100)) : 0;
|
||||
const txPct = has(s.tx_power_dbm) ? Math.min(100, Math.max(0, (s.tx_power_dbm + 10) / 8 * 100)) : 0;
|
||||
const item = (label, cls, value, tip, pct) => `
|
||||
<div class="sfp-stat">
|
||||
<span class="sfp-stat-label" data-tooltip="${esc(tip)}">${label}</span>
|
||||
<span class="sfp-stat-value ${cls}">${value}</span>
|
||||
${pct === undefined ? '' : `<div class="power-row"><div class="power-track"><div class="power-fill ${powerFill(cls)}" style="width:${pct}%"></div></div></div>`}
|
||||
</div>`;
|
||||
const vendor = [s.vendor, s.part_no, s.sfp_type, s.connector, s.wavelength_nm && `${s.wavelength_nm} nm`]
|
||||
.filter(Boolean).map(esc).join(' / ');
|
||||
return `
|
||||
<div class="sfp-panel">
|
||||
<div class="sfp-vendor-row">${vendor}</div>
|
||||
<div class="sfp-grid">
|
||||
${item('Temp', tempClass(s.temp_c), fmtUnit(s.temp_c, '°C', 1), 'Module temperature. Normal: below 70°C.')}
|
||||
${item('Voltage', voltClass(s.voltage_v), fmtUnit(s.voltage_v, 'V', 2), 'Supply voltage. Normal: 3.1–3.5 V.')}
|
||||
${item('Bias', '', fmtUnit(s.bias_ma, 'mA', 2), 'Laser bias current. High values may mean an aging laser.')}
|
||||
${item('TX Power', tx, fmtUnit(s.tx_power_dbm, 'dBm', 2), 'Optical transmit power.', txPct)}
|
||||
${item('RX Power', rx, fmtUnit(s.rx_power_dbm, 'dBm', 2), 'Optical receive power. Very low values may mean dirty or damaged fiber.', rxPct)}
|
||||
${has(s.rx_power_dbm) && has(s.tx_power_dbm) ? item('RX−TX Δ', '', `${(s.rx_power_dbm - s.tx_power_dbm).toFixed(2)} dBm`, 'Difference between receive and transmit power.') : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Normalized rows, so hosts and switches share one table ───────────
|
||||
function hostRows(interfaces) {
|
||||
return Object.entries(interfaces)
|
||||
.sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true }))
|
||||
.map(([name, d]) => {
|
||||
const up = d.state ? d.state === 'up' : d.link_detected !== false;
|
||||
return {
|
||||
id: name, label: name, up, data: d, sub: d.port_type || '',
|
||||
detail: `
|
||||
<div class="link-stats-grid">
|
||||
${stat('Duplex', esc(d.duplex || '–'), 'Full = both directions at once. Half can cause collisions.')}
|
||||
${stat('Auto-neg', d.auto_neg === null || d.auto_neg === undefined ? '–' : d.auto_neg ? 'On' : 'Off')}
|
||||
${stat('Carrier Δ', d.carrier_changes === null || d.carrier_changes === undefined ? '–' : esc(d.carrier_changes), 'Times the link went up or down. High values mean flapping.')}
|
||||
${stat('TX Err/s', fmtErrors(d.tx_errs_rate))}
|
||||
${stat('RX Err/s', fmtErrors(d.rx_errs_rate))}
|
||||
${stat('TX Drop/s', fmtErrors(d.tx_drops_rate))}
|
||||
${stat('RX Drop/s', fmtErrors(d.rx_drops_rate))}
|
||||
</div>
|
||||
${trafficBars(d)}
|
||||
${sfpPanel(d.sfp)}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function portRows(ports) {
|
||||
return Object.entries(ports)
|
||||
.sort(([, a], [, b]) => (a.port_idx || 0) - (b.port_idx || 0))
|
||||
.map(([name, d]) => {
|
||||
const neighbor = d.lldp && d.lldp.system_name;
|
||||
return {
|
||||
id: name, label: name, num: d.port_idx, up: !!d.up, data: d,
|
||||
neighbor: d.lldp ? { name: neighbor, port: d.lldp.port_id } : null,
|
||||
sub: [d.is_uplink ? 'uplink' : '', d.media || '', d.poe_power ? `PoE ${d.poe_power.toFixed(1)} W` : ''].filter(Boolean).join(' · '),
|
||||
detail: `
|
||||
<div class="link-stats-grid">
|
||||
${stat('Duplex', d.full_duplex === null || d.full_duplex === undefined ? '–' : d.full_duplex ? 'Full' : 'Half')}
|
||||
${stat('Auto-neg', d.autoneg === null || d.autoneg === undefined ? '–' : d.autoneg ? 'On' : 'Off')}
|
||||
${stat('TX Err/s', fmtErrors(d.tx_errs_rate))}
|
||||
${stat('RX Err/s', fmtErrors(d.rx_errs_rate))}
|
||||
${stat('TX Drop/s', fmtErrors(d.tx_drops_rate))}
|
||||
${stat('RX Drop/s', fmtErrors(d.rx_drops_rate))}
|
||||
${d.poe_power || d.poe_max_power ? stat('PoE', `${esc(d.poe_class || '–')} · max ${fmtUnit(d.poe_max_power, 'W', 1)}`) : ''}
|
||||
</div>
|
||||
${d.lldp ? `<div class="port-lldp">LLDP: ${esc(d.lldp.system_name || '')} (${esc(d.lldp.port_id || '')})${d.lldp.chassis_id ? ` · ${esc(d.lldp.chassis_id)}` : ''}${(d.lldp.mgmt_ips || []).length ? ` · ${esc(d.lldp.mgmt_ips.join(', '))}` : ''}</div>` : ''}
|
||||
${trafficBars(d)}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Down alone isn't a problem: most down ports are simply unused.
|
||||
function rowHasProblem(r) {
|
||||
return !!badges(r.data);
|
||||
}
|
||||
|
||||
function renderTable(rows) {
|
||||
const q = _query.trim().toLowerCase();
|
||||
const shown = rows.filter(r => {
|
||||
if (_filter === 'up' && !r.up) return false;
|
||||
if (_filter === 'problems' && !rowHasProblem(r)) return false;
|
||||
return !q || `${r.label} ${r.num || ''} ${r.sub} ${(r.neighbor && r.neighbor.name) || ''}`.toLowerCase().includes(q);
|
||||
});
|
||||
if (!shown.length) return '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Nothing matches this filter</div></div>';
|
||||
const isPorts = rows.some(r => r.num !== undefined);
|
||||
const body = shown.map(r => {
|
||||
const open = _openRows.has(r.id);
|
||||
const d = r.data;
|
||||
const neighbor = r.neighbor
|
||||
? (r.neighbor.name ? `<a class="device-link" href="${esc(deviceUrl(r.neighbor.name))}">${esc(r.neighbor.name)}</a>` : esc(r.neighbor.port || ''))
|
||||
: '–';
|
||||
return `
|
||||
<tr class="device-row${r.up ? '' : ' device-row--down'}">
|
||||
<td><button type="button" class="device-row-toggle" data-row="${esc(r.id)}" aria-expanded="${open}" aria-label="Details for ${esc(r.label)}">${open ? '▾' : '▸'}</button></td>
|
||||
<td><span class="dot-${r.up ? 'up' : 'down'}"></span></td>
|
||||
<td class="device-row-name">${r.num !== undefined ? `<span class="port-badge port-badge-num">#${esc(r.num)}</span> ` : ''}${esc(r.label)}<div class="device-row-sub">${esc(r.sub)}</div></td>
|
||||
<td class="device-num">${r.up ? fmtSpeed(d.speed_mbps) : '–'}</td>
|
||||
<td class="device-num device-col-wide">${fmtRate(d.rx_bytes_rate)}</td>
|
||||
<td class="device-num device-col-wide">${fmtRate(d.tx_bytes_rate)}</td>
|
||||
${isPorts ? `<td class="device-col-wide">${neighbor}</td>` : ''}
|
||||
<td>${badges(d) || (r.up ? '<span class="val-good">OK</span>' : '<span class="device-row-sub">down</span>')}</td>
|
||||
</tr>
|
||||
${open ? `<tr class="device-row-detail"><td colspan="${isPorts ? 8 : 7}">${r.detail}</td></tr>` : ''}`;
|
||||
}).join('');
|
||||
return `
|
||||
<div class="lt-table-wrap">
|
||||
<table class="lt-table device-table">
|
||||
<caption class="lt-sr-only">Interfaces</caption>
|
||||
<thead><tr>
|
||||
<th><span class="lt-sr-only">Expand</span></th><th><span class="lt-sr-only">State</span></th>
|
||||
<th>${isPorts ? 'Port' : 'Interface'}</th><th>Speed</th><th class="device-col-wide">RX</th><th class="device-col-wide">TX</th>
|
||||
${isPorts ? '<th class="device-col-wide">Neighbor</th>' : ''}<th>Health</th>
|
||||
</tr></thead>
|
||||
<tbody>${body}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function section(title, inner, id) {
|
||||
return `
|
||||
<section class="g-section"${id ? ` id="${id}"` : ''}>
|
||||
<div class="g-section-header"><h2 class="g-section-title">${esc(title)}</h2></div>
|
||||
<div class="lt-frame">
|
||||
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||
${inner}
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function kvGrid(pairs) {
|
||||
return `<div class="modem-stats">${pairs.filter(([, v]) => v !== null && v !== undefined && v !== '').map(([k, v]) => `
|
||||
<div class="modem-stat"><span class="modem-stat-label">${esc(k)}</span><span class="modem-stat-value">${esc(v)}</span></div>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderOverview(d) {
|
||||
const info = d.info || {};
|
||||
const pct = v => (v === null || v === undefined ? null : `${v.toFixed(0)}%`);
|
||||
const temps = (info.temps || []).filter(t => t.value_c !== null).map(t => `${t.name} ${t.value_c.toFixed(0)}°C`).join(', ');
|
||||
return section('Overview', kvGrid([
|
||||
['Status', (d.status || 'unknown').toUpperCase()],
|
||||
['Type', d.kind],
|
||||
['IP', d.ip],
|
||||
['MAC', d.mac],
|
||||
['Model', d.model],
|
||||
['Firmware', info.firmware],
|
||||
['Uptime', info.uptime_s === undefined ? null : fmtUptime(info.uptime_s)],
|
||||
['CPU', pct(info.cpu_pct)],
|
||||
['Memory', pct(info.mem_pct)],
|
||||
['Clients', info.clients],
|
||||
['Temperature', temps],
|
||||
['Power draw', info.power_w === null || info.power_w === undefined ? null : `${info.power_w.toFixed(0)} W`],
|
||||
['Data source', d.source],
|
||||
]));
|
||||
}
|
||||
|
||||
function renderUplinks(d) {
|
||||
if (!(d.uplinks || []).length) return '';
|
||||
const rows = d.uplinks.map(u => `
|
||||
<tr>
|
||||
<td><span class="dot-${u.up ? 'up' : 'down'}"></span></td>
|
||||
<td>${esc(u.host_interface || '–')}</td>
|
||||
<td><a class="device-link" href="${esc(deviceUrl(u.switch))}">${esc(u.switch)}</a></td>
|
||||
<td>#${esc(u.port_idx)} ${esc(u.port)}</td>
|
||||
<td>${fmtSpeed(u.speed_mbps)}</td>
|
||||
</tr>`).join('');
|
||||
return section('Connected to', `<div class="lt-table-wrap"><table class="lt-table">
|
||||
<caption class="lt-sr-only">Switch ports this host connects to, from LLDP</caption>
|
||||
<thead><tr><th><span class="lt-sr-only">State</span></th><th>Interface</th><th>Switch</th><th>Port</th><th>Speed</th></tr></thead>
|
||||
<tbody>${rows}</tbody></table></div>`);
|
||||
}
|
||||
|
||||
function renderWan(d) {
|
||||
const wan = (d.info && d.info.wan) || {};
|
||||
const names = Object.keys(wan);
|
||||
if (!names.length) return '';
|
||||
const rows = names.map(n => {
|
||||
const w = wan[n];
|
||||
return `<tr><td><span class="dot-${w.up ? 'up' : 'down'}"></span></td><td>${esc(n.toUpperCase())}</td>
|
||||
<td>${fmtSpeed(w.speed_mbps)}</td><td>${w.latency_ms === null || w.latency_ms === undefined ? '–' : `${esc(w.latency_ms)} ms`}</td>
|
||||
<td>${w.availability === null || w.availability === undefined ? '–' : `${esc(w.availability)}%`}</td></tr>`;
|
||||
}).join('');
|
||||
return section('WAN', `<div class="lt-table-wrap"><table class="lt-table">
|
||||
<caption class="lt-sr-only">WAN connections</caption>
|
||||
<thead><tr><th><span class="lt-sr-only">State</span></th><th>Connection</th><th>Speed</th><th>Latency</th><th>Availability</th></tr></thead>
|
||||
<tbody>${rows}</tbody></table></div>`);
|
||||
}
|
||||
|
||||
function renderOutlets(d) {
|
||||
const outlets = (d.info && d.info.outlets) || [];
|
||||
if (!outlets.length) return '';
|
||||
const rows = outlets.map(o => `
|
||||
<tr>
|
||||
<td><span class="dot-${o.on ? 'up' : 'down'}"></span></td>
|
||||
<td>#${esc(o.index)}</td>
|
||||
<td>${esc(o.name || '–')}</td>
|
||||
<td class="device-num">${o.power_w === null || o.power_w === undefined ? '–' : `${o.power_w.toFixed(1)} W`}</td>
|
||||
<td>${o.auto_power_cycle ? '<span class="lt-badge">auto power-cycle</span>' : ''}</td>
|
||||
</tr>`).join('');
|
||||
return section('Outlets', `<div class="lt-table-wrap"><table class="lt-table">
|
||||
<caption class="lt-sr-only">PDU outlets</caption>
|
||||
<thead><tr><th><span class="lt-sr-only">State</span></th><th>Outlet</th><th>Name</th><th>Power</th><th></th></tr></thead>
|
||||
<tbody>${rows}</tbody></table></div>`);
|
||||
}
|
||||
|
||||
function renderEvents(events) {
|
||||
const body = !(events || []).length
|
||||
? '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-icon">✔</div><div class="lt-empty-state-title">No recorded alerts</div></div>'
|
||||
: `<div class="lt-table-wrap"><table class="lt-table">
|
||||
<caption class="lt-sr-only">Alerts for this device</caption>
|
||||
<thead><tr><th>Sev</th><th>Type</th><th>Detail</th><th>Description</th><th>First seen</th><th>State</th></tr></thead>
|
||||
<tbody>${events.map(e => `
|
||||
<tr class="${e.resolved_at ? '' : `row-${esc(e.severity)}`}">
|
||||
<td><span class="lt-badge badge-${e.resolved_at ? 'resolved' : esc(e.severity)}">${esc(e.severity)}</span>${e.is_suppressed && !e.resolved_at ? '<span class="lt-badge badge-suppressed">🔕 sup</span>' : ''}</td>
|
||||
<td>${esc(e.event_type.replace(/_/g, ' '))}</td>
|
||||
<td>${esc(e.target_detail || '–')}</td>
|
||||
<td class="desc-cell">${esc(e.description || '')}</td>
|
||||
<td class="ts-cell" title="${esc(e.first_seen || '')}">${esc(lt.time.ago(_toIso(e.first_seen)))}</td>
|
||||
<td>${e.resolved_at ? `resolved ${esc(lt.time.ago(_toIso(e.resolved_at)))}` : '<strong>active</strong>'}</td>
|
||||
</tr>`).join('')}</tbody></table></div>`;
|
||||
return section('Alerts', body);
|
||||
}
|
||||
|
||||
function currentRows() {
|
||||
if (!_device) return [];
|
||||
if (_device.ports) return portRows(_device.ports);
|
||||
if (_device.interfaces) return hostRows(_device.interfaces);
|
||||
return [];
|
||||
}
|
||||
|
||||
function renderInterfaces() {
|
||||
const wrap = document.getElementById('device-table-wrap');
|
||||
if (wrap) wrap.innerHTML = renderTable(currentRows());
|
||||
}
|
||||
|
||||
function renderInterfaceSection(rows, title) {
|
||||
if (!rows.length) return '';
|
||||
const up = rows.filter(r => r.up).length;
|
||||
const problems = rows.filter(rowHasProblem).length;
|
||||
const pill = (value, label) => `<button type="button" class="pill${_filter === value ? ' active' : ''}" data-device-filter="${value}" aria-pressed="${_filter === value}">${label}</button>`;
|
||||
return section(`${title} · ${up} of ${rows.length} up`, `
|
||||
<div class="lt-toolbar device-toolbar">
|
||||
<div class="lt-toolbar-left">
|
||||
<div class="lt-search">
|
||||
<input type="search" class="lt-input lt-search-input lt-search-input--sm" id="device-table-search"
|
||||
placeholder="Filter…" autocomplete="off" aria-label="Filter ${esc(title.toLowerCase())}" value="${esc(_query)}">
|
||||
</div>
|
||||
<div class="sev-pills" role="group" aria-label="Filter by state">
|
||||
${pill('all', 'All')}${pill('up', 'Up')}${pill('problems', `Errors (${problems})`)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="device-table-wrap"></div>`);
|
||||
}
|
||||
|
||||
function render(d) {
|
||||
_device = d;
|
||||
document.getElementById('device-dot').className = `dot-${d.status || 'unknown'}`;
|
||||
document.getElementById('device-subtitle').textContent =
|
||||
[d.kind, d.ip, d.model].filter(Boolean).join(' · ') +
|
||||
(d.updated ? ` · updated ${new Date(_toIso(d.updated)).toLocaleTimeString()}` : '');
|
||||
|
||||
const unifi = d.source === 'unifi';
|
||||
document.getElementById('device-actions').innerHTML = `
|
||||
${d.ports && Object.keys(d.ports).length ? `<a class="lt-btn lt-btn-secondary lt-btn-sm" href="/inspector">⬡ Inspector</a>` : ''}
|
||||
<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress" data-sup-type="${unifi ? 'unifi_device' : 'host'}"
|
||||
data-sup-name="${esc(d.name)}" data-sup-detail="" aria-label="Suppress alerts for ${esc(d.name)}">🔕 Suppress</button>`;
|
||||
|
||||
// Keep focus and caret in the filter box across auto-refresh
|
||||
const active = document.activeElement && document.activeElement.id === 'device-table-search';
|
||||
const caret = active ? document.activeElement.selectionStart : 0;
|
||||
|
||||
const rows = currentRows();
|
||||
document.getElementById('device-root').innerHTML =
|
||||
renderOverview(d) +
|
||||
renderEvents(d.events) +
|
||||
renderWan(d) +
|
||||
renderUplinks(d) +
|
||||
renderOutlets(d) +
|
||||
renderInterfaceSection(rows, d.ports ? 'Ports' : 'Interfaces');
|
||||
renderInterfaces();
|
||||
|
||||
if (active) {
|
||||
const box = document.getElementById('device-table-search');
|
||||
box.focus();
|
||||
box.setSelectionRange(caret, caret);
|
||||
}
|
||||
if (d.kind === 'modem') refreshModem();
|
||||
}
|
||||
|
||||
async function loadDevice() {
|
||||
try {
|
||||
render(await lt.api.get(`/api/device/${encodeURIComponent(DEVICE_NAME)}`));
|
||||
} catch (err) {
|
||||
if (_device) {
|
||||
lt.toast.error('Could not refresh device data');
|
||||
return;
|
||||
}
|
||||
const notFound = /404|No device/.test(err.message || '');
|
||||
document.getElementById('device-root').innerHTML = `
|
||||
<div class="lt-empty-state">
|
||||
<div class="lt-empty-state-icon">✕</div>
|
||||
<div class="lt-empty-state-title">${notFound ? 'No device with this name' : 'Could not load this device'}</div>
|
||||
<div class="lt-empty-state-body">${notFound ? 'It may have been renamed, or the monitor may not have seen it yet.' : 'Check that the Gandalf service is healthy, then refresh.'}</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
const toggle = e.target.closest('.device-row-toggle');
|
||||
if (toggle) {
|
||||
const id = toggle.dataset.row;
|
||||
if (_openRows.has(id)) _openRows.delete(id); else _openRows.add(id);
|
||||
renderInterfaces();
|
||||
return;
|
||||
}
|
||||
const pill = e.target.closest('[data-device-filter]');
|
||||
if (pill) {
|
||||
_filter = pill.dataset.deviceFilter;
|
||||
document.querySelectorAll('[data-device-filter]').forEach(p => {
|
||||
const on = p === pill;
|
||||
p.classList.toggle('active', on);
|
||||
p.setAttribute('aria-pressed', String(on));
|
||||
});
|
||||
renderInterfaces();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('input', e => {
|
||||
if (e.target.id === 'device-table-search') {
|
||||
_query = e.target.value;
|
||||
renderInterfaces();
|
||||
}
|
||||
});
|
||||
|
||||
loadDevice();
|
||||
const _deviceInterval = window.gandalfSettings?.refreshInterval ?? 60;
|
||||
if (_deviceInterval > 0) lt.autoRefresh.start(loadDevice, Math.max(_deviceInterval, 15) * 1000);
|
||||
|
||||
window.onGandalfSettingsChanged = function (s) {
|
||||
lt.autoRefresh.stop();
|
||||
if (s.refreshInterval > 0) lt.autoRefresh.start(loadDevice, Math.max(s.refreshInterval, 15) * 1000);
|
||||
};
|
||||
@@ -1064,3 +1064,88 @@
|
||||
.inspector-panel.open { width: 100%; }
|
||||
.inspector-panel-inner{ width: 100%; }
|
||||
}
|
||||
|
||||
/* ── WAN modem / fiber panel ──────────────────────────────────────── */
|
||||
.modem-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.modem-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
.modem-stat { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.modem-stat-label { font-size: .68em; color: var(--text-muted); text-transform: uppercase; letter-spacing: .05em; }
|
||||
.modem-stat-value { font-size: 1.05em; font-weight: bold; overflow-wrap: anywhere; }
|
||||
.modem-stat-note { font-size: .7em; color: var(--text-muted); display: block; }
|
||||
.modem-error { margin: 12px; padding: 8px 10px; border: 1px solid var(--terminal-red, #f44); color: var(--terminal-red, #f44); }
|
||||
.modem-chart { padding: 0 12px 8px; }
|
||||
.modem-chart-title { font-size: .72em; color: var(--text-muted); margin-bottom: 4px; }
|
||||
.modem-chart-title span { margin-left: 8px; opacity: .8; }
|
||||
.modem-chart svg { display: block; width: 100%; height: 70px; border: 1px solid var(--border-color); }
|
||||
.modem-chart svg polyline { stroke: var(--terminal-cyan, #0ff); stroke-width: 1.5; }
|
||||
.modem-chart-empty { padding: 0 12px 8px; font-size: .72em; color: var(--text-muted); }
|
||||
.modem-updated { padding: 0 12px 10px; }
|
||||
|
||||
/* ── Device search (home page) ────────────────────────────────────── */
|
||||
.device-search { position: relative; }
|
||||
.device-search label { display: block; margin-bottom: 6px; }
|
||||
.device-search .lt-search { width: 100%; }
|
||||
.device-search-input { width: 100%; font-size: 1em; padding-top: 10px; padding-bottom: 10px; }
|
||||
.device-search-results {
|
||||
position: absolute; left: 0; right: 0; z-index: 50; margin-top: 4px;
|
||||
background: var(--bg-card, var(--bg-secondary)); border: 1px solid var(--border-color);
|
||||
max-height: 420px; overflow-y: auto;
|
||||
}
|
||||
.device-search-results[hidden] { display: none; }
|
||||
.device-search-row {
|
||||
display: grid; grid-template-columns: 14px minmax(0, 1.4fr) 70px minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 10px; align-items: center; padding: 9px 12px; color: inherit; text-decoration: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.device-search-row:last-child { border-bottom: 0; }
|
||||
.device-search-row:hover, .device-search-row.is-active { background: var(--bg-tertiary); }
|
||||
.device-search-name { font-weight: bold; overflow-wrap: anywhere; }
|
||||
.device-search-kind { font-size: .72em; color: var(--text-muted); text-transform: uppercase; }
|
||||
.device-search-meta { font-size: .8em; color: var(--text-muted); overflow-wrap: anywhere; }
|
||||
.device-search-empty { padding: 12px; color: var(--text-muted); }
|
||||
@media (max-width: 700px) {
|
||||
.device-search-row { grid-template-columns: 14px minmax(0, 1fr) auto; }
|
||||
.device-search-row .device-search-meta { grid-column: 2 / -1; }
|
||||
}
|
||||
|
||||
/* ── Device page ───────────────────────────────────────────────────── */
|
||||
a.device-link { color: inherit; text-decoration: none; border-bottom: 1px dotted var(--text-muted); }
|
||||
a.device-link:hover { color: var(--text-accent); border-bottom-color: currentColor; }
|
||||
.device-title { display: flex; align-items: center; gap: 10px; overflow-wrap: anywhere; }
|
||||
.device-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.device-toolbar { padding: 8px 12px; margin: 0; }
|
||||
.device-table td { vertical-align: middle; }
|
||||
.device-num { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.device-row--down { opacity: .55; }
|
||||
.device-row-name { overflow-wrap: anywhere; }
|
||||
.device-row-sub { font-size: .72em; color: var(--text-muted); }
|
||||
.device-row-toggle { background: none; border: 0; color: inherit; cursor: pointer; font: inherit; padding: 2px 6px; }
|
||||
.device-row-detail > td { background: var(--bg-tertiary); padding: 10px 14px; }
|
||||
.topo-host[data-host] { cursor: pointer; }
|
||||
|
||||
/* WAN outage list in the modem panel */
|
||||
.modem-outages { padding: 0 12px 8px; }
|
||||
.modem-outages ul { list-style: none; margin: 0; padding: 0; border: 1px solid var(--border-color); }
|
||||
.modem-outages li { display: flex; justify-content: space-between; gap: 12px; padding: 4px 8px; font-size: .8em; }
|
||||
.modem-outages li + li { border-top: 1px solid var(--border-color); }
|
||||
|
||||
.g-section-actions[hidden] { display: none; }
|
||||
/* Rows open a detail panel holding the fields that are hidden here on narrow screens */
|
||||
.device-row-detail .link-stats-grid { grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); }
|
||||
.device-table th:nth-child(-n+2), .device-table td:nth-child(-n+2) { width: 1%; white-space: nowrap; }
|
||||
@media (max-width: 700px) {
|
||||
.device-col-wide { display: none; }
|
||||
.device-table th, .device-table td { padding-left: 6px; padding-right: 6px; }
|
||||
}
|
||||
.device-search-row.is-active { box-shadow: inset 3px 0 0 var(--terminal-cyan, #0ff); }
|
||||
@media (max-width: 700px) {
|
||||
/* Scroll inside the wrapper rather than squeezing every column to a few letters */
|
||||
#events-table { min-width: 760px; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user