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}
+
+
+
+ | Time | Node | Type | Src |
+ Dst | BSSID | Reason | RSSI |
+
+ ${evRows}
+
+ ${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 @@
+
+
+
+
+
+
+
+ |
+ Source MAC |
+ Vendor |
+ Start |
+ Duration |
+ Frames |
+ BSSIDs |
+ Targets |
+ Nodes |
+ Peak RSSI |
+ Severity |
+
+
+
+
+
No sessions in the last 7 days.
+
+
+