Add deauth/disassoc detection — firmware capture, coordinator ingestion, Alerts tab with burst detection
This commit is contained in:
@@ -379,6 +379,26 @@ thead th.sort-active::after { content: ' ' attr(data-arrow); }
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* ── Alerts ── */
|
||||
.alerts-summary {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.alerts-summary .stat { display: flex; flex-direction: column; gap: 2px; }
|
||||
.alerts-summary .stat .val { font-size: 18px; font-weight: 500; color: var(--text); }
|
||||
.alerts-summary .stat.alert .val { color: #e74c3c; }
|
||||
.burst-row { background: rgba(231,76,60,0.07); }
|
||||
.burst-row:hover { background: rgba(231,76,60,0.14) !important; }
|
||||
.burst-multi { color: #e74c3c; font-weight: 500; }
|
||||
td.deauth-type { color: #e74c3c; }
|
||||
td.disassoc-type { color: var(--orange); }
|
||||
|
||||
/* ── Scrollbar ── */
|
||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg); }
|
||||
|
||||
@@ -9,6 +9,7 @@ let netSort = { col: 'times_seen', dir: 'desc' };
|
||||
let crossNodeData = { node_ids: [], networks: [] };
|
||||
let clientsData = [];
|
||||
let presenceData = {};
|
||||
let alertsData = {};
|
||||
let chartBssid = null;
|
||||
let chartHours = 2;
|
||||
|
||||
@@ -437,6 +438,95 @@ function renderPresenceView() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Alerts view ───────────────────────────────────────────────────────────────
|
||||
async function fetchAlerts() {
|
||||
alertsData = await fetch('/api/alerts').then(r => r.json());
|
||||
renderAlertsView();
|
||||
}
|
||||
|
||||
function renderAlertsView() {
|
||||
const a = alertsData;
|
||||
const sum = a.summary || {};
|
||||
|
||||
// Summary strip
|
||||
document.getElementById('alerts-summary').innerHTML = `
|
||||
<div class="stat ${sum.total > 0 ? 'alert' : ''}">
|
||||
<span class="val">${sum.total ?? 0}</span>
|
||||
<span>events (last 1h)</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="val">${sum.deauth_count ?? 0}</span>
|
||||
<span>deauth</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="val">${sum.disassoc_count ?? 0}</span>
|
||||
<span>disassoc</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="val">${sum.unique_bssids ?? 0}</span>
|
||||
<span>unique BSSIDs</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="val">${sum.unique_srcs ?? 0}</span>
|
||||
<span>unique sources</span>
|
||||
</div>`;
|
||||
|
||||
// Bursts
|
||||
const burstsTbody = document.getElementById('bursts-tbody');
|
||||
const burstsEmpty = document.getElementById('bursts-empty');
|
||||
const burstsCount = document.getElementById('bursts-count');
|
||||
const bursts = a.bursts || [];
|
||||
burstsCount.textContent = bursts.length ? `${bursts.length} active` : '';
|
||||
if (!bursts.length) {
|
||||
burstsTbody.innerHTML = '';
|
||||
burstsEmpty.style.display = 'block';
|
||||
} else {
|
||||
burstsEmpty.style.display = 'none';
|
||||
burstsTbody.innerHTML = bursts.map(b => {
|
||||
const multiNode = b.node_count > 1;
|
||||
const rowCls = multiNode ? 'burst-row burst-multi' : 'burst-row';
|
||||
const nodeCls = multiNode ? 'burst-multi' : 'muted';
|
||||
const typeCls = b.subtype === 'deauth' ? 'deauth-type' : 'disassoc-type';
|
||||
return `<tr class="${rowCls}">
|
||||
<td class="dim">${fmt(b.bssid)}</td>
|
||||
<td>${fmt(b.ssid, '<hidden>')}</td>
|
||||
<td class="${typeCls}">${b.subtype}</td>
|
||||
<td style="color:#e74c3c;font-weight:500">${b.count}</td>
|
||||
<td class="muted">${b.unique_srcs}</td>
|
||||
<td class="${nodeCls}">${b.node_count}</td>
|
||||
<td class="dim">${shortTime(b.first_seen)}</td>
|
||||
<td class="dim">${shortTime(b.last_seen)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Raw deauth feed
|
||||
const deauthTbody = document.getElementById('deauth-tbody');
|
||||
const deauthEmpty = document.getElementById('deauth-empty');
|
||||
const deauthCount = document.getElementById('deauth-count');
|
||||
const recent = a.recent || [];
|
||||
deauthCount.textContent = `${recent.length} events`;
|
||||
if (!recent.length) {
|
||||
deauthTbody.innerHTML = '';
|
||||
deauthEmpty.style.display = 'block';
|
||||
} else {
|
||||
deauthEmpty.style.display = 'none';
|
||||
deauthTbody.innerHTML = recent.map(r => {
|
||||
const typeCls = r.subtype === 'deauth' ? 'deauth-type' : 'disassoc-type';
|
||||
return `<tr>
|
||||
<td class="dim">${shortTime(r.received_at)}</td>
|
||||
<td class="muted">${fmt(r.node_id)}</td>
|
||||
<td class="${typeCls}">${r.subtype}</td>
|
||||
<td class="dim">${fmt(r.src)}</td>
|
||||
<td class="dim">${fmt(r.dst)}</td>
|
||||
<td class="dim">${fmt(r.bssid)}</td>
|
||||
<td class="muted">${fmt(r.reason)}</td>
|
||||
<td class="${rssiClass(r.rssi)}">${fmt(r.rssi)} dBm</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// ── RSSI chart ────────────────────────────────────────────────────────────────
|
||||
async function showNetworkChart(bssid) {
|
||||
chartBssid = bssid;
|
||||
@@ -568,11 +658,13 @@ function setView(view) {
|
||||
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('view-alerts').classList.toggle('active', view === 'alerts');
|
||||
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');
|
||||
document.getElementById('tab-alerts').classList.toggle('active', view === 'alerts');
|
||||
}
|
||||
|
||||
function showFeed() {
|
||||
@@ -591,6 +683,8 @@ async function showClients() { setView('clients'); await fetchClients(); }
|
||||
|
||||
async function showPresence() { setView('presence'); await fetchPresence(); }
|
||||
|
||||
async function showAlerts() { setView('alerts'); await fetchAlerts(); }
|
||||
|
||||
async function showDetail(node_id) {
|
||||
currentNode = node_id;
|
||||
setView('detail');
|
||||
@@ -643,6 +737,7 @@ function startSSE() {
|
||||
else if (currentView === 'network-chart') await loadAndRenderChart();
|
||||
else if (currentView === 'crossnode') await fetchCrossNode();
|
||||
else if (currentView === 'presence') await fetchPresence();
|
||||
else if (currentView === 'alerts') await fetchAlerts();
|
||||
|
||||
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