dashboard changes for Session tab

This commit is contained in:
bot
2026-04-05 20:59:22 +03:00
parent e01bbd9e56
commit fee23f0f66
4 changed files with 340 additions and 0 deletions
+106
View File
@@ -407,6 +407,107 @@ def build_presence() -> dict:
}
SESSION_GAP_SECS = 120 # gap > 2 min between events from same src = new session
def _finalize_session(active: dict) -> dict:
reasons = active["reasons"]
dominant = max(set(reasons), key=reasons.count) if reasons else 0
_, classification = deauth_reason_info(dominant)
rssi_vals = [r for r in active["rssi_vals"] if r is not None]
start_dt = datetime.datetime.fromisoformat(active["start"])
end_dt = datetime.datetime.fromisoformat(active["end"])
duration = int((end_dt - start_dt).total_seconds())
frames = len(active["frames"])
if classification == "attack" and frames >= 5:
severity = "attack"
elif frames >= 3 or classification in ("attack", "suspicious"):
severity = "suspicious"
else:
severity = "normal"
src_vendor, src_rand = oui_lookup(active["src"])
return {
"src": active["src"],
"src_vendor": src_vendor,
"src_randomized": src_rand,
"start": active["start"],
"end": active["end"],
"duration_secs": duration,
"frame_count": frames,
"unique_bssids": list(active["bssids"]),
"unique_targets": list(active["targets"]),
"node_count": len(active["nodes"]),
"nodes": list(active["nodes"]),
"peak_rssi": max(rssi_vals) if rssi_vals else None,
"avg_rssi": round(sum(rssi_vals) / len(rssi_vals), 1) if rssi_vals else None,
"dominant_reason": dominant,
"severity": severity,
"events": active["frames"][:50],
}
def build_sessions() -> list[dict]:
cutoff = (datetime.datetime.utcnow() - datetime.timedelta(days=7)).isoformat(timespec="seconds")
rows = query("""
SELECT src, dst, bssid, reason, rssi, received_at, node_id, subtype
FROM deauth_events
WHERE received_at > ?
ORDER BY src, received_at
""", (cutoff,))
if not rows:
return []
sessions: list[dict] = []
active: dict | None = None
for row in rows:
src = row["src"]
ts = row["received_at"]
new_session = False
if active is None or active["src"] != src:
new_session = True
else:
last_dt = datetime.datetime.fromisoformat(active["end"])
curr_dt = datetime.datetime.fromisoformat(ts)
if (curr_dt - last_dt).total_seconds() > SESSION_GAP_SECS:
new_session = True
if new_session:
if active is not None:
sessions.append(_finalize_session(active))
active = {
"src": src,
"start": ts,
"end": ts,
"bssids": set(),
"targets": set(),
"nodes": set(),
"reasons": [],
"rssi_vals": [],
"frames": [],
}
active["end"] = ts
active["bssids"].add(row["bssid"])
active["targets"].add(row["dst"])
active["nodes"].add(row["node_id"])
active["reasons"].append(row["reason"])
active["rssi_vals"].append(row["rssi"])
active["frames"].append(row)
if active is not None:
sessions.append(_finalize_session(active))
sessions.sort(key=lambda s: s["start"], reverse=True)
return sessions[:100]
def build_alerts() -> dict:
now = datetime.datetime.utcnow()
cutoff_5m = (now - datetime.timedelta(minutes=5)).isoformat(timespec="seconds")
@@ -881,6 +982,11 @@ async def api_rssi_history(bssid: str, hours: int = 2):
return series
@app.get("/api/sessions")
async def api_sessions():
return build_sessions()
@app.get("/api/search")
async def api_search(q: str = ""):
return build_search(q)
+53
View File
@@ -450,6 +450,59 @@ th.group-active { color: var(--green-hi); cursor: pointer; }
th.group-col { cursor: pointer; }
th.group-col:hover { color: var(--text); }
.nodes-multi { color: var(--green-hi); }
.mono { font-family: monospace; font-size: 11px; }
/* ── Sessions ── */
.sev-attack { border-left: 2px solid #c0392b; }
.sev-suspicious { border-left: 2px solid #d4ac0d; }
.sev-normal { border-left: 2px solid transparent; }
.sev-label { font-size: 10px; font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase; }
.sev-label.sev-attack { color: #e74c3c; }
.sev-label.sev-suspicious { color: #f1c40f; }
.sev-label.sev-normal { color: var(--text-muted); }
tr.session-row { cursor: pointer; }
tr.session-row:hover td { background: var(--bg-hover); }
.expand-col { width: 24px; text-align: center; }
.expand-btn { font-size: 9px; color: var(--text-muted); user-select: none; }
td.session-detail-cell { padding: 0; }
.session-detail-inner {
padding: 10px 16px 12px;
background: var(--bg-panel);
border-top: 1px solid var(--border);
}
.session-meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: 11px;
color: var(--text-dim);
margin-bottom: 8px;
}
.session-meta strong { color: var(--text); }
.session-events-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
}
.session-events-table th {
text-align: left;
color: var(--text-muted);
font-weight: 400;
padding: 3px 8px;
border-bottom: 1px solid var(--border);
}
.session-events-table td {
padding: 3px 8px;
border-bottom: 1px solid var(--border);
color: var(--text-dim);
}
.session-events-table tr:last-child td { border-bottom: none; }
/* ── Cross-node cells ── */
.cn-best { color: var(--green-hi); font-weight: 500; }
+150
View File
@@ -11,6 +11,8 @@ let clientsData = [];
let clientsGroupBy = null; // null | 'vendor' | 'ssid'
let presenceData = {};
let alertsData = {};
let sessionsData = [];
let sessionSort = { col: 'frame_count', dir: 'desc' };
let chartBssid = null;
let chartHours = 2;
@@ -570,6 +572,150 @@ function renderDeauthHeatmap(rows) {
});
}
// ── Sessions view ─────────────────────────────────────────────────────────────
function initSessionSort() {
document.querySelectorAll('#sessions-table thead th.sortable').forEach(th => {
th.style.cursor = 'pointer';
th.addEventListener('click', () => {
const col = th.dataset.col;
if (sessionSort.col === col) {
sessionSort.dir = sessionSort.dir === 'desc' ? 'asc' : 'desc';
} else {
sessionSort.col = col;
sessionSort.dir = 'desc';
}
document.querySelectorAll('#sessions-table thead th.sortable').forEach(h => {
h.classList.remove('sort-active');
h.dataset.arrow = '';
});
th.classList.add('sort-active');
th.dataset.arrow = sessionSort.dir === 'desc' ? '↓' : '↑';
renderSessionsView();
});
});
}
async function fetchSessions() {
sessionsData = await fetch('/api/sessions').then(r => r.json());
renderSessionsView();
}
function renderSessionsView() {
const tbody = document.getElementById('sessions-tbody');
const empty = document.getElementById('sessions-empty');
const count = document.getElementById('sessions-count');
// Sort
const { col, dir } = sessionSort;
const sevOrder = { attack: 0, suspicious: 1, normal: 2 };
const data = [...sessionsData].sort((a, b) => {
let av, bv;
if (col === 'bssids') { av = a.unique_bssids.length; bv = b.unique_bssids.length; }
else if (col === 'targets') { av = a.unique_targets.length; bv = b.unique_targets.length; }
else if (col === 'severity') { av = sevOrder[a.severity] ?? 9; bv = sevOrder[b.severity] ?? 9; }
else if (col === 'peak_rssi') { av = a.peak_rssi ?? -200; bv = b.peak_rssi ?? -200; }
else { av = a[col]; bv = b[col]; }
if (av < bv) return dir === 'desc' ? 1 : -1;
if (av > bv) return dir === 'desc' ? -1 : 1;
return 0;
});
count.textContent = `${data.length} session${data.length !== 1 ? 's' : ''}`;
if (!data.length) {
tbody.innerHTML = '';
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
tbody.innerHTML = '';
data.forEach((s, idx) => {
const sevClass = `sev-${s.severity}`;
const duration = s.duration_secs < 60
? `${s.duration_secs}s`
: `${Math.floor(s.duration_secs / 60)}m ${s.duration_secs % 60}s`;
const vendor = s.src_randomized ? '<span class="muted">Randomized</span>' : (s.src_vendor || '<span class="muted">Unknown</span>');
const mainRow = document.createElement('tr');
mainRow.className = `session-row ${sevClass}`;
mainRow.dataset.idx = idx;
mainRow.innerHTML = `
<td class="expand-col"><span class="expand-btn">▶</span></td>
<td class="mono">${s.src}</td>
<td>${vendor}</td>
<td>${s.start.replace('T', ' ')}</td>
<td>${duration}</td>
<td>${s.frame_count}</td>
<td>${s.unique_bssids.length}</td>
<td>${s.unique_targets.length}</td>
<td>${s.node_count}</td>
<td>${s.peak_rssi != null ? s.peak_rssi + ' dBm' : '—'}</td>
<td><span class="sev-label ${sevClass}">${s.severity}</span></td>
`;
tbody.appendChild(mainRow);
// Build detail row
const detailRow = document.createElement('tr');
detailRow.className = 'session-detail';
detailRow.dataset.sessionIdx = idx;
const bssidList = s.unique_bssids.join(', ') || '—';
const targetList = s.unique_targets.join(', ') || '—';
const nodeList = (s.nodes || []).join(', ') || '—';
let evRows = '';
(s.events || []).forEach(ev => {
evRows += `<tr>
<td>${ev.received_at.replace('T', ' ')}</td>
<td>${ev.node_id}</td>
<td>${ev.subtype}</td>
<td class="mono">${ev.src}</td>
<td class="mono">${ev.dst}</td>
<td class="mono">${ev.bssid}</td>
<td>${ev.reason}</td>
<td>${ev.rssi} dBm</td>
</tr>`;
});
const truncNote = s.frame_count > 50
? `<div class="muted" style="font-size:10px;margin-top:4px">Showing first 50 of ${s.frame_count} events</div>`
: '';
detailRow.innerHTML = `
<td colspan="11" class="session-detail-cell">
<div class="session-detail-inner">
<div class="session-meta">
<span><strong>BSSIDs:</strong> ${bssidList}</span>
<span><strong>Targets:</strong> ${targetList}</span>
<span><strong>Nodes:</strong> ${nodeList}</span>
<span><strong>Avg RSSI:</strong> ${s.avg_rssi != null ? s.avg_rssi + ' dBm' : '—'}</span>
<span><strong>Reason:</strong> ${s.dominant_reason}</span>
</div>
<table class="session-events-table">
<thead><tr>
<th>Time</th><th>Node</th><th>Type</th><th>Src</th>
<th>Dst</th><th>BSSID</th><th>Reason</th><th>RSSI</th>
</tr></thead>
<tbody>${evRows}</tbody>
</table>
${truncNote}
</div>
</td>
`;
detailRow.style.display = 'none';
tbody.appendChild(detailRow);
mainRow.addEventListener('click', () => {
const isOpen = detailRow.style.display !== 'none';
detailRow.style.display = isOpen ? 'none' : '';
mainRow.querySelector('.expand-btn').textContent = isOpen ? '▶' : '▼';
});
});
}
// ── Alerts view ───────────────────────────────────────────────────────────────
async function fetchAlerts() {
alertsData = await fetch('/api/alerts').then(r => r.json());
@@ -1022,6 +1168,7 @@ function setView(view) {
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('view-sessions').classList.toggle('active', view === 'sessions');
document.getElementById('view-analysis').classList.toggle('active', view === 'analysis');
document.getElementById('view-search').classList.toggle('active', view === 'search');
document.getElementById('tab-feed').classList.toggle('active', view === 'feed' || view === 'detail');
@@ -1030,6 +1177,7 @@ function setView(view) {
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');
document.getElementById('tab-sessions').classList.toggle('active', view === 'sessions');
document.getElementById('tab-analysis').classList.toggle('active', view === 'analysis');
document.getElementById('tab-search').classList.toggle('active', view === 'search');
}
@@ -1052,6 +1200,8 @@ async function showPresence() { setView('presence'); await fetchPresence(); }
async function showAlerts() { setView('alerts'); await fetchAlerts(); }
async function showSessions() { setView('sessions'); initSessionSort(); await fetchSessions(); }
async function showAnalysis() {
setView('analysis');
const data = await fetch('/api/alerts').then(r => r.json());
+31
View File
@@ -30,6 +30,7 @@
<button class="tab-btn" id="tab-clients" onclick="showClients()">Clients</button>
<button class="tab-btn" id="tab-presence" onclick="showPresence()">Presence</button>
<button class="tab-btn" id="tab-alerts" onclick="showAlerts()">Alerts</button>
<button class="tab-btn" id="tab-sessions" onclick="showSessions()">Sessions</button>
<button class="tab-btn" id="tab-analysis" onclick="showAnalysis()">Analysis</button>
<button class="tab-btn" id="tab-search" onclick="showSearch()">Search</button>
</div>
@@ -404,6 +405,36 @@
</div><!-- /#view-alerts -->
<!-- Sessions view -->
<div id="view-sessions" class="view">
<div class="section-header">
<span class="section-title">Attack Sessions</span>
<span class="section-count" id="sessions-count"></span>
<span class="presence-sub">last 7 days · deauth events grouped by source MAC + 2-min gap</span>
</div>
<div class="tbl-wrap">
<table id="sessions-table">
<thead>
<tr>
<th></th>
<th>Source MAC</th>
<th>Vendor</th>
<th class="sortable" data-col="start" data-arrow="">Start</th>
<th class="sortable" data-col="duration_secs" data-arrow="">Duration</th>
<th class="sortable sort-active" data-col="frame_count" data-arrow="↓">Frames</th>
<th class="sortable" data-col="bssids" data-arrow="">BSSIDs</th>
<th class="sortable" data-col="targets" data-arrow="">Targets</th>
<th class="sortable" data-col="node_count" data-arrow="">Nodes</th>
<th class="sortable" data-col="peak_rssi" data-arrow="">Peak RSSI</th>
<th class="sortable" data-col="severity" data-arrow="">Severity</th>
</tr>
</thead>
<tbody id="sessions-tbody"></tbody>
</table>
<div class="empty" id="sessions-empty" style="display:none">No sessions in the last 7 days.</div>
</div>
</div>
<!-- Analysis view -->
<div id="view-analysis" class="view">