From fee23f0f66048ba9179c229074f05468d8e5e673 Mon Sep 17 00:00:00 2001 From: bot Date: Sun, 5 Apr 2026 20:59:22 +0300 Subject: [PATCH] dashboard changes for Session tab --- coordinator/dashboard.py | 106 +++++++++++++++++++ coordinator/static/dashboard.css | 53 ++++++++++ coordinator/static/dashboard.js | 150 +++++++++++++++++++++++++++ coordinator/templates/dashboard.html | 31 ++++++ 4 files changed, 340 insertions(+) diff --git a/coordinator/dashboard.py b/coordinator/dashboard.py index db56b71..d47c6c8 100644 --- a/coordinator/dashboard.py +++ b/coordinator/dashboard.py @@ -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) diff --git a/coordinator/static/dashboard.css b/coordinator/static/dashboard.css index 5134932..9173306 100644 --- a/coordinator/static/dashboard.css +++ b/coordinator/static/dashboard.css @@ -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; } diff --git a/coordinator/static/dashboard.js b/coordinator/static/dashboard.js index 49dd31b..61ffdd0 100644 --- a/coordinator/static/dashboard.js +++ b/coordinator/static/dashboard.js @@ -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 ? 'Randomized' : (s.src_vendor || 'Unknown'); + + const mainRow = document.createElement('tr'); + mainRow.className = `session-row ${sevClass}`; + mainRow.dataset.idx = idx; + mainRow.innerHTML = ` + + ${s.src} + ${vendor} + ${s.start.replace('T', ' ')} + ${duration} + ${s.frame_count} + ${s.unique_bssids.length} + ${s.unique_targets.length} + ${s.node_count} + ${s.peak_rssi != null ? s.peak_rssi + ' dBm' : '—'} + ${s.severity} + `; + 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 += ` + ${ev.received_at.replace('T', ' ')} + ${ev.node_id} + ${ev.subtype} + ${ev.src} + ${ev.dst} + ${ev.bssid} + ${ev.reason} + ${ev.rssi} dBm + `; + }); + const truncNote = s.frame_count > 50 + ? `
Showing first 50 of ${s.frame_count} events
` + : ''; + + detailRow.innerHTML = ` + +
+
+ BSSIDs: ${bssidList} + Targets: ${targetList} + Nodes: ${nodeList} + Avg RSSI: ${s.avg_rssi != null ? s.avg_rssi + ' dBm' : '—'} + Reason: ${s.dominant_reason} +
+ + + + + + ${evRows} +
TimeNodeTypeSrcDstBSSIDReasonRSSI
+ ${truncNote} +
+ + `; + + 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()); diff --git a/coordinator/templates/dashboard.html b/coordinator/templates/dashboard.html index 26f1d36..b30f165 100644 --- a/coordinator/templates/dashboard.html +++ b/coordinator/templates/dashboard.html @@ -30,6 +30,7 @@ + @@ -404,6 +405,36 @@ + +
+
+ Attack Sessions + + last 7 days · deauth events grouped by source MAC + 2-min gap +
+
+ + + + + + + + + + + + + + + + + +
Source MACVendorStartDurationFramesBSSIDsTargetsNodesPeak RSSISeverity
+ +
+
+