Add Presence tab, RSSI chart, heartbeat; fix SSE flood, chart timezone, N+1 clients query

This commit is contained in:
bot
2026-04-03 21:50:41 +03:00
parent a73aae21ad
commit cbef4143ee
5 changed files with 376 additions and 9 deletions
+127 -1
View File
@@ -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]));