Add Presence tab, RSSI chart, heartbeat; fix SSE flood, chart timezone, N+1 clients query
This commit is contained in:
@@ -8,6 +8,7 @@ let networksData = [];
|
||||
let netSort = { col: 'times_seen', dir: 'desc' };
|
||||
let crossNodeData = { node_ids: [], networks: [] };
|
||||
let clientsData = [];
|
||||
let presenceData = {};
|
||||
let chartBssid = null;
|
||||
let chartHours = 2;
|
||||
|
||||
@@ -316,6 +317,126 @@ function renderClientsTable() {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Presence view ─────────────────────────────────────────────────────────────
|
||||
async function fetchPresence() {
|
||||
presenceData = await fetch('/api/presence').then(r => r.json());
|
||||
renderPresenceView();
|
||||
}
|
||||
|
||||
function renderPresenceView() {
|
||||
const p = presenceData;
|
||||
|
||||
// Present Now
|
||||
const presentTbody = document.getElementById('present-tbody');
|
||||
const presentEmpty = document.getElementById('present-empty');
|
||||
const presentCount = document.getElementById('present-count');
|
||||
const present = p.present || [];
|
||||
presentCount.textContent = `${present.length} devices`;
|
||||
if (!present.length) {
|
||||
presentTbody.innerHTML = '';
|
||||
presentEmpty.style.display = 'block';
|
||||
} else {
|
||||
presentEmpty.style.display = 'none';
|
||||
presentTbody.innerHTML = present.map(r => {
|
||||
const nodeCls = r.node_count > 1 ? 'nodes-multi' : 'muted';
|
||||
return `<tr>
|
||||
<td>${fmt(r.src_mac)}</td>
|
||||
<td class="dim">${r.vendor || '—'}</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>${fmt(r.times_seen)}</td>
|
||||
<td class="${nodeCls}">${fmt(r.node_count)}</td>
|
||||
<td class="dim">${shortTime(r.last_seen)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// New Arrivals — devices
|
||||
const arrMacsTbody = document.getElementById('arrivals-macs-tbody');
|
||||
const arrMacsEmpty = document.getElementById('arrivals-macs-empty');
|
||||
const arrCount = document.getElementById('arrivals-count');
|
||||
const newMacs = p.new_macs || [];
|
||||
const newNets = p.new_networks || [];
|
||||
arrCount.textContent = `${newMacs.length} devices · ${newNets.length} networks`;
|
||||
if (!newMacs.length) {
|
||||
arrMacsTbody.innerHTML = '';
|
||||
arrMacsEmpty.style.display = 'block';
|
||||
} else {
|
||||
arrMacsEmpty.style.display = 'none';
|
||||
arrMacsTbody.innerHTML = newMacs.map(r => `<tr>
|
||||
<td>${fmt(r.src_mac)}</td>
|
||||
<td class="dim">${r.vendor || '—'}</td>
|
||||
<td class="dim">${shortTime(r.first_seen)}</td>
|
||||
<td class="${rssiClass(r.best_rssi)}">${fmt(r.best_rssi)} dBm</td>
|
||||
<td>${fmt(r.times_seen)}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
// New Arrivals — networks
|
||||
const arrNetsTbody = document.getElementById('arrivals-nets-tbody');
|
||||
const arrNetsEmpty = document.getElementById('arrivals-nets-empty');
|
||||
if (!newNets.length) {
|
||||
arrNetsTbody.innerHTML = '';
|
||||
arrNetsEmpty.style.display = 'block';
|
||||
} else {
|
||||
arrNetsEmpty.style.display = 'none';
|
||||
arrNetsTbody.innerHTML = newNets.map(r => {
|
||||
const encCls = (!r.encryption || r.encryption === 'OPEN' || r.encryption === 'None') ? 'enc-open' : 'muted';
|
||||
return `<tr>
|
||||
<td>${fmt(r.ssid, '<hidden>')}</td>
|
||||
<td class="dim">${fmt(r.bssid)}</td>
|
||||
<td class="dim">${shortTime(r.first_seen)}</td>
|
||||
<td class="${rssiClass(r.best_rssi)}">${fmt(r.best_rssi)} dBm</td>
|
||||
<td class="muted">${fmt(r.channel)}</td>
|
||||
<td class="${encCls}">${r.encryption || 'OPEN'}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Regulars — devices
|
||||
const regMacsTbody = document.getElementById('regulars-macs-tbody');
|
||||
const regMacsEmpty = document.getElementById('regulars-macs-empty');
|
||||
const regCount = document.getElementById('regulars-count');
|
||||
const regMacs = p.regular_macs || [];
|
||||
const regNets = p.regular_networks || [];
|
||||
regCount.textContent = `${regMacs.length} devices · ${regNets.length} networks`;
|
||||
if (!regMacs.length) {
|
||||
regMacsTbody.innerHTML = '';
|
||||
regMacsEmpty.style.display = 'block';
|
||||
} else {
|
||||
regMacsEmpty.style.display = 'none';
|
||||
regMacsTbody.innerHTML = regMacs.map(r => `<tr>
|
||||
<td>${fmt(r.src_mac)}</td>
|
||||
<td class="dim">${r.vendor || '—'}</td>
|
||||
<td>${fmt(r.days_seen)}</td>
|
||||
<td class="dim">${shortTime(r.first_seen)}</td>
|
||||
<td class="dim">${shortTime(r.last_seen)}</td>
|
||||
<td class="${rssiClass(r.best_rssi)}">${fmt(r.best_rssi)} dBm</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
// Regulars — networks
|
||||
const regNetsTbody = document.getElementById('regulars-nets-tbody');
|
||||
const regNetsEmpty = document.getElementById('regulars-nets-empty');
|
||||
if (!regNets.length) {
|
||||
regNetsTbody.innerHTML = '';
|
||||
regNetsEmpty.style.display = 'block';
|
||||
} else {
|
||||
regNetsEmpty.style.display = 'none';
|
||||
regNetsTbody.innerHTML = regNets.map(r => {
|
||||
const encCls = (!r.encryption || r.encryption === 'OPEN' || r.encryption === 'None') ? 'enc-open' : 'muted';
|
||||
return `<tr>
|
||||
<td>${fmt(r.ssid, '<hidden>')}</td>
|
||||
<td class="dim">${fmt(r.bssid)}</td>
|
||||
<td>${fmt(r.days_seen)}</td>
|
||||
<td class="dim">${shortTime(r.first_seen)}</td>
|
||||
<td class="${rssiClass(r.best_rssi)}">${fmt(r.best_rssi)} dBm</td>
|
||||
<td class="muted">${fmt(r.channel)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// ── RSSI chart ────────────────────────────────────────────────────────────────
|
||||
async function showNetworkChart(bssid) {
|
||||
chartBssid = bssid;
|
||||
@@ -344,7 +465,7 @@ 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 nodeIds = Object.keys(series).sort();
|
||||
const allPts = nodeIds.flatMap(id => series[id]);
|
||||
|
||||
if (!allPts.length) {
|
||||
@@ -446,10 +567,12 @@ function setView(view) {
|
||||
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('view-presence').classList.toggle('active', view === 'presence');
|
||||
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');
|
||||
document.getElementById('tab-presence').classList.toggle('active', view === 'presence');
|
||||
}
|
||||
|
||||
function showFeed() {
|
||||
@@ -466,6 +589,8 @@ async function showCrossNode() { setView('crossnode'); await fetchCrossNode(); }
|
||||
|
||||
async function showClients() { setView('clients'); await fetchClients(); }
|
||||
|
||||
async function showPresence() { setView('presence'); await fetchPresence(); }
|
||||
|
||||
async function showDetail(node_id) {
|
||||
currentNode = node_id;
|
||||
setView('detail');
|
||||
@@ -517,6 +642,7 @@ function startSSE() {
|
||||
if (currentView === 'networks') await fetchNetworks();
|
||||
else if (currentView === 'network-chart') await loadAndRenderChart();
|
||||
else if (currentView === 'crossnode') await fetchCrossNode();
|
||||
else if (currentView === 'presence') await fetchPresence();
|
||||
|
||||
const nodes = await fetch('/api/nodes').then(r => r.json());
|
||||
nodeData = Object.fromEntries(nodes.map(n => [n.node_id, n]));
|
||||
|
||||
Reference in New Issue
Block a user