feat: live event notifications + dashboard status strip
The notification bell now receives event-driven alerts: new handshake captures, new enterprise credentials, pineapd down/recovery and monitor drops (15s poll, diffs against last-seen state). Dashboard gains an Attacks / PineAPd / Recon live status row.
This commit is contained in:
@@ -435,6 +435,9 @@ const Live = (() => {
|
|||||||
let ever = false;
|
let ever = false;
|
||||||
let poll = null;
|
let poll = null;
|
||||||
let pollHealthTimer = null;
|
let pollHealthTimer = null;
|
||||||
|
let pollEventsTimer = null;
|
||||||
|
const lastEvents = { hsSeen: {}, hsPrimed: false, creds: null, credsPrimed: false,
|
||||||
|
pineapUp: null, mon0: null, mon1: null };
|
||||||
const subs = [];
|
const subs = [];
|
||||||
let timer = null;
|
let timer = null;
|
||||||
function stopPoll() {
|
function stopPoll() {
|
||||||
@@ -447,6 +450,10 @@ const Live = (() => {
|
|||||||
pollHealthTimer = setInterval(pollHealth, 15000);
|
pollHealthTimer = setInterval(pollHealth, 15000);
|
||||||
pollHealth();
|
pollHealth();
|
||||||
}
|
}
|
||||||
|
if (!pollEventsTimer) {
|
||||||
|
pollEventsTimer = setInterval(pollEvents, 15000);
|
||||||
|
pollEvents();
|
||||||
|
}
|
||||||
try { ws = new WebSocket(App.wsUrl('/api/ws')); }
|
try { ws = new WebSocket(App.wsUrl('/api/ws')); }
|
||||||
catch (e) { fallback(); return; }
|
catch (e) { fallback(); return; }
|
||||||
ws.onopen = () => { ever = true; };
|
ws.onopen = () => { ever = true; };
|
||||||
@@ -509,8 +516,43 @@ const Live = (() => {
|
|||||||
el.textContent = 'PINEAP OK';
|
el.textContent = 'PINEAP OK';
|
||||||
el.className = 'health-chip good';
|
el.className = 'health-chip good';
|
||||||
}
|
}
|
||||||
|
const prev = lastEvents;
|
||||||
|
if (prev.pineapUp === false && h.pineap_up) {
|
||||||
|
toast('PineAPd recovered', 'success');
|
||||||
|
} else if (prev.pineapUp === true && h.pineap_up === false) {
|
||||||
|
toast('PineAPd is down — health monitor is repairing it', 'error');
|
||||||
|
}
|
||||||
|
lastEvents.pineapUp = !!h.pineap_up;
|
||||||
|
const monState = [h.wlan0mon_up, h.wlan1mon_up];
|
||||||
|
if (prev.mon0 === true && monState[0] === false) toast('wlan0mon went down', 'error');
|
||||||
|
if (prev.mon1 === true && monState[1] === false) toast('wlan1mon went down', 'error');
|
||||||
|
lastEvents.mon0 = !!monState[0];
|
||||||
|
lastEvents.mon1 = !!monState[1];
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
function pollEvents() {
|
||||||
|
Promise.all([
|
||||||
|
fetch(App.apiBase + '/api/pineap/handshakes', { credentials: 'include' }).then((r) => r.json()).catch(() => ({})),
|
||||||
|
fetch(App.apiBase + '/api/attacks/status', { credentials: 'include' }).then((r) => r.json()).catch(() => ({}))
|
||||||
|
]).then(([hs, atk]) => {
|
||||||
|
const files = (hs && hs.files) || [];
|
||||||
|
const fresh = files.filter((f) => !lastEvents.hsSeen[f.name]);
|
||||||
|
if (lastEvents.hsPrimed && fresh.length) {
|
||||||
|
fresh.forEach((f) => {
|
||||||
|
const bssid = (f.name.match(/^[0-9]+_([0-9A-F]+)_/) || [])[1] || '';
|
||||||
|
toast('Handshake captured: ' + (bssid || f.name), 'success');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
files.forEach((f) => { lastEvents.hsSeen[f.name] = true; });
|
||||||
|
lastEvents.hsPrimed = true;
|
||||||
|
const creds = ((atk.enterprise || {}).creds) == null ? null : atk.enterprise.creds;
|
||||||
|
if (lastEvents.credsPrimed && creds !== null && creds > lastEvents.creds) {
|
||||||
|
toast('Enterprise credential captured (' + (creds - lastEvents.creds) + ' new)', 'success');
|
||||||
|
}
|
||||||
|
if (creds !== null) lastEvents.creds = creds;
|
||||||
|
lastEvents.credsPrimed = true;
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
function onTick(fn) {
|
function onTick(fn) {
|
||||||
subs.push(fn);
|
subs.push(fn);
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -127,6 +127,50 @@ views.dashboard = (root) => {
|
|||||||
cards[k] = card.querySelector('.card-value');
|
cards[k] = card.querySelector('.card-value');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const live = h('div', { class: 'cards', style: 'margin-top:8px' });
|
||||||
|
root.appendChild(live);
|
||||||
|
const liveCards = {};
|
||||||
|
[['attacks', 'Attacks'], ['health', 'PineAPd / Monitors'], ['recon', 'Recon']]
|
||||||
|
.forEach(([k, label]) => {
|
||||||
|
const card = h('div', { class: 'card' },
|
||||||
|
h('div', { class: 'card-label', text: label }),
|
||||||
|
h('div', { class: 'card-value', style: 'font-size:12px;line-height:1.8', text: 'Loading…' }));
|
||||||
|
live.appendChild(card);
|
||||||
|
liveCards[k] = card.querySelector('.card-value');
|
||||||
|
});
|
||||||
|
function loadLive() {
|
||||||
|
PagerAPI.get('/api/attacks/status').then((r) => {
|
||||||
|
const s = r.data || {};
|
||||||
|
const st = (x) => (x && x.enabled ? (x.live ? 'LIVE' : 'ON') : 'off');
|
||||||
|
const wpa = st(s.wpa && s.wpa.radio0) === 'LIVE' || st(s.wpa && s.wpa.radio1) === 'LIVE'
|
||||||
|
? 'LIVE' : (st(s.wpa && s.wpa.radio0) !== 'off' || st(s.wpa && s.wpa.radio1) !== 'off' ? 'ON' : 'OFF');
|
||||||
|
const open = st(s.open && s.open.radio0) === 'LIVE' || st(s.open && s.open.radio1) === 'LIVE'
|
||||||
|
? 'LIVE' : (st(s.open && s.open.radio0) !== 'off' || st(s.open && s.open.radio1) !== 'off' ? 'ON' : 'OFF');
|
||||||
|
const ent = s.enterprise && s.enterprise.ap
|
||||||
|
? (s.enterprise.ap.live ? 'LIVE' : s.enterprise.ap.enabled ? 'ON' : 'OFF') : 'OFF';
|
||||||
|
liveCards.attacks.textContent = 'WPA ' + wpa + ' · Open ' + open + ' · Ent ' + ent +
|
||||||
|
' · HS ' + (s.handshakes || 0) + ' · creds ' + ((s.enterprise || {}).creds || 0);
|
||||||
|
liveCards.attacks.style.color = wpa === 'LIVE' || open === 'LIVE' || ent === 'LIVE'
|
||||||
|
? 'var(--primary)' : '';
|
||||||
|
}).catch(() => {});
|
||||||
|
PagerAPI.get('/api/health').then((r) => {
|
||||||
|
const h2 = r.data || {};
|
||||||
|
liveCards.health.textContent = (h2.pineap_up ? 'pineapd up' : 'pineapd DOWN') +
|
||||||
|
' · wlan0mon ' + (h2.wlan0mon_up ? 'up' : 'down') +
|
||||||
|
' · wlan1mon ' + (h2.wlan1mon_up ? 'up' : 'down') +
|
||||||
|
(h2.pool_disabled ? ' · pool off' : '');
|
||||||
|
liveCards.health.style.color = h2.pineap_up ? '' : '#b71c1c';
|
||||||
|
}).catch(() => {});
|
||||||
|
PagerAPI.get('/api/recon/status').then((r) => {
|
||||||
|
const s = r.data || {};
|
||||||
|
const last = s.last_activity || s.last_scan;
|
||||||
|
liveCards.recon.textContent = (s.scanning ? 'scanning' : 'idle') +
|
||||||
|
(last ? ' · last activity ' + fmtTime(last) : '');
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
loadLive();
|
||||||
|
const liveIv = setInterval(loadLive, 10000);
|
||||||
|
|
||||||
const chartBox = h('div', { class: 'section' },
|
const chartBox = h('div', { class: 'section' },
|
||||||
h('h2', {}, 'Clients'),
|
h('h2', {}, 'Clients'),
|
||||||
h('canvas', { id: 'dash-chart', style: 'width:100%;height:140px' }));
|
h('canvas', { id: 'dash-chart', style: 'width:100%;height:140px' }));
|
||||||
@@ -192,7 +236,7 @@ views.dashboard = (root) => {
|
|||||||
loadClients();
|
loadClients();
|
||||||
loadHandshakes();
|
loadHandshakes();
|
||||||
const iv = setInterval(loadClients, 10000);
|
const iv = setInterval(loadClients, 10000);
|
||||||
return { destroy: () => { clearInterval(iv); unsubscribe(); } };
|
return { destroy: () => { clearInterval(iv); clearInterval(liveIv); unsubscribe(); } };
|
||||||
};
|
};
|
||||||
|
|
||||||
const PINEAP_TABS = [
|
const PINEAP_TABS = [
|
||||||
|
|||||||
Reference in New Issue
Block a user