'use strict';
const views = {};
// One-shot prefill passed between views (Recon target -> PineAP twin form).
// consume() clears the value so a stale prefill can never leak into a
// manually opened form.
const PineAPPrefill = {
data: null,
set(d) { PineAPPrefill.data = d || null; },
consume() {
const d = PineAPPrefill.data;
PineAPPrefill.data = null;
return d;
}
};
window.PineAPPrefill = PineAPPrefill;
const h = (tag, attrs, ...children) => {
const n = document.createElement(tag);
if (attrs) {
for (const k in attrs) {
if (k === 'class') n.className = attrs[k];
else if (k === 'text') n.textContent = attrs[k];
else if (k === 'html') n.innerHTML = attrs[k];
else if (k.startsWith('on')) n.addEventListener(k.slice(2), attrs[k]);
else n.setAttribute(k, attrs[k]);
}
}
for (const c of children) {
if (c == null) continue;
n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
}
return n;
};
const table = (columns, rows, rowAttrs) => {
const t = h('table', { class: 'tbl' });
const thead = h('thead'), tr = h('tr');
columns.forEach((c) => tr.appendChild(h('th', { text: c.label })));
thead.appendChild(tr); t.appendChild(thead);
const tb = h('tbody');
(rows || []).forEach((r) => {
const trr = h('tr', rowAttrs ? rowAttrs(r) : {});
columns.forEach((c) => {
const v = c.render ? c.render(r) : r[c.key];
const td = h('td');
if (v == null) td.textContent = '';
else if (typeof v === 'string' || typeof v === 'number') td.textContent = String(v);
else td.appendChild(v);
trr.appendChild(td);
});
tb.appendChild(trr);
});
t.appendChild(tb);
return t;
};
const fmtTime = (ts) => {
if (!ts) return '--';
const d = new Date(ts * 1000);
return d.toLocaleString();
};
const fmtShortTime = (ts) => {
if (!ts) return '--';
const d = new Date(ts * 1000);
const now = new Date();
const sameDay = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
const hm = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
if (sameDay) return hm;
return (d.getMonth() + 1) + '/' + d.getDate() + ' ' + hm;
};
const fmtDur = (secs) => {
if (secs == null) return '--';
const d = Math.floor(secs / 86400), hh = Math.floor((secs % 86400) / 3600),
mm = Math.floor((secs % 3600) / 60);
return (d ? d + 'd ' : '') + hh + 'h ' + mm + 'm';
};
const fmtBytes = (bytes) => {
if (bytes == null || !isFinite(Number(bytes))) return '--';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = Number(bytes), unit = 0;
while (Math.abs(value) >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return value.toFixed(unit < 2 ? 0 : 1) + ' ' + units[unit];
};
const downloadText = (filename, text) => {
const url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' }));
const a = h('a', { href: url, download: filename });
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
};
const copyText = (text, okMsg) => {
const value = String(text == null ? '' : text);
if (!value) return Promise.resolve();
const done = () => App.toast(okMsg || 'Copied');
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(value).then(done).catch(() => downloadText('copy.txt', value));
}
downloadText('copy.txt', value);
return Promise.resolve();
};
const badge = (on) => h('span', { class: 'badge ' + (on ? 'on' : 'off'), text: on ? 'ON' : 'OFF' });
function markBusy(button, busy, busyText) {
if (!button) return;
const labeled = button.classList.contains('btn');
if (busy) {
if (labeled && !button.dataset.label) button.dataset.label = button.textContent;
button.disabled = true;
button.classList.add('busy');
button.setAttribute('aria-busy', 'true');
if (busyText && labeled) button.textContent = busyText;
} else {
button.disabled = false;
button.classList.remove('busy');
button.removeAttribute('aria-busy');
if (labeled && button.dataset.label) button.textContent = button.dataset.label;
}
}
function runAction(button, work, busyText) {
if (button && (button.disabled || button.classList.contains('busy'))) {
return Promise.resolve();
}
markBusy(button, true, busyText);
return Promise.resolve()
.then(work)
.catch((e) => {
App.toast((e && e.message) || 'Action failed', 'error');
})
.finally(() => markBusy(button, false));
}
function armBusy(button, onclk) {
if (!onclk) return;
button.addEventListener('click', (event) => {
if (button.disabled || button.classList.contains('busy')) {
event.preventDefault();
return;
}
const result = onclk(event);
if (!result || typeof result.then !== 'function') return;
if (!button.classList.contains('busy')) markBusy(button, true);
Promise.resolve(result).catch(() => {}).finally(() => {
if (button.classList.contains('busy')) markBusy(button, false);
});
});
}
const btn = (label, onclk, cls) => {
const b = h('button', { class: 'btn ' + (cls || ''), text: label });
armBusy(b, onclk);
return b;
};
const iconBtn = (name, title, onclk) => {
const b = h('button', { class: 'icon-btn', title: title || '' });
b.innerHTML = PineappleIcons[name] || '';
armBusy(b, onclk);
return b;
};
const tabBar = (box, items, activeHash) => {
const bar = h('div', { class: 'tabbar' });
items.forEach((it) => {
const t = h('a', { class: 'tab' + (it.hash === activeHash ? ' active' : ''), href: it.hash, text: it.label });
t.addEventListener('click', (e) => { e.preventDefault(); location.hash = it.hash; });
bar.appendChild(t);
});
box.appendChild(bar);
return bar;
};
views.dashboard = (root) => {
const history = { clients: [] };
const max = 60;
const title = h('h1', { class: 'page-title', text: 'Dashboard' });
root.appendChild(title);
const grid = h('div', { class: 'cards' });
root.appendChild(grid);
const defs = [
['clients', 'Clients Connected'], ['handshakes', 'Handshakes Captured'],
['disk', 'Disk Usage'], ['uptime', 'Uptime']
];
const cards = {};
defs.forEach(([k, label]) => {
const card = h('div', { class: 'card' },
h('div', { class: 'card-label', text: label }),
h('div', { class: 'card-value', text: 'Loading…' }));
grid.appendChild(card);
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');
});
const counterDefs = [
['boots', 'Boots'], ['unexpected_boots', 'Unexpected Boots'],
['rollbacks', 'Rollbacks'], ['restarts', 'Restarts'], ['guard_fixes', 'Guard Fixes']
];
const counterVals = {};
const counterRow = h('div', { class: 'mk8-counter-row' });
counterDefs.forEach(([k, label]) => {
const val = h('div', { class: 'mk8-counter-value', text: '—' });
counterRow.appendChild(h('div', { class: 'mk8-counter' },
val, h('div', { class: 'mk8-counter-label', text: label })));
counterVals[k] = val;
});
const guardChip = h('span', { class: 'health-chip', text: 'GUARD —' });
const feedBody = h('div', { class: 'mk8-events-feed' },
h('div', { class: 'empty', text: 'No events recorded.' }));
root.appendChild(h('div', { class: 'section' },
h('div', { class: 'mk8-rel-head' }, h('h2', {}, 'Reliability'), guardChip),
counterRow,
feedBody));
function renderEvents(events) {
feedBody.innerHTML = '';
if (!Array.isArray(events) || !events.length) {
feedBody.appendChild(h('div', { class: 'empty', text: 'No events recorded.' }));
return;
}
events.forEach((ev) => {
const sev = ev.sev === 'error' ? 'error' : ev.sev === 'warn' ? 'warn' : 'info';
feedBody.appendChild(h('div', { class: 'mk8-event-row sev-' + sev },
h('span', { class: 'mk8-event-time', text: fmtShortTime(ev.ts) }),
h('span', { class: 'mk8-event-kind', text: String(ev.kind || '?') }),
h('span', { class: 'mk8-event-msg', text: String(ev.msg || '') })));
});
}
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/pineap/handshakes').then((r) => {
const hs = (r.data.files || []).length;
const cur = liveCards.attacks.textContent.replace(/HS \d+/, 'HS ' + hs);
liveCards.attacks.textContent = cur;
}).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' : '') +
((h2.env || {}).overall ? ' · env ' + h2.env.overall : '');
liveCards.health.style.color = h2.pineap_up ? '' : '#b71c1c';
const rel = h2.reliability || {};
Object.keys(counterVals).forEach((k) => {
counterVals[k].textContent = rel[k] == null ? '—' : String(rel[k]);
});
const g = h2.guard || {};
if (typeof g.in_sync !== 'boolean') {
guardChip.className = 'health-chip';
guardChip.textContent = 'GUARD —';
} else if (g.in_sync) {
guardChip.className = 'health-chip good';
guardChip.textContent = 'GUARD IN SYNC';
} else {
guardChip.className = 'health-chip warn';
guardChip.textContent = 'GUARD PENDING' + (g.pending && g.pending.length ? ' · ' + g.pending.length : '');
}
renderEvents(h2.events);
}).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' },
h('h2', {}, 'Clients'),
h('canvas', { id: 'dash-chart', style: 'width:100%;height:140px' }));
root.appendChild(chartBox);
const canvas = chartBox.querySelector('#dash-chart');
const update = (msg) => {
const s = msg.status || {};
const n = (msg.clients || []).length;
history.clients.push(n);
if (history.clients.length > max) { history.clients.shift(); }
cards.clients.textContent = n;
cards.uptime.textContent = s.uptime == null ? 'Unavailable' : fmtDur(s.uptime);
cards.disk.textContent = s.disk && s.disk.size != null
? fmtBytes(s.disk.used) + ' / ' + fmtBytes(s.disk.size) : 'Unavailable';
if (typeof MiniChart !== 'undefined') {
MiniChart.draw(canvas, [
{ label: 'Clients', color: '#1976d2', points: history.clients }
]);
}
};
const unsubscribe = Live.onTick(update);
PagerAPI.get('/api/status').then((r) => update({ status: r.data, clients: r.data.clients }))
.catch(() => {
cards.clients.textContent = '0';
cards.disk.textContent = '—';
cards.uptime.textContent = '—';
});
PagerAPI.get('/api/pineap/handshakes').then((r) => {
cards.handshakes.textContent = (r.data.files || []).length;
}).catch(() => { cards.handshakes.textContent = 'Unavailable'; });
const clBody = h('div', {});
const clBox = h('div', { class: 'section' },
h('h2', {}, 'Connected Clients'), clBody);
root.appendChild(clBox);
const hsBody = h('div', {});
const hsBox = h('div', { class: 'section' },
h('h2', {}, 'Captured WPA Handshakes'), hsBody);
root.appendChild(hsBox);
function loadClients() {
PagerAPI.get('/api/pineap/clients').then((r) => {
clBody.innerHTML = '';
clBody.appendChild(table(
[{ label: 'MAC', key: 'mac' }, { label: 'Interface', key: 'iface' }, { label: 'RSSI', key: 'rssi' },
{ label: '', render: (c) => {
const deauthBtn = btn('Deauth', () => {
if (!confirm('Deauthenticate ' + c.mac + '?')) return;
return PagerAPI.post('/api/pineap/deauth/client', { mac: c.mac })
.then(() => App.toast('Deauthenticated'))
.then(loadClients);
}, 'danger');
return deauthBtn;
} }],
r.data.clients));
const cols = ['MAC', 'Interface', 'RSSI'];
clBody.querySelectorAll('.tbl th').forEach((th, i) => { if (i >= cols.length) th.textContent = 'Deauth'; });
}).catch(() => { clBody.textContent = 'Unable to load connected clients.'; });
}
function loadHandshakes() {
PagerAPI.get('/api/pineap/handshakes').then((r) => {
hsBody.innerHTML = '';
hsBody.appendChild(table(
[{ label: 'File', key: 'name' }, { label: 'Size', key: 'size' }, { label: 'Modified', key: 'mtime' }],
(r.data.files || []).map((f) => ({ name: f.name, size: f.size, mtime: fmtTime(f.mtime) }))));
}).catch(() => { hsBody.textContent = 'Unable to load captured handshakes.'; });
}
loadClients();
loadHandshakes();
const iv = setInterval(loadClients, 10000);
return { destroy: () => { clearInterval(iv); clearInterval(liveIv); unsubscribe(); } };
};
const PINEAP_TABS = [
{ label: 'PineAP', hash: '#/pineap' },
{ label: 'OpenAP', hash: '#/pineap/open' },
{ label: 'Evil WPA', hash: '#/pineap/evilwpa' },
{ label: 'Evil Enterprise', hash: '#/pineap/enterprise' },
{ label: 'Impersonation', hash: '#/pineap/impersonation' },
{ label: 'Clients', hash: '#/pineap/clients' },
{ label: 'Filtering', hash: '#/pineap/filtering' }
];
// The Pager daemon can change these states but cannot read them back. Keep
// them explicitly unknown until this WebUI successfully changes them.
const PINEAP_SESSION = { karma: null, advertise: null, collect: null };
function setKnownCheckbox(cb, value) {
cb.indeterminate = value == null;
if (value != null) cb.checked = !!value;
}
function pineapShell(root, activeHash) {
root.appendChild(h('h1', { class: 'page-title', text: 'PineAP' }));
tabBar(root, PINEAP_TABS, activeHash);
const box = h('div', {});
root.appendChild(box);
return box;
}
function pineapTitleCard(titleText, linkHash, valueNode) {
const title = linkHash
? h('a', { class: 'pineap-card-title-link', onclick: (e) => { e.preventDefault(); App.go(linkHash); } }, titleText)
: titleText;
return h('div', { class: 'pineap-title-card' },
h('div', { class: 'pineap-card-title' }, title),
h('div', { class: 'pineap-card-title-content' }, valueNode));
}
views.pineap = (root) => {
const box = pineapShell(root, '#/pineap');
const stats = {};
const statWrap = h('div', { class: 'pineap-title-card-container' });
const statDefs = [
['ssids', 'Total SSIDs in Pool', '#/pineap/impersonation'],
['clients', 'Clients Connected', '#/pineap/clients'],
['handshakes', 'Handshakes Captured', '#/pineap/evilwpa']
];
statDefs.forEach(([k, label, hash]) => {
const val = h('span', { text: '—' });
stats[k] = val;
statWrap.appendChild(pineapTitleCard(label, hash, val));
});
box.appendChild(statWrap);
const quick = {
collect: h('input', { type: 'checkbox', id: 'po-collect' }),
advertise: h('input', { type: 'checkbox', id: 'po-advertise' })
};
const quickCard = h('div', { class: 'pineap-title-card pineap-card-settings' },
h('div', { class: 'pineap-card-title' }, 'Quick Settings'));
quickCard.appendChild(h('label', { class: 'switch' }, quick.collect, h('span', { class: 'track' }), 'Capture SSIDs to Pool'));
quickCard.appendChild(h('label', { class: 'switch' }, quick.advertise, h('span', { class: 'track' }), 'Advertise AP Impersonation Pool'));
const poolNotice = h('div', { class: 'pineap-infobox warn', style: 'margin-top:8px',
text: 'Pool broadcast disabled: it segfaults pineapd on this firmware (crash-loop fix).' });
quickCard.appendChild(poolNotice);
quickCard.appendChild(h('div', { class: 'muted', style: 'margin-top:8px;font-size:12px' },
'Client connect/disconnect notifications are handled by the Pager alert payload system.'));
const rfSel = h('select', {},
h('option', { value: 'uplink', text: 'Uplink (station)' }),
h('option', { value: 'attack', text: 'Attack' }),
h('option', { value: 'idle', text: 'Idle' }));
const rfSsid = h('input', { placeholder: 'Uplink network SSID', autocomplete: 'off' });
const rfPsk = h('input', { type: 'password', placeholder: 'Leave blank for an open network',
autocomplete: 'new-password' });
const rfStatusBadge = h('span', { class: 'badge unknown', text: '—' });
const rfStatusInfo = h('span', { class: 'muted', text: '' });
const rfResult = h('div', { class: 'mk8-rf-result', text: '' });
function renderRfStatus(d) {
const role = d.role || 'idle';
if (document.activeElement !== rfSel) rfSel.value = role;
rfStatusBadge.textContent = role.toUpperCase();
rfStatusBadge.className = 'badge ' +
(role === 'uplink' ? (d.assoc ? 'on' : 'warn') : role === 'attack' ? 'warn' : 'off');
const parts = [];
if (role === 'uplink') parts.push(d.assoc ? 'associated to ' + d.assoc : 'not associated');
if (d.hop_paused != null) parts.push('hop ' + (d.hop_paused ? 'paused' : 'running'));
rfStatusInfo.textContent = parts.join(' · ');
}
const rfApply = btn('Apply Role', () => {
const role = rfSel.value;
if (role === 'uplink' && !rfSsid.value.trim()) {
rfResult.textContent = 'SSID is required for the uplink role.';
rfResult.className = 'mk8-rf-result error';
return;
}
return PagerAPI.post('/api/rfplan/role', { role, ssid: rfSsid.value.trim(), psk: rfPsk.value })
.then((r) => {
const d = r.data || {};
rfResult.textContent = 'Role applied: ' + (d.role || role) +
(d.assoc ? ' — associated to ' + d.assoc : '');
rfResult.className = 'mk8-rf-result ok';
rfPsk.value = '';
return PagerAPI.get('/api/rfplan').then((s) => renderRfStatus(s.data || {}));
})
.catch((e) => {
rfResult.textContent = (e && e.message) || 'Role change failed.';
rfResult.className = 'mk8-rf-result error';
});
});
const rfCard = h('div', { class: 'pineap-title-card pineap-card-settings' },
h('div', { class: 'pineap-card-title' }, 'RF Role (radio1)'),
h('p', { class: 'pineap-card-subtitle',
text: 'Radio1 is shared between an uplink client and attack work; applying a role makes them mutually exclusive.' }));
rfCard.appendChild(h('div', { class: 'rf-role-status' }, rfStatusBadge, rfStatusInfo));
rfCard.appendChild(h('label', {}, 'Role', rfSel));
rfCard.appendChild(h('div', { class: 'rf-role-grid' },
h('label', {}, 'Uplink SSID', rfSsid),
h('label', {}, 'Uplink Password', rfPsk)));
rfCard.appendChild(rfApply);
rfCard.appendChild(rfResult);
const modeRow = h('div', { class: 'pineap-title-card-container' });
modeRow.appendChild(rfCard);
modeRow.appendChild(quickCard);
box.appendChild(modeRow);
const cards = { karma: {}, open: {}, wpa: {} };
const cardWrap = h('div', { class: 'pineap-title-card-container' });
const statusDefs = [
['karma', 'Karma', '#/pineap/open'],
['open', 'Open Network', '#/pineap/open'],
['wpa', 'Evil WPA', '#/pineap/evilwpa']
];
statusDefs.forEach(([k, label, hash]) => {
const val = h('span', { text: '—' });
cards[k].value = val;
cardWrap.appendChild(pineapTitleCard(label, hash, val));
});
box.appendChild(cardWrap);
function bind(cb, on, remember) {
cb.addEventListener('change', () => {
if (cb.disabled) return;
const requested = cb.checked;
cb.indeterminate = false;
cb.disabled = true;
on(requested).then(() => {
if (remember) remember(requested);
load();
}).catch((e) => {
cb.checked = !requested;
load();
App.toast((e && e.message) || 'Failed', 'error');
}).finally(() => { cb.disabled = false; });
});
}
function rememberAdvanced(key, value) {
PINEAP_SESSION[key] = value;
}
bind(quick.collect, (v) => PagerAPI.post('/api/pineap/ssidpool/collect', { enable: v }),
(v) => rememberAdvanced('collect', v));
bind(quick.advertise, (v) => PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: v }),
(v) => rememberAdvanced('advertise', v));
setKnownCheckbox(quick.advertise, PINEAP_SESSION.advertise);
let loadPending = false;
function load() {
if (loadPending) return;
loadPending = true;
const stateRequest = Promise.all([
PagerAPI.get('/api/pineap/get_config').catch(() => ({ data: {} })),
PagerAPI.post('/api/pineap/wifi/get_ap').catch(() => ({ data: {} }))
]).then(([cfg, ap]) => {
const c = cfg.data || {}, a = ap.data || {};
const wpa = a.wpa || {};
const collect = typeof PINEAP_SESSION.collect === 'boolean'
? PINEAP_SESSION.collect
: Object.prototype.hasOwnProperty.call(c, 'autossidpool') ? !!c.autossidpool : null;
setKnownCheckbox(quick.collect, collect);
const pool = a.pool || {};
const poolDisabled = pool.disabled === true || pool.broadcast_blocked === true;
poolNotice.classList.toggle('hidden', !poolDisabled);
quick.advertise.disabled = poolDisabled;
quick.advertise.title = poolDisabled ? 'Disabled by firmware crash fix' : '';
if (pool.broadcast_blocked) PINEAP_SESSION.advertise = false;
else if (pool.disabled != null) PINEAP_SESSION.advertise = pool.disabled === false;
setKnownCheckbox(quick.advertise, PINEAP_SESSION.advertise);
cards.karma.value.textContent = PINEAP_SESSION.karma == null ? 'Unknown' : (PINEAP_SESSION.karma ? 'On' : 'Off');
const open = a.open || {};
cards.open.value.textContent = open.enabled == null ? 'Unavailable' : (open.enabled ? 'On' : 'Off');
cards.wpa.value.textContent = wpa.enabled == null ? 'Unavailable' : (wpa.enabled ? 'On' : 'Off');
});
const statRequests = [
PagerAPI.get('/api/pineap/ssids').then((ss) => {
stats.ssids.textContent = Array.isArray((ss.data || {}).ssids) ? ss.data.ssids.length : 'Unavailable';
}).catch(() => { stats.ssids.textContent = 'Unavailable'; }),
PagerAPI.get('/api/pineap/clients').then((cl) => {
stats.clients.textContent = typeof (cl.data || {}).count === 'number' ? cl.data.count : '0';
}).catch(() => { stats.clients.textContent = '0'; }),
PagerAPI.get('/api/pineap/handshakes').then((hs) => {
stats.handshakes.textContent = Array.isArray((hs.data || {}).files) ? hs.data.files.length : 'Unavailable';
}).catch(() => { stats.handshakes.textContent = 'Unavailable'; }),
PagerAPI.get('/api/rfplan').then((rf) => {
renderRfStatus(rf.data || {});
}).catch(() => {
rfStatusBadge.textContent = '—';
rfStatusBadge.className = 'badge unknown';
rfStatusInfo.textContent = 'RF plan unavailable';
})
];
Promise.allSettled([stateRequest].concat(statRequests)).finally(() => { loadPending = false; });
}
load();
const iv = setInterval(load, 5000);
return { destroy: () => clearInterval(iv) };
};
const EVIL_ENC = [
['psk2', 'WPA2 PSK'], ['sae', 'WPA3 SAE'], ['owe', 'WPA3 OWE']
];
const BAND_GROUPS = [
{ band: '2.4', label: '2.4 GHz', dfs: false,
channels: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] },
{ band: '5', label: '5 GHz', dfs: true,
channels: [36, 40, 44, 48, 52, 56, 60, 64,
100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144,
149, 153, 157, 161, 165] },
{ band: '6', label: '6 GHz (WPA3/OWE only)', dfs: false,
channels: Array.from({ length: 14 }, (_, i) => 181 + i * 4) }
];
const DFS_SET = new Set([52, 56, 60, 64, 100, 104, 108, 112, 116, 120,
124, 128, 132, 136, 140, 144]);
function chanFreq(band, ch) {
if (band === '2.4') return 2412 + (ch - 1) * 5;
if (band === '5') return 5180 + (ch - 36) * 5;
return 5955 + (ch - 1) * 5; // 6 GHz
}
function chanLabel(band, ch) {
return 'Channel ' + ch + ' (' + chanFreq(band, ch) + ' MHz)'
+ (band === '5' && DFS_SET.has(ch) ? ' (DFS)' : '');
}
function chanSelect(sel, value) {
sel.appendChild(h('option', { value: '', text: 'Auto — best channel for the target' }));
BAND_GROUPS.forEach((g) => {
const og = h('optgroup', { label: g.label });
g.channels.forEach((ch) => {
og.appendChild(h('option', { value: ch, text: chanLabel(g.band, ch) }));
});
sel.appendChild(og);
});
if (value == null || value === '') {
sel.value = '';
} else {
const opts = Array.prototype.slice.call(sel.options);
const hit = opts.find((o) => Number(o.value) === Number(value));
if (hit) sel.value = hit.value;
}
return sel;
}
function bandOfChannel(ch) {
if (ch == null || ch === '') return '2.4';
ch = Number(ch);
if (ch >= 1 && ch <= 14) return '2.4';
if (ch >= 36 && ch <= 177) return '5';
return '6';
}
const OPEN_COUNTRIES = [
['US', 'United States'], ['DZ', 'Algeria'], ['AR', 'Argentina'], ['AU', 'Australia'],
['AT', 'Austria'], ['BH', 'Bahrain'], ['BM', 'Bermuda'], ['BO', 'Bolivia'], ['BR', 'Brazil'],
['BG', 'Bulgaria'], ['CA', 'Canada'], ['CL', 'Chile'], ['CN', 'China'], ['CO', 'Colombia'],
['CR', 'Costa Rica'], ['CY', 'Cyprus'], ['CZ', 'Czech Republic'], ['DK', 'Denmark'],
['DO', 'Dominican Republic'], ['EC', 'Ecuador'], ['EG', 'Egypt'], ['SV', 'El Salvador'],
['EE', 'Estonia'], ['FI', 'Finland'], ['FR', 'France'], ['DE', 'Germany'], ['GR', 'Greece'],
['GT', 'Guatemala'], ['HN', 'Honduras'], ['HK', 'Hong Kong'], ['IS', 'Iceland'], ['IN', 'India'],
['ID', 'Indonesia'], ['IE', 'Ireland'], ['PK', 'Islamic Republic of Pakistan'], ['IL', 'Israel'],
['IT', 'Italy'], ['JM', 'Jamaica'], ['JO', 'Jordan'], ['KE', 'Kenya'], ['KW', 'Kuwait'],
['LB', 'Lebanon'], ['LI', 'Liechtenstein'], ['LT', 'Lithuania'], ['LU', 'Luxembourg'],
['MU', 'Mauritius'], ['MX', 'Mexico'], ['MA', 'Morocco'], ['NL', 'Netherlands'], ['NZ', 'New Zealand'],
['NO', 'Norway'], ['OM', 'Oman'], ['PA', 'Panama'], ['PE', 'Peru'], ['PH', 'Philippines'],
['PL', 'Poland'], ['PT', 'Portugal'], ['PR', 'Puerto Rico'], ['QA', 'Qatar'],
['KR', 'Republic of Korea (South Korea)'], ['RO', 'Romania'], ['RU', 'Russia'], ['SA', 'Saudi Arabia'],
['SG', 'Singapore'], ['SI', 'Slovenia'], ['SK', 'Slovak Republic'], ['ZA', 'South Africa'],
['ES', 'Spain'], ['LK', 'Sri Lanka'], ['SE', 'Sweden'], ['CH', 'Switzerland'], ['TW', 'Taiwan'],
['TH', 'Thailand'], ['TT', 'Trinidad and Tobago'], ['TN', 'Tunisia'], ['TR', 'Turkey'],
['UA', 'Ukraine'], ['AE', 'United Arab Emirates'], ['GB', 'United Kingdom'], ['UY', 'Uruguay'],
['VE', 'Venezuela'], ['VN', 'Vietnam']
];
views.pineap_evilwpa = attackLauncher('wpa', {
title: 'Evil WPA',
passphrase: true,
encodings: EVIL_ENC,
handshakes: true,
export: true,
deauth: true,
tabHash: '#/pineap/evilwpa'
});
views.pineap_open = attackLauncher('open', {
title: 'OpenAP',
bssid: true,
country: true,
tabHash: '#/pineap/open'
});
views.pineap_impersonation = (root) => {
const box = pineapShell(root, '#/pineap/impersonation');
const poolCount = h('span', { text: '—' });
const countRow = h('div', { class: 'pineap-title-card-container' },
pineapTitleCard('Total SSIDs in Pool', '#/pineap/impersonation', poolCount));
box.appendChild(countRow);
const input = h('input', { id: 'imp-ssid' });
const list = h('div', {});
const advCb = h('input', { type: 'checkbox', id: 'imp-advertise' });
const colCb = h('input', { type: 'checkbox', id: 'imp-collect' });
const poolBox = h('div', { class: 'pineap-title-card pineap-card-pool' },
h('div', { class: 'pineap-card-title' }, 'SSID Pool'));
const addBtn = btn('Add', () => {
const v = input.value.trim(); if (!v) return;
return runAction(addBtn, () => PagerAPI.post('/api/pineap/ssids', { action: 'add', ssid: v }).then((r) => {
input.value = ''; render(r.data.ssids); App.toast('Added');
}), 'Adding…');
});
const clearBtn = btn('Clear', () => {
if (!confirm('Clear the entire SSID pool?')) return;
return runAction(clearBtn, () => PagerAPI.post('/api/pineap/ssids', { action: 'clear' }).then((r) => {
render(r.data.ssids); App.toast('Pool cleared');
}), 'Clearing…');
}, 'danger');
poolBox.appendChild(h('div', { class: 'row' },
h('div', {}, h('label', {}, 'SSID', input)),
h('div', {}, addBtn),
h('div', {}, clearBtn)));
poolBox.appendChild(h('label', { class: 'switch' }, advCb, h('span', { class: 'track' }), 'Advertise AP Impersonation Pool'));
poolBox.appendChild(h('label', { class: 'switch' }, colCb, h('span', { class: 'track' }), 'Capture SSIDs to Pool'));
poolBox.appendChild(list);
box.appendChild(poolBox);
setKnownCheckbox(advCb, PINEAP_SESSION.advertise);
advCb.addEventListener('change', () => {
if (advCb.disabled) return;
const requested = advCb.checked;
advCb.indeterminate = false;
advCb.disabled = true;
PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: requested }).then(() => {
PINEAP_SESSION.advertise = requested;
load();
}).catch(() => { setKnownCheckbox(advCb, PINEAP_SESSION.advertise); App.toast('Failed', 'error'); })
.finally(() => { advCb.disabled = false; });
});
colCb.addEventListener('change', () => {
if (colCb.disabled) return;
colCb.disabled = true;
PagerAPI.post('/api/pineap/ssidpool/collect', { enable: colCb.checked }).then(load)
.catch(() => { colCb.checked = !colCb.checked; App.toast('Failed', 'error'); })
.finally(() => { colCb.disabled = false; });
});
function render(ssids) {
poolCount.textContent = Array.isArray(ssids) ? ssids.length : 0;
list.innerHTML = '';
list.appendChild(table(
[{ label: 'SSID', key: 'ssid' },
{ label: '', render: (r) => btn('Remove', () => {
if (!confirm('Remove ' + r.ssid + '?')) return;
return PagerAPI.post('/api/pineap/ssids', { action: 'remove', ssid: r.ssid })
.then((x) => render(x.data.ssids));
}, 'danger') }],
(ssids || []).map((s) => ({ ssid: s }))));
list.querySelectorAll('.tbl th').forEach((th, i) => { if (i === 1) th.textContent = 'Remove'; });
if (!ssids || !ssids.length) list.appendChild(h('div', { class: 'pineap-handshakes-none', text: 'No SSIDs in pool.' }));
}
function load() {
PagerAPI.get('/api/pineap/ssids').then((r) => render(r.data.ssids)).catch((e) => {
list.innerHTML = '';
list.appendChild(h('div', { class: 'empty', text: 'SSID pool unavailable: ' + ((e && e.message) || 'error') }));
});
PagerAPI.post('/api/pineap/wifi/get_ap').then((r) => {
const p = (r.data || {}).pool || {};
if (p.broadcast_blocked) {
PINEAP_SESSION.advertise = false;
advCb.disabled = true;
advCb.title = 'Disabled by firmware crash fix';
} else {
advCb.disabled = false;
advCb.title = '';
if (p.disabled != null) PINEAP_SESSION.advertise = p.disabled === false;
}
setKnownCheckbox(advCb, PINEAP_SESSION.advertise);
setKnownCheckbox(colCb, Object.prototype.hasOwnProperty.call(p, 'collecting') ? !!p.collecting : null);
}).catch(() => {});
}
load();
return { destroy: () => {} };
};
views.pineap_clients = (root) => {
const box = pineapShell(root, '#/pineap/clients');
const state = { clients: [] };
const count = h('span', { text: '—' });
const countRow = h('div', { class: 'pineap-title-card-container' },
pineapTitleCard('Clients Connected', '#/pineap/clients', count));
box.appendChild(countRow);
const body = h('div', {});
const tableCard = h('div', { class: 'pineap-title-card' },
h('div', { class: 'pineap-card-title-flex' },
h('span', { text: 'Connected Clients' }),
h('span', { class: 'toolbar-spacer' }),
btn('Refresh', load, 'ghost')),
body);
box.appendChild(tableCard);
function render() {
body.innerHTML = '';
body.appendChild(table(
[{ label: 'MAC', key: 'mac' }, { label: 'Interface', key: 'iface' },
{ label: 'RSSI', key: 'rssi' }, { label: 'Source', key: 'source' },
{ label: '', render: (r) => btn('Kick', () => {
if (!confirm('Kick ' + r.mac + '?')) return;
return PagerAPI.post('/api/pineap/clients/kick', { mac: r.mac })
.then(() => App.toast('Kicked'))
.then(load);
}, 'danger') }],
state.clients));
const cols = ['MAC', 'Interface', 'RSSI', 'Source'];
body.querySelectorAll('.tbl th').forEach((th, i) => { if (i === cols.length) th.textContent = 'Kick'; });
}
let pending = false;
function load() {
if (pending) return Promise.resolve();
pending = true;
return PagerAPI.get('/api/pineap/clients').then((r) => {
state.clients = (r.data && r.data.clients) || [];
count.textContent = (r.data && typeof r.data.count === 'number') ? r.data.count : String(state.clients.length);
render();
if (r.data && r.data.error) {
body.appendChild(h('div', { class: 'muted', style: 'margin-top:8px;font-size:12px',
text: r.data.error }));
}
if (!state.clients.length) {
body.appendChild(h('div', { class: 'empty',
text: 'No associated clients on AP interfaces. Evil Enterprise stations appear here and on the Evil Enterprise page (wlan1ent is not shown in the stock Pager UI).' }));
}
}).catch((e) => {
count.textContent = '0';
state.clients = [];
render();
body.appendChild(h('div', { class: 'empty',
text: 'Client listing failed (' + (e.message || 'error') + '). Try Evil Enterprise for wlan1ent stations.' }));
})
.finally(() => { pending = false; });
}
load();
const iv = setInterval(load, 5000);
return { destroy: () => clearInterval(iv) };
};
views.pineap_filtering = (root) => {
const box = pineapShell(root, '#/pineap/filtering');
function filterCard(title, kind) {
const path = '/api/pineap/filters/' + kind;
const noun = kind === 'client' ? 'client MACs' : 'SSIDs';
const singular = kind === 'client' ? 'client MAC' : 'SSID';
const state = { mode: 'deny', entries: [], pending: false, error: '' };
const allowDefault = h('button', { class: 'seg-btn', text: 'Allow by default' });
const denyDefault = h('button', { class: 'seg-btn', text: 'Deny by default' });
const modeBar = h('div', { class: 'seg filter-mode-seg' }, allowDefault, denyDefault);
const explanation = h('div', { class: 'pineap-filter-description' });
const valueIn = h('input', { id: 'fv-' + kind, autocomplete: 'off' });
const valueLabel = h('label', {}, h('span', { class: 'pineap-filter-input-label' }), valueIn);
const list = h('div', {});
const addButton = btn('Add', addEntry);
const clearButton = btn('Clear current list', clearCurrent, 'danger');
const allowAllButton = btn('Allow all', allowAll, 'ghost');
const card = h('div', { class: 'pineap-title-card pineap-filter-card' },
h('div', { class: 'pineap-card-title' }, title),
h('div', { class: 'pineap-settings-section', text: 'Default behavior' }),
modeBar,
explanation,
h('div', { class: 'row pineap-filter-entry-row' },
h('div', { class: 'pineap-filter-value' }, valueLabel),
h('div', {}, addButton)),
h('div', { class: 'pineap-filter-actions' }, allowAllButton, clearButton),
list);
box.appendChild(card);
function setPending(value) {
state.pending = value;
[allowDefault, denyDefault, addButton, clearButton, allowAllButton, valueIn]
.forEach((el) => {
el.disabled = value;
if (el.classList) el.classList.toggle('busy', value);
});
}
function applyResponse(r) {
const data = (r && r.data) || {};
state.mode = data.mode === 'allow' ? 'allow' : 'deny';
state.entries = Array.isArray(data.entries) ? data.entries : [];
state.error = data.error || '';
render();
}
function fail(message) {
App.toast(message, 'error');
}
function mutate(payload, success) {
if (state.pending) return Promise.resolve();
setPending(true);
return PagerAPI.post(path, Object.assign({ mode: state.mode }, payload))
.then((r) => { applyResponse(r); if (success) App.toast(success); })
.catch(() => fail('Failed to update ' + title.toLowerCase()))
.finally(() => setPending(false));
}
function setMode(mode) {
if (state.mode === mode || state.pending) return Promise.resolve();
return mutate({ action: 'set_mode', mode }, mode === 'deny'
? title + ': allowing by default'
: title + ': denying by default');
}
function addEntry() {
const value = valueIn.value.trim();
if (!value) { fail(singular + ' required'); return; }
valueIn.value = '';
return mutate({ action: 'add', value }, singular + ' added');
}
function removeEntry(value) {
if (!confirm('Remove ' + value + ' from this ' + (state.mode === 'deny' ? 'deny' : 'allow') + ' list?')) return;
return mutate({ action: 'delete', value }, singular + ' removed');
}
function clearCurrent() {
if (!confirm('Clear every entry from the current ' + (state.mode === 'deny' ? 'deny' : 'allow') + ' list?')) return;
return mutate({ action: 'clear' }, 'Current list cleared');
}
function allowAll() {
if (!confirm('Allow all ' + noun + '? This clears the deny list and changes the default behavior to allow.')) return;
return mutate({ action: 'allow_all' }, 'All ' + noun + ' allowed');
}
function render() {
const isDenyList = state.mode === 'deny';
allowDefault.classList.toggle('active', isDenyList);
denyDefault.classList.toggle('active', !isDenyList);
explanation.textContent = isDenyList
? 'New ' + noun + ' are allowed. Only entries in the deny list are blocked.'
: 'New ' + noun + ' are blocked. Only entries in the allow list are permitted.';
if (state.error) {
explanation.textContent += ' Live list is temporarily unavailable.';
}
valueLabel.querySelector('.pineap-filter-input-label').textContent =
'Add to ' + (isDenyList ? 'deny' : 'allow') + ' list';
valueIn.placeholder = kind === 'client' ? 'AA:BB:CC:DD:EE:FF' : 'Network name';
list.innerHTML = '';
list.appendChild(table(
[{ label: (isDenyList ? 'Denied ' : 'Allowed ') + singular, key: 'value' },
{ label: '', render: (row) => btn('Remove', () => removeEntry(row.value), 'danger') }],
state.entries.map((e) => ({ value: e }))));
if (!state.entries.length) list.appendChild(h('div', {
class: 'pineap-handshakes-none',
text: 'The ' + (isDenyList ? 'deny' : 'allow') + ' list is empty.'
}));
}
allowDefault.addEventListener('click', () => setMode('deny'));
denyDefault.addEventListener('click', () => setMode('allow'));
valueIn.addEventListener('keydown', (e) => { if (e.key === 'Enter') addEntry(); });
PagerAPI.get(path).then(applyResponse).catch((e) => {
applyResponse({ data: { mode: 'deny', entries: [], error: (e && e.message) || 'unavailable' } });
});
}
filterCard('Client Filter', 'client');
filterCard('SSID Filter', 'ssid');
return { destroy: () => {} };
};
const RECON_TABS = [
{ label: 'Scanning', hash: '#/recon' },
{ label: 'Reports', hash: '#/recon/reports' },
{ label: 'Handshakes', hash: '#/recon/handshakes' }
];
const RECON_LANDSCAPE_COLORS = ['#2ecc71', '#2980b9', '#8e44ad'];
const RECON_ENC_COLORS = ['#2ecc71', '#2980b9', '#8e44ad', '#e74c3c', '#ff0000', '#34495e'];
const RECON_ENC_ORDER = ['Open', 'WEP', 'WPA', 'WPA2-PSK', 'WPA2-Enterprise', 'WPA3-Personal', 'WPA3-PSK', 'WPA3-Enterprise', 'Unknown'];
const RECON_COMPARE_COLORS = ['#2ecc71', '#2980b9', '#8e44ad', '#e67e22', '#c0392b', '#16a085'];
const RECON_MAX_HISTORY = 90;
const RECON_CHANNEL_COLORS = ['#FC68AC','#4545FF','#19DE8F','#FF294A','#23E8DB','#0FD349','#4D4AFF','#E2FF68','#FF8368','#B1FF6A','#FFFF3B','#FF677E','#D0FF6E','#F57D67','#F828E4','#EAFF6D','#3676F9','#F169E8','#3B2AE4','#3197F5','#4040FF','#FFF26A','#FCAD67','#0ACE28','#FF9E68','#55FF4A','#F9FF68','#EE687E','#FFFC67','#FFE167','#7FFF6C','#FFF236','#F26868','#6DFF74','#F568D5','#FF402A','#CAFF69','#28C20A','#6B29E9','#C7FF40','#FFB631','#D429F3','#F868C1','#14D96B','#9E29EF','#8EFF45','#FF2980','#FD29B3','#FF7A2C','#FF6967','#FFD569','#27D6EC','#98FF6B','#1EE3B5','#FFFF6B','#FFB969','#FFFF6C','#FF6795','#0BC80A','#3B54FD','#F99467','#FFC667','#2CB7F1','#6EFF91'];
const RECON_AP_COLS = [
{ key: 'ssid', label: 'SSID', render: (a) => a.ssid || '(hidden)' },
{ key: 'bssid', label: 'MAC', render: (a) => a.bssid || '--' },
{ key: 'band', label: 'Band', render: (a) => a.band || '--' },
{ key: 'channel', label: 'Channel', render: (a) => a.channel == null ? '--' : a.channel },
{ key: 'signal', label: 'Signal', render: (a) => a.signal == null ? '--' : dbmCell(a.signal) },
{ key: 'identity', label: 'Identity', search: (a) => displayIdentity(a.device_identity), render: (a) => displayIdentity(a.device_identity) },
{ key: 'clients', label: 'Clients', search: (a) => a.client_count == null ? (a.clients || []).length : a.client_count, render: (a) => a.client_count == null ? (a.clients || []).length : a.client_count },
{ key: 'encryption', label: 'Encryption', render: (a) => a.encryption || '--' },
{ key: 'first_seen', label: 'First Seen', render: (a) => fmtShortTime(a.first_seen) },
{ key: 'last_seen', label: 'Last Seen', render: (a) => fmtShortTime(a.last_seen) },
{ key: 'hidden', label: 'Hidden', render: (a) => a.hidden ? 'Yes' : 'No' }
];
const RECON_CLIENT_COLS = [
{ key: 'mac', label: 'Client MAC', render: (c) => c.mac },
{ key: 'signal', label: 'Signal', render: (c) => c.signal == null ? '--' : c.signal + ' dBm' },
{ key: 'freq', label: 'Frequency', render: (c) => c.freq || '--' },
{ key: 'packets', label: 'Packets', render: (c) => c.packets || 0 },
{ key: 'vendor', label: 'Vendor', search: (c) => displayIdentity(c.vendor), render: (c) => displayIdentity(c.vendor) },
{ key: 'associated_ssid', label: 'Associated SSID', search: (c) => (c.associations || []).map((a) => a.ssid || '').join(', '), render: (c) => associationCell(c.associations) }
];
function displayIdentity(identity) {
if (typeof identity === 'string') return identity || '--';
const value = identity || {};
const manufacturer = value.manufacturer || 'Unknown';
if (value.model) return manufacturer + ' ' + value.model;
if (manufacturer === 'Unknown' && value.oui) return manufacturer + ' (' + value.oui + ')';
return manufacturer;
}
function associationSummary(associations) {
const ssids = [];
(associations || []).forEach((association) => {
const ssid = association && association.ssid;
if (ssid && ssids.indexOf(ssid) === -1) ssids.push(ssid);
});
if (!ssids.length) return '--';
return ssids.length === 1 ? ssids[0] : ssids[0] + ' +' + (ssids.length - 1);
}
function associationCell(associations) {
const summary = associationSummary(associations);
const full = (associations || []).map((a) => {
if (!a || !a.ssid) return null;
const parts = [a.ssid];
if (a.bssid) parts.push(a.bssid);
if (a.sources && a.sources.length) parts.push('[' + a.sources.join(', ') + ']');
return parts.join(' ');
}).filter(Boolean).join('; ');
return h('span', { title: full || summary, text: summary });
}
function reconBandOf(freq) {
if (freq == null) return null;
if (freq >= 2400 && freq < 2500) return '2.4';
if (freq >= 4900 && freq < 5900) return '5';
if (freq >= 5900 && freq < 7125) return '6';
return null;
}
function reconSigColor(dbm) {
if (dbm == null) return '#9e9e9e';
if (dbm >= -50) return '#2e7d32';
if (dbm >= -67) return '#f9a825';
if (dbm >= -80) return '#ef6c00';
return '#c62828';
}
function dbmCell(dbm) {
const pct = dbm == null ? 0 : Math.max(0, Math.min(100, ((dbm + 100) / 60) * 100));
const color = reconSigColor(dbm);
const bar = h('span', { class: 'recon-dbm-bar' },
h('span', { class: 'recon-dbm-fill', style: 'width:' + pct.toFixed(0) + '%;background:' + color }));
return h('span', { class: 'recon-dbm-cell' },
bar, h('span', { class: 'recon-dbm-val', style: 'color:' + color, text: (dbm == null ? '--' : dbm + ' dBm') }));
}
function reconBandLabel(band) {
return band == null ? '--' : band + ' GHz';
}
function reconDefaultCols() {
return {
ap: { compare: true, ssid: true, bssid: true, band: true, channel: true, signal: true, identity: true, clients: true, encryption: true, first_seen: true, last_seen: true, hidden: true },
client: { mac: true, signal: true, freq: true, packets: true, vendor: true, associated_ssid: true }
};
}
function reconLoadCols() {
try {
const v = JSON.parse(localStorage.getItem('pw_recon_cols'));
if (v && v.ap && v.client) {
const defaults = reconDefaultCols();
v.ap = Object.assign(defaults.ap, v.ap);
v.client = Object.assign(defaults.client, v.client);
if (typeof v.ap.compare !== 'boolean') v.ap.compare = true;
return v;
}
} catch (e) {}
return reconDefaultCols();
}
function reconFiltered(rows, q, colsArr) {
const ql = (q || '').toLowerCase();
if (!ql) return rows;
return rows.filter((r) => colsArr.some((c) => {
const value = c.search ? c.search(r) : r[c.key];
return String(value == null ? '' : value).toLowerCase().indexOf(ql) !== -1;
}));
}
// ---------------------------------------------------------------------------
// Attacks: one-click Evil WPA / Open / Enterprise launchers.
// ---------------------------------------------------------------------------
function attackBadge(ap) {
if (!ap) return badge(false);
if (!ap.enabled) return h('span', { class: 'badge off', text: 'OFF' });
return h('span', { class: 'badge' + (ap.live ? ' on' : ' warn'), text: ap.live ? 'LIVE' : 'CONFIGURED' });
}
function attackStatusCard() {
const card = h('div', { class: 'pineap-title-card' });
card.appendChild(h('div', { class: 'pineap-card-title' }, 'Status'));
const rows = h('div', { style: 'font-size:13px;line-height:1.9' });
card.appendChild(rows);
const append = (label, node) => rows.appendChild(
h('div', { class: 'row' }, h('div', { style: 'min-width:130px', text: label }), node));
return { card, rows, append };
}
function verifiedToast(result) {
if (result && result.verified) App.toast('Applied and verified on device');
else if (result && result.ok) App.toast('Applied (verification pending — radio reloading)', 'error');
else App.toast('Deploy failed', 'error');
}
function attackLauncher(kind, opts) {
return (root) => {
const box = pineapShell(root, opts.tabHash || '#/pineap/evilwpa');
const form = h('div', { class: 'pineap-title-card' });
form.appendChild(h('div', { class: 'pineap-card-title' }, opts.title));
box.appendChild(form);
const f = h('div', {});
form.appendChild(f);
const ssidIn = h('input', { id: 'atk-ssid' });
const hiddenCb = h('input', { type: 'checkbox', id: 'atk-hidden' });
const chanSel = h('select', { id: 'atk-channel' });
chanSelect(chanSel, null);
const bandHint = h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px' });
let pskIn = null, encSel = null;
chanSel.addEventListener('change', () => {
const b = bandOfChannel(chanSel.value);
bandHint.textContent = b === '6' ? '6 GHz requires WPA3 (SAE/OWE).' : '';
if (encSel) {
Array.prototype.forEach.call(encSel.options, (o) => {
o.disabled = b === '6' && o.value !== 'sae' && o.value !== 'owe';
});
if (b === '6' && encSel.value === 'psk2') encSel.value = 'sae';
}
});
f.appendChild(h('label', {}, 'SSID (target network)', ssidIn));
if (opts.passphrase) {
pskIn = h('input', { id: 'atk-psk', type: 'password', autocomplete: 'new-password' });
encSel = h('select', { id: 'atk-enc' });
(opts.encodings || EVIL_ENC).forEach(([v, l]) => encSel.appendChild(h('option', { value: v, text: l })));
f.appendChild(h('label', {}, 'Passphrase', pskIn));
f.appendChild(h('label', {}, 'Encryption', encSel));
}
let bssidIn = null, coSel = null;
if (opts.bssid) {
bssidIn = h('input', { id: 'atk-bssid', placeholder: 'AA:BB:CC:DD:EE:FF' });
f.appendChild(h('label', {}, 'BSSID (spoof, optional)', bssidIn));
}
if (opts.country) {
coSel = h('select', { id: 'atk-country' });
OPEN_COUNTRIES.forEach(([v, l]) => coSel.appendChild(h('option', { value: v, text: l })));
f.appendChild(h('label', {}, 'Country', coSel));
}
f.appendChild(h('label', { class: 'switch' }, hiddenCb, h('span', { class: 'track' }), 'Hidden'));
f.appendChild(h('label', {}, 'Channel', chanSel));
f.appendChild(bandHint);
// Consume a Recon -> PineAP prefill (set by the Recon focus sidebar's
// "Send to PineAP" button). The form is filled but never auto-deploys:
// an attack always requires an explicit user action.
const prefill = PineAPPrefill.consume();
if (prefill && prefill.ssid) {
ssidIn.value = prefill.ssid;
hiddenCb.checked = !!prefill.hidden;
if (prefill.channel != null && prefill.channel !== '') {
const chanOpts = Array.prototype.slice.call(chanSel.options);
const hit = chanOpts.find((o) => Number(o.value) === Number(prefill.channel));
if (hit) chanSel.value = hit.value;
chanSel.dispatchEvent(new Event('change'));
}
if (encSel && prefill.enctype) {
const encOpts = Array.prototype.slice.call(encSel.options);
const hit = encOpts.find((o) => o.value === prefill.enctype);
if (hit) encSel.value = hit.value;
}
if (pskIn) pskIn.placeholder = 'Passphrase for ' + prefill.ssid;
if (bssidIn && prefill.bssid) bssidIn.value = prefill.bssid;
f.appendChild(h('div', { class: 'pineap-infobox info', style: 'margin:10px 0 0;font-size:12px',
text: 'Prefilled from Recon (' + (prefill.source || 'target') + '). Set the passphrase, verify the settings, then Deploy.' }));
}
f.appendChild(h('div', { class: 'row', style: 'margin-top:10px' },
h('div', {}, (function () {
const deployBtn = btn('Deploy Attack', () => {
const body = {
kind, ssid: ssidIn.value.trim(),
hidden: hiddenCb.checked,
channel: chanSel.value ? parseInt(chanSel.value, 10) : null
};
if (pskIn) { body.passphrase = pskIn.value; body.enctype = encSel.value; }
if (bssidIn) body.bssid = bssidIn.value.trim();
if (coSel) body.country = coSel.value;
runAction(deployBtn, () => PagerAPI.post('/api/attacks/deploy', body)
.then((r) => { verifiedToast(r.data || {}); load(); }), 'Deploying…');
});
return deployBtn;
})()),
h('div', {}, (function () {
const stopBtn = btn('Stop Attack', () => {
runAction(stopBtn, () => PagerAPI.post('/api/attacks/stop', { kind })
.then(() => { App.toast('Attack stopped'); load(); }), 'Stopping…');
}, 'danger');
return stopBtn;
})()),
h('div', { class: 'muted', style: 'align-self:center;font-size:12px' },
'Deploy reconfigures the radio — you may be disconnected briefly.')));
const status = attackStatusCard();
box.appendChild(status.card);
const hsBox = h('div', {});
const captureBox = h('div', { class: 'pineap-title-card' });
captureBox.appendChild(h('div', { class: 'pineap-card-title' }, 'Monitor Capture'));
const capBody = h('div', { style: 'font-size:13px' });
captureBox.appendChild(capBody);
box.appendChild(captureBox);
let capIface = 'wlan0mon';
function capRow(st) {
capBody.innerHTML = '';
const run = !!(st && st.running);
capBody.appendChild(h('div', { class: 'row' },
h('span', { text: run ? ('Capturing on ' + (st.iface || capIface)) : 'Not capturing' }),
h('div', {}, (function () {
const capBtn = btn(run ? 'Stop Capture' : 'Start Capture', () => {
runAction(capBtn, () => PagerAPI.post('/api/attacks/capture', {
iface: capIface, action: run ? 'stop' : 'start'
}).then((r) => capRow(r.data || {})), run ? 'Stopping…' : 'Starting…');
});
return capBtn;
})())));
capBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px',
text: run && st.path ? st.path : '' }));
}
if (opts.export) {
const exp = h('div', { class: 'pineap-title-card' });
exp.appendChild(h('div', { class: 'pineap-card-title' }, 'Hashcat Export'));
const expBody = h('div', { style: 'font-size:13px;line-height:1.8' });
exp.appendChild(expBody);
expBody.appendChild(h('div', {}, (function () {
const expBtn = btn('Export .hc22000', () => {
runAction(expBtn, () => PagerAPI.get('/api/attacks/export/hc22000').then((r) => {
const d = r.data || {};
if (d.file) {
expBody.appendChild(h('div', { class: 'row', style: 'margin-top:8px' },
h('a', { class: 'btn ghost', href: '/api/attacks/export/hc22000/' + d.name,
style: 'text-decoration:none', download: d.name }, 'Download ' + d.name),
h('code', { style: 'font-size:12px', text: d.hashcat || '' })));
App.toast('Export ready');
} else App.toast('No captures to export', 'error');
}), 'Exporting…');
});
return expBtn;
})()));
box.appendChild(exp);
}
let deauthCard = null;
const ssidRef = { current: '' };
if (opts.deauth) {
deauthCard = deauthPanel(ssidRef);
box.appendChild(deauthCard);
}
let loadPending = false;
function load() {
if (loadPending) return;
loadPending = true;
const statusReq = PagerAPI.get('/api/attacks/status').then((r) => {
const s = r.data || {};
const ap = s[kind] || {};
const which = (ap.radio0 && ap.radio0.enabled) ? ap.radio0
: (ap.radio1 && ap.radio1.enabled) ? ap.radio1 : null;
status.rows.innerHTML = '';
status.append('Attack', attackBadge(which));
status.append('SSID', h('span', { text: which && which.ssid ? which.ssid : '—' }));
status.append('Interface', h('span', { text: which ? which.iface : '—' }));
status.append('Band', h('span', { text: which && which.band ? which.band + ' GHz' : '—' }));
status.append('Channel', h('span', { text: which && which.channel != null ? which.channel : '—' }));
if (which && !which.live) {
status.append('Note', h('span', { class: 'pineap-infobox warn',
text: 'Configured but interface not live yet — radio reload may still be in progress.' }));
}
capIface = (which && (which.band === '5' || which.band === '6')) ? 'wlan1mon' : 'wlan0mon';
if (ssidRef) {
ssidRef.current = which && which.ssid ? which.ssid : '';
if (ssidRef.tick) ssidRef.tick();
}
if (opts.handshakes) {
hsBox.innerHTML = '';
hsBox.appendChild(h('div', { class: 'pineap-card-title', text: 'Handshakes Captured (' + (s.handshakes || 0) + ')' }));
PagerAPI.get('/api/pineap/handshakes').then((hr) => {
const hd = (hr.data || {}).parsed || [];
const rows = hd.slice(-10).reverse().map((x) => ({
ssid: x.ssid || '—', bssid: x.bssid || '—',
when: fmtTime(x.ts || 0), type: x.type || ''
}));
hsBox.appendChild(rows.length ? table(
[{ key: 'when', label: 'When' }, { key: 'ssid', label: 'SSID' },
{ key: 'bssid', label: 'BSSID' }, { key: 'type', label: 'Type' }], rows)
: h('div', { class: 'empty', text: 'No handshakes yet.' }));
}).catch(() => {});
box.appendChild(hsBox);
}
}).catch(() => {});
const capReq = PagerAPI.post('/api/attacks/capture', { action: 'status' }).then((r) => capRow(r.data || {})).catch(() => {});
Promise.allSettled([statusReq, capReq]).finally(() => { loadPending = false; });
}
load();
const iv = setInterval(load, 5000);
return { destroy: () => clearInterval(iv) };
};
}
views.harness = (root) => {
root.appendChild(h('h1', { class: 'page-title', text: 'Harness' }));
const box = h('div', {});
root.appendChild(box);
const info = h('div', { class: 'pineap-title-card' });
info.appendChild(h('div', { class: 'pineap-card-title' }, 'Local Harness (MCP)'));
const infoBody = h('div', { style: 'font-size:13px;line-height:1.9' });
info.appendChild(infoBody);
box.appendChild(info);
const tok = h('code', { style: 'font-size:12px', text: '…' });
const endpoint = h('code', { style: 'font-size:12px', text: location.origin + '/mcp' });
infoBody.appendChild(h('div', { class: 'row' }, h('div', { style: 'min-width:130px', text: 'Endpoint' }), endpoint));
infoBody.appendChild(h('div', { class: 'row' },
h('div', { style: 'min-width:130px', text: 'Bearer token' }), tok,
h('div', {}, btn('Copy Token', () => {
return navigator.clipboard.writeText(tok.textContent).then(() => App.toast('Token copied'));
}))));
infoBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px', text: 'Agents call POST /mcp with JSON-RPC 2.0 (MCP Streamable HTTP). The token is the current session token.' }));
const snippet = h('pre', { style: 'font-size:12px;overflow:auto;background:rgba(127,127,127,.12);padding:10px;border-radius:4px;white-space:pre-wrap' });
const capBox = h('div', { class: 'pineap-title-card' });
capBox.appendChild(h('div', { class: 'pineap-card-title' }, 'Capabilities'));
const capBody = h('div', { style: 'font-size:13px' });
capBox.appendChild(capBody);
box.appendChild(capBox);
function load() {
PagerAPI.get('/api/harness/capabilities').then((r) => {
const d = r.data || {};
capBody.innerHTML = '';
const tools = d.tools || [];
const prompts = d.prompts || [];
capBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px',
text: tools.length + ' tools, ' + prompts.length + ' playbooks, ' +
((d.resources || []).length) + ' resources' }));
const list = h('ul', { style: 'font-size:12px;padding-left:18px' });
tools.forEach((t) => list.appendChild(h('li', { text: t.name + ' — ' + t.description })));
capBody.appendChild(list);
}).catch(() => {});
PagerAPI.get('/api/harness/token').then((r) => {
const t = (r.data || {}).token || '';
tok.textContent = t ? t.slice(0, 12) + '…' : '(none)';
snippet.textContent = 'curl -s -X POST ' + location.origin + '/mcp \\\n' +
' -H "Content-Type: application/json" \\\n' +
' -H "Authorization: Bearer ' + t + '" \\\n' +
' -d \'{"jsonrpc":"2.0","id":1,"method":"tools/list"}\'';
infoBody.appendChild(snippet);
}).catch(() => {});
}
load();
return { destroy: () => {} };
};
function deauthPanel(ssidRef) {
const wrap = h('div', { class: 'pineap-title-card' });
wrap.appendChild(h('div', { class: 'pineap-card-title' }, 'Deauth Targeting'));
const body = h('div', { style: 'font-size:13px' });
wrap.appendChild(body);
const ssidBox = h('input', { placeholder: 'SSID to find clients for' });
const apSel = h('select', {});
const clTable = h('div', {});
let lastLookup = '';
function lookup(q) {
if (!q || q === lastLookup) return Promise.resolve();
lastLookup = q;
return PagerAPI.get('/api/attacks/clients?ssid=' + encodeURIComponent(q)).then((r) => {
const d = r.data || {};
apSel.innerHTML = '';
(d.aps || []).forEach((a) => apSel.appendChild(h('option', {
value: (a.bssid || '') + '|' + (a.channel || 1),
text: (a.ssid || q) + ' — ' + (a.bssid || '?') + ' ch' + (a.channel || 1)
})));
if (!(d.aps || []).length) apSel.appendChild(h('option', { value: '|1', text: 'No APs found — check SSID' }));
clTable.innerHTML = '';
const cl = (d.clients || []).slice(0, 30);
if (!cl.length) {
clTable.appendChild(h('div', { class: 'empty', text: 'No devices in recon yet.' }));
return;
}
clTable.appendChild(table(
[{ key: 'mac', label: 'MAC' }, { key: 'freq', label: 'Freq (MHz)' },
{ key: 'signal', label: 'Signal' }, { key: 'packets', label: 'Packets' },
{ key: '_deauth', label: '' }],
cl.map((c) => {
const mac = c.mac || c.client_mac || '';
return { mac: mac || '—', freq: c.freq || '', signal: c.signal || '',
packets: c.packets || '', _deauth: (function () {
const deauthBtn = btn('Deauth', () => {
const parts = apSel.value.split('|');
if (!mac || !parts[0]) { App.toast('Pick an AP and device first', 'error'); return; }
runAction(deauthBtn, () => PagerAPI.post('/api/attacks/deauth', {
bssid: parts[0], client: mac, channel: parseInt(parts[1], 10)
}).then(() => App.toast('Deauth frames sent')), 'Sending…');
}, 'danger');
return deauthBtn;
})() };
})));
}).catch(() => App.toast('Lookup failed', 'error'));
}
body.appendChild(h('div', { class: 'row' }, ssidBox,
h('div', {}, (function () {
const findBtn = btn('Find', () => {
lastLookup = '';
runAction(findBtn, () => lookup(ssidBox.value.trim()), 'Finding…');
});
return findBtn;
})())));
body.appendChild(apSel);
body.appendChild(clTable);
body.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px',
text: 'Only deauth targets you are authorized to test.' }));
if (ssidRef) {
ssidRef.tick = () => {
const liveSsid = ssidRef.current && ssidRef.current.trim();
if (liveSsid && ssidBox.value.trim() !== liveSsid) {
ssidBox.value = liveSsid;
lastLookup = '';
lookup(liveSsid);
}
};
}
return wrap;
}
views.pineap_enterprise = (root) => {
const box = pineapShell(root, '#/pineap/enterprise');
const form = h('div', { class: 'pineap-title-card' });
form.appendChild(h('div', { class: 'pineap-card-title' }, 'Evil Enterprise'));
box.appendChild(form);
const f = h('div', {});
form.appendChild(f);
const ssidIn = h('input', { id: 'ent-ssid' });
const encSel = h('select', { id: 'ent-enc' });
[['wpa2', 'WPA2 Enterprise'], ['wpa3', 'WPA3 Enterprise']]
.forEach(([v, l]) => encSel.appendChild(h('option', { value: v, text: l })));
const methodSel = h('select', { id: 'ent-method' });
[['any', 'Any (MSCHAPv2 + GTC)'], ['mschapv2', 'MSCHAPv2 hashes'], ['gtc', 'GTC (plaintext)']]
.forEach(([v, l]) => methodSel.appendChild(h('option', { value: v, text: l })));
const pskIn = h('input', { id: 'ent-pass', type: 'password', autocomplete: 'new-password' });
const hiddenCb = h('input', { type: 'checkbox', id: 'ent-hidden' });
const chanSel = h('select', { id: 'ent-channel' });
chanSelect(chanSel, null);
f.appendChild(h('label', {}, 'SSID', ssidIn));
f.appendChild(h('label', {}, 'Encryption', encSel));
f.appendChild(h('label', {}, 'Inner EAP', methodSel));
f.appendChild(h('label', {}, 'EAP user password (optional \u2014 not a RADIUS shared secret)', pskIn));
f.appendChild(h('label', { class: 'switch' }, hiddenCb, h('span', { class: 'track' }), 'Hidden'));
f.appendChild(h('label', {}, 'Channel (5 GHz only \u2014 Auto uses the target SSID\u2019s recon channel)', chanSel));
f.appendChild(h('div', { class: 'row', style: 'margin-top:10px' },
h('div', {}, (function () {
const deployBtn = btn('Deploy Attack', () => {
runAction(deployBtn, () => PagerAPI.post('/api/attacks/deploy', {
kind: 'enterprise', ssid: ssidIn.value.trim(),
enctype: encSel.value, passphrase: pskIn.value,
hidden: hiddenCb.checked, auth_method: methodSel.value,
channel: chanSel.value ? parseInt(chanSel.value, 10) : null
}).then((r) => { verifiedToast(r.data || {}); load(); }), 'Deploying…');
});
return deployBtn;
})()),
h('div', {}, (function () {
const stopBtn = btn('Stop Attack', () => {
runAction(stopBtn, () => PagerAPI.post('/api/attacks/stop', { kind: 'enterprise' })
.then(() => { App.toast('Attack stopped'); load(); }), 'Stopping…');
}, 'danger');
return stopBtn;
})())));
const status = attackStatusCard();
box.appendChild(status.card);
const pApe = h('div', { class: 'pineap-title-card' });
pApe.appendChild(h('div', { class: 'pineap-card-title' }, 'PineAPE'));
const pApeBody = h('div', {});
pApe.appendChild(pApeBody);
box.appendChild(pApe);
const authCb = h('input', { type: 'checkbox', id: 'ent-auth' });
authCb.addEventListener('change', () => {
if (authCb.disabled) return;
authCb.disabled = true;
PagerAPI.post('/api/pineap/hostapd', { pineape_auth_pass: authCb.checked })
.then(load).catch(() => { authCb.checked = !authCb.checked; App.toast('Failed', 'error'); })
.finally(() => { authCb.disabled = false; });
});
const radiusCard = h('div', { class: 'pineap-title-card' });
radiusCard.appendChild(h('div', { class: 'pineap-card-title-flex' },
h('span', { text: 'RADIUS / EAP captures' }),
h('span', { class: 'toolbar-spacer' }),
btn('Export hashcat -m 5500', () => {
return PagerAPI.get('/api/pineap/enterprise/export/hashcat').then((r) => {
downloadText('pineape-mschapv2.5500', typeof r.data === 'string' ? r.data : '');
App.toast('Exported hashcat -m 5500');
});
}, 'ghost'),
btn('Export john', () => {
return PagerAPI.get('/api/pineap/enterprise/export/john').then((r) => {
downloadText('pineape-mschapv2.john', typeof r.data === 'string' ? r.data : '');
App.toast('Exported john netntlm');
});
}, 'ghost'),
(function () {
const clearAll = btn('Clear all', () => {
if (!confirm('Clear all enterprise captures?')) return;
runAction(clearAll, () => PagerAPI.post('/api/pineap/enterprise/clear', { table: 'all' }).then(load), 'Clearing…');
}, 'danger');
return clearAll;
})()));
const radiusNote = h('div', { class: 'muted', style: 'font-size:12px;margin-bottom:8px' });
const radiusBody = h('div', {});
radiusCard.appendChild(radiusNote);
radiusCard.appendChild(radiusBody);
box.appendChild(radiusCard);
const identCard = h('div', { class: 'pineap-title-card' });
identCard.appendChild(h('div', { class: 'pineap-card-title-flex' },
h('span', { text: 'EAP identities' }),
h('span', { class: 'toolbar-spacer' }),
(function () {
const clearIdent = btn('Clear', () => {
if (!confirm('Clear EAP identities?')) return;
runAction(clearIdent, () => PagerAPI.post('/api/pineap/enterprise/clear', { table: 'basic' }).then(load), 'Clearing…');
}, 'danger');
return clearIdent;
})()));
const identBody = h('div', {});
identCard.appendChild(identBody);
box.appendChild(identCard);
const mschapCard = h('div', { class: 'pineap-title-card' });
mschapCard.appendChild(h('div', { class: 'pineap-card-title-flex' },
h('span', { text: 'MSCHAPv2 / RADIUS inner auth' }),
h('span', { class: 'toolbar-spacer' }),
(function () {
const clearMschap = btn('Clear', () => {
if (!confirm('Clear MSCHAPv2 captures?')) return;
runAction(clearMschap, () => PagerAPI.post('/api/pineap/enterprise/clear', { table: 'challenge' }).then(load), 'Clearing…');
}, 'danger');
return clearMschap;
})()));
const mschapBody = h('div', {});
mschapCard.appendChild(mschapBody);
box.appendChild(mschapCard);
const clientCard = h('div', { class: 'pineap-title-card' });
clientCard.appendChild(h('div', { class: 'pineap-card-title' }, 'Associated enterprise clients'));
const clientBody = h('div', {});
clientCard.appendChild(clientBody);
box.appendChild(clientCard);
const logCard = h('div', { class: 'pineap-title-card' });
logCard.appendChild(h('div', { class: 'pineap-card-title' }, 'hostapd / PineAPE log'));
const logBody = h('pre', { class: 'muted', style: 'font-size:11px;white-space:pre-wrap;max-height:240px;overflow:auto' });
logCard.appendChild(logBody);
box.appendChild(logCard);
const hashCol = {
label: 'hashcat -m 5500',
key: 'hashcat',
render: (r) => {
const val = r.hashcat || r.hashcat || '';
const shown = val.length > 42 ? val.slice(0, 38) + '\u2026' : val;
return h('div', { class: 'row' },
h('span', { text: shown || '\u2014', title: val }),
val ? btn('Copy', () => copyText(val, 'Hash copied'), 'ghost') : null);
}
};
function load() {
PagerAPI.get('/api/attacks/status').then((r) => {
const s = r.data || {};
const ent = s.enterprise || {};
const ap = ent.ap || null;
status.rows.innerHTML = '';
status.append('Attack', attackBadge(ap));
status.append('SSID', h('span', { text: ap && ap.ssid ? ap.ssid : '\u2014' }));
status.append('Interface', h('span', { text: ap ? ap.iface : '\u2014' }));
status.append('Band', h('span', { text: ap && ap.band ? ap.band + ' GHz' : '5 GHz' }));
status.append('Channel', h('span', { text: ap && ap.channel != null ? ap.channel : '\u2014' }));
status.append('Identities', h('span', { text: String(ent.identities != null ? ent.identities : 0) }));
status.append('MSCHAPv2', h('span', { text: String(ent.mschapv2 != null ? ent.mschapv2 : 0) }));
status.append('Credentials', h('span', { text: String(ent.creds || 0) }));
const stations = ent.stations || (ap && ap.stations) || [];
status.append('Stations', h('span', { text: stations.length ? stations.join(', ') : '\u2014' }));
status.append('Inner EAP', h('span', { text: ent.auth_method || (ap && ap.auth_method) || '\u2014' }));
status.append('TLS certs', h('span', { text: (ap && ap.certs) || ent.certs ? 'installed' : '\u2014' }));
status.append('Log captures', h('span', { text: String((ap && ap.captures != null) ? ap.captures : (ent.captures != null ? ent.captures : 0)) }));
pApeBody.innerHTML = '';
authCb.checked = !!(ent.pineape && ent.pineape.enabled) || !!(ap && ap.live);
pApeBody.appendChild(h('label', { class: 'switch' }, authCb, h('span', { class: 'track' }), 'Auth Pass Capture'));
if (ent.pineape) {
pApeBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px',
text: 'PineAPE ' + (ent.pineape.enabled ? 'enabled' : 'disabled')
+ ((ap && ap.ctrl_linked) || ent.ctrl_linked ? ' · pineapd linked' : '') }));
}
}).catch((e) => { status.rows.innerHTML = ''; status.append('Status', h('span', { text: e.message || 'Failed to load' })); });
PagerAPI.get('/api/pineap/enterprise/radius').then((r) => {
const d = r.data || {};
radiusNote.textContent = d.note || d.note || '';
const rows = (d.captures || []).slice();
radiusBody.innerHTML = '';
radiusBody.appendChild(table([
{ label: 'When', key: 'time', render: (x) => fmtTime(x.time) },
{ label: 'Kind', key: 'kind' },
{ label: 'Username', key: 'username' },
{ label: 'Password', key: 'password' },
{ label: 'Challenge', key: 'challenge' },
{ label: 'Response', key: 'response' },
{ label: 'Source', key: 'source' },
hashCol
], rows));
if (!rows.length) radiusBody.appendChild(h('div', { class: 'empty', text: 'No EAP/RADIUS captures yet. Accept the client certificate warning, then retry login.' }));
const liveMacs = d.stations || [];
const byMac = {};
(d.clients || []).forEach((c) => { if (c && c.mac) byMac[String(c.mac).toUpperCase()] = c; });
liveMacs.forEach((mac) => {
const key = String(mac || '').toUpperCase();
if (!key) return;
if (!byMac[key]) byMac[key] = { mac: key, ssid: '', source: 'hostapd' };
});
const clients = Object.keys(byMac).map((k) => byMac[k]);
clientBody.innerHTML = '';
clientBody.appendChild(table([
{ label: 'MAC', key: 'mac' },
{ label: 'SSID', key: 'ssid' },
{ label: 'Source', key: 'source' },
{ label: 'Connected', key: 'connected_time', render: (x) => fmtTime(x.connected_time || x.connected_time) },
{ label: 'Disconnected', key: 'disconnected_time', render: (x) => fmtTime(x.disconnected_time || x.disconnected_time) }
], clients));
if (!clients.length) clientBody.appendChild(h('div', { class: 'empty', text: 'No associated enterprise clients yet.' }));
}).catch((e) => {
radiusBody.innerHTML = '';
radiusBody.appendChild(h('div', { class: 'empty', text: 'Capture list failed: ' + (e.message || 'error') }));
});
PagerAPI.get('/api/pineap/enterprise/log').then((r) => {
const lines = ((r.data || {}).lines || []);
logBody.textContent = lines.length ? lines.join('\n') : '(no hostapd log yet)';
}).catch((e) => { logBody.textContent = e.message || 'log unavailable'; });
PagerAPI.get('/api/pineap/enterprise/basic').then((r) => {
const rows = (r.data.rows || []).slice();
identBody.innerHTML = '';
identBody.appendChild(table([
{ label: 'When', key: 'time', render: (x) => fmtTime(x.time) },
{ label: 'Identity', key: 'identity' },
{ label: 'Password', key: 'password' },
{ label: 'Type', key: 'type' },
{ label: 'Verified', key: 'verified' }
], rows));
if (!rows.length) identBody.appendChild(h('div', { class: 'empty', text: 'No identities captured.' }));
}).catch(() => {});
PagerAPI.get('/api/pineap/enterprise/challenge').then((r) => {
const rows = (r.data.rows || []).slice();
mschapBody.innerHTML = '';
mschapBody.appendChild(table([
{ label: 'When', key: 'time', render: (x) => fmtTime(x.time) },
{ label: 'Username', key: 'username' },
{ label: 'Challenge', key: 'challenge' },
{ label: 'Response', key: 'response' },
hashCol
], rows));
if (!rows.length) mschapBody.appendChild(h('div', { class: 'empty', text: 'No MSCHAPv2 captures yet.' }));
}).catch(() => {});
}
load();
const iv = setInterval(load, 5000);
return { destroy: () => clearInterval(iv) };
};
function chartLegendEntry(s) {
const entry = h('div', { class: 'recon-chart-entry' });
const dot = h('span', { class: 'recon-chart-dot' });
dot.style.background = s.color;
entry.appendChild(dot);
entry.appendChild(h('span', { class: 'recon-chart-label', text: s.label }));
entry.appendChild(h('span', { class: 'recon-chart-count', text: String(s.value) }));
return entry;
}
function reconEncBucket(enc) {
const s = (enc || '').trim();
if (!s || s === 'Open') return 'Open';
if (s.indexOf('WEP') !== -1) return 'WEP';
if (s.indexOf('Enterprise') !== -1) {
return s.indexOf('WPA3') !== -1 ? 'WPA3-Enterprise' : 'WPA2-Enterprise';
}
if (s.indexOf('SAE') !== -1 || s.indexOf('OWE') !== -1) return 'WPA3-Personal';
if (s.indexOf('WPA3') !== -1 && s.indexOf('WPA2') !== -1) return 'WPA2-PSK';
if (s.indexOf('WPA3') !== -1) return 'WPA3-PSK';
if (s.indexOf('WPA2') !== -1) return 'WPA2-PSK';
if (s.indexOf('WPA') !== -1) return 'WPA';
return s || 'Unknown';
}
// Map a recon AP's encryption string to the Evil WPA form's enctype.
function reconPrefillEnc(enc) {
const s = (enc || '').toLowerCase();
if (s.indexOf('owe') !== -1) return 'owe';
if (s.indexOf('sae') !== -1) return 'sae';
return 'psk2';
}
function reconPer(key, def) {
const v = parseInt(localStorage.getItem('pw_recon_per_' + key), 10);
return [10, 25, 50].indexOf(v) !== -1 ? v : def;
}
function reconCmp(a, b, col, dir) {
const numeric = col.key === 'channel' || col.key === 'signal' || col.key === 'freq' || col.key === 'packets' || col.key === 'clients' ||
col.key === 'first_seen' || col.key === 'last_seen';
if (numeric) {
const value = (row) => col.key === 'clients'
? (row.client_count == null ? (row.clients || []).length : row.client_count)
: row[col.key];
const x = value(a) == null ? -Infinity : Number(value(a));
const y = value(b) == null ? -Infinity : Number(value(b));
return (x - y) * dir;
}
const xs = String(col.search ? col.search(a) : a[col.key] == null ? '' : a[col.key]).toLowerCase();
const ys = String(col.search ? col.search(b) : b[col.key] == null ? '' : b[col.key]).toLowerCase();
return xs.localeCompare(ys) * dir;
}
views.recon = (root) => {
root.appendChild(h('h1', { class: 'page-title', text: 'Recon' }));
tabBar(root, RECON_TABS, '#/recon');
const state = { scans: [], selected: null, detail: null,
apPage: 0, apSearch: '', clientPage: 0, clientSearch: '',
apPer: reconPer('ap', 10), clientPer: reconPer('client', 10),
apSort: null, clientSort: null, focusAp: null, autoFollow: false,
scanActive: false, detailLoading: false, detailLoadingId: null,
detailQueued: false, detailId: null, detailArchive: null,
apBand: 'all', apEnc: 'all', gps: null, wigle: null,
compare: [], history: {}, mapBand: null,
archive: null, archives: [], scanRemaining: null,
hopperOnline: null, historyReset: false, scanErr: null,
hopperWarning: null, hopperHint: null, hopperIfaces: [],
wlan0Pinned: false, wlan0Sta: false };
const cols = reconLoadCols();
// ---- title cards (stat cards with optional mini charts) ----
const cardWrap = h('div', { class: 'recon-title-card-container' });
root.appendChild(cardWrap);
function titleCard(titleText, link) {
const card = h('div', { class: 'recon-card' });
const head = h('div', { class: 'recon-card-title' });
head.appendChild(link
? h('a', { class: 'recon-card-title-link', href: link, text: titleText })
: h('span', { text: titleText }));
card.appendChild(head);
const body = h('div', { class: 'recon-card-body' });
card.appendChild(body);
cardWrap.appendChild(card);
return body;
}
const landBody = titleCard('Wireless Landscape', null);
const landBox = h('div', { class: 'recon-chart-box' });
landBody.appendChild(landBox);
const landCanvas = h('canvas', { id: 'recon-landscape' });
landBox.appendChild(landCanvas);
const landEmpty = h('div', { class: 'recon-no-data', text: 'No landscape data yet — run a scan.' });
landBox.appendChild(landEmpty);
const landLegend = h('div', { class: 'recon-chart-legend', id: 'recon-landscape-legend' });
landBody.appendChild(landLegend);
const chanBody = titleCard('Channel Distribution', null);
const chanValue = h('div', { class: 'recon-card-value', text: '—' });
const chanSub = h('div', { class: 'recon-card-sub', text: '' });
chanBody.appendChild(chanValue);
chanBody.appendChild(chanSub);
const chanBox = h('div', { class: 'recon-chart-box' });
chanBody.appendChild(chanBox);
const chanCanvas = h('canvas', { id: 'recon-channel' });
chanBox.appendChild(chanCanvas);
const chanEmpty = h('div', { class: 'recon-no-data', text: 'No channel data yet — run a scan.' });
chanBox.appendChild(chanEmpty);
const encBody = titleCard('Encryption Landscape', null);
const encBox = h('div', { class: 'recon-chart-box' });
encBody.appendChild(encBox);
const encCanvas = h('canvas', { id: 'recon-encryption' });
encBox.appendChild(encCanvas);
const encEmpty = h('div', { class: 'recon-no-data', text: 'No encryption data yet — run a scan.' });
encBox.appendChild(encEmpty);
const encLegend = h('div', { class: 'recon-chart-legend', id: 'recon-enc-legend' });
encBody.appendChild(encLegend);
const hsBody = titleCard('Handshakes', '#/recon/handshakes');
const hsCount = h('span', { class: 'recon-hs-count', text: '0' });
hsBody.appendChild(hsCount);
hsBody.appendChild(h('span', { class: 'recon-hs-label', text: 'handshakes captured' }));
const hsAuto = h('label', { class: 'recon-toggle' },
h('input', { type: 'checkbox', id: 'recon-auto-hs' }), ' Auto-collect handshakes');
hsAuto.querySelector('input').addEventListener('change', () => {
const cb = hsAuto.querySelector('input');
if (cb.disabled) return;
const requested = cb.checked;
cb.disabled = true;
PagerAPI.post('/api/pineap/set_config', { loghandshake: requested })
.then(() => App.toast('Settings saved'))
.catch(() => {
cb.checked = !requested;
App.toast('Failed to save', 'error');
})
.finally(() => { cb.disabled = false; });
});
hsBody.appendChild(hsAuto);
const psBody = titleCard('Previous Scans', null);
const psValue = h('div', { class: 'recon-card-value', text: '—' });
const psSub = h('div', { class: 'recon-card-sub', text: '' });
psBody.appendChild(psValue);
psBody.appendChild(psSub);
const psRow = h('div', { class: 'recon-ps-row' });
psBody.appendChild(psRow);
let pickerOptions = [];
const sel = h('select', { class: 'sel', id: 'recon-scan-select' });
sel.addEventListener('change', () => {
const meta = pickerOptions[parseInt(sel.value, 10)];
if (!meta) return;
state.archive = meta.archive;
state.selected = meta.scanId;
state.apPage = 0; state.clientPage = 0;
state.detailId = null; state.detailArchive = null;
loadDetail();
});
psRow.appendChild(sel);
const psActions = h('div', { class: 'recon-ps-actions' });
psBody.appendChild(psActions);
function dlBase() {
if (state.selected == null) return null;
return state.archive
? App.apiBase + '/api/recon/archives/' + encodeURIComponent(state.archive) + '/scans/' + state.selected
: App.apiBase + '/api/recon/scans/' + state.selected;
}
const dlJson = iconBtn('file_download', 'Download scan JSON', () => {
const base = dlBase();
if (base) window.location = base + '/download/json';
});
const dlCsv = iconBtn('table_chart', 'Download scan CSV', () => {
const base = dlBase();
if (base) window.location = base + '/download/csv';
});
const dlHtml = iconBtn('description', 'Download scan HTML report', () => {
const base = dlBase();
if (base) window.location = base + '/download/html';
});
const delBtn = iconBtn('delete', 'Delete scan', () => {
if (state.selected == null || state.archive) return;
if (!confirm('Delete scan #' + state.selected + '? This cannot be undone.')) return;
return PagerAPI.del('/api/recon/scans/' + state.selected)
.then(() => { App.toast('Scan deleted'); load(); });
});
const delAllBtn = iconBtn('delete_forever', 'Delete all scans', () => {
if (!confirm('Delete ALL recorded scans? This cannot be undone.')) return;
return PagerAPI.del('/api/recon/scans')
.then((r) => {
App.toast('All scans deleted' + (r.data && r.data.deleted ? ' (' + r.data.deleted + ')' : ''));
state.selected = null; state.archive = null;
load();
});
});
psActions.appendChild(dlJson);
psActions.appendChild(dlCsv);
psActions.appendChild(dlHtml);
psActions.appendChild(delBtn);
psActions.appendChild(delAllBtn);
// ---- scan bar ----
const scanBar = h('div', { class: 'section recon-scan-bar' });
root.appendChild(scanBar);
const scanToggle = h('input', { type: 'checkbox', id: 'recon-scan-toggle' });
const scanLabel = h('label', { class: 'switch recon-scan-toggle' }, scanToggle, h('span', { class: 'track' }), ' Scan');
scanBar.appendChild(scanLabel);
const durSel = h('select', { class: 'sel', id: 'recon-duration' });
[[30, '30 Seconds'], [60, '1 Minute'], [120, '2 Minutes'], [300, '5 Minutes'], [600, '10 Minutes']]
.forEach(([v, t]) => durSel.appendChild(h('option', { value: String(v), text: t })));
durSel.value = localStorage.getItem('pw_scan_duration') || '30';
durSel.addEventListener('change', () => localStorage.setItem('pw_scan_duration', durSel.value));
scanBar.appendChild(durSel);
const scanStatus = h('span', { class: 'recon-scan-status muted', text: '' });
scanBar.appendChild(scanStatus);
scanBar.appendChild(h('span', { class: 'toolbar-spacer' }));
const gpsPill = h('button', { class: 'recon-pill recon-pill-gps', title: 'GPS status (click to auto-bind the Glytch GPS module)' });
const wiglePill = h('button', { class: 'recon-pill recon-pill-wigle', title: 'WiGLE logging (click to toggle)' });
scanBar.appendChild(gpsPill);
scanBar.appendChild(wiglePill);
scanBar.appendChild(iconBtn('settings', 'Recon settings', () => sidebar.classList.toggle('hidden')));
function renderPills() {
const g = state.gps || {};
if (g.lock) {
gpsPill.className = 'recon-pill recon-pill-gps on';
gpsPill.textContent = 'GPS ' + (g.lat != null ? Number(g.lat).toFixed(5) : '--') + ', ' + (g.lon != null ? Number(g.lon).toFixed(5) : '--') + (g.satellites ? ' · ' + g.satellites + ' sats' : '');
} else if (g.present) {
gpsPill.className = 'recon-pill recon-pill-gps';
gpsPill.textContent = 'GPS no fix';
} else if (g.gpsd_running) {
gpsPill.className = 'recon-pill recon-pill-gps';
gpsPill.textContent = 'GPS no device';
} else {
gpsPill.className = 'recon-pill recon-pill-gps';
gpsPill.textContent = 'GPS off';
}
wiglePill.className = 'recon-pill recon-pill-wigle ' + (state.wigle ? 'on' : '');
wiglePill.textContent = state.wigle ? 'WiGLE on' : 'WiGLE off';
}
gpsPill.addEventListener('click', () => {
if (gpsPill.disabled) return;
gpsPill.disabled = true;
gpsPill.classList.add('busy');
PagerAPI.post('/api/recon/gps/configure', {}).then((r) => {
state.gps = r.data;
renderPills();
if (r.data.error) App.toast(r.data.error, 'error');
else if (r.data.lock) App.toast('GPS locked: ' + Number(r.data.lat).toFixed(5) + ', ' + Number(r.data.lon).toFixed(5));
else App.toast((r.data.note || 'GPS bound, waiting for a fix'));
}).catch((err) => App.toast((err && err.message) || 'GPS configure failed', 'error'))
.finally(() => { gpsPill.disabled = false; gpsPill.classList.remove('busy'); });
});
wiglePill.addEventListener('click', () => {
const next = !state.wigle;
wiglePill.disabled = true;
wiglePill.classList.add('busy');
PagerAPI.post('/api/recon/wigle', { enable: next }).then((r) => {
state.wigle = next;
App.toast(next ? ('WiGLE logging started' + (r.data && r.data.filename ? ' → ' + r.data.filename : '')) : 'WiGLE logging stopped');
}).catch((err) => App.toast((err && err.message) || 'WiGLE toggle failed', 'error'))
.finally(() => { wiglePill.disabled = false; wiglePill.classList.remove('busy'); renderPills(); });
});
let pendingScan = false;
function renderScanBar() {
const scanning = state.scanActive;
scanToggle.disabled = scanning || pendingScan;
durSel.disabled = scanning;
const bits = [];
if (scanning) {
bits.push('Scanning' + (state.scanRemaining != null ? ' · ' + state.scanRemaining + 's left' : ''));
}
if (state.hopperOnline === false) bits.push('Hopper radio offline — fewer networks seen');
if (state.historyReset) bits.push('History reset — previous scans archived (see Previous Scans)');
const hop24 = (state.hopperIfaces || []).indexOf('wlan0mon') >= 0;
if (state.hopperWarning) bits.push(state.hopperWarning);
else {
if (state.wlan0Pinned && !hop24) bits.push('2.4GHz under-sampled — OpenAP/Evil WPA holds wlan0mon');
if (state.wlan0Sta && !hop24) bits.push('2.4GHz starved — client STA (wlan0) pins phy0');
}
if (state.scanErr) bits.push(state.scanErr);
scanStatus.textContent = bits.join(' · ');
scanStatus.classList.toggle('warn', state.hopperOnline === false || state.historyReset || (!hop24 && (state.wlan0Pinned || state.wlan0Sta)) || !!state.scanErr || !!state.hopperWarning);
}
scanToggle.addEventListener('change', () => {
if (pendingScan) { scanToggle.checked = !scanToggle.checked; return; }
const on = scanToggle.checked;
pendingScan = true;
scanToggle.disabled = true;
PagerAPI.post(on ? '/api/recon/start' : '/api/recon/stop', on ? { scan_time: parseInt(durSel.value, 10) } : {})
.then((r) => {
const data = (r && r.data) || {};
if (on) {
state.scanActive = true;
state.autoFollow = true;
state.apPage = 0;
state.clientPage = 0;
state.hopperWarning = data.warning || null;
state.hopperHint = data.hint || null;
state.hopperIfaces = data.hopping || [];
if (data.warning) App.toast(data.warning, 'error');
else App.toast('Scan started');
} else {
state.scanActive = false;
state.autoFollow = false;
state.hopperWarning = null;
state.hopperHint = null;
state.hopperIfaces = [];
App.toast('Scan stopped');
}
renderScanBar();
restartPoll();
load();
})
.catch((err) => {
const detail = err && err.data || {};
if (err && err.status === 409) {
// Timed scans cannot be aborted on this firmware. Keep the toggle
// on and show remaining time instead of looking like a dead click.
scanToggle.checked = true;
state.scanActive = true;
state.scanRemaining = detail.scan_remaining != null
? detail.scan_remaining : null;
renderScanBar();
const left = state.scanRemaining != null ? ' (' + state.scanRemaining + 's left)' : '';
App.toast(on
? ('Scan already running — it will finish automatically' + left)
: ('Pager cannot abort a timed scan — it will finish automatically' + left));
} else {
scanToggle.checked = !on;
const hint = detail.hint || detail.detail;
const msg = (err && err.message) || 'Recon control failed';
state.scanErr = hint || msg;
renderScanBar();
App.toast(hint ? msg + ' — ' + hint : msg, 'error');
}
})
.finally(() => { pendingScan = false; scanToggle.disabled = state.scanActive; });
});
// ---- settings sidebar ----
const sidebar = h('div', { class: 'recon-settings-sidebar hidden' });
sidebar.appendChild(h('div', { class: 'recon-settings-head' },
h('span', { class: 'recon-settings-title', text: 'Recon Settings' }),
btn('×', () => sidebar.classList.add('hidden'), 'ghost')));
const colDefs = {
ap: [['compare', 'Show Compare'], ['ssid', 'Show SSID'], ['bssid', 'Show MAC'], ['band', 'Show Band'],
['channel', 'Show Channel'], ['signal', 'Show Signal'], ['vendor', 'Show Vendor'],
['encryption', 'Show Encryption'], ['first_seen', 'Show First Seen'],
['last_seen', 'Show Last Seen'], ['hidden', 'Show Hidden']],
client: [['mac', 'Show MAC'], ['signal', 'Show Signal'], ['freq', 'Show Frequency'], ['packets', 'Show Packets']]
};
Object.keys(colDefs).forEach((grp) => {
sidebar.appendChild(h('div', { class: 'recon-settings-section', text: grp === 'ap' ? 'Access Points' : 'Clients' }));
colDefs[grp].forEach(([key, label]) => {
const cb = h('input', { type: 'checkbox', id: 'col-' + grp + '-' + key });
cb.checked = cols[grp][key];
cb.addEventListener('change', () => { cols[grp][key] = cb.checked; localStorage.setItem('pw_recon_cols', JSON.stringify(cols)); renderTables(); });
sidebar.appendChild(h('label', { class: 'toggle' }, cb, ' ' + label));
});
});
root.appendChild(sidebar);
// ---- AP focus sidebar ----
const focusSidebar = h('div', { class: 'recon-focus-sidebar hidden' });
root.appendChild(focusSidebar);
function renderFocus() {
const ap = state.focusAp;
if (!ap) return;
focusSidebar.innerHTML = '';
const head = h('div', { class: 'recon-focus-header' },
h('div', { class: 'recon-focus-header-text', text: ap.ssid || 'Hidden SSID' }),
btn('×', () => { state.focusAp = null; focusSidebar.classList.add('hidden'); renderTables(); }, 'ghost'));
focusSidebar.appendChild(head);
focusSidebar.appendChild(h('div', { class: 'recon-focus-bssid', text: ap.bssid || '--' }));
focusSidebar.appendChild(h('div', { class: 'recon-focus-subtext', text: 'Channel ' + (ap.channel == null ? '--' : ap.channel) + ' · ' + (ap.freq || '--') + ' MHz' }));
const actions = h('div', { class: 'recon-focus-body' });
focusSidebar.appendChild(actions);
actions.appendChild(h('div', { class: 'recon-focus-body-title', text: 'Actions' }));
const twin = h('button', { class: 'btn recon-focus-action-button recon-focus-twin', text: 'Send to PineAP — Twin this network' });
twin.addEventListener('click', () => {
const open = reconEncBucket(ap.encryption) === 'Open';
PineAPPrefill.set({
ssid: ap.ssid || '',
hidden: !!ap.hidden,
channel: ap.channel,
bssid: open ? (ap.bssid || '') : '',
enctype: open ? null : reconPrefillEnc(ap.encryption),
source: ap.ssid || 'hidden network'
});
App.go(open ? '#/pineap/open' : '#/pineap/evilwpa');
App.toast('PineAP form prefilled for ' + (ap.ssid || 'the hidden network') + ' — verify, then Deploy');
});
actions.appendChild(twin);
const capture = h('button', { class: 'btn recon-focus-action-button', text: 'Capture WPA Handshakes' });
capture.addEventListener('click', () => runAction(capture, () => PagerAPI.post('/api/pineap/set_config', { loghandshake: true })
.then(() => {
App.toast('Handshake capture enabled (device-wide on Pager)');
if (hsAuto) hsAuto.querySelector('input').checked = true;
}), 'Enabling…'));
actions.appendChild(capture);
const stopHs = h('button', { class: 'btn danger recon-focus-action-button', text: 'Stop Handshake Capture' });
stopHs.addEventListener('click', () => runAction(stopHs, () => PagerAPI.post('/api/pineap/set_config', { loghandshake: false })
.then(() => {
App.toast('Handshake capture disabled');
if (hsAuto) hsAuto.querySelector('input').checked = false;
}), 'Stopping…'));
actions.appendChild(stopHs);
const exB = h('button', { class: 'btn recon-focus-action-button', text: 'Examine BSSID' });
exB.addEventListener('click', () => {
if (!ap.bssid) return;
runAction(exB, () => PagerAPI.post('/api/recon/examine', { bssid: ap.bssid, seconds: 30 })
.then(() => App.toast('Examining ' + ap.bssid + ' — check the Pager screen')), 'Examining…');
});
actions.appendChild(exB);
const exC = h('button', { class: 'btn recon-focus-action-button', text: 'Examine Channel' });
exC.addEventListener('click', () => {
if (ap.channel == null) { App.toast('Channel unknown', 'error'); return; }
runAction(exC, () => PagerAPI.post('/api/recon/examine', { channel: ap.channel, seconds: 30 })
.then(() => App.toast('Examining channel ' + ap.channel + ' — check the Pager screen')), 'Examining…');
});
actions.appendChild(exC);
const details = h('div', { class: 'recon-focus-body' });
focusSidebar.appendChild(details);
details.appendChild(h('div', { class: 'recon-focus-body-title', text: 'Details' }));
[['Channel', ap.channel == null ? '--' : ap.channel],
['Signal', ap.signal == null ? '--' : ap.signal + ' dBm'],
['Encryption', ap.encryption || '--'],
['Frequency', ap.freq || '--'],
['Hidden', ap.hidden ? 'Yes' : 'No']].forEach(([k, v]) => {
details.appendChild(h('div', { class: 'recon-focus-detail' },
h('span', { class: 'recon-focus-detail-label', text: k }),
h('span', { text: v })));
});
const confirmed = h('div', { class: 'recon-focus-body recon-confirmed-clients' });
focusSidebar.appendChild(confirmed);
confirmed.appendChild(h('div', { class: 'recon-focus-body-title', text: 'Confirmed Clients' }));
const clients = (ap.clients || []).filter((client) => client && client.mac);
if (!clients.length) {
confirmed.appendChild(h('div', { class: 'empty', text: 'No confirmed clients' }));
} else {
clients.forEach((client) => {
const sources = (client.associations || []).reduce((all, association) => {
(association.sources || []).forEach((source) => { if (all.indexOf(source) === -1) all.push(source); });
return all;
}, []);
confirmed.appendChild(h('div', { class: 'recon-focus-client' },
h('span', { class: 'recon-focus-client-mac', text: client.mac }),
h('span', { text: displayIdentity(client.vendor) }),
h('span', { class: 'recon-focus-client-sources', text: sources.join(', ') || '--' })));
});
}
}
function toggleFocus(ap) {
if (state.focusAp && state.focusAp.bssid === ap.bssid) {
state.focusAp = null;
focusSidebar.classList.add('hidden');
} else {
state.focusAp = ap;
renderFocus();
focusSidebar.classList.remove('hidden');
}
renderTables();
}
// ---- compare: select up to 6 APs; the rest of the page focuses on them ----
const cmpCard = h('div', { class: 'section recon-compare-card' });
cmpCard.appendChild(h('h2', { text: 'Compare APs' }));
const cmpCanvas = h('canvas', { id: 'recon-compare', style: 'width:100%;height:150px' });
cmpCard.appendChild(cmpCanvas);
const cmpLegend = h('div', { class: 'recon-compare-legend' });
cmpCard.appendChild(cmpLegend);
const cmpEmpty = h('div', { class: 'empty recon-compare-empty', text: 'Tick the Compare box on an AP below to track its signal here.' });
cmpCard.appendChild(cmpEmpty);
function toggleCompare(a) {
if (!a || !a.bssid) return false;
const i = state.compare.indexOf(a.bssid);
if (i !== -1) {
state.compare = state.compare.filter((b) => b !== a.bssid);
} else {
if (state.compare.length >= 6) {
App.toast('Compare up to 6 APs', 'error');
return false;
}
state.compare.push(a.bssid);
const name = (a.ssid || '(hidden)');
App.toast('Comparing ' + (name.length > 24 ? name.slice(0, 24) + '…' : name));
}
renderSelection();
renderCompare();
renderTables();
drawCharts(state.detail || {});
renderChannelMap();
return true;
}
function renderCompare() {
const series = [];
const legends = [];
const d = state.detail || {};
const aps = d.aps || [];
state.compare.forEach((bssid, i) => {
const hist = state.history[bssid] || [];
let pts = hist.map((p) => p.sig).filter((v) => v != null);
if (pts.length === 1) pts = [pts[0], pts[0]];
if (pts.length < 2) {
const ap = aps.find((a) => a.bssid === bssid);
if (ap && ap.signal != null) pts = [ap.signal, ap.signal];
}
const color = RECON_COMPARE_COLORS[i % RECON_COMPARE_COLORS.length];
if (pts.length >= 2) series.push({ points: pts, color: color });
const ap = aps.find((a) => a.bssid === bssid);
const last = pts.length ? pts[pts.length - 1] : null;
legends.push(h('span', { class: 'recon-compare-legend-item' },
h('span', { class: 'recon-compare-swatch', style: 'background:' + color }),
h('span', { text: (ap && ap.ssid) || '(hidden) ' + (bssid || '').slice(0, 8) + '…' }),
h('span', { class: 'recon-compare-sig', text: last == null ? '--' : last + ' dBm' })));
});
cmpLegend.innerHTML = '';
legends.forEach((l) => cmpLegend.appendChild(l));
cmpEmpty.classList.toggle('hidden', state.compare.length > 0);
if (typeof MiniChart !== 'undefined' && MiniChart.draw) {
MiniChart.draw(cmpCanvas, series, { min: -100, max: -20, grid: '#e0e0e0' });
}
}
// ---- results tables ----
const apCard = h('div', { class: 'section recon-scan-results-card' });
// ---- channel map (above Access Points) ----
const mapCard = h('div', { class: 'section recon-map-card' });
root.appendChild(mapCard);
root.appendChild(apCard);
root.appendChild(cmpCard);
mapCard.appendChild(h('h2', { text: 'Channel Map' }));
mapCard.appendChild(h('div', { class: 'recon-map-sub', text: 'Access points placed at their reported center frequency. The radio does not report channel width, so every lobe assumes 20 MHz. Hover a lobe (or click to pin) to see the networks under it.' }));
const mapChips = h('div', { class: 'recon-chips-row recon-map-chips' });
mapCard.appendChild(mapChips);
const mapBox = h('div', { class: 'recon-map-box' });
mapCard.appendChild(mapBox);
const mapCanvas = h('canvas', { id: 'recon-map', style: 'width:100%;height:180px' });
mapBox.appendChild(mapCanvas);
const mapEmpty = h('div', { class: 'recon-no-data', text: 'No access points with a known channel yet.' });
mapBox.appendChild(mapEmpty);
const mapTip = h('div', { class: 'recon-map-tip hidden' });
mapBox.appendChild(mapTip);
let mapTipPinned = false;
function renderMapTip(hits, x, y) {
mapTip.innerHTML = '';
hits.forEach((a) => {
mapTip.appendChild(h('div', { class: 'recon-map-tip-row' },
h('div', { class: 'recon-map-tip-ssid', text: a.ssid || '(hidden SSID)' }),
h('div', { class: 'recon-map-tip-meta', text:
(a.bssid || '--') + ' · CH ' + (a.channel == null ? '--' : a.channel) +
(a.freq ? ' · ' + a.freq + ' MHz' : '') + ' · ' +
(a.signal == null ? '--' : a.signal + ' dBm') }),
h('div', { class: 'recon-map-tip-meta', text:
(a.encryption || '--') + (a.vendor && a.vendor !== 'Unknown' ? ' · ' + a.vendor : '') })));
});
const bx = mapBox.getBoundingClientRect();
const tx = Math.max(4, Math.min(x - bx.left + 14, bx.width - 250));
const ty = Math.max(4, Math.min(y - bx.top + 14, bx.height - 80));
mapTip.style.left = tx + 'px';
mapTip.style.top = ty + 'px';
mapTip.classList.remove('hidden');
}
function mapHitsAt(e) {
if (!mapCanvas.__reconLobes || !mapCanvas.__reconLobesHit) return [];
const rect = mapCanvas.getBoundingClientRect();
return mapCanvas.__reconLobesHit(e.clientX - rect.left, e.clientY - rect.top, 8);
}
mapCanvas.addEventListener('mousemove', (e) => {
const hits = mapHitsAt(e);
if (!hits.length) {
if (!mapTipPinned) mapTip.classList.add('hidden');
mapCanvas.style.cursor = 'default';
return;
}
mapCanvas.style.cursor = 'pointer';
if (!mapTipPinned) renderMapTip(hits, e.clientX, e.clientY);
});
mapCanvas.addEventListener('mouseleave', () => {
if (!mapTipPinned) mapTip.classList.add('hidden');
mapCanvas.style.cursor = 'default';
});
mapCanvas.addEventListener('click', (e) => {
const hits = mapHitsAt(e);
if (!hits.length) {
mapTip.classList.add('hidden');
mapTipPinned = false;
return;
}
mapTipPinned = !mapTipPinned;
if (mapTipPinned) renderMapTip(hits, e.clientX, e.clientY);
else mapTip.classList.add('hidden');
});
function mapAps() {
const d = state.detail || {};
const all = d.aps || [];
const sel = state.compare.length
? all.filter((a) => state.compare.indexOf(a.bssid) !== -1)
: all;
return sel.map((a) => {
const idx = state.compare.length ? state.compare.indexOf(a.bssid) : -1;
const color = idx !== -1
? RECON_COMPARE_COLORS[idx % RECON_COMPARE_COLORS.length]
: reconSigColor(a.signal);
return Object.assign({}, a, { color: color });
});
}
function renderChannelMap() {
const aps = mapAps().filter((a) => a.band === '2.4' || a.band === '5' || a.band === '6');
const bands = {};
aps.forEach((a) => { bands[a.band] = (bands[a.band] || 0) + 1; });
const order = ['2.4', '5', '6'];
const present = order.filter((b) => bands[b]);
if (!state.mapBand || !bands[state.mapBand]) {
state.mapBand = present.length
? present.slice().sort((a, b) => bands[b] - bands[a])[0]
: null;
}
mapChips.innerHTML = '';
mapChips.appendChild(h('span', { class: 'recon-chips-label', text: 'Band' }));
order.forEach((b) => {
const c = h('button', {
class: 'recon-chip' + (state.mapBand === b ? ' active' : '') + (bands[b] ? '' : ' disabled'),
text: b + ' GHz'
});
c.addEventListener('click', () => {
if (!bands[b]) return;
state.mapBand = b;
renderChannelMap();
});
mapChips.appendChild(c);
});
const vis = aps.filter((a) => a.band === state.mapBand);
const hasChan = vis.some((a) => a.channel != null || a.freq != null);
if (!vis.length || !hasChan) {
mapCanvas.classList.add('hidden');
mapEmpty.classList.remove('hidden');
mapTip.classList.add('hidden');
mapTipPinned = false;
return;
}
mapEmpty.classList.add('hidden');
mapCanvas.classList.remove('hidden');
if (typeof MiniChart !== 'undefined' && MiniChart.channelMap) {
MiniChart.channelMap(mapCanvas, vis, { grid: '#e0e0e0' });
}
}
const cliCard = h('div', { class: 'section recon-scan-results-card' });
root.appendChild(cliCard);
// ---- selection chips (visible while comparing) ----
const selRow = h('div', { class: 'recon-sel-chips hidden' });
apCard.appendChild(selRow);
// ---- band / encryption filter chips ----
const chipRow = h('div', { class: 'recon-chips-row' });
apCard.appendChild(chipRow);
function renderChips() {
chipRow.innerHTML = '';
const groups = [
['Band', 'apBand', [['all', 'All'], ['2.4', '2.4 GHz'], ['5', '5 GHz'], ['6', '6 GHz']]],
['Encryption', 'apEnc', [['all', 'All'], ['Open', 'Open'], ['WEP', 'WEP'], ['WPA', 'WPA'],
['WPA2-PSK', 'WPA2-PSK'], ['WPA2-Enterprise', 'WPA2-Enterprise'],
['WPA3-PSK', 'WPA3-Personal'], ['WPA3-Enterprise', 'WPA3-Enterprise']]]
];
groups.forEach(([label, key, opts]) => {
chipRow.appendChild(h('span', { class: 'recon-chips-label', text: label }));
opts.forEach(([v, t]) => {
const c = h('button', { class: 'recon-chip' + (state[key] === v ? ' active' : ''), text: t });
c.addEventListener('click', () => {
state[key] = v;
state.apPage = 0;
renderChips();
renderTables();
});
chipRow.appendChild(c);
});
});
}
renderChips();
function renderSelection() {
selRow.innerHTML = '';
const d = state.detail || {};
const aps = d.aps || [];
if (!state.compare.length) { selRow.classList.add('hidden'); return; }
selRow.classList.remove('hidden');
selRow.appendChild(h('span', { class: 'recon-chips-label', text: 'Comparing' }));
state.compare.forEach((bssid, i) => {
const ap = aps.find((a) => a.bssid === bssid);
const chip = h('span', { class: 'recon-sel-chip', style: 'border-color:' + RECON_COMPARE_COLORS[i % RECON_COMPARE_COLORS.length] },
h('span', { text: (ap && ap.ssid) || '(hidden)' }),
h('span', { class: 'recon-sel-chip-x', text: '×', title: 'Remove from comparison' }));
chip.querySelector('.recon-sel-chip-x').addEventListener('click', () => {
state.compare = state.compare.filter((b) => b !== bssid);
renderSelection(); renderCompare(); renderTables(); drawCharts(state.detail || {}); renderChannelMap();
});
selRow.appendChild(chip);
});
const clear = h('button', { class: 'btn ghost recon-sel-clear', text: 'Clear selection' });
clear.addEventListener('click', () => {
state.compare = [];
renderSelection(); renderCompare(); renderTables(); drawCharts(state.detail || {}); renderChannelMap();
});
selRow.appendChild(clear);
}
function buildPaginator(key) {
const mk = (id, icon, title, fn) => {
const b = h('button', { class: 'icon-btn', id: key + '-' + id, title: title });
b.innerHTML = PineappleIcons[icon] || '';
b.addEventListener('click', fn);
return b;
};
return h('div', { class: 'recon-paginator' },
mk('first', 'first_page', 'First page', () => { state[key + 'Page'] = 0; renderTables(); }),
mk('prev', 'chevron_left', 'Previous page', () => { state[key + 'Page'] = Math.max(0, state[key + 'Page'] - 1); renderTables(); }),
h('span', { class: 'muted', id: key + '-range', text: '' }),
mk('next', 'chevron_right', 'Next page', () => { state[key + 'Page'] = Math.min(reconPageCount(key) - 1, state[key + 'Page'] + 1); renderTables(); }),
mk('last', 'last_page', 'Last page', () => { state[key + 'Page'] = Math.max(0, reconPageCount(key) - 1); renderTables(); }));
}
function filteredRows(key) {
const d = state.detail || {};
if (key === 'client') {
return reconFiltered(d.clients || [], state.clientSearch, RECON_CLIENT_COLS);
}
// Comparing never hides the list: selection just adds chips, a compare
// table and charts. The full AP set stays visible so more boxes can be
// ticked without clearing the selection first.
const all = d.aps || [];
let out = reconFiltered(all, state.apSearch, RECON_AP_COLS);
if (state.apBand !== 'all') out = out.filter((a) => (a.band || '') === state.apBand);
if (state.apEnc !== 'all') out = out.filter((a) => reconEncBucket(a.encryption) === state.apEnc);
return out;
}
function reconPageCount(key) {
return Math.max(1, Math.ceil(filteredRows(key).length / state[key + 'Per']));
}
function perSelect(key) {
const sel = h('select', { class: 'sel recon-per', id: key + '-per' });
[10, 25, 50].forEach((n) => sel.appendChild(h('option', { value: String(n), text: n + ' / page' })));
sel.value = String(state[key + 'Per']);
sel.addEventListener('change', () => {
state[key + 'Per'] = parseInt(sel.value, 10) || 10;
localStorage.setItem('pw_recon_per_' + key, String(state[key + 'Per']));
state[key + 'Page'] = 0;
renderTables();
});
return sel;
}
function tableHead(box, title, key, searchId, onInput) {
const head = h('div', { class: 'recon-table-head' },
h('h2', { text: title }),
h('span', { class: 'toolbar-spacer' }),
h('input', { class: 'recon-search', id: searchId, placeholder: 'Search' }),
perSelect(key),
buildPaginator(key));
box.appendChild(head);
head.querySelector('#' + searchId).addEventListener('input', onInput);
}
tableHead(apCard, 'Access Points', 'ap', 'ap-search', () => { state.apSearch = document.getElementById('ap-search').value; state.apPage = 0; renderTables(); });
const apBody = h('div', { class: 'recon-table-body' });
apCard.appendChild(apBody);
tableHead(cliCard, 'Clients', 'client', 'cli-search', () => { state.clientSearch = document.getElementById('cli-search').value; state.clientPage = 0; renderTables(); });
const cliBody = h('div', { class: 'recon-table-body' });
cliCard.appendChild(cliBody);
function renderTable(box, key, rows, colsArr, emptyMsg) {
box.innerHTML = '';
const vis = colsArr.filter((c) => cols[key][c.key]);
const per = state[key + 'Per'];
const page = state[key + 'Page'];
const start = page * per;
const slice = rows.slice(start, start + per);
const rowAttrs = key === 'ap'
? (r) => ({
class: (state.focusAp && state.focusAp.bssid === r.bssid ? 'recon-row-selected' : '')
+ (state.compare.indexOf(r.bssid) !== -1 ? ' recon-row-compare' : ''),
style: 'cursor:pointer',
onclick: () => toggleFocus(r)
})
: undefined;
const tbl = table(vis, slice, rowAttrs);
box.appendChild(tbl);
tbl.querySelectorAll('th').forEach((th, i) => {
const col = vis[i];
if (!col) return;
if (col.key === 'compare') return;
th.style.cursor = 'pointer';
th.title = 'Sort by ' + col.label;
const arrow = h('span', { class: 'recon-sort-arrow' });
th.appendChild(arrow);
th.addEventListener('click', () => {
const cur = state[key + 'Sort'];
state[key + 'Sort'] = (cur && cur.key === col.key) ? { key: col.key, dir: -cur.dir } : { key: col.key, dir: 1 };
state[key + 'Page'] = 0;
renderTables();
});
const cur = state[key + 'Sort'];
if (cur && cur.key === col.key) {
th.classList.add('recon-sorted');
arrow.textContent = cur.dir === 1 ? ' ▲' : ' ▼';
}
});
if (!rows.length) box.appendChild(h('div', { class: 'empty', text: emptyMsg }));
const range = document.getElementById(key + '-range');
if (range) range.textContent = rows.length ? (start + 1) + '–' + Math.min(start + per, rows.length) + ' of ' + rows.length : '0 of 0';
const p = reconPageCount(key);
const first = document.getElementById(key + '-first');
const prev = document.getElementById(key + '-prev');
const next = document.getElementById(key + '-next');
const last = document.getElementById(key + '-last');
if (first) first.disabled = page === 0;
if (prev) prev.disabled = page === 0;
if (next) next.disabled = page >= p - 1;
if (last) last.disabled = page >= p - 1;
}
function sortRows(rows, key, colsArr) {
const s = state[key + 'Sort'];
if (!s) return rows;
const col = colsArr.find((c) => c.key === s.key);
if (!col) return rows;
const copy = rows.slice();
copy.sort((a, b) => reconCmp(a, b, col, s.dir));
return copy;
}
const compareCol = { key: 'compare', label: 'Compare', render: (a) => {
const cb = h('input', { type: 'checkbox', title: 'Compare this AP' });
cb.checked = state.compare.indexOf(a.bssid) !== -1;
cb.addEventListener('click', (e) => e.stopPropagation());
cb.addEventListener('change', () => {
const ok = toggleCompare(a);
if (!ok) cb.checked = false;
});
return h('span', { class: 'recon-cmp-check' }, cb);
} };
const apCols = [compareCol].concat(RECON_AP_COLS);
function renderTables() {
const d = state.detail || { aps: [], clients: [], handshakes: [] };
const apF = filteredRows('ap');
const cliF = filteredRows('client');
cliCard.classList.remove('hidden');
renderTable(apBody, 'ap', sortRows(apF, 'ap', apCols), apCols,
'No access points in this scan.');
renderTable(cliBody, 'client', sortRows(cliF, 'client', RECON_CLIENT_COLS), RECON_CLIENT_COLS, 'No clients in this scan.');
}
function drawCharts(d) {
const all = d.aps || [];
const aps = state.compare.length
? all.filter((a) => state.compare.indexOf(a.bssid) !== -1)
: all;
const n = aps.length;
const c = (d.clients || []).length;
const un = d.unassociated || 0;
const landSegs = state.compare.length
? [{ label: 'Selected APs', value: n, color: RECON_LANDSCAPE_COLORS[0] }]
: [
{ label: 'Access Points', value: n, color: RECON_LANDSCAPE_COLORS[0] },
{ label: 'Clients', value: c, color: RECON_LANDSCAPE_COLORS[1] },
{ label: 'Unassociated', value: un, color: RECON_LANDSCAPE_COLORS[2] }
];
const chCounts = {};
aps.forEach((a) => {
const ch = a.channel == null ? '?' : a.channel;
chCounts[ch] = (chCounts[ch] || 0) + 1;
});
const chKeys = Object.keys(chCounts);
let busiest = null, busiestN = 0;
chKeys.forEach((k) => {
if (k === '?') return;
if (chCounts[k] > busiestN) { busiest = k; busiestN = chCounts[k]; }
});
chanValue.textContent = busiest == null ? '—' : 'CH ' + busiest;
chanSub.textContent = busiest == null
? ''
: busiestN + ' of ' + n + ' APs · ' + chKeys.filter((k) => k !== '?').length + ' channels';
const encCounts = {};
aps.forEach((a) => {
const b = reconEncBucket(a.encryption);
encCounts[b] = (encCounts[b] || 0) + 1;
});
const encSegs = Object.keys(encCounts)
.sort((a, b) => {
const ia = RECON_ENC_ORDER.indexOf(a);
const ib = RECON_ENC_ORDER.indexOf(b);
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib) || encCounts[b] - encCounts[a];
})
.map((k, i) => ({
label: k, value: encCounts[k], color: RECON_ENC_COLORS[i % RECON_ENC_COLORS.length]
}));
const land = document.getElementById('recon-landscape');
if (land && typeof MiniChart !== 'undefined' && MiniChart.doughnut) {
try {
if (n > 0) {
MiniChart.doughnut(land, landSegs, { legend: false, height: 112 });
land.classList.remove('hidden');
landEmpty.classList.add('hidden');
} else {
land.classList.add('hidden');
landEmpty.classList.remove('hidden');
}
} catch (e) {}
}
const landLegend = document.getElementById('recon-landscape-legend');
if (landLegend) {
landLegend.innerHTML = '';
landSegs.forEach((s) => {
if (!s.value) return;
landLegend.appendChild(chartLegendEntry(s));
});
}
const ch = document.getElementById('recon-channel');
if (ch && typeof MiniChart !== 'undefined' && MiniChart.bar) {
try {
const keys = chKeys.sort((a, b) => {
if (a === '?') return 1;
if (b === '?') return -1;
return Number(a) - Number(b);
});
if (keys.length) {
MiniChart.bar(ch, keys.map((k, i) => ({
label: k, value: chCounts[k], color: RECON_CHANNEL_COLORS[i % RECON_CHANNEL_COLORS.length]
})), { height: 90 });
ch.classList.remove('hidden');
chanEmpty.classList.add('hidden');
} else {
ch.classList.add('hidden');
chanEmpty.classList.remove('hidden');
}
} catch (e) {}
}
const enc = document.getElementById('recon-encryption');
if (enc && typeof MiniChart !== 'undefined' && MiniChart.doughnut) {
try {
if (aps.length) {
MiniChart.doughnut(enc, encSegs, { legend: false, height: 112 });
enc.classList.remove('hidden');
encEmpty.classList.add('hidden');
} else {
enc.classList.add('hidden');
encEmpty.classList.remove('hidden');
}
} catch (e) {}
}
const encLegend = document.getElementById('recon-enc-legend');
if (encLegend) {
encLegend.innerHTML = '';
encSegs.forEach((s) => {
encLegend.appendChild(chartLegendEntry(s));
});
}
}
function detailUrl() {
return state.archive
? '/api/recon/archives/' + encodeURIComponent(state.archive) + '/scans/' + state.selected
: '/api/recon/scans/' + state.selected;
}
function loadDetail() {
if (state.selected == null) return;
const scanId = state.selected;
const arch = state.archive;
if (state.detailLoading) {
if (state.detailLoadingId !== scanId) state.detailQueued = true;
return;
}
if (!state.scanActive && state.detailId === scanId && state.detailArchive === arch) return;
state.detailLoading = true;
state.detailLoadingId = scanId;
PagerAPI.get(detailUrl()).then((r) => {
if (state.selected !== scanId || state.archive !== arch) return;
try {
const isNewScan = state.detailId !== scanId || state.detailArchive !== arch;
state.detail = r.data;
state.detailId = scanId;
state.detailArchive = arch;
if (isNewScan) state.history = {};
const aps = r.data.aps || [];
const seen = {};
aps.forEach((a) => { if (a.bssid) seen[a.bssid] = true; });
if (state.compare.some((b) => !seen[b])) {
state.compare = state.compare.filter((b) => seen[b]);
}
if (!arch) {
// Signal-over-time history only makes sense for the live database.
const nowT = Date.now() / 1000;
aps.forEach((a) => {
if (a.bssid == null || a.signal == null) return;
const hist = state.history[a.bssid] || (state.history[a.bssid] = []);
hist.push({ t: nowT, sig: a.signal });
while (hist.length > RECON_MAX_HISTORY) hist.shift();
});
Object.keys(state.history).forEach((b) => {
if (!seen[b]) {
const hist = state.history[b];
const recent = hist.filter((p) => nowT - p.t < 30);
if (!recent.length) delete state.history[b];
else state.history[b] = recent;
}
});
}
drawCharts(r.data);
renderTables();
renderSelection();
renderCompare();
renderChannelMap();
hsCount.textContent = (r.data.handshakes || []).length;
if (state.scanErr) { state.scanErr = null; renderScanBar(); }
} catch (err) {
// A render failure must never brick the page: reset the detail state
// so the next poll re-fetches and re-renders.
state.detail = null;
state.detailId = null;
state.detailArchive = null;
state.scanErr = 'Scan data failed to render — retrying…';
renderScanBar();
}
}).catch(() => {
// Transient failure (recon DB busy / 503 / timeout): keep detailId
// unset so the next poll retries, and tell the user.
if (state.selected === scanId && state.archive === arch) {
state.detail = null;
state.detailId = null;
state.detailArchive = null;
state.scanErr = 'Scan data unavailable — retrying…';
renderScanBar();
}
}).finally(() => {
state.detailLoading = false;
state.detailLoadingId = null;
if (state.detailQueued) {
state.detailQueued = false;
loadDetail();
}
});
}
function pickFollow(scans) {
// Prefer the newest scan with real data so a 0-AP restart row does not
// blank the table while following a live scan.
const nonEmpty = scans.find((s) => s.aps > 0 || s.devices > 0 || s.handshakes > 0);
return nonEmpty ? nonEmpty.id : (scans[0] ? scans[0].id : null);
}
function renderPicker() {
sel.innerHTML = '';
pickerOptions = [];
const cur = { archive: state.archive, scanId: state.selected };
const liveEmpty = (s) => !(s.aps > 0 || s.devices > 0 || s.handshakes > 0);
state.scans.forEach((s) => {
pickerOptions.push({ archive: null, scanId: s.id });
const opt = document.createElement('option');
opt.value = String(pickerOptions.length - 1);
opt.textContent = 'Scan #' + s.id + ' — ' + fmtTime(s.time) + (liveEmpty(s) ? ' (empty)' : '');
if (liveEmpty(s)) opt.style.opacity = '0.55';
sel.appendChild(opt);
});
state.archives.forEach((a) => {
const og = document.createElement('optgroup');
og.label = 'Archive ·' + (a.max_id ? ' scans 1–' + a.max_id : '') +
(a.mtime ? ' · ' + fmtTime(a.mtime) : '');
(a.scans || []).forEach((s) => {
pickerOptions.push({ archive: a.id, scanId: s.id });
const opt = document.createElement('option');
opt.value = String(pickerOptions.length - 1);
opt.textContent = '#' + s.id + ' — ' + fmtTime(s.time) +
((s.aps > 0 || s.devices > 0 || s.handshakes > 0) ? '' : ' (empty)');
og.appendChild(opt);
});
sel.appendChild(og);
});
const idx = pickerOptions.findIndex((m) => m.archive === cur.archive && m.scanId === cur.scanId);
if (idx !== -1) sel.value = String(idx);
delBtn.disabled = state.archive !== null;
delBtn.title = state.archive ? 'Archived scans are read-only' : 'Delete scan';
const archCount = state.archives.reduce((m, a) => m + ((a.scans || []).length), 0);
const total = state.scans.length + archCount;
psValue.textContent = total ? String(total) : '—';
const latest = state.scans[0];
psSub.textContent = total
? 'Latest: ' + fmtTime(latest ? latest.time : null) + (archCount ? ' · ' + archCount + ' archived' : '')
: 'No scans recorded yet';
}
let loadPending = false;
function load() {
if (loadPending) return;
loadPending = true;
PagerAPI.get('/api/recon/scans').then((r) => {
state.scanErr = null;
state.scans = r.data.scans || [];
const newest = state.scans[0] ? state.scans[0].id : null;
let keep = null;
if (state.autoFollow) {
// Following a live run jumps back to the live database.
state.archive = null;
state.detailArchive = null;
keep = pickFollow(state.scans);
} else if (!state.archive) {
keep = state.selected && state.scans.some((s) => s.id === state.selected)
? state.selected : newest;
}
renderPicker();
if (keep == null) {
if (!state.archive) {
state.detail = null;
state.detailId = null;
state.detailArchive = null;
drawCharts({ aps: [], clients: [], handshakes: [] });
renderTables();
renderSelection();
renderCompare();
renderChannelMap();
hsCount.textContent = '0';
}
} else {
if (keep !== state.selected) {
state.selected = keep;
state.detailId = null;
state.detailArchive = null;
}
if (state.selected != null) loadDetail();
}
}).catch(() => {
state.scanErr = 'Scan list unavailable — retrying…';
renderScanBar();
}).finally(() => { loadPending = false; });
PagerAPI.get('/api/recon/status').then((r) => {
const scanning = !!r.data.scanning;
const wasScanning = state.scanActive;
const completed = wasScanning && !scanning;
state.scanActive = scanning;
state.scanRemaining = r.data.scan_remaining != null ? r.data.scan_remaining : null;
state.hopperOnline = r.data.hopper_online;
state.historyReset = !!r.data.history_reset;
state.wlan0Pinned = !!r.data.wlan0_pinned;
state.wlan0Sta = !!r.data.wlan0_sta;
state.hopperIfaces = r.data.hopper_ifaces || [];
if (r.data.hopper_warning) state.hopperWarning = r.data.hopper_warning;
else if (!scanning) state.hopperWarning = null;
state.hopperHint = r.data.hopper_hint || state.hopperHint;
if (!pendingScan) scanToggle.checked = scanning;
renderScanBar();
if (wasScanning !== scanning) restartPoll();
if (completed) {
state.autoFollow = false;
App.toast('Scan complete');
}
}).catch(() => {});
}
function loadSlow() {
// GPS and archive discovery change rarely; polling them on every 5s tick
// piles sqlite/iwinfo/gpsd work onto the same cycles as the scan data.
PagerAPI.get('/api/recon/gps').then((r) => {
state.gps = r.data;
state.wigle = !!(r.data || {}).wigle;
renderPills();
}).catch(() => {});
PagerAPI.get('/api/recon/archives').then((r) => {
state.archives = (r.data && r.data.archives) || [];
renderPicker();
}).catch(() => {});
// Keep the auto-collect toggle in sync with the device's actual
// loghandshake setting (the pager's own UI can change it).
PagerAPI.get('/api/pineap/get_config').then((r) => {
hsAuto.querySelector('input').checked = !!((r.data || {}).loghandshake);
}).catch(() => {});
}
load();
loadSlow();
let pollIv = null;
const restartPoll = () => {
clearInterval(pollIv);
pollIv = setInterval(load, state.scanActive ? 5000 : 10000);
};
restartPoll();
let slowIv = null;
const restartSlow = () => {
clearInterval(slowIv);
slowIv = setInterval(loadSlow, 30000);
};
restartSlow();
return { destroy: () => { clearInterval(pollIv); clearInterval(slowIv); } };
};
views.recon_reports = (root) => {
root.appendChild(h('h1', { class: 'page-title', text: 'Recon' }));
tabBar(root, RECON_TABS, '#/recon/reports');
const reportCard = h('div', { class: 'section' });
const wigleCard = h('div', { class: 'section' });
function dl(path) { window.location = App.apiBase + path; }
function renderScans() {
reportCard.appendChild(h('h2', { text: 'Scan Reports' }));
const box = h('div');
reportCard.appendChild(box);
let gpsFix = null;
PagerAPI.get('/api/recon/gps').then((r) => {
const g = r.data || {};
gpsFix = g.lock ? { lat: g.lat, lon: g.lon, sats: g.satellites } : null;
}).catch(() => {}).then(() => {
PagerAPI.get('/api/recon/scans').then((r) => {
const scans = (r.data && r.data.scans) || [];
box.innerHTML = '';
if (!scans.length) { box.appendChild(h('div', { class: 'empty', text: 'No scans recorded yet.' })); return; }
const gpsCell = (s) => gpsFix
? h('span', { class: 'recon-gps-cell', text: Number(gpsFix.lat).toFixed(5) + ', ' + Number(gpsFix.lon).toFixed(5) })
: h('span', { class: 'muted', text: '—' });
box.appendChild(table(
[
{ key: 'id', label: 'Scan', render: (s) => '#' + s.id },
{ key: 'time', label: 'Started', render: (s) => fmtTime(s.time) },
{ key: 'gps', label: 'GPS', render: gpsCell },
{ key: 'aps', label: 'APs' },
{ key: 'devices', label: 'Clients' },
{ key: 'handshakes', label: 'Handshakes' },
{ key: 'actions', label: 'Download', render: (s) => h('span', { class: 'hs-actions' },
iconBtn('file_download', 'JSON', () => dl('/api/recon/scans/' + s.id + '/download/json')),
iconBtn('table_chart', 'CSV', () => dl('/api/recon/scans/' + s.id + '/download/csv')),
iconBtn('description', 'HTML report', () => dl('/api/recon/scans/' + s.id + '/download/html'))) }
],
scans));
}).catch(() => box.appendChild(h('div', { class: 'empty', text: 'Failed to load scans.' })));
});
}
function renderWigle() {
wigleCard.appendChild(h('h2', { text: 'WiGLE Uploads' }));
const box = h('div');
wigleCard.appendChild(box);
PagerAPI.get('/api/recon/wigle/files').then((r) => {
const files = (r.data && r.data.files) || [];
box.innerHTML = '';
if (!files.length) { box.appendChild(h('div', { class: 'empty', text: 'No WiGLE files yet. WiGLE logging writes a CSV per capture session.' })); return; }
box.appendChild(table(
[
{ key: 'name', label: 'File', render: (f) => f.name },
{ key: 'mtime', label: 'Modified', render: (f) => fmtTime(f.mtime) },
{ key: 'size', label: 'Size', render: (f) => fmtBytes(f.size) },
{ key: 'rows', label: 'AP rows', render: (f) => f.rows == null ? '--' : f.rows },
{ key: 'warn', label: '', render: (f) => f.rows === 0 ? h('span', { class: 'wigle-warn', text: 'Header only — no data yet (needs a GPS fix)' }) : h('span', {}) },
{ key: 'dl', label: 'Download', render: (f) => iconBtn('file_download', 'Download ' + f.name, () => dl('/api/recon/wigle/files/' + encodeURIComponent(f.name))) }
],
files));
}).catch(() => box.appendChild(h('div', { class: 'empty', text: 'Failed to load WiGLE files.' })));
}
root.appendChild(reportCard);
root.appendChild(wigleCard);
renderScans();
renderWigle();
return { destroy: () => {} };
};
const LOGGING_TABS = [
{ label: 'PineAP', hash: '#/logging' },
{ label: 'System', hash: '#/logging/system' }
];
function hsType(name) {
const n = (name || '').toLowerCase();
if (/\.(cap|pcap|pcapng)$/.test(n)) return 'PCAP';
if (/\.(hccapx|hc22000|22000|hccap)$/.test(n)) return 'Hashcat';
return 'Unknown';
}
views.recon_handshakes = (root) => {
root.appendChild(h('h1', { class: 'page-title', text: 'Recon' }));
tabBar(root, RECON_TABS, '#/recon/handshakes');
const box = h('div', { class: 'section recon-handshakes-card' });
root.appendChild(box);
let flashTimer = null;
function flash(ok, msg) {
const head = box.querySelector('.recon-table-head');
if (!head) return;
const old = head.querySelector('.hs-flash');
if (old) old.remove();
const el = h('span', { class: 'hs-flash ' + (ok ? 'hs-flash-ok' : 'hs-flash-error'), text: msg });
head.appendChild(el);
clearTimeout(flashTimer);
flashTimer = setTimeout(() => el.remove(), ok ? 3000 : 5000);
}
function hsIconBtn(name, title, cls, onclk) {
const b = h('button', { class: 'icon-btn ' + (cls || ''), title: title });
b.innerHTML = PineappleIcons[name] || '';
armBusy(b, onclk);
return b;
}
function textCell(v) {
return h('td', { class: 'mat-cell', text: v == null || v === '' ? '--' : String(v) });
}
function naGlyph() {
return h('td', { class: 'mat-cell hs-cell-center' },
h('span', { class: 'hs-na',
title: "This information isn't available. This is common when a handshake file has been found, but the associated Recon scan has been lost or deleted.",
html: PineappleIcons.question_mark }));
}
function boolGlyph(v) {
return h('td', { class: 'mat-cell hs-cell-center' },
h('span', { class: v ? 'hs-ok' : 'hs-bad', html: v ? PineappleIcons.check : PineappleIcons.close }));
}
function msgCell(inDb, present) {
return inDb ? boolGlyph(present) : naGlyph();
}
function load(done) {
return PagerAPI.get('/api/pineap/handshakes').then((r) => {
box.innerHTML = '';
const head = h('div', { class: 'recon-table-head' },
h('h2', { text: 'Captured WPA Handshakes' }),
h('span', { class: 'toolbar-spacer' }),
iconBtn('settings', 'Handshakes settings', openSettings));
box.appendChild(head);
box.appendChild(h('div', { class: 'row' },
h('div', {}, btn('Download all (zip)', () => { window.location = App.apiBase + '/api/loot/zip'; })),
h('div', {}, (function () {
const archiveBtn = btn('Archive', () => {
return runAction(archiveBtn, () => PagerAPI.post('/api/loot/archive').then(() => App.toast('Archived')), 'Archiving…');
});
return archiveBtn;
})()),
h('div', {}, btn('Refresh', () => load(), 'ghost'))));
const hs = r.data.handshakes || [];
if (!hs.length) {
box.appendChild(h('div', { class: 'empty', text: 'No Handshakes Available' }));
if (done) done();
return;
}
const t = h('table', { class: 'tbl' });
const thead = h('thead');
const th = h('tr');
['BSSID', 'Client', 'Source', 'Type', 'Captured', 'Message 1', 'Message 2',
'Message 3', 'Message 4', 'Beacon Frame', '']
.forEach((c) => th.appendChild(h('th', { text: c })));
thead.appendChild(th);
t.appendChild(thead);
const tb = h('tbody');
hs.forEach((f) => {
const trr = h('tr');
trr.appendChild(textCell(f.mac));
trr.appendChild(textCell(f.client));
trr.appendChild(textCell(f.source));
trr.appendChild(textCell(String(f.type).charAt(0).toUpperCase() + String(f.type).slice(1) + ' ' + hsType(f.name)));
trr.appendChild(textCell(fmtTime(f.timestamp)));
[1, 2, 4, 8].forEach((bit) => trr.appendChild(msgCell(f.in_db, (f.part_mask & bit) !== 0)));
trr.appendChild(msgCell(f.in_db, !!f.beacon));
const act = h('td', { class: 'mat-cell' },
h('span', { class: 'hs-actions' },
hsIconBtn('file_download', 'Download', '', () => {
window.location = App.apiBase + '/api/pineap/handshakes/' + encodeURIComponent(f.name);
}),
hsIconBtn('delete', 'Delete', 'hs-warn', () => {
return PagerAPI.del('/api/pineap/handshakes', { name: f.name })
.then(() => load(() => flash(true, 'Deleted ' + f.name)));
})));
trr.appendChild(act);
tb.appendChild(trr);
});
t.appendChild(tb);
box.appendChild(t);
if (done) done();
}).catch(() => {
if (box.querySelector('.recon-table-head')) flash(false, 'Failed to load handshakes');
else App.toast('Failed to load handshakes', 'error');
});
}
function openSettings() {
return PagerAPI.get('/api/pineap/handshakes/location').then((r) => {
const loc = (r.data || {}).location || '--';
const overlay = h('div', { class: 'modal-overlay' });
function close() { overlay.remove(); }
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
const modal = h('div', { class: 'modal' },
h('div', { class: 'modal-title', text: 'Handshake Settings' }),
h('div', { class: 'modal-body' },
h('div', { class: 'hs-settings-row' },
h('span', { class: 'hs-settings-label', text: 'Handshake Location' }),
h('span', { class: 'hs-settings-value', text: loc })),
(function () {
const delAll = btn('Delete All Handshakes', () => {
return runAction(delAll, () => PagerAPI.del('/api/pineap/handshakes/all')
.then(() => { close(); load(() => flash(true, 'All handshakes deleted')); }), 'Deleting…');
}, 'danger');
return delAll;
})()),
h('div', { class: 'modal-actions' }, btn('Close', close, 'ghost')));
overlay.appendChild(modal);
document.body.appendChild(overlay);
}).catch(() => App.toast('Failed to load handshake settings', 'error'));
}
load();
return { destroy: () => {} };
};
views.logging = (root) => {
root.appendChild(h('h1', { class: 'page-title', text: 'Logging' }));
tabBar(root, LOGGING_TABS, '#/logging');
const lines = [];
const search = h('input', { class: 'log-search', placeholder: 'Search activity log' });
const output = h('pre', { class: 'logs' });
const pineBox = h('div', { class: 'section' },
h('div', { class: 'log-toolbar' },
h('h2', {}, 'Activity Log'),
h('span', { class: 'toolbar-spacer' }),
search,
btn('Download', () => downloadText('pineap.log', lines.join('\n')), 'ghost'),
btn('Refresh', load, 'ghost')),
output);
root.appendChild(pineBox);
function render() {
const q = search.value.trim().toLowerCase();
output.textContent = lines.filter((line) => !q || line.toLowerCase().indexOf(q) !== -1).join('\n');
}
search.addEventListener('input', render);
let pending = false;
function load() {
if (pending) return Promise.resolve();
pending = true;
return PagerAPI.get('/api/logging/pineap?lines=200').then((r) => {
lines.splice(0, lines.length, ...((r.data || {}).lines || []));
render();
}).catch((e) => {
lines.splice(0, lines.length, e.message || 'PineAP log unavailable');
render();
})
.finally(() => { pending = false; });
}
load();
const iv = setInterval(load, 10000);
return { destroy: () => clearInterval(iv) };
};
views.logging_system = (root) => {
root.appendChild(h('h1', { class: 'page-title', text: 'Logging' }));
tabBar(root, LOGGING_TABS, '#/logging/system');
const lines = [];
const search = h('input', { class: 'log-search', placeholder: 'Search system log' });
const output = h('pre', { class: 'logs' });
const box = h('div', { class: 'section' },
h('div', { class: 'log-toolbar' },
h('h2', {}, 'System Log'),
h('span', { class: 'toolbar-spacer' }),
search,
btn('Download', () => downloadText('system.log', lines.join('\n')), 'ghost'),
btn('Refresh', load, 'ghost')),
output);
root.appendChild(box);
function render() {
const q = search.value.trim().toLowerCase();
output.textContent = lines.filter((line) => !q || line.toLowerCase().indexOf(q) !== -1).join('\n');
}
search.addEventListener('input', render);
let pending = false;
function load() {
if (pending) return Promise.resolve();
pending = true;
return PagerAPI.get('/api/logging/system?lines=400').then((r) => {
lines.splice(0, lines.length, ...((r.data || {}).lines || []));
render();
}).catch((e) => {
lines.splice(0, lines.length, e.message || 'System log unavailable');
render();
})
.finally(() => { pending = false; });
}
load();
const iv = setInterval(load, 10000);
return { destroy: () => clearInterval(iv) };
};
const PAYLOAD_TABS = [
{ label: 'Installed', hash: '#/modules' },
{ label: 'Payloads', hash: '#/modules/online' },
{ label: 'Running', hash: '#/modules/running' },
{ label: 'Develop', hash: '#/modules/develop' }
];
function payloadShell(root, activeHash) {
root.appendChild(h('h1', { class: 'page-title', text: 'Payloads' }));
tabBar(root, PAYLOAD_TABS, activeHash);
const box = h('div', { class: 'payload-content' });
root.appendChild(box);
return box;
}
function payloadToolbar(title, description, action) {
const head = h('div', { class: 'payload-heading' },
h('div', {}, h('h2', { text: title }), h('p', { class: 'muted', text: description })));
if (action) head.appendChild(action);
return head;
}
function payloadFilters(onchange) {
const search = h('input', { class: 'payload-search', placeholder: 'Search payloads' });
const category = h('select', { class: 'payload-category' }, h('option', { value: '', text: 'All categories' }));
search.addEventListener('input', onchange);
category.addEventListener('change', onchange);
return { search, category, node: h('div', { class: 'payload-filters' }, search, category) };
}
function fillPayloadCategories(select, rows, key) {
const values = Array.from(new Set((rows || []).map((row) => row[key] || '').filter(Boolean))).sort();
values.forEach((value) => select.appendChild(h('option', { value, text: value.replace(/_/g, ' ') })));
}
function payloadVersionTag(version) {
const value = String(version || '').trim();
return value ? (/^v/i.test(value) ? value : 'v' + value) : '';
}
function payloadCard(item, actions, tags) {
const meta = h('div', { class: 'payload-meta' });
(tags || []).filter(Boolean).forEach((tag) => meta.appendChild(h('span', { class: 'payload-tag', text: tag })));
const controls = h('div', { class: 'payload-actions' });
(actions || []).forEach((action) => controls.appendChild(action));
return h('article', { class: 'payload-card' },
h('div', { class: 'payload-card-main' },
h('h3', { text: item.title || item.key || 'Untitled payload' }),
h('p', { class: 'payload-author muted', text: item.author ? 'by ' + item.author : 'Local payload' }),
h('p', { class: 'payload-description', text: item.description || 'No description provided.' }),
meta), controls);
}
function setPayloadBusy(button, busy, label) {
markBusy(button, busy, label);
}
views.modules = (root) => {
const box = payloadShell(root, '#/modules');
let alive = true;
let rows = [];
const list = h('div', { class: 'payload-list' });
const status = h('p', { class: 'payload-result-count muted' });
const filters = payloadFilters(render);
const refresh = btn('Refresh', load, 'ghost');
box.appendChild(h('div', { class: 'section' },
payloadToolbar('Installed Payloads', 'Payloads installed on this Pager. Launch them here or manage their Pager Portal version.', refresh),
filters.node, status, list));
function render() {
if (!alive) return;
const q = filters.search.value.trim().toLowerCase();
const category = filters.category.value;
const visible = rows.filter((item) => (!category || item.category === category) &&
(!q || [item.title, item.author, item.description, item.key].join(' ').toLowerCase().indexOf(q) !== -1));
status.textContent = visible.length + ' of ' + rows.length + ' installed';
list.innerHTML = '';
if (!visible.length) {
list.appendChild(h('div', { class: 'payload-empty', text: rows.length ? 'No payloads match these filters.' : 'No payloads are installed. Get payloads from the Payloads tab.' }));
return;
}
visible.forEach((item) => {
const launch = btn('Run', () => runAction(launch, () => PagerAPI.post('/api/payloads/run', { key: item.key }).then(() => {
App.toast(item.title + ' started');
location.hash = '#/modules/running';
}), 'Starting...'));
launch.dataset.label = 'Run';
if (item.disabled || item.key === 'user~remote_access~pager-webui') {
launch.disabled = true;
launch.title = item.disabled ? 'This payload is disabled' : 'The WebUI cannot launch itself';
}
const update = item.update ? btn('Update', () => installPayload(item, update), 'ghost') : null;
if (update) update.dataset.label = 'Update';
const remove = btn('Remove', () => {
if (!confirm('Remove "' + item.title + '" from this Pager?')) return;
return runAction(remove, () => PagerAPI.post('/api/payloads/remove', { key: item.key }).then(() => {
App.toast(item.title + ' removed'); load();
}), 'Removing...');
}, 'danger');
remove.dataset.label = 'Remove';
if (item.key === 'user~remote_access~pager-webui') {
remove.disabled = true;
remove.title = 'The active WebUI cannot remove itself';
}
list.appendChild(payloadCard(item, [launch, update, remove].filter(Boolean),
[item.category && item.category.replace(/_/g, ' '), payloadVersionTag(item.version),
item.update && 'Update available', item.missingmanifest && 'Local manifest']));
});
}
function installPayload(item, button) {
return runAction(button, () => PagerAPI.post('/api/payloads/install', { key: item.key }).then(() => {
App.toast(item.title + ' updated'); load();
}), 'Updating...');
}
function load() {
return PagerAPI.get('/api/payloads/installed').then((r) => {
if (!alive) return;
rows = (r.data && r.data.payloads) || [];
filters.category.innerHTML = '';
fillPayloadCategories(filters.category, rows, 'category');
render();
}).catch(apiError('Unable to load installed payloads'));
}
load();
return { destroy: () => { alive = false; } };
};
views.modules_online = (root) => {
const box = payloadShell(root, '#/modules/online');
let alive = true;
let rows = [], installed = new Set();
const list = h('div', { class: 'payload-list' });
const status = h('p', { class: 'payload-result-count muted' });
const filters = payloadFilters(render);
const refresh = btn('Get Available Payloads', () => {
return PagerAPI.post('/api/payloads/refresh').then(() => {
App.toast('Pager Portal listings refreshed'); return load();
});
});
box.appendChild(h('div', { class: 'section' },
payloadToolbar('Available Payloads', 'Browse community payloads from the Pager Portal and install them directly on this Pager.', refresh),
filters.node, status, list));
function render() {
if (!alive) return;
const q = filters.search.value.trim().toLowerCase();
const category = filters.category.value;
const matches = rows.filter((item) => (!category || item.parent === category) &&
(!q || [item.title, item.author, item.description, item.key].join(' ').toLowerCase().indexOf(q) !== -1));
const visible = matches.slice(0, 120);
status.textContent = matches.length + ' result' + (matches.length === 1 ? '' : 's') +
(matches.length > visible.length ? ' - showing first ' + visible.length : '');
list.innerHTML = '';
if (!visible.length) {
list.appendChild(h('div', { class: 'payload-empty', text: rows.length ? 'No payloads match these filters.' : 'No Portal listing is cached. Select Get Available Payloads.' }));
return;
}
visible.forEach((item) => {
const isInstalled = installed.has(item.key);
const install = btn(isInstalled ? 'Installed' : 'Install', () => {
if (installed.has(item.key)) return;
return runAction(install, () => PagerAPI.post('/api/payloads/install', { key: item.key }).then(() => {
installed.add(item.key); App.toast(item.title + ' installed'); render();
}), 'Installing...');
}, isInstalled ? 'ghost' : '');
install.dataset.label = isInstalled ? 'Installed' : 'Install';
install.disabled = isInstalled;
list.appendChild(payloadCard(item, [install],
[item.parent && item.parent.replace('/', ' / ').replace(/_/g, ' '), payloadVersionTag(item.version)]));
});
}
function load() {
return Promise.all([PagerAPI.get('/api/payloads/index'), PagerAPI.get('/api/payloads/installed')]).then((results) => {
if (!alive) return;
rows = (results[0].data && results[0].data.payloads) || [];
installed = new Set(((results[1].data && results[1].data.payloads) || []).map((item) => item.key));
filters.category.innerHTML = '';
fillPayloadCategories(filters.category, rows, 'parent');
render();
}).catch(apiError('Unable to load Pager Portal payloads'));
}
load();
return { destroy: () => { alive = false; } };
};
views.modules_running = (root) => {
const box = payloadShell(root, '#/modules/running');
let alive = true, pending = false;
const list = h('div', { class: 'payload-runs' });
const refresh = btn('Refresh', load, 'ghost');
box.appendChild(h('div', { class: 'section' },
payloadToolbar('Running Payloads', 'Tracks payloads launched from this WebUI session. Output is read from each payload process.', refresh), list));
function render(rows) {
if (!alive) return;
list.innerHTML = '';
if (!rows.length) {
list.appendChild(h('div', { class: 'payload-empty', text: 'No payloads have been launched from this WebUI session.' }));
return;
}
rows.forEach((run) => {
const state = h('span', { class: 'payload-run-state ' + (run.running ? 'running' : 'finished'),
text: run.running ? 'Running' : (run.returncode === 0 ? 'Completed' : 'Exited ' + run.returncode) });
const actions = h('div', { class: 'payload-actions' });
if (run.running) {
const stop = btn('Stop', () => PagerAPI.post('/api/payloads/stop', { id: run.id }).then(() => {
App.toast(run.title + ' stop requested'); setTimeout(load, 500);
}), 'danger');
actions.appendChild(stop);
}
const output = h('pre', { class: 'payload-output', text: run.output || 'No output yet.' });
list.appendChild(h('article', { class: 'payload-run' },
h('div', { class: 'payload-run-head' },
h('div', {}, h('h3', { text: run.title }),
h('p', { class: 'muted', text: 'PID ' + run.pid + ' - started ' + fmtTime(run.started) })),
state, actions), output));
});
}
function load() {
if (pending) return Promise.resolve();
pending = true;
return PagerAPI.get('/api/payloads/runs').then((r) => render((r.data && r.data.runs) || []))
.catch(apiError('Unable to load payload runs')).finally(() => { pending = false; });
}
load();
const iv = setInterval(load, 3000);
return { destroy: () => { alive = false; clearInterval(iv); } };
};
views.modules_develop = (root) => {
const box = payloadShell(root, '#/modules/develop');
box.appendChild(h('div', { class: 'section payload-develop' },
h('h2', { text: 'Developing Pager Payloads' }),
h('p', { text: 'Pager payloads are native shell or script-based workflows, not Pineapple modules. A payload lives in a context and category folder and declares its launchpoint and metadata in _hak5_manifest.json.' }),
h('div', { class: 'payload-dev-grid' },
h('div', { class: 'payload-dev-block' },
h('h3', { text: 'Payload structure' }),
h('pre', { class: 'payload-code', text: '/root/payloads/user/general/my_payload/\n payload.sh\n _hak5_manifest.json\n assets/' })),
h('div', { class: 'payload-dev-block' },
h('h3', { text: 'Native capabilities' }),
h('p', { text: 'Scripts launched here keep access to Pager commands such as ALERT, LOG, CONFIRMATION_DIALOG, TEXT_PICKER and other hak5cmd-backed helpers.' }))),
h('p', { class: 'muted', text: 'Use the terminal to create or inspect payload files. Refresh Installed when the manifest changes so the firmware inventory is re-read.' }),
btn('Open Terminal', () => {
const terminal = document.getElementById('terminal-btn');
if (terminal) terminal.click();
})));
return { destroy: () => {} };
};
const SETTINGS_TABS = [
{ label: 'General', hash: '#/settings' },
{ label: 'Networking', hash: '#/settings/networking' },
{ label: 'WiFi', hash: '#/settings/wifi' },
{ label: 'LED', hash: '#/settings/led' },
{ label: 'Advanced', hash: '#/settings/advanced' },
{ label: 'Help', hash: '#/settings/help' }
];
function settingsShell(root, activeHash) {
root.appendChild(h('h1', { class: 'page-title', text: 'Settings' }));
tabBar(root, SETTINGS_TABS, activeHash);
const box = h('div', { class: 'settings-content' });
root.appendChild(box);
return box;
}
function settingsCard(box, title, subtitle) {
const card = h('div', { class: 'section settings-card' }, h('h2', {}, title));
if (subtitle) card.appendChild(h('p', { class: 'muted settings-subtitle', text: subtitle }));
box.appendChild(card);
return card;
}
function apiError(message) {
return (err) => App.toast((err && err.message && err.message !== 'request failed') ? err.message : message, 'error');
}
const WIFI_CLIENT_ENC_VALUES = {
'Open': 'open', 'WPA2': 'wpa2', 'WPA3': 'wpa3', 'WPA2/WPA3': 'wpa2wpa3'
};
function clientModeEncValue(label) {
return WIFI_CLIENT_ENC_VALUES[label] || (label && label !== 'Open' ? 'wpa2' : 'open');
}
function clientModePanel(box, options) {
const state = { status: null, networks: [], connecting: false, generation: 0 };
const statusEl = h('div', { class: 'client-status' });
const routingSwitch = h('input', { type: 'checkbox' });
const routingRow = h('label', { class: 'switch client-routing' },
routingSwitch, h('span', { class: 'track' }), 'Route LAN clients through this connection');
const actionsEl = h('div', { class: 'client-actions' });
const netsEl = h('div', { class: 'client-networks' });
routingSwitch.addEventListener('change', () => {
if (!(state.status || {}).enabled) {
routingSwitch.checked = false;
App.toast('Client mode must be enabled before routing can be changed', 'error');
return;
}
if (routingSwitch.disabled) return;
routingSwitch.disabled = true;
PagerAPI.post('/api/settings/wifi/client/route', { routed: routingSwitch.checked })
.then(() => {
if (state.status) state.status.routed = routingSwitch.checked;
App.toast('Client routing ' + (routingSwitch.checked ? 'enabled' : 'disabled'));
App.checkInternet(false);
})
.catch((e) => {
routingSwitch.checked = !routingSwitch.checked;
App.toast(e.message || 'Failed to update routing', 'error');
})
.finally(() => { routingSwitch.disabled = false; });
});
function renderStatus() {
const s = state.status || {};
statusEl.innerHTML = '';
const lines = [];
if (s.connected) {
lines.push(['Status', 'Connected'], ['Network', s.connected_ssid || s.ssid || '\u2014'],
['IP Address', s.ip || '\u2014'],
['Signal', s.signal != null ? s.signal + ' dBm' : '\u2014']);
} else if (s.enabled) {
lines.push(['Status', 'Enabled \u2014 not associated'], ['Network', s.ssid || '\u2014']);
} else {
lines.push(['Status', 'Disabled']);
}
lines.forEach(([k, v]) => statusEl.appendChild(h('div', { class: 'client-kv' },
h('span', { text: k }), h('code', { text: v }))));
routingSwitch.checked = !!s.routed;
routingRow.classList.toggle('hidden', !s.enabled);
}
function refresh() {
return PagerAPI.get('/api/settings/wifi/client').then((r) => {
state.status = r.data || {};
renderStatus();
}).catch(apiError('Failed to load client mode status'));
}
function renderNetworks() {
netsEl.innerHTML = '';
if (!state.networks.length) {
netsEl.appendChild(h('div', { class: 'empty', text: 'No networks shown. Scan to discover nearby WiFi.' }));
return;
}
state.networks.forEach((net) => {
const needsPw = net.encryption !== 'Open';
const row = h('div', { class: 'client-net-row' },
h('div', { class: 'client-net-main' },
h('span', { class: 'client-net-ssid', text: net.ssid || '(hidden)' }),
h('span', { class: 'client-net-enc', text: net.encryption })),
h('span', { class: 'client-net-signal', text: net.signal != null ? net.signal + ' dBm' : '\u2014' }),
btn(needsPw ? 'Connect' : 'Join', () => connectRow(row, net), needsPw ? '' : 'primary'));
netsEl.appendChild(row);
});
}
function connectRow(row, net) {
row.innerHTML = '';
const encSel = h('select', {},
h('option', { value: 'wpa2', text: 'WPA2' }),
h('option', { value: 'wpa3', text: 'WPA3' }),
h('option', { value: 'wpa2wpa3', text: 'WPA2/WPA3' }));
const value = clientModeEncValue(net.encryption);
encSel.value = value === 'open' ? 'wpa2' : value;
const pw = h('input', { type: 'password', placeholder: 'Password', autocomplete: 'new-password' });
const routedCb = h('input', { type: 'checkbox' });
const routedLabel = h('label', { class: 'switch client-routing' }, routedCb,
h('span', { class: 'track' }), 'Route LAN clients');
const connectBtn = btn('Connect', () => {
doConnect({ ssid: net.ssid, encryption: encSel.value,
password: pw.value, routed: routedCb.checked }, connectBtn);
}, 'primary');
const form = h('div', { class: 'client-connect-form' }, encSel, pw, routedLabel,
connectBtn,
btn('Cancel', () => renderNetworks(), 'ghost'));
row.appendChild(form);
pw.focus();
}
let connectingButton = null;
function doConnect(opts, button) {
if (state.connecting) return;
state.connecting = true;
connectingButton = button || null;
setBusy(true);
if (connectingButton) markBusy(connectingButton, true, 'Connecting…');
PagerAPI.post('/api/settings/wifi/client/connect', opts)
.then(() => {
App.toast('Connecting to ' + opts.ssid + '\u2026');
state.status = null;
pollConnected(opts.ssid);
})
.catch((e) => {
clearConnecting();
App.toast(e.message || 'Failed to connect', 'error');
refresh();
});
}
function clearConnecting() {
state.connecting = false;
setBusy(false);
if (connectingButton) {
markBusy(connectingButton, false);
connectingButton = null;
}
}
function pollConnected(targetSsid) {
const gen = ++state.generation;
let attempts = 0;
let ipWait = 0;
const tick = () => {
if (gen !== state.generation) return;
PagerAPI.get('/api/settings/wifi/client').then((r) => {
const s = r.data || {};
state.status = s;
renderStatus();
if (s.connected && s.ip) {
finish('Connected to ' + (s.connected_ssid || targetSsid));
} else if (s.connected && ++ipWait < 6) {
setTimeout(tick, 2000);
} else if (!s.connected && ++attempts >= 22) {
finish('Timed out waiting for ' + targetSsid + ' to connect', 'error');
} else if (!s.connected) {
setTimeout(tick, 2000);
} else {
finish('Connected to ' + (s.connected_ssid || targetSsid));
}
}).catch(() => {
clearConnecting();
App.toast('Failed to check connection status', 'error');
});
};
function finish(message, kind) {
clearConnecting();
App.toast(message, kind);
App.checkInternet(false);
}
setTimeout(tick, 2000);
}
function doDisconnect() {
if (state.connecting) return Promise.resolve();
state.connecting = true;
setBusy(true);
return PagerAPI.post('/api/settings/wifi/client/disconnect')
.then(() => {
App.toast('WiFi client disabled');
state.networks = [];
refresh();
App.checkInternet(false);
})
.catch((e) => App.toast(e.message || 'Failed to disconnect', 'error'))
.finally(() => { state.connecting = false; setBusy(false); });
}
let scanningNets = false;
function scan() {
if (state.connecting || scanningNets) return Promise.resolve();
scanningNets = true;
setBusy(true);
netsEl.textContent = 'Scanning\u2026';
return PagerAPI.post('/api/settings/wifi/client/scan').then((r) => {
state.networks = (r.data && r.data.networks) || [];
renderNetworks();
}).catch((e) => {
netsEl.innerHTML = '';
netsEl.appendChild(h('div', { class: 'empty', text: 'Scan failed: ' + (e.message || 'unknown error') }));
}).finally(() => { scanningNets = false; setBusy(false); });
}
function setBusy(busy) {
const buttons = actionsEl.querySelectorAll('button');
buttons.forEach((b) => {
b.disabled = busy;
b.classList.toggle('busy', busy);
if (busy) b.setAttribute('aria-busy', 'true');
else b.removeAttribute('aria-busy');
});
}
actionsEl.appendChild(btn('Scan for Networks', scan));
actionsEl.appendChild(btn('Disconnect', doDisconnect));
actionsEl.appendChild(btn('Refresh', refresh, 'ghost'));
if (options && options.close) actionsEl.appendChild(btn('Close', options.close, 'ghost'));
box.appendChild(statusEl);
box.appendChild(routingRow);
box.appendChild(actionsEl);
box.appendChild(netsEl);
refresh();
return { refresh };
}
views.openClientModeModal = () => {
const overlay = h('div', { class: 'modal-overlay' });
function close() { overlay.remove(); }
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
const modal = h('div', { class: 'modal client-modal' },
h('div', { class: 'modal-title', text: 'Internet Connection' }),
h('div', { class: 'modal-body' }));
overlay.appendChild(modal);
document.body.appendChild(overlay);
clientModePanel(modal.querySelector('.modal-body'), { close });
};
views.settings = (root) => {
const box = settingsShell(root, '#/settings');
const user = settingsCard(box, 'User Management & Timezone');
const currentPw = h('input', { type: 'password', autocomplete: 'current-password' });
const newPw = h('input', { type: 'password', autocomplete: 'new-password' });
const repeatPw = h('input', { type: 'password', autocomplete: 'new-password' });
user.appendChild(h('div', { class: 'settings-form-grid' },
h('label', {}, 'Current Password', currentPw),
h('label', {}, 'New Password', newPw),
h('label', {}, 'Repeat New Password', repeatPw)));
const pwBtn = btn('Update Password', () => {
if (!currentPw.value) { App.toast('Current password is required', 'error'); return; }
if (newPw.value.length < 4) { App.toast('New password is too short', 'error'); return; }
if (newPw.value !== repeatPw.value) { App.toast('New passwords do not match', 'error'); return; }
runAction(pwBtn, () => PagerAPI.post('/api/settings/password', {
current_password: currentPw.value, new_password: newPw.value,
repeat_password: repeatPw.value
}).then(() => {
currentPw.value = ''; newPw.value = ''; repeatPw.value = '';
App.toast('Root password updated');
}), 'Updating…');
});
user.appendChild(pwBtn);
const timezones = [
['UTC', '(GMT+0) Coordinated Universal Time', 'Etc/UTC'],
['EST5EDT,M3.2.0,M11.1.0', '(GMT-5) Eastern Time (US & Canada)', 'America/New_York'],
['CST6CDT,M3.2.0,M11.1.0', '(GMT-6) Central Time (US & Canada)', 'America/Chicago'],
['MST7MDT,M3.2.0,M11.1.0', '(GMT-7) Mountain Time (US & Canada)', 'America/Denver'],
['MST7', '(GMT-7) Arizona', 'America/Phoenix'],
['PST8PDT,M3.2.0,M11.1.0', '(GMT-8) Pacific Time (US & Canada)', 'America/Los_Angeles'],
['AKST9AKDT,M3.2.0,M11.1.0', '(GMT-9) Alaska', 'America/Anchorage'],
['HST10', '(GMT-10) Hawaii', 'Pacific/Honolulu'],
['GMT0BST,M3.5.0/1,M10.5.0', '(GMT+0) London', 'Europe/London'],
['CET-1CEST,M3.5.0,M10.5.0/3', '(GMT+1) Central Europe', 'Europe/Berlin'],
['JST-9', '(GMT+9) Japan', 'Asia/Tokyo'],
['AEST-10AEDT,M10.1.0,M4.1.0/3', '(GMT+10) Sydney', 'Australia/Sydney']
];
const tzSel = h('select', { 'aria-label': 'Timezone' });
timezones.forEach(([value, label, zone]) => tzSel.appendChild(h('option', { value, text: label, 'data-zone': zone })));
const tzBtn = btn('Update Timezone', () => {
const opt = tzSel.options[tzSel.selectedIndex];
runAction(tzBtn, () => PagerAPI.post('/api/settings/timezone', { timezone: tzSel.value, zonename: opt.getAttribute('data-zone') || '' })
.then(() => App.toast('Timezone updated')), 'Updating…');
});
const syncBtn = btn('Sync Browser Time', () => {
const d = new Date();
const stamp = d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' +
String(d.getUTCDate()).padStart(2, '0') + ' ' + String(d.getUTCHours()).padStart(2, '0') + ':' +
String(d.getUTCMinutes()).padStart(2, '0') + ':' + String(d.getUTCSeconds()).padStart(2, '0');
runAction(syncBtn, () => PagerAPI.post('/api/settings/synctime', { timestamp: stamp })
.then(() => App.toast('Pager time synchronized')), 'Syncing…');
}, 'ghost');
const tzActions = h('div', { class: 'settings-actions' }, tzBtn, syncBtn);
user.appendChild(h('label', {}, 'Timezone', tzSel));
user.appendChild(tzActions);
PagerAPI.get('/api/settings/timezone').then((r) => {
const value = (r.data || {}).timezone || 'UTC';
if (!Array.from(tzSel.options).some((o) => o.value === value)) {
tzSel.insertBefore(h('option', { value, text: value + ' (current)' }), tzSel.firstChild);
}
tzSel.value = value;
}).catch(apiError('Unable to load timezone'));
const ntp = settingsCard(box, 'Network Time', 'Configure the Pager\'s NTP client.');
const ntpEnabled = h('input', { type: 'checkbox' });
const ntpServers = h('input', {});
ntp.appendChild(h('label', { class: 'switch' }, ntpEnabled, h('span', { class: 'track' }), 'Enabled'));
ntp.appendChild(h('label', {}, 'NTP servers (comma separated)', ntpServers));
const ntpSave = btn('Save', () => {
runAction(ntpSave, () => PagerAPI.post('/api/settings/ntp', {
enabled: ntpEnabled.checked,
servers: ntpServers.value.split(',').map((s) => s.trim()).filter(Boolean)
}).then(() => App.toast('Network time settings saved')), 'Saving…');
});
ntp.appendChild(ntpSave);
PagerAPI.get('/api/settings/ntp').then((r) => {
ntpEnabled.checked = !!r.data.enabled;
ntpServers.value = (r.data.servers || []).join(', ');
}).catch(apiError('Unable to load network time settings'));
const details = settingsCard(box, 'Device & Web Interface');
const detailBody = h('div', { class: 'settings-kv-grid' });
details.appendChild(detailBody);
Promise.all([PagerAPI.get('/api/status'), PagerAPI.get('/api/settings/service')]).then(([status, service]) => {
const s = status.data || {}, svc = service.data || {};
[['Model', 'WiFi Pineapple Pager'], ['Firmware', s.firmware || 'Unavailable'],
['Uptime', fmtDur(s.uptime)], ['Disk', s.disk && s.disk.size != null ? fmtBytes(s.disk.used) + ' / ' + fmtBytes(s.disk.size) : 'Unavailable'],
['WebUI Service', (svc.running ? 'Running' : 'Stopped') + (svc.background ? ' (background)' : '')]]
.forEach(([k, v]) => detailBody.appendChild(h('div', { class: 'settings-kv' }, h('span', { text: k }), h('code', { text: v }))));
}).catch(apiError('Unable to load device details'));
const overlay = settingsCard(box, 'Mark VIII overlay',
'PineAP pool contents stay with the Pager daemon. Radio1 APs, hop pause, and enterprise overlays revert to the Pager snapshot when Mark VIII stops.');
const overlayStatus = h('p', { class: 'muted', text: 'Loading overlay status…' });
overlay.appendChild(overlayStatus);
function loadOverlay() {
PagerAPI.get('/api/mode').then((r) => {
const d = r.data || {};
const ov = d.overlay || {};
overlayStatus.textContent = (d.snapshot ? 'Pager snapshot is captured. ' : 'No snapshot. ') +
'Radio1 AP ' + (ov.radio1_ap ? 'ON' : 'off') +
' · Enterprise ' + (ov.enterprise ? 'ON' : 'off') +
' · Recon hopper ' + (ov.recon_hopper ? 'ON' : 'off') +
'. Stop the Mark VIII service to make the Pager UI the source of truth.';
}).catch(apiError('Unable to load overlay status'));
}
const restoreBtn = btn('Restore Pager overlays now', () => {
runAction(restoreBtn, () => PagerAPI.post('/api/mode/release').then(() => {
App.toast('Pager overlays restored');
loadOverlay();
}), 'Restoring…');
});
overlay.appendChild(restoreBtn);
loadOverlay();
const profiles = settingsCard(box, 'Reliability Profiles',
'Snapshots of the PineAP, wireless, and network UCI config. Save one before risky changes; restoring rolls the config back and reloads the radios.');
const profBody = h('div', { class: 'settings-table-wrap', text: 'Loading…' });
profiles.appendChild(profBody);
function loadProfiles() {
PagerAPI.get('/api/reliability/profiles').then((r) => {
const names = (r.data || {}).profiles || [];
profBody.innerHTML = '';
if (!names.length) {
profBody.appendChild(h('div', { class: 'empty', text: 'No saved profiles yet.' }));
return;
}
profBody.appendChild(table([
{ label: 'Profile', key: 'name' },
{ label: '', render: (row) => {
const doRestore = btn('Restore', () => runAction(doRestore,
() => PagerAPI.post('/api/reliability/restore', { name: row.name })
.then(() => App.toast('Profile restored; radios reloaded')), 'Restoring…'), 'ghost');
return doRestore;
} }
], names.map((name) => ({ name }))));
}).catch(() => { profBody.textContent = 'Unable to load profiles.'; });
}
const profName = h('input', { placeholder: 'e.g. pre-evilwpa', maxlength: '64',
autocomplete: 'off' });
const profSave = btn('Save Profile', () => {
const name = profName.value.trim();
if (!name) { App.toast('Profile name is required', 'error'); return; }
return runAction(profSave, () => PagerAPI.post('/api/reliability/profile', { name })
.then(() => { profName.value = ''; App.toast('Config profile saved'); loadProfiles(); }), 'Saving…');
});
profiles.appendChild(h('div', { class: 'settings-form-grid' },
h('label', {}, 'Profile Name', profName)));
profiles.appendChild(h('div', { class: 'settings-actions' }, profSave,
btn('Refresh', () => { loadProfiles(); }, 'ghost')));
loadProfiles();
settingsCard(box, 'Button Script', 'The Mark VII button script has no safe Pager equivalent. Pager buttons remain managed by the native input and payload-launcher system.');
const resources = settingsCard(box, 'Resources');
const resourceBody = h('div', { class: 'settings-table-wrap', text: 'Loading…' });
resources.appendChild(resourceBody);
PagerAPI.get('/api/settings/resources').then((r) => {
resourceBody.innerHTML = '';
resourceBody.appendChild(table([
{ label: 'Filesystem', key: 'filesystem' }, { label: 'Format', key: 'format' },
{ label: 'Size', render: (x) => fmtBytes(x.size) }, { label: 'Used', render: (x) => fmtBytes(x.used) },
{ label: 'Available', render: (x) => fmtBytes(x.available) }, { label: 'Used %', key: 'used_percent' },
{ label: 'Mount Point', key: 'mount' }
], (r.data || {}).filesystems || []));
}).catch(() => { resourceBody.textContent = 'Unable to load filesystems.'; });
const usb = settingsCard(box, 'USB Devices');
const usbBody = h('div', { class: 'settings-table-wrap', text: 'Loading…' });
usb.appendChild(usbBody);
PagerAPI.get('/api/settings/usb').then((r) => {
usbBody.innerHTML = '';
const rows = (r.data || {}).devices || [];
usbBody.appendChild(table([{ label: 'Bus', key: 'bus' }, { label: 'Device', key: 'device' },
{ label: 'VID:PID', key: 'id' }, { label: 'Name', key: 'name' }], rows));
if (!rows.length) usbBody.appendChild(h('div', { class: 'empty', text: 'No USB devices reported.' }));
}).catch(() => { usbBody.textContent = 'Unable to load USB devices.'; });
return { destroy: () => {} };
};
views.settings_networking = (root) => {
const box = settingsShell(root, '#/settings/networking');
const client = settingsCard(box, 'Wireless Client Mode', 'Connect the Pager to an in-range WiFi network for internet access. The management AP keeps running; the radio channel follows the selected network.');
clientModePanel(client, {});
const recon = settingsCard(box, 'Recon Wireless Interfaces');
const reconBody = h('div', { class: 'settings-chip-row', text: 'Loading…' });
recon.appendChild(reconBody);
const interfaces = settingsCard(box, 'Interfaces');
const interfaceBody = h('div', { class: 'settings-table-wrap', text: 'Loading…' });
interfaces.appendChild(interfaceBody);
const routes = settingsCard(box, 'Routing Table');
const routeBody = h('div', { class: 'settings-table-wrap', text: 'Loading…' });
routes.appendChild(routeBody);
PagerAPI.get('/api/settings/network').then((r) => {
const d = r.data || {};
reconBody.innerHTML = '';
(d.recon_interfaces || []).forEach((name) => reconBody.appendChild(h('code', { class: 'settings-chip', text: name })));
if (!(d.recon_interfaces || []).length) reconBody.textContent = 'No monitor interfaces detected.';
interfaceBody.innerHTML = '';
interfaceBody.appendChild(table([{ label: 'Name', key: 'name' },
{ label: 'IP Address', render: (x) => (x.addresses || []).join(', ') || '—' },
{ label: 'MAC Address', render: (x) => x.mac || '—' },
{ label: 'Flags', render: (x) => (x.flags || []).join(', ') }], d.interfaces || []));
routeBody.innerHTML = '';
routeBody.appendChild(table([{ label: 'Destination', key: 'destination' }, { label: 'Gateway', key: 'gateway' },
{ label: 'Genmask', key: 'genmask' }, { label: 'Interface', key: 'interface' },
{ label: 'Flags', key: 'flags' }, { label: 'Metric', key: 'metric' },
{ label: 'Ref', key: 'ref' }, { label: 'Use', key: 'use' }], d.routes || []));
}).catch(apiError('Unable to load networking information'));
return { destroy: () => {} };
};
views.settings_wifi = (root) => {
const box = settingsShell(root, '#/settings/wifi');
const card = settingsCard(box, 'Management Access Point', 'Configure the Pager management WiFi interface. Saving reloads wireless services; USB management access remains available.');
const ssid = h('input', {}), bssid = h('input', { placeholder: 'Optional' });
const password = h('input', { type: 'password', placeholder: 'Leave blank to keep the existing password', autocomplete: 'new-password' });
const confirm = h('input', { type: 'password', placeholder: 'Repeat only when changing it', autocomplete: 'new-password' });
const hidden = h('input', { type: 'checkbox' }), enabled = h('input', { type: 'checkbox' });
card.appendChild(h('div', { class: 'settings-form-grid' }, h('label', {}, 'Management SSID', ssid), h('label', {}, 'BSSID', bssid)));
card.appendChild(h('label', { class: 'switch' }, hidden, h('span', { class: 'track' }), 'Hidden'));
card.appendChild(h('label', { class: 'switch' }, enabled, h('span', { class: 'track' }), 'Enabled'));
card.appendChild(h('div', { class: 'settings-form-grid' }, h('label', {}, 'Password', password), h('label', {}, 'Confirm Password', confirm)));
const save = btn('Save', () => {
if (password.value !== confirm.value) { App.toast('Management passwords do not match', 'error'); return; }
return PagerAPI.post('/api/settings/wifi/management', { ssid: ssid.value.trim(), bssid: bssid.value.trim(),
password: password.value, hidden: hidden.checked, enabled: enabled.checked })
.then(() => { password.value = ''; confirm.value = ''; App.toast('Management WiFi saved'); });
});
card.appendChild(save);
PagerAPI.get('/api/settings/wifi/management').then((r) => {
const d = r.data || {}; ssid.value = d.ssid || ''; bssid.value = d.bssid || '';
hidden.checked = !!d.hidden; enabled.checked = !!d.enabled;
}).catch(apiError('Unable to load management WiFi'));
return { destroy: () => {} };
};
views.settings_led = (root) => {
const box = settingsShell(root, '#/settings/led');
const card = settingsCard(box, 'LED Configuration', 'Pager-specific equivalents of the Mark VII LED controls. Settings are stored in the native Pager configuration.');
const color = h('select', {});
['red', 'green', 'blue', 'yellow', 'cyan', 'magenta', 'white'].forEach((v) => color.appendChild(h('option', { value: v, text: v[0].toUpperCase() + v.slice(1) })));
const vibrate = h('input', { type: 'checkbox' }), clock24 = h('input', { type: 'checkbox' });
const lcd = h('input', { type: 'number', min: '1', max: '11' });
const dim = h('input', { type: 'number', min: '0', max: '11' });
const dimTimeout = h('input', { type: 'number', min: '0', max: '3600' });
const lcdTimeout = h('input', { type: 'number', min: '0', max: '86400' });
card.appendChild(h('label', {}, 'Status LED color', color));
card.appendChild(h('label', { class: 'switch' }, vibrate, h('span', { class: 'track' }), 'Vibrate with alerts'));
card.appendChild(h('label', { class: 'switch' }, clock24, h('span', { class: 'track' }), '24-hour clock'));
card.appendChild(h('div', { class: 'settings-form-grid' },
h('label', {}, 'LCD brightness (1–11)', lcd), h('label', {}, 'Dim brightness (0–11)', dim),
h('label', {}, 'Dim timeout (seconds)', dimTimeout), h('label', {}, 'LCD timeout (seconds)', lcdTimeout)));
function values() { return { led_color: color.value, vibrate: vibrate.checked, clock24hr: clock24.checked,
lcd_brightness: Number(lcd.value), dim_brightness: Number(dim.value),
dim_timeout: Number(dimTimeout.value), lcd_timeout: Number(lcdTimeout.value) }; }
const ledSave = btn('Save', () => {
runAction(ledSave, () => PagerAPI.post('/api/settings/hardware', values())
.then(() => App.toast('Pager hardware preferences saved')), 'Saving…');
});
card.appendChild(ledSave);
PagerAPI.get('/api/settings/hardware').then((r) => {
const d = r.data || {}; color.value = d.led_color || 'magenta'; vibrate.checked = !!d.vibrate;
clock24.checked = !!d.clock24hr; lcd.value = d.lcd_brightness; dim.value = d.dim_brightness;
dimTimeout.value = d.dim_timeout; lcdTimeout.value = d.lcd_timeout;
}).catch(apiError('Unable to load hardware preferences'));
return { destroy: () => {} };
};
views.settings_advanced = (root) => {
const box = settingsShell(root, '#/settings/advanced');
const updates = settingsCard(box, 'Alternative Updates', 'Select the Pager software update channel. Update checking and firmware installation remain in the native Pager workflow.');
const channel = h('select', {}, h('option', { value: 'stable', text: 'Stable (Recommended)' }),
h('option', { value: 'beta', text: 'Beta' }), h('option', { value: 'nightly', text: 'Nightly' }));
updates.appendChild(h('label', {}, 'Selected Software Update Channel', channel));
const chBtn = btn('Set Update Channel', () => {
runAction(chBtn, () => PagerAPI.post('/api/settings/advanced', { update_channel: channel.value })
.then(() => App.toast('Update channel saved')), 'Saving…');
});
updates.appendChild(chBtn);
const host = settingsCard(box, 'Hostname', 'The hostname appears in SSH and DHCP client requests.');
const hostname = h('input', {});
host.appendChild(h('label', {}, 'Hostname', hostname));
const hostSave = btn('Save', () => {
runAction(hostSave, () => PagerAPI.post('/api/settings/hostname', { hostname: hostname.value.trim() })
.then(() => App.toast('Hostname saved')), 'Saving…');
});
host.appendChild(hostSave);
const webui = settingsCard(box, 'Web Interface');
const theme = h('select', {}, h('option', { value: 'light', text: 'Light' }), h('option', { value: 'dark', text: 'Dark' }));
const poll = h('input', { type: 'number', min: '2', max: '120' });
const hotkeys = h('input', { type: 'checkbox' });
theme.value = Theme.current(); poll.value = localStorage.getItem('pw-poll') || '5';
hotkeys.checked = localStorage.getItem('pw_hotkeys') !== 'false';
webui.appendChild(h('div', { class: 'settings-form-grid' }, h('label', {}, 'UI Theme', theme), h('label', {}, 'Poll interval (seconds)', poll)));
webui.appendChild(h('label', { class: 'switch' }, hotkeys, h('span', { class: 'track' }), 'Enable Hot Keys'));
webui.appendChild(btn('Save Web Interface', () => {
localStorage.setItem(Theme.KEY, theme.value); localStorage.setItem('pw-poll', poll.value);
localStorage.setItem('pw_hotkeys', hotkeys.checked ? 'true' : 'false'); Theme.apply();
App.toast('Web interface preferences saved');
}));
PagerAPI.get('/api/settings/advanced').then((r) => { channel.value = r.data.update_channel || 'stable'; hostname.value = r.data.hostname || ''; })
.catch(apiError('Unable to load advanced settings'));
return { destroy: () => {} };
};
views.settings_help = (root) => {
const box = settingsShell(root, '#/settings/help');
const help = settingsCard(box, 'Help & Information');
help.appendChild(h('p', {}, 'Documentation: ', h('a', { href: 'https://docs.hak5.org/wifi-pineapple/', target: '_blank', rel: 'noopener', text: 'docs.hak5.org' })));
help.appendChild(h('p', {}, 'Community support: ', h('a', { href: 'https://community.hak5.org/', target: '_blank', rel: 'noopener', text: 'community.hak5.org' })));
help.appendChild(h('p', {}, 'Downloads: ', h('a', { href: 'https://downloads.hak5.org/', target: '_blank', rel: 'noopener', text: 'downloads.hak5.org' })));
const diagnostics = settingsCard(box, 'Diagnostics', 'Generate a local support report containing device, network, storage, radio, USB, and recent log information.');
const output = h('pre', { class: 'logs settings-diagnostics', text: 'No report generated.' });
let report = '';
const generate = btn('Generate Diagnostics', () => {
output.textContent = 'Generating diagnostics…';
return PagerAPI.get('/api/settings/diagnostics').then((r) => { report = r.data.report || ''; output.textContent = report; })
.catch((e) => { output.textContent = 'Diagnostics failed.'; throw e; });
});
diagnostics.appendChild(h('div', { class: 'settings-actions' }, generate,
btn('Download Report', () => { if (report) downloadText('pager-diagnostics.txt', report); }, 'ghost')));
diagnostics.appendChild(output);
const license = settingsCard(box, 'Licenses');
license.appendChild(h('p', { class: 'muted', text: 'This community WebUI runs alongside the licensed WiFi Pineapple Pager firmware. Third-party component notices remain available in their distributed source files.' }));
return { destroy: () => {} };
};