ESP32 WiFi recon cluster
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0c110e;
|
||||
--bg-panel: #111a14;
|
||||
--bg-hover: #1a2b1e;
|
||||
--border: #1e3024;
|
||||
--green-dim: #2a5c38;
|
||||
--green-mid: #3a8c50;
|
||||
--green-hi: #4ec96a;
|
||||
--green-live: #00ff6a;
|
||||
--text: #c8d8cc;
|
||||
--text-dim: #5a7a60;
|
||||
--text-muted: #3a5040;
|
||||
--orange: #e67e22;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Layout ── */
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-rows: 44px 42px 1fr;
|
||||
grid-template-columns: 220px 1fr;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Topbar ── */
|
||||
#topbar {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
#topbar .title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--green-hi);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
#topbar .sep { color: var(--text-muted); }
|
||||
#topbar .build-info {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
#live-dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
margin-left: auto;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
#live-dot.alive { background: var(--green-live); box-shadow: 0 0 6px var(--green-live); }
|
||||
#live-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
#live-dot.alive + #live-label { color: var(--green-hi); }
|
||||
|
||||
/* ── Navbar ── */
|
||||
#navbar {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 20px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-muted);
|
||||
padding: 0 18px;
|
||||
cursor: pointer;
|
||||
margin-bottom: -1px;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.tab-btn:hover { color: var(--text-dim); }
|
||||
.tab-btn.active {
|
||||
color: var(--green-hi);
|
||||
border-bottom-color: var(--green-hi);
|
||||
}
|
||||
|
||||
/* ── Sidebar ── */
|
||||
#sidebar {
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--bg-panel);
|
||||
overflow-y: auto;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.sidebar-section {
|
||||
padding: 0 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.sidebar-label {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.stat-row span:last-child { color: var(--text); }
|
||||
|
||||
#node-list { padding: 0; }
|
||||
.node-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
border-left: 2px solid transparent;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.node-item:hover { background: var(--bg-hover); }
|
||||
.node-item.active {
|
||||
background: var(--bg-hover);
|
||||
border-left-color: var(--green-hi);
|
||||
}
|
||||
.node-dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.node-dot.online { background: var(--green-live); box-shadow: 0 0 4px var(--green-live); }
|
||||
.node-dot.offline { background: var(--text-muted); }
|
||||
.node-info { flex: 1; min-width: 0; }
|
||||
.node-name {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.node-meta {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.node-count {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.node-hb {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* ── Main content ── */
|
||||
#main {
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.view { display: none; flex-direction: column; gap: 16px; }
|
||||
.view.active { display: flex; }
|
||||
|
||||
/* ── Back button ── */
|
||||
#back-btn, .back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
padding: 5px 12px;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.05em;
|
||||
align-self: flex-start;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
#back-btn:hover, .back-btn:hover { color: var(--green-hi); border-color: var(--green-dim); }
|
||||
|
||||
/* ── RSSI chart ── */
|
||||
#rssi-chart {
|
||||
width: 100%;
|
||||
display: block;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
#chart-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.legend-item { display: flex; align-items: center; gap: 6px; }
|
||||
.legend-swatch { width: 24px; height: 2px; flex-shrink: 0; }
|
||||
|
||||
.range-btns { display: flex; gap: 4px; }
|
||||
.range-btn {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
padding: 3px 10px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.range-btn:hover { color: var(--text-dim); border-color: var(--green-dim); }
|
||||
.range-btn.active { color: var(--green-hi); border-color: var(--green-dim); }
|
||||
|
||||
.chart-btn {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 10px;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.chart-btn:hover { color: var(--green-hi); border-color: var(--green-dim); }
|
||||
|
||||
/* ── Section header ── */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.section-title {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.section-count { font-size: 10px; color: var(--text-muted); }
|
||||
|
||||
/* ── Node detail stats ── */
|
||||
#detail-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.stat-card .label {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.stat-card .value {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--green-hi);
|
||||
}
|
||||
.stat-card .sub { font-size: 10px; color: var(--text-dim); margin-top: 2px; }
|
||||
|
||||
/* ── Table ── */
|
||||
.tbl-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
thead th {
|
||||
text-align: left;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody tr { border-bottom: 1px solid var(--border); transition: background 0.1s; }
|
||||
tbody tr:hover { background: var(--bg-hover); }
|
||||
tbody tr.new-row { animation: fadeIn 0.4s ease; }
|
||||
@keyframes fadeIn {
|
||||
from { background: var(--green-dim); }
|
||||
to { background: transparent; }
|
||||
}
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
td.dim { color: var(--text-dim); }
|
||||
td.muted { color: var(--text-muted); }
|
||||
|
||||
/* ── Badges ── */
|
||||
.badge { display: inline-block; padding: 1px 6px; font-size: 10px; letter-spacing: 0.04em; }
|
||||
.badge.high { color: var(--green-live); border: 1px solid var(--green-dim); }
|
||||
.badge.normal { color: var(--text-dim); border: 1px solid var(--border); }
|
||||
.badge.low { color: var(--text-muted); border: 1px solid var(--text-muted); }
|
||||
|
||||
/* ── RSSI colours ── */
|
||||
.rssi-hi { color: var(--green-hi); }
|
||||
.rssi-mid { color: var(--text); }
|
||||
.rssi-low { color: var(--text-muted); }
|
||||
|
||||
/* ── Sortable headers ── */
|
||||
thead th.sortable { cursor: pointer; user-select: none; }
|
||||
thead th.sortable:hover { color: var(--text-dim); }
|
||||
thead th.sort-active { color: var(--green-hi); }
|
||||
thead th.sort-active::after { content: ' ' attr(data-arrow); }
|
||||
|
||||
/* ── Enc / node colours ── */
|
||||
.enc-open { color: var(--orange); }
|
||||
.nodes-multi { color: var(--green-hi); }
|
||||
|
||||
/* ── Cross-node cells ── */
|
||||
.cn-best { color: var(--green-hi); font-weight: 500; }
|
||||
.cn-none { color: var(--text-muted); font-style: italic; }
|
||||
|
||||
/* ── Empty state ── */
|
||||
.empty {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ── */
|
||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg); }
|
||||
::-webkit-scrollbar-thumb { background: var(--green-dim); }
|
||||
@@ -0,0 +1,537 @@
|
||||
// ── State ────────────────────────────────────────────────────────────────────
|
||||
const MAX_ROWS = 100;
|
||||
let generalEvents = [];
|
||||
let currentNode = null;
|
||||
let currentView = 'feed';
|
||||
let nodeData = {};
|
||||
let networksData = [];
|
||||
let netSort = { col: 'times_seen', dir: 'desc' };
|
||||
let crossNodeData = { node_ids: [], networks: [] };
|
||||
let clientsData = [];
|
||||
let chartBssid = null;
|
||||
let chartHours = 2;
|
||||
|
||||
const NODE_COLORS = ['#4ec96a', '#e67e22', '#7eb3ff', '#c678dd'];
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function fmt(val, fallback = '—') {
|
||||
return (val !== null && val !== undefined && val !== '') ? val : fallback;
|
||||
}
|
||||
|
||||
function rssiClass(rssi) {
|
||||
if (rssi >= -50) return 'rssi-hi';
|
||||
if (rssi >= -80) return 'rssi-mid';
|
||||
return 'rssi-low';
|
||||
}
|
||||
|
||||
function impBadge(imp) {
|
||||
const cls = ['high', 'normal', 'low'].includes(imp) ? imp : 'normal';
|
||||
return `<span class="badge ${cls}">${fmt(imp)}</span>`;
|
||||
}
|
||||
|
||||
function shortTime(iso) {
|
||||
if (!iso) return '—';
|
||||
return iso.replace('T', ' ');
|
||||
}
|
||||
|
||||
function fmtUptime(ms) {
|
||||
if (ms == null) return '—';
|
||||
const s = Math.floor(ms / 1000);
|
||||
const m = Math.floor(s / 60);
|
||||
const h = Math.floor(m / 60);
|
||||
const d = Math.floor(h / 24);
|
||||
if (d > 0) return `${d}d ${h % 24}h`;
|
||||
if (h > 0) return `${h}h ${m % 60}m`;
|
||||
if (m > 0) return `${m}m`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
// ── Sidebar ──────────────────────────────────────────────────────────────────
|
||||
function renderSidebar(nodes) {
|
||||
const online = nodes.filter(n => n.status === 'online').length;
|
||||
document.getElementById('sum-nodes').textContent = nodes.length;
|
||||
document.getElementById('sum-online').textContent = online;
|
||||
|
||||
document.getElementById('node-list').innerHTML = nodes.map(n => {
|
||||
const hbParts = [];
|
||||
if (n.uptime_ms != null) hbParts.push(`↑${fmtUptime(n.uptime_ms)}`);
|
||||
if (n.free_heap != null) hbParts.push(`${Math.round(n.free_heap / 1024)}K`);
|
||||
if (n.wifi_rssi != null) hbParts.push(`${n.wifi_rssi}dBm`);
|
||||
const hbLine = hbParts.join(' · ');
|
||||
return `
|
||||
<div class="node-item ${currentNode === n.node_id ? 'active' : ''}"
|
||||
data-node-id="${n.node_id}"
|
||||
onclick="showDetail('${n.node_id}')">
|
||||
<div class="node-dot ${n.status}"></div>
|
||||
<div class="node-info">
|
||||
<div class="node-name">${n.node_id}</div>
|
||||
<div class="node-meta">${shortTime(n.last_seen)}</div>
|
||||
${hbLine ? `<div class="node-hb">${hbLine}</div>` : ''}
|
||||
</div>
|
||||
<div class="node-count">${n.total_events}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Feed view ─────────────────────────────────────────────────────────────────
|
||||
function renderGeneralTable(animate = false) {
|
||||
const tbody = document.getElementById('general-tbody');
|
||||
const empty = document.getElementById('general-empty');
|
||||
document.getElementById('general-count').textContent = `${generalEvents.length} events`;
|
||||
|
||||
if (!generalEvents.length) {
|
||||
tbody.innerHTML = '';
|
||||
empty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
empty.style.display = 'none';
|
||||
|
||||
if (animate) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'new-row';
|
||||
tr.innerHTML = generalRowHTML(generalEvents[0]);
|
||||
tbody.prepend(tr);
|
||||
while (tbody.rows.length > MAX_ROWS) tbody.deleteRow(tbody.rows.length - 1);
|
||||
} else {
|
||||
tbody.innerHTML = generalEvents.map(generalRowHTML).join('');
|
||||
}
|
||||
}
|
||||
|
||||
function generalRowHTML(r) {
|
||||
return `<tr>
|
||||
<td class="dim">${shortTime(r.received_at)}</td>
|
||||
<td>${fmt(r.node_id)}</td>
|
||||
<td>${fmt(r.ssid, '<hidden>')}</td>
|
||||
<td class="dim">${fmt(r.bssid)}</td>
|
||||
<td class="${rssiClass(r.rssi)}">${fmt(r.rssi)} dBm</td>
|
||||
<td class="muted">${fmt(r.channel)}</td>
|
||||
<td class="muted">${fmt(r.encryption)}</td>
|
||||
<td>${impBadge(r.importance)}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// ── Node detail view ──────────────────────────────────────────────────────────
|
||||
function renderDetail(d) {
|
||||
document.getElementById('detail-node-id').textContent = d.node_id;
|
||||
document.getElementById('ds-status').textContent = d.status.toUpperCase();
|
||||
document.getElementById('ds-status').style.color = d.status === 'online' ? 'var(--green-live)' : 'var(--text-muted)';
|
||||
document.getElementById('ds-total').textContent = d.total_events;
|
||||
document.getElementById('ds-ssids').textContent = d.unique_ssids;
|
||||
document.getElementById('ds-bssids').textContent = d.unique_bssids;
|
||||
document.getElementById('ds-rssi-avg').textContent = d.avg_rssi !== null ? `${d.avg_rssi} dBm` : '—';
|
||||
document.getElementById('ds-rssi-range').textContent = d.min_rssi !== null ? `${d.min_rssi} / ${d.max_rssi} dBm` : '';
|
||||
document.getElementById('ds-first').textContent = shortTime(d.first_seen);
|
||||
document.getElementById('ds-last').textContent = shortTime(d.last_seen);
|
||||
document.getElementById('ds-uptime').textContent = d.uptime_ms != null ? fmtUptime(d.uptime_ms) : '—';
|
||||
document.getElementById('ds-heap').textContent = d.free_heap != null ? Math.round(d.free_heap / 1024) + ' KB' : '—';
|
||||
document.getElementById('ds-ap-rssi').textContent = d.wifi_rssi != null ? d.wifi_rssi + ' dBm' : '—';
|
||||
|
||||
document.getElementById('detail-count').textContent = `${d.events.length} events (last ${MAX_ROWS})`;
|
||||
document.getElementById('detail-tbody').innerHTML = d.events.map(r => `
|
||||
<tr>
|
||||
<td class="dim">${shortTime(r.received_at)}</td>
|
||||
<td>${fmt(r.ssid, '<hidden>')}</td>
|
||||
<td class="dim">${fmt(r.bssid)}</td>
|
||||
<td class="${rssiClass(r.rssi)}">${fmt(r.rssi)} dBm</td>
|
||||
<td class="muted">${fmt(r.channel)}</td>
|
||||
<td class="muted">${fmt(r.encryption)}</td>
|
||||
<td>${impBadge(r.importance)}</td>
|
||||
<td class="muted">${fmt(r.confidence)}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
// ── Networks view ─────────────────────────────────────────────────────────────
|
||||
async function fetchNetworks() {
|
||||
networksData = await fetch('/api/networks').then(r => r.json());
|
||||
renderNetworksTable();
|
||||
}
|
||||
|
||||
function renderNetworksTable() {
|
||||
const tbody = document.getElementById('networks-tbody');
|
||||
const empty = document.getElementById('networks-empty');
|
||||
const count = document.getElementById('networks-count');
|
||||
|
||||
if (!networksData.length) {
|
||||
tbody.innerHTML = '';
|
||||
empty.style.display = 'block';
|
||||
count.textContent = '';
|
||||
return;
|
||||
}
|
||||
empty.style.display = 'none';
|
||||
|
||||
const sorted = [...networksData].sort((a, b) => {
|
||||
let av = a[netSort.col], bv = b[netSort.col];
|
||||
if (av == null) av = netSort.dir === 'asc' ? Infinity : -Infinity;
|
||||
if (bv == null) bv = netSort.dir === 'asc' ? Infinity : -Infinity;
|
||||
if (typeof av === 'string') av = av.toLowerCase();
|
||||
if (typeof bv === 'string') bv = bv.toLowerCase();
|
||||
if (av < bv) return netSort.dir === 'asc' ? -1 : 1;
|
||||
if (av > bv) return netSort.dir === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
count.textContent = `${sorted.length} unique networks`;
|
||||
tbody.innerHTML = sorted.map(r => {
|
||||
const encCls = (!r.encryption || r.encryption === 'OPEN' || r.encryption === 'None') ? 'enc-open' : 'muted';
|
||||
const nodeCls = r.node_count > 1 ? 'nodes-multi' : 'muted';
|
||||
return `<tr>
|
||||
<td>${fmt(r.ssid, '<hidden>')}</td>
|
||||
<td class="dim">${fmt(r.bssid)}</td>
|
||||
<td class="${rssiClass(r.best_rssi)}">${fmt(r.best_rssi)} dBm</td>
|
||||
<td class="${rssiClass(r.avg_rssi)}">${fmt(r.avg_rssi)} dBm</td>
|
||||
<td class="muted">${fmt(r.channel)}</td>
|
||||
<td class="${encCls}">${r.encryption || 'OPEN'}</td>
|
||||
<td>${fmt(r.times_seen)}</td>
|
||||
<td class="${nodeCls}">${fmt(r.node_count)}</td>
|
||||
<td class="dim">${shortTime(r.last_seen)}</td>
|
||||
<td><button class="chart-btn" onclick="showNetworkChart('${r.bssid}')">▶</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function initNetworkSort() {
|
||||
document.querySelectorAll('#view-networks thead th.sortable').forEach(th => {
|
||||
th.addEventListener('click', () => {
|
||||
const col = th.dataset.col;
|
||||
if (netSort.col === col) {
|
||||
netSort.dir = netSort.dir === 'desc' ? 'asc' : 'desc';
|
||||
} else {
|
||||
netSort.col = col;
|
||||
netSort.dir = ['ssid', 'bssid', 'encryption'].includes(col) ? 'asc' : 'desc';
|
||||
}
|
||||
document.querySelectorAll('#view-networks thead th').forEach(h => {
|
||||
h.classList.remove('sort-active');
|
||||
h.dataset.arrow = '';
|
||||
});
|
||||
th.classList.add('sort-active');
|
||||
th.dataset.arrow = netSort.dir === 'desc' ? '↓' : '↑';
|
||||
renderNetworksTable();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Cross-node view ───────────────────────────────────────────────────────────
|
||||
async function fetchCrossNode() {
|
||||
crossNodeData = await fetch('/api/cross-node').then(r => r.json());
|
||||
renderCrossNodeTable();
|
||||
}
|
||||
|
||||
function renderCrossNodeTable() {
|
||||
const thead = document.getElementById('crossnode-thead');
|
||||
const tbody = document.getElementById('crossnode-tbody');
|
||||
const empty = document.getElementById('crossnode-empty');
|
||||
const count = document.getElementById('crossnode-count');
|
||||
const filter = document.getElementById('crossnode-filter').checked;
|
||||
const nodeIds = crossNodeData.node_ids || [];
|
||||
|
||||
let networks = crossNodeData.networks || [];
|
||||
if (filter) networks = networks.filter(n => n.node_count > 1);
|
||||
|
||||
if (!networks.length) {
|
||||
thead.innerHTML = tbody.innerHTML = '';
|
||||
empty.style.display = 'block';
|
||||
count.textContent = '';
|
||||
return;
|
||||
}
|
||||
empty.style.display = 'none';
|
||||
count.textContent = `${networks.length} networks`;
|
||||
|
||||
thead.innerHTML = `
|
||||
<tr>
|
||||
<th>SSID</th><th>BSSID</th><th>Ch</th><th>Enc</th>
|
||||
${nodeIds.map(id => `<th colspan="2" style="text-align:center;border-left:1px solid var(--border)">${id}</th>`).join('')}
|
||||
<th>Nodes</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th></th><th></th><th></th><th></th>
|
||||
${nodeIds.map(() => `
|
||||
<th style="border-left:1px solid var(--border);font-size:8px">best</th>
|
||||
<th style="font-size:8px">avg</th>
|
||||
`).join('')}
|
||||
<th></th>
|
||||
</tr>`;
|
||||
|
||||
tbody.innerHTML = networks.map(net => {
|
||||
const seenRssis = nodeIds.filter(id => net.nodes[id]).map(id => net.nodes[id].best_rssi);
|
||||
const globalBest = seenRssis.length ? Math.max(...seenRssis) : null;
|
||||
const encCls = (!net.encryption || net.encryption === 'OPEN' || net.encryption === 'None') ? 'enc-open' : 'muted';
|
||||
const nodeCls = net.node_count > 1 ? 'nodes-multi' : 'muted';
|
||||
|
||||
const nodeCells = nodeIds.map(id => {
|
||||
if (!net.nodes[id]) return `<td class="cn-none" style="border-left:1px solid var(--border)">—</td><td class="cn-none">—</td>`;
|
||||
const n = net.nodes[id];
|
||||
const isBest = n.best_rssi === globalBest && net.node_count > 1;
|
||||
const bestCls = isBest ? 'cn-best' : rssiClass(n.best_rssi);
|
||||
return `<td class="${bestCls}" style="border-left:1px solid var(--border)">${n.best_rssi} dBm</td>
|
||||
<td class="${isBest ? 'cn-best' : 'dim'}">${n.avg_rssi} dBm</td>`;
|
||||
}).join('');
|
||||
|
||||
return `<tr>
|
||||
<td>${fmt(net.ssid, '<hidden>')}</td>
|
||||
<td class="dim">${fmt(net.bssid)}</td>
|
||||
<td class="muted">${fmt(net.channel)}</td>
|
||||
<td class="${encCls}">${net.encryption || 'OPEN'}</td>
|
||||
${nodeCells}
|
||||
<td class="${nodeCls}">${net.node_count} / ${nodeIds.length}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Clients view ──────────────────────────────────────────────────────────────
|
||||
async function fetchClients() {
|
||||
clientsData = await fetch('/api/clients').then(r => r.json());
|
||||
renderClientsTable();
|
||||
}
|
||||
|
||||
function renderClientsTable() {
|
||||
const tbody = document.getElementById('clients-tbody');
|
||||
const empty = document.getElementById('clients-empty');
|
||||
const count = document.getElementById('clients-count');
|
||||
|
||||
if (!clientsData.length) {
|
||||
tbody.innerHTML = '';
|
||||
empty.style.display = 'block';
|
||||
count.textContent = '';
|
||||
return;
|
||||
}
|
||||
empty.style.display = 'none';
|
||||
count.textContent = `${clientsData.length} unique MACs`;
|
||||
|
||||
tbody.innerHTML = clientsData.map(r => {
|
||||
const probing = r.probed_ssids.length
|
||||
? r.probed_ssids.map(s => `<span style="color:var(--text-dim)">${s}</span>`).join(', ')
|
||||
: '<span style="color:var(--text-muted)">wildcard only</span>';
|
||||
const vendorCls = r.randomized ? 'muted' : 'dim';
|
||||
const nodeCls = r.node_count > 1 ? 'nodes-multi' : 'muted';
|
||||
return `<tr>
|
||||
<td>${fmt(r.src_mac)}</td>
|
||||
<td class="${vendorCls}">${r.vendor || '—'}</td>
|
||||
<td style="max-width:260px;white-space:normal;line-height:1.8">${probing}</td>
|
||||
<td class="${rssiClass(r.best_rssi)}">${fmt(r.best_rssi)} dBm</td>
|
||||
<td class="${rssiClass(r.avg_rssi)}">${fmt(r.avg_rssi)} dBm</td>
|
||||
<td class="${nodeCls}">${fmt(r.node_count)}</td>
|
||||
<td>${fmt(r.times_seen)}</td>
|
||||
<td class="dim">${shortTime(r.last_seen)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── RSSI chart ────────────────────────────────────────────────────────────────
|
||||
async function showNetworkChart(bssid) {
|
||||
chartBssid = bssid;
|
||||
const net = networksData.find(n => n.bssid === bssid);
|
||||
document.getElementById('chart-title').textContent = fmt(net?.ssid, '<hidden>');
|
||||
document.getElementById('chart-subtitle').textContent = bssid;
|
||||
setView('network-chart');
|
||||
// requestAnimationFrame ensures the view is laid out before we read canvas.offsetWidth
|
||||
requestAnimationFrame(() => loadAndRenderChart());
|
||||
}
|
||||
|
||||
async function setChartRange(hours) {
|
||||
chartHours = hours;
|
||||
document.querySelectorAll('.range-btn').forEach(b => {
|
||||
b.classList.toggle('active', parseInt(b.dataset.hours) === hours);
|
||||
});
|
||||
await loadAndRenderChart();
|
||||
}
|
||||
|
||||
async function loadAndRenderChart() {
|
||||
const data = await fetch(`/api/rssi-history?bssid=${encodeURIComponent(chartBssid)}&hours=${chartHours}`).then(r => r.json());
|
||||
renderRssiChart(data);
|
||||
}
|
||||
|
||||
function renderRssiChart(series) {
|
||||
const canvas = document.getElementById('rssi-chart');
|
||||
const empty = document.getElementById('chart-empty');
|
||||
const legend = document.getElementById('chart-legend');
|
||||
const nodeIds = Object.keys(series);
|
||||
const allPts = nodeIds.flatMap(id => series[id]);
|
||||
|
||||
if (!allPts.length) {
|
||||
canvas.style.display = 'none';
|
||||
empty.style.display = 'block';
|
||||
legend.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
canvas.style.display = 'block';
|
||||
empty.style.display = 'none';
|
||||
|
||||
const W = canvas.offsetWidth;
|
||||
const H = canvas.height;
|
||||
canvas.width = W;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const pad = { top: 20, right: 16, bottom: 36, left: 52 };
|
||||
const pw = W - pad.left - pad.right;
|
||||
const ph = H - pad.top - pad.bottom;
|
||||
|
||||
const now = Date.now();
|
||||
const tStart = now - chartHours * 3600000;
|
||||
|
||||
const rssis = allPts.map(p => p.rssi);
|
||||
const minR = Math.floor((Math.min(...rssis) - 5) / 5) * 5;
|
||||
const maxR = Math.ceil ((Math.max(...rssis) + 5) / 5) * 5;
|
||||
const rng = maxR - minR || 1;
|
||||
|
||||
const xMap = t => pad.left + (new Date(t + 'Z').getTime() - tStart) / (now - tStart) * pw;
|
||||
const yMap = r => pad.top + (1 - (r - minR) / rng) * ph;
|
||||
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
ctx.font = '10px IBM Plex Mono';
|
||||
|
||||
// Horizontal gridlines + Y labels
|
||||
const step = rng <= 20 ? 5 : rng <= 50 ? 10 : 20;
|
||||
for (let r = minR; r <= maxR; r += step) {
|
||||
const y = yMap(r);
|
||||
ctx.strokeStyle = '#1e3024'; ctx.lineWidth = 1;
|
||||
ctx.setLineDash([3, 4]);
|
||||
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + pw, y); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.fillStyle = '#3a5040'; ctx.textAlign = 'right';
|
||||
ctx.fillText(`${r}`, pad.left - 6, y + 3);
|
||||
}
|
||||
|
||||
// X axis time ticks
|
||||
const ticks = Math.min(6, Math.floor(pw / 80));
|
||||
ctx.fillStyle = '#3a5040'; ctx.textAlign = 'center';
|
||||
for (let i = 0; i <= ticks; i++) {
|
||||
const t = tStart + (i / ticks) * (now - tStart);
|
||||
const x = pad.left + (i / ticks) * pw;
|
||||
ctx.fillText(new Date(t).toTimeString().slice(0, 5), x, H - 8);
|
||||
ctx.strokeStyle = '#1e3024'; ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(x, pad.top + ph); ctx.lineTo(x, pad.top + ph + 4); ctx.stroke();
|
||||
}
|
||||
|
||||
// Plot border
|
||||
ctx.strokeStyle = '#1e3024'; ctx.lineWidth = 1; ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad.left, pad.top);
|
||||
ctx.lineTo(pad.left, pad.top + ph);
|
||||
ctx.lineTo(pad.left + pw, pad.top + ph);
|
||||
ctx.stroke();
|
||||
|
||||
// Series
|
||||
nodeIds.forEach((nid, i) => {
|
||||
const pts = series[nid];
|
||||
const color = NODE_COLORS[i % NODE_COLORS.length];
|
||||
ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
pts.forEach((p, j) => {
|
||||
const x = xMap(p.t), y = yMap(p.rssi);
|
||||
j === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = color;
|
||||
pts.forEach(p => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(xMap(p.t), yMap(p.rssi), 2.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
});
|
||||
|
||||
// Legend
|
||||
legend.innerHTML = nodeIds.map((nid, i) => `
|
||||
<div class="legend-item">
|
||||
<div class="legend-swatch" style="background:${NODE_COLORS[i % NODE_COLORS.length]}"></div>
|
||||
<span>${nid}</span>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
// ── View switching ────────────────────────────────────────────────────────────
|
||||
function setView(view) {
|
||||
currentView = view;
|
||||
document.getElementById('view-general').classList.toggle('active', view === 'feed');
|
||||
document.getElementById('view-networks').classList.toggle('active', view === 'networks');
|
||||
document.getElementById('view-network-chart').classList.toggle('active', view === 'network-chart');
|
||||
document.getElementById('view-crossnode').classList.toggle('active', view === 'crossnode');
|
||||
document.getElementById('view-clients').classList.toggle('active', view === 'clients');
|
||||
document.getElementById('view-detail').classList.toggle('active', view === 'detail');
|
||||
document.getElementById('tab-feed').classList.toggle('active', view === 'feed' || view === 'detail');
|
||||
document.getElementById('tab-networks').classList.toggle('active', view === 'networks' || view === 'network-chart');
|
||||
document.getElementById('tab-crossnode').classList.toggle('active', view === 'crossnode');
|
||||
document.getElementById('tab-clients').classList.toggle('active', view === 'clients');
|
||||
}
|
||||
|
||||
function showFeed() {
|
||||
currentNode = null;
|
||||
setView('feed');
|
||||
document.querySelectorAll('.node-item').forEach(el => el.classList.remove('active'));
|
||||
}
|
||||
|
||||
function showGeneral() { showFeed(); }
|
||||
|
||||
function showNetworks() { setView('networks'); fetchNetworks(); }
|
||||
|
||||
async function showCrossNode() { setView('crossnode'); await fetchCrossNode(); }
|
||||
|
||||
async function showClients() { setView('clients'); await fetchClients(); }
|
||||
|
||||
async function showDetail(node_id) {
|
||||
currentNode = node_id;
|
||||
setView('detail');
|
||||
document.querySelectorAll('.node-item').forEach(el => {
|
||||
el.classList.toggle('active', el.dataset.nodeId === node_id);
|
||||
});
|
||||
const data = await fetch(`/api/nodes/${node_id}`).then(r => r.json());
|
||||
renderDetail(data);
|
||||
}
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────────────────
|
||||
async function init() {
|
||||
const [nodes, events] = await Promise.all([
|
||||
fetch('/api/nodes').then(r => r.json()),
|
||||
fetch(`/api/events?limit=${MAX_ROWS}`).then(r => r.json()),
|
||||
]);
|
||||
generalEvents = events;
|
||||
nodeData = Object.fromEntries(nodes.map(n => [n.node_id, n]));
|
||||
document.getElementById('sum-events').textContent = generalEvents.length;
|
||||
renderSidebar(nodes);
|
||||
renderGeneralTable(false);
|
||||
}
|
||||
|
||||
// ── SSE ───────────────────────────────────────────────────────────────────────
|
||||
function startSSE() {
|
||||
const es = new EventSource('/api/stream');
|
||||
const dot = document.getElementById('live-dot');
|
||||
let sseTimer = null;
|
||||
|
||||
es.onopen = () => dot.classList.add('alive');
|
||||
|
||||
es.onmessage = (e) => {
|
||||
dot.classList.remove('alive');
|
||||
requestAnimationFrame(() => dot.classList.add('alive'));
|
||||
|
||||
// Update the feed array immediately — this is just JS, no API call needed
|
||||
const ev = JSON.parse(e.data);
|
||||
generalEvents.unshift(ev);
|
||||
if (generalEvents.length > MAX_ROWS) generalEvents.pop();
|
||||
document.getElementById('sum-events').textContent = generalEvents.length;
|
||||
if (currentView === 'feed') renderGeneralTable(true);
|
||||
|
||||
// Debounce all API-backed refreshes: a scan fires 20+ events in rapid
|
||||
// succession — only act on the trailing edge, at most once per 2 seconds.
|
||||
if (sseTimer) return;
|
||||
sseTimer = setTimeout(async () => {
|
||||
sseTimer = null;
|
||||
|
||||
if (currentView === 'networks') await fetchNetworks();
|
||||
else if (currentView === 'network-chart') await loadAndRenderChart();
|
||||
else if (currentView === 'crossnode') await fetchCrossNode();
|
||||
|
||||
const nodes = await fetch('/api/nodes').then(r => r.json());
|
||||
nodeData = Object.fromEntries(nodes.map(n => [n.node_id, n]));
|
||||
renderSidebar(nodes);
|
||||
|
||||
if (currentView === 'detail' && currentNode) {
|
||||
const data = await fetch(`/api/nodes/${currentNode}`).then(r => r.json());
|
||||
renderDetail(data);
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
es.onerror = () => { dot.classList.remove('alive'); setTimeout(startSSE, 3000); };
|
||||
}
|
||||
|
||||
// ── Boot ──────────────────────────────────────────────────────────────────────
|
||||
initNetworkSort();
|
||||
init().then(startSSE);
|
||||
Reference in New Issue
Block a user