Files
Mark-VIII/payload/user/remote_access/pager-webui/www/js/views.js
T

3379 lines
153 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const views = {};
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 badge = (on) => h('span', { class: 'badge ' + (on ? 'on' : 'off'), text: on ? 'ON' : 'OFF' });
const btn = (label, onclk, cls) => h('button', { class: 'btn ' + (cls || ''), onclick: onclk, text: label });
const iconBtn = (name, title, onclk) => {
const b = h('button', { class: 'icon-btn', title: title || '', onclick: onclk });
b.innerHTML = PineappleIcons[name] || '';
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');
});
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' : '');
liveCards.health.style.color = h2.pineap_up ? '' : '#b71c1c';
}).catch(() => {});
PagerAPI.get('/api/recon/status').then((r) => {
const s = r.data || {};
const last = s.last_activity || s.last_scan;
liveCards.recon.textContent = (s.scanning ? 'scanning' : 'idle') +
(last ? ' · last activity ' + fmtTime(last) : '');
}).catch(() => {});
}
loadLive();
const liveIv = setInterval(loadLive, 10000);
const chartBox = h('div', { class: 'section' },
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 = 'Unavailable';
cards.disk.textContent = 'Unavailable';
cards.uptime.textContent = 'Unavailable';
});
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: () => '' }],
r.data.clients,
(c) => ({ style: 'cursor:pointer',
onclick: () => { if (confirm('Deauthenticate ' + c.mac + '?')) PagerAPI.post('/api/pineap/deauth/client', { mac: c.mac }).then(() => App.toast('Deauthenticated')).then(loadClients); } })));
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: 'Evil WPA', hash: '#/pineap/evilwpa' },
{ label: 'Evil Open', hash: '#/pineap/open' },
{ 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 = { mode: null, 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 mode = h('span', { class: 'badge', text: '—' });
let selectedMode = 'unknown';
let modeDirty = false;
let modePending = false;
const segBtns = {};
const modeBar = h('div', { class: 'seg' });
['passive', 'active', 'advanced'].forEach((m) => {
const b = h('button', { class: 'seg-btn', text: m[0].toUpperCase() + m.slice(1) });
b.addEventListener('click', () => selectMode(m, true));
modeBar.appendChild(b);
segBtns[m] = b;
});
const modeInfo = h('div', { class: 'muted', style: 'margin-top:8px;font-size:12px' });
const saveModeBtn = btn('Save Mode', saveMode, 'ghost');
saveModeBtn.disabled = true;
const modeCard = h('div', { class: 'pineap-title-card' },
h('div', { class: 'pineap-card-title-flex' }, mode),
h('div', { class: 'pineap-card-button-group' }, modeBar),
modeInfo,
h('div', { class: 'pineap-mode-save' }, saveModeBtn));
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 modeRow = h('div', { class: 'pineap-title-card-container' });
modeRow.appendChild(modeCard);
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', () => {
const requested = cb.checked;
cb.indeterminate = false;
on(requested).then(() => {
if (remember) remember(requested);
load();
}).catch((e) => {
cb.checked = !requested;
load();
App.toast((e && e.message) || 'Failed', 'error');
});
});
}
function rememberAdvanced(key, value) {
PINEAP_SESSION[key] = value;
PINEAP_SESSION.mode = 'advanced';
modeDirty = false;
selectMode('advanced', false);
}
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);
function renderModeInfo(m) {
modeInfo.innerHTML = '';
if (m === 'unknown') {
modeInfo.textContent = 'Select a mode to establish the Pager\'s PineAP preset.';
return;
}
const descriptions = {
passive: ['Capture SSIDs to the impersonation pool', 'Do not broadcast the pool', 'Keep the PineAP response engine disabled'],
active: ['Capture SSIDs to the impersonation pool', 'Enable the PineAP response engine', 'Pool broadcast stays disabled (firmware crash fix)']
};
if (m === 'advanced') {
modeInfo.textContent = 'All supported PineAP features are individually customizable from Quick Settings and the PineAP tabs.';
return;
}
modeInfo.appendChild(h('div', { text: 'In ' + m[0].toUpperCase() + m.slice(1) + ' Mode:' }));
const list = h('ul', { class: 'pineap-mode-features' });
descriptions[m].forEach((text) => list.appendChild(h('li', { text })));
modeInfo.appendChild(list);
}
function selectMode(m, dirty) {
selectedMode = m;
if (dirty) modeDirty = true;
Object.keys(segBtns).forEach((k) => segBtns[k].classList.toggle('active', k === m));
mode.textContent = m === 'unknown' ? 'Unknown' : m[0].toUpperCase() + m.slice(1);
mode.className = 'badge ' + (m === 'unknown' ? 'unknown' : 'on');
renderModeInfo(m);
saveModeBtn.disabled = !modeDirty || modePending || m === 'unknown';
}
function saveMode() {
if (!modeDirty || modePending || selectedMode === 'unknown') return;
modePending = true;
saveModeBtn.disabled = true;
saveModeBtn.classList.add('busy');
PagerAPI.post('/api/pineap/mode', { mode: selectedMode }).then((r) => {
const state = r.data || {};
PINEAP_SESSION.mode = state.mode || selectedMode;
['karma', 'advertise', 'collect'].forEach((key) => {
if (typeof state[key] === 'boolean') PINEAP_SESSION[key] = state[key];
});
modeDirty = false;
selectMode(PINEAP_SESSION.mode, false);
App.toast('Mode: ' + PINEAP_SESSION.mode[0].toUpperCase() + PINEAP_SESSION.mode.slice(1));
load();
}).catch(() => { App.toast('Failed to save PineAP mode', 'error'); })
.finally(() => {
modePending = false;
saveModeBtn.classList.remove('busy');
saveModeBtn.disabled = !modeDirty;
});
}
let loadPending = false;
function load() {
if (loadPending) return;
loadPending = true;
const stateRequest = Promise.all([
PagerAPI.get('/api/pineap/get_config').catch(() => ({ data: {} })),
PagerAPI.get('/api/pineap/hostapd').catch(() => ({ data: {} })),
PagerAPI.post('/api/pineap/wifi/get_ap').catch(() => ({ data: {} })),
PagerAPI.get('/api/pineap/mode').catch(() => ({ data: {} }))
]).then(([cfg, host, ap, preset]) => {
const c = cfg.data || {}, hh = host.data || {}, a = ap.data || {}, p = preset.data || {};
const disabled = Object.prototype.hasOwnProperty.call(hh, 'pineap_disabled') ? !!hh.pineap_disabled : null;
const wpa = a.wpa || {};
PINEAP_SESSION.mode = ['passive', 'active', 'advanced'].indexOf(p.mode) !== -1 ? p.mode : 'unknown';
['karma', 'advertise', 'collect'].forEach((key) => {
if (typeof p[key] === 'boolean') PINEAP_SESSION[key] = p[key];
});
if (!modeDirty) selectMode(PINEAP_SESSION.mode, false);
mode.className = 'badge ' + (disabled === true ? 'off' : disabled === false ? 'on' : 'unknown');
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;
poolNotice.classList.toggle('hidden', !poolDisabled);
quick.advertise.disabled = poolDisabled;
quick.advertise.title = poolDisabled ? 'Disabled by firmware crash fix' : '';
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 : 'Unavailable';
}).catch(() => { stats.clients.textContent = 'Unavailable'; }),
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'; })
];
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',
subtitle: 'WPA2-PSK / WPA3-SAE / WPA3-OWE evil twin with handshake capture',
passphrase: true,
encodings: EVIL_ENC,
handshakes: true,
export: true,
deauth: true,
tabHash: '#/pineap/evilwpa',
playbook: {
steps: ['Deploy the evil twin',
'Wait for a client to associate',
'Deauth the target client to force the 4-way',
'Export .hc22000 and crack with hashcat'],
currentStep: (s, w) => {
if (!w || !w.enabled) return 'Deploy the evil twin';
if (!s || !(s.handshakes > 0)) return 'Wait for a client to associate';
return 'Export .hc22000 and crack with hashcat';
},
hint: (s, w) => {
if (!w || !w.enabled) return '1. Set the target SSID and passphrase, pick a channel (Auto finds it from recon), Deploy. 2. When the target client is near, use Deauth Targeting below. 3. Captured handshakes appear above — Export and run the hashcat command.';
if (s && s.handshakes > 0) return 'Handshake captured! Export .hc22000 and run hashcat -m 22000.';
return 'AP is live on ' + (w.ssid || 'the target') + '. Watch the handshakes list — use Deauth Targeting to nudge the client. If a client refuses to join the twin, its reconnect to the real AP is still captured passively.';
}
}
});
views.pineap_open = attackLauncher('open', {
title: 'Evil Open',
subtitle: 'Open network evil twin',
bssid: true,
country: true,
tabHash: '#/pineap/open',
playbook: {
steps: ['Deploy the open AP',
'Wait for clients to associate',
'Watch connected clients under PineAP → Clients'],
currentStep: (s, w) => {
if (!w || !w.enabled) return 'Deploy the open AP';
return 'Wait for clients to associate';
},
hint: (s, w) => !w || !w.enabled
? 'Set the SSID (optionally spoof a BSSID), pick a channel (Auto finds it from recon), Deploy.'
: 'Open AP is live on ' + (w.ssid || 'the target') + ' — clients that join appear in the Clients list.'
}
});
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'));
poolBox.appendChild(h('div', { class: 'row' },
h('div', {}, h('label', {}, 'SSID', input)),
h('div', {}, btn('Add', () => {
const v = input.value.trim(); if (!v) return;
PagerAPI.post('/api/pineap/ssids', { action: 'add', ssid: v }).then((r) => { input.value = ''; render(r.data.ssids); App.toast('Added'); });
})),
h('div', {}, btn('Clear', () => PagerAPI.post('/api/pineap/ssids', { action: 'clear' }).then((r) => render(r.data.ssids)), 'danger'))));
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', () => {
const requested = advCb.checked;
advCb.indeterminate = false;
PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: requested }).then(() => {
PINEAP_SESSION.advertise = requested;
load();
}).catch(() => { setKnownCheckbox(advCb, PINEAP_SESSION.advertise); App.toast('Failed', 'error'); });
});
colCb.addEventListener('change', () => PagerAPI.post('/api/pineap/ssidpool/collect', { enable: colCb.checked }).then(load).catch(() => { colCb.checked = !colCb.checked; App.toast('Failed', 'error'); }));
function render(ssids) {
poolCount.textContent = Array.isArray(ssids) ? ssids.length : 0;
list.innerHTML = '';
list.appendChild(table(
[{ label: 'SSID', key: 'ssid' }, { label: '', render: () => '' }],
(ssids || []).map((s) => ({ ssid: s })),
(r) => ({ onclick: () => { if (confirm('Remove ' + r.ssid + '?')) PagerAPI.post('/api/pineap/ssids', { action: 'remove', ssid: r.ssid }).then((x) => render(x.data.ssids)); } })));
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(() => {});
PagerAPI.post('/api/pineap/wifi/get_ap').then((r) => {
const p = (r.data || {}).pool || {};
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: '', render: () => '' }],
state.clients,
(r) => ({ style: 'cursor:pointer',
onclick: () => { if (confirm('Kick ' + r.mac + '?')) PagerAPI.post('/api/pineap/clients/kick', { mac: r.mac }).then(() => App.toast('Kicked')).then(load); } })));
const cols = ['MAC', 'Interface', 'RSSI'];
body.querySelectorAll('.tbl th').forEach((th, i) => { if (i >= cols.length) th.textContent = 'Kick'; });
}
let pending = false;
function load() {
if (pending) return;
pending = true;
PagerAPI.get('/api/pineap/clients').then((r) => {
state.clients = r.data.clients || [];
count.textContent = (r.data && typeof r.data.count === 'number') ? r.data.count : '—';
render();
}).catch(() => { count.textContent = 'Unavailable'; body.textContent = 'Unable to load connected clients.'; })
.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 };
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; });
}
function applyResponse(r) {
const data = (r && r.data) || {};
state.mode = data.mode === 'allow' ? 'allow' : 'deny';
state.entries = Array.isArray(data.entries) ? data.entries : [];
render();
}
function fail(message) {
App.toast(message, 'error');
}
function mutate(payload, success) {
if (state.pending) return;
setPending(true);
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;
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; }
mutate({ action: 'add', value }, singular + ' added');
valueIn.value = '';
}
function removeEntry(value) {
if (confirm('Remove ' + value + ' from this ' + (state.mode === 'deny' ? 'deny' : 'allow') + ' list?')) {
mutate({ action: 'delete', value }, singular + ' removed');
}
}
function clearCurrent() {
if (confirm('Clear every entry from the current ' + (state.mode === 'deny' ? 'deny' : 'allow') + ' list?')) {
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.')) {
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.';
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: () => 'Remove' }],
state.entries.map((e) => ({ value: e })),
(row) => ({ onclick: () => removeEntry(row.value) })));
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(() => fail('Unable to load ' + title.toLowerCase()));
}
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_BUCKETS = ['Open', 'WEP', 'WPA', 'WPA2', 'WPA3', 'Enterprise'];
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: 'vendor', label: 'Vendor', render: (a) => a.vendor || '--' },
{ 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 }
];
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, vendor: true, encryption: true, first_seen: true, last_seen: true, hidden: true },
client: { mac: true, signal: true, freq: true, packets: true }
};
}
function reconLoadCols() {
try {
const v = JSON.parse(localStorage.getItem('pw_recon_cols'));
if (v && v.ap && 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) => String(r[c.key] == null ? '' : r[c.key]).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 + (opts.subtitle ? ' — ' + opts.subtitle : '')));
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);
f.appendChild(h('div', { class: 'row', style: 'margin-top:10px' },
h('div', {}, 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;
PagerAPI.post('/api/attacks/deploy', body)
.then((r) => { verifiedToast(r.data || {}); load(); })
.catch((e) => { App.toast(e.message || 'Deploy failed', 'error'); });
})),
h('div', {}, btn('Stop Attack', () => {
PagerAPI.post('/api/attacks/stop', { kind })
.then(() => { App.toast('Attack stopped'); load(); })
.catch(() => App.toast('Stop failed', 'error'));
}, 'danger')),
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', {}, btn(run ? 'Stop Capture' : 'Start Capture', () => {
PagerAPI.post('/api/attacks/capture', {
iface: capIface, action: run ? 'stop' : 'start'
}).then((r) => capRow(r.data || {})).catch(() => App.toast('Capture failed', 'error'));
}))));
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', {}, btn('Export .hc22000', () => {
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');
}).catch(() => App.toast('Export failed', 'error'));
})));
box.appendChild(exp);
}
let deauthCard = null;
const ssidRef = { current: '' };
if (opts.deauth) {
deauthCard = deauthPanel(ssidRef);
box.appendChild(deauthCard);
}
if (opts.playbook) {
const pb = h('div', { class: 'pineap-title-card' });
pb.appendChild(h('div', { class: 'pineap-card-title' }, 'Playbook'));
const pbBody = h('div', { style: 'font-size:13px;line-height:1.8' });
pb.appendChild(pbBody);
box.appendChild(pb);
const steps = opts.playbook.steps;
const stepEls = {};
const stepList = h('ol', { style: 'margin:0;padding-left:20px' });
steps.forEach((s) => {
const li = h('li', { style: 'margin-top:4px' }, h('span', { text: s }));
stepEls[s] = li;
stepList.appendChild(li);
});
pbBody.appendChild(stepList);
const pbHint = h('div', { class: 'muted', style: 'font-size:12px;margin-top:6px' });
pbBody.appendChild(pbHint);
const pbTick = (status, which) => {
const activeStep = opts.playbook.currentStep(status, which);
steps.forEach((s) => {
stepEls[s].style.color = s === activeStep ? 'var(--primary)' : '';
stepEls[s].style.fontWeight = s === activeStep ? '600' : '';
});
pbHint.textContent = opts.playbook.hint(status, which) || '';
};
pbTick(null, null);
box.__pbTick = pbTick;
}
function load() {
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 (box.__pbTick) box.__pbTick(s, which);
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(() => {});
PagerAPI.post('/api/attacks/capture', { action: 'status' }).then((r) => capRow(r.data || {})).catch(() => {});
}
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', () => {
navigator.clipboard.writeText(tok.textContent).then(() => App.toast('Token copied'))
.catch(() => App.toast('Copy failed', 'error'));
}))));
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;
lastLookup = q;
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: btn('Deauth', () => {
const parts = apSel.value.split('|');
if (!mac || !parts[0]) { App.toast('Pick an AP and device first', 'error'); return; }
PagerAPI.post('/api/attacks/deauth', {
bssid: parts[0], client: mac, channel: parseInt(parts[1], 10)
}).then(() => App.toast('Deauth frames sent'))
.catch((e) => App.toast(e.message || 'Deauth failed', 'error'));
}, 'danger') };
})));
}).catch(() => App.toast('Lookup failed', 'error'));
}
body.appendChild(h('div', { class: 'row' }, ssidBox,
h('div', {}, btn('Find', () => lookup(ssidBox.value.trim())))));
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 — WPA2/3-Enterprise with PineAPE credential harvest'));
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 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', {}, 'Passphrase (EAP server secret)', pskIn));
f.appendChild(h('label', { class: 'switch' }, hiddenCb, h('span', { class: 'track' }), 'Hidden'));
f.appendChild(h('label', {}, 'Channel (5 GHz only — Auto uses the target SSID\u2019s recon channel)', chanSel));
f.appendChild(h('div', { class: 'row', style: 'margin-top:10px' },
h('div', {}, btn('Deploy Attack', () => {
PagerAPI.post('/api/attacks/deploy', {
kind: 'enterprise', ssid: ssidIn.value.trim(),
enctype: encSel.value, passphrase: pskIn.value, hidden: hiddenCb.checked,
channel: chanSel.value ? parseInt(chanSel.value, 10) : null
}).then((r) => { verifiedToast(r.data || {}); load(); })
.catch((e) => App.toast(e.message || 'Deploy failed', 'error'));
})),
h('div', {}, btn('Stop Attack', () => {
PagerAPI.post('/api/attacks/stop', { kind: 'enterprise' })
.then(() => { App.toast('Attack stopped'); load(); })
.catch(() => App.toast('Stop failed', 'error'));
}, 'danger'))));
const status = attackStatusCard();
box.appendChild(status.card);
const pb = h('div', { class: 'pineap-title-card' });
pb.appendChild(h('div', { class: 'pineap-card-title' }, 'Playbook'));
const pbHint = h('div', { style: 'font-size:13px;line-height:1.8', text:
'1. Deploy the enterprise twin (5 GHz). 2. When a client attempts 802.1X, its identity and MSCHAPv2 challenge/response land in the tables below. 3. Crack offline: hashcat -m 5500 on the challenge data.' });
pb.appendChild(pbHint);
box.appendChild(pb);
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', () => PagerAPI.post('/api/pineap/hostapd', { pineape_auth_pass: authCb.checked })
.then(load).catch(() => { authCb.checked = !authCb.checked; App.toast('Failed', 'error'); }));
const tables = [['Basic Data', '/api/pineap/enterprise/basic', 'basic'],
['Challenge Data', '/api/pineap/enterprise/challenge', 'challenge']]
.map(([name, endpoint, tableKey]) => {
const tb = h('div', { class: 'pineap-title-card' });
const body = h('div', {});
tb.appendChild(h('div', { class: 'pineap-card-title-flex' },
h('span', { text: name }),
h('span', { class: 'toolbar-spacer' }),
btn('Clear', () => PagerAPI.post('/api/pineap/enterprise/clear', { table: tableKey }).then(load), 'danger')));
tb.appendChild(body);
box.appendChild(tb);
return { body, endpoint };
});
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 : '—' }));
status.append('Interface', h('span', { text: ap ? ap.iface : '—' }));
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 : '—' }));
status.append('Credentials', h('span', { text: String(ent.creds || 0) }));
pApeBody.innerHTML = '';
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') }));
}
}).catch(() => {});
tables.forEach((t) => {
PagerAPI.get(t.endpoint).then((r) => {
const rows = (r.data.rows || []).slice();
t.body.innerHTML = '';
const cols = rows.length ? Object.keys(rows[0]).map((k) => ({ label: k, key: k }))
: [{ label: '—', key: '_none' }];
t.body.appendChild(table(cols, rows));
if (!rows.length) t.body.appendChild(h('div', { class: 'empty', text: 'No data captured.' }));
}).catch(() => {});
});
}
load();
const iv = setInterval(load, 5000);
return { destroy: () => clearInterval(iv) };
};
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';
}
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 === 'first_seen' || col.key === 'last_seen';
if (numeric) {
const x = a[col.key] == null ? -Infinity : Number(a[col.key]);
const y = b[col.key] == null ? -Infinity : Number(b[col.key]);
return (x - y) * dir;
}
const xs = String(a[col.key] == null ? '' : a[col.key]).toLowerCase();
const ys = String(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 };
const cols = reconLoadCols();
// ---- title cards ----
const cardWrap = h('div', { class: 'recon-title-card-container' });
root.appendChild(cardWrap);
function titleCard(titleText, link) {
const wrap = h('div', { class: 'recon-title-card' });
const card = h('div', { class: 'recon-card' });
wrap.appendChild(card);
card.appendChild(link
? h('a', { class: 'recon-card-title-link', href: '#/recon/handshakes', text: titleText })
: h('div', { class: 'recon-title-card-title', text: titleText }));
const content = h('div', { class: 'recon-title-card-content' });
card.appendChild(content);
cardWrap.appendChild(wrap);
return content;
}
const landContent = titleCard('Wireless Landscape', false);
const landBox = h('div', { class: 'recon-chart-box' });
landContent.appendChild(landBox);
const landCanvas = h('canvas', { id: 'recon-landscape' });
landBox.appendChild(landCanvas);
const landEmpty = h('div', { class: 'recon-no-data', text: 'No wireless landscape data is available yet.' });
landBox.appendChild(landEmpty);
const chanContent = titleCard('Channel Distribution', false);
const chanBox = h('div', { class: 'recon-chart-box' });
chanContent.appendChild(chanBox);
const chanCanvas = h('canvas', { id: 'recon-channel' });
chanBox.appendChild(chanCanvas);
const chanEmpty = h('div', { class: 'recon-no-data', text: 'No channel distribution data is available yet.' });
chanBox.appendChild(chanEmpty);
const encContent = titleCard('Encryption Landscape', false);
const encBox = h('div', { class: 'recon-chart-box' });
encContent.appendChild(encBox);
const encCanvas = h('canvas', { id: 'recon-encryption' });
encBox.appendChild(encCanvas);
const encEmpty = h('div', { class: 'recon-no-data', text: 'No encryption data is available yet.' });
encBox.appendChild(encEmpty);
const hsContent = titleCard('Handshakes', true);
const hsCol = h('div', { class: 'recon-hs-col' });
hsContent.appendChild(hsCol);
const hsCount = h('span', { class: 'recon-hs-count', text: '0' });
hsCol.appendChild(hsCount);
hsCol.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' }), ' Automatically Collect Any Handshakes');
hsAuto.querySelector('input').addEventListener('change', () => {
PagerAPI.post('/api/pineap/set_config', { loghandshake: hsAuto.querySelector('input').checked })
.then(() => App.toast('Settings saved')).catch(() => App.toast('Failed to save', 'error'));
});
hsCol.appendChild(hsAuto);
const psContent = titleCard('Previous Scans', false);
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();
});
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;
PagerAPI.del('/api/recon/scans/' + state.selected)
.then(() => { App.toast('Scan deleted'); load(); })
.catch(() => App.toast('Delete failed', 'error'));
});
const delAllBtn = iconBtn('delete_forever', 'Delete all scans', () => {
if (!confirm('Delete ALL recorded scans? This cannot be undone.')) 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();
})
.catch(() => App.toast('Delete failed', 'error'));
});
const psActions = h('div', { class: 'row', style: 'margin:6px 0 8px' });
psActions.appendChild(dlJson);
psActions.appendChild(dlCsv);
psActions.appendChild(dlHtml);
psActions.appendChild(delBtn);
psActions.appendChild(delAllBtn);
psContent.appendChild(psActions);
const psRow = h('div', { class: 'recon-ps-row' });
psContent.appendChild(psRow);
psRow.appendChild(sel);
// ---- 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', () => {
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'));
});
wiglePill.addEventListener('click', () => {
const next = !state.wigle;
wiglePill.disabled = true;
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; 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)');
scanStatus.textContent = bits.join(' · ');
scanStatus.classList.toggle('warn', state.hopperOnline === false || state.historyReset);
}
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(() => {
if (on) {
state.scanActive = true;
state.autoFollow = true;
state.apPage = 0;
state.clientPage = 0;
App.toast('Scan started');
} else {
state.scanActive = false;
state.autoFollow = false;
App.toast('Scan stopped');
}
restartPoll();
load();
})
.catch((err) => {
if (err && err.status === 409) {
// A native timed scan is already running; keep the toggle on and
// show the remaining time instead of pretending the start failed.
scanToggle.checked = true;
state.scanActive = true;
state.scanRemaining = (err.data && err.data.scan_remaining) != null
? err.data.scan_remaining : null;
renderScanBar();
App.toast('Scan already running — it will finish automatically' +
(state.scanRemaining != null ? ' (' + state.scanRemaining + 's left)' : ''), 'error');
} else {
scanToggle.checked = !on;
App.toast((err && err.message) || 'Recon control failed', '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 capture = h('button', { class: 'btn recon-focus-action-button', text: 'Capture WPA Handshakes' });
capture.addEventListener('click', () => {
PagerAPI.post('/api/pineap/set_config', { loghandshake: true })
.then(() => App.toast('Handshake capture enabled (device-wide on Pager)'))
.catch(() => App.toast('Failed to enable handshake capture', 'error'));
});
actions.appendChild(capture);
const stopHs = h('button', { class: 'btn danger recon-focus-action-button', text: 'Stop Handshake Capture' });
stopHs.addEventListener('click', () => {
PagerAPI.post('/api/pineap/set_config', { loghandshake: false })
.then(() => App.toast('Handshake capture disabled'))
.catch(() => App.toast('Failed to disable handshake capture', 'error'));
});
actions.appendChild(stopHs);
const exB = h('button', { class: 'btn recon-focus-action-button', text: 'Examine BSSID' });
exB.addEventListener('click', () => {
if (!ap.bssid) return;
PagerAPI.post('/api/recon/examine', { bssid: ap.bssid })
.then(() => App.toast('Examining ' + ap.bssid))
.catch(() => App.toast('Examine failed', 'error'));
});
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; }
PagerAPI.post('/api/recon/examine', { channel: ap.channel })
.then(() => App.toast('Examining channel ' + ap.channel))
.catch(() => App.toast('Examine failed', 'error'));
});
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 })));
});
}
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' });
root.appendChild(cmpCard);
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' });
root.appendChild(apCard);
// ---- channel map (under Access Points) ----
const mapCard = h('div', { class: 'section recon-map-card' });
root.appendChild(mapCard);
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.' }));
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);
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');
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 land = document.getElementById('recon-landscape');
if (land && typeof MiniChart !== 'undefined' && MiniChart.doughnut) {
if (n > 0) {
const segs = 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: d.unassociated || 0, color: RECON_LANDSCAPE_COLORS[2] }
];
MiniChart.doughnut(land, segs, { legend: true, height: 130 });
land.classList.remove('hidden');
landEmpty.classList.add('hidden');
} else {
land.classList.add('hidden');
landEmpty.classList.remove('hidden');
}
}
const counts = {};
aps.forEach((a) => {
const ch = a.channel == null ? '?' : a.channel;
counts[ch] = (counts[ch] || 0) + 1;
});
const keys = Object.keys(counts).sort((a, b) => {
if (a === '?') return 1;
if (b === '?') return -1;
return Number(a) - Number(b);
});
const ch = document.getElementById('recon-channel');
if (ch && typeof MiniChart !== 'undefined' && MiniChart.bar) {
if (keys.length) {
MiniChart.bar(ch, keys.map((k, i) => ({
label: k, value: counts[k], color: RECON_CHANNEL_COLORS[i % RECON_CHANNEL_COLORS.length]
})), { height: 130 });
ch.classList.remove('hidden');
chanEmpty.classList.add('hidden');
} else {
ch.classList.add('hidden');
chanEmpty.classList.remove('hidden');
}
}
const encCounts = {};
aps.forEach((a) => {
const b = reconEncBucket(a.encryption);
encCounts[b] = (encCounts[b] || 0) + 1;
});
const enc = document.getElementById('recon-encryption');
if (enc && typeof MiniChart !== 'undefined' && MiniChart.doughnut) {
if (aps.length) {
MiniChart.doughnut(enc, RECON_ENC_BUCKETS.map((k, i) => ({
label: k, value: encCounts[k] || 0, color: RECON_ENC_COLORS[i]
})), { legend: true, height: 130 });
enc.classList.remove('hidden');
encEmpty.classList.add('hidden');
} else {
enc.classList.add('hidden');
encEmpty.classList.remove('hidden');
}
}
}
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;
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;
}).catch(() => {}).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';
}
function load() {
PagerAPI.get('/api/recon/scans').then((r) => {
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(() => {});
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;
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(() => {});
}
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, onclick: onclk });
b.innerHTML = PineappleIcons[name] || '';
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) {
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', {}, btn('Archive', () => PagerAPI.post('/api/loot/archive').then(() => App.toast('Archived')))),
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', () => {
PagerAPI.del('/api/pineap/handshakes', { name: f.name })
.then(() => load(() => flash(true, 'Deleted ' + f.name)))
.catch(() => flash(false, 'Failed to delete ' + 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() {
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 })),
btn('Delete All Handshakes', () => {
PagerAPI.del('/api/pineap/handshakes/all')
.then(() => { close(); load(() => flash(true, 'All handshakes deleted')); })
.catch(() => { close(); flash(false, 'Failed to delete all handshakes'); });
}, 'danger')),
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;
pending = true;
PagerAPI.get('/api/logging/pineap?lines=200').then((r) => {
lines.splice(0, lines.length, ...((r.data || {}).lines || []));
render();
}).catch(() => { App.toast('Failed to load PineAP log', 'error'); })
.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;
pending = true;
PagerAPI.get('/api/logging/system?lines=400').then((r) => {
lines.splice(0, lines.length, ...((r.data || {}).lines || []));
render();
}).catch(() => { App.toast('Failed to load system log', 'error'); })
.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) {
button.disabled = busy;
button.textContent = busy ? (label || button.dataset.label || button.textContent)
: (button.dataset.label || button.textContent);
}
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', () => {
setPayloadBusy(launch, true, 'Starting...');
PagerAPI.post('/api/payloads/run', { key: item.key }).then(() => {
App.toast(item.title + ' started');
location.hash = '#/modules/running';
}).catch(apiError('Unable to start payload')).finally(() => setPayloadBusy(launch, false));
});
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;
setPayloadBusy(remove, true, 'Removing...');
PagerAPI.post('/api/payloads/remove', { key: item.key }).then(() => {
App.toast(item.title + ' removed'); load();
}).catch(apiError('Unable to remove payload')).finally(() => setPayloadBusy(remove, false));
}, '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) {
setPayloadBusy(button, true, 'Updating...');
PagerAPI.post('/api/payloads/install', { key: item.key }).then(() => {
App.toast(item.title + ' updated'); load();
}).catch(apiError('Unable to update payload')).finally(() => setPayloadBusy(button, false));
}
function load() {
refresh.disabled = true;
PagerAPI.get('/api/payloads/installed').then((r) => {
if (!alive) return;
rows = (r.data && r.data.payloads) || [];
filters.category.innerHTML = '<option value="">All categories</option>';
fillPayloadCategories(filters.category, rows, 'category');
render();
}).catch(apiError('Unable to load installed payloads')).finally(() => { refresh.disabled = false; });
}
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', () => {
refresh.disabled = true;
PagerAPI.post('/api/payloads/refresh').then(() => {
App.toast('Pager Portal listings refreshed'); return load();
}).catch(apiError('Unable to refresh Pager Portal')).finally(() => { refresh.disabled = false; });
});
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;
install.dataset.label = 'Install';
setPayloadBusy(install, true, 'Installing...');
PagerAPI.post('/api/payloads/install', { key: item.key }).then(() => {
installed.add(item.key); App.toast(item.title + ' installed'); render();
}).catch(apiError('Unable to install payload')).finally(() => setPayloadBusy(install, false));
}, 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 = '<option value="">All categories</option>';
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', () => {
stop.disabled = true;
PagerAPI.post('/api/payloads/stop', { id: run.id }).then(() => {
App.toast(run.title + ' stop requested'); setTimeout(load, 500);
}).catch(apiError('Unable to stop payload')).finally(() => { stop.disabled = false; });
}, '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;
pending = true; refresh.disabled = true;
PagerAPI.get('/api/payloads/runs').then((r) => render((r.data && r.data.runs) || []))
.catch(apiError('Unable to load payload runs')).finally(() => { pending = false; refresh.disabled = 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;
}
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');
});
});
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() {
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 form = h('div', { class: 'client-connect-form' }, encSel, pw, routedLabel,
btn('Connect', () => doConnect({ ssid: net.ssid, encryption: encSel.value,
password: pw.value, routed: routedCb.checked }), 'primary'),
btn('Cancel', () => renderNetworks(), 'ghost'));
row.appendChild(form);
pw.focus();
}
function doConnect(opts) {
if (state.connecting) return;
state.connecting = true;
setBusy(true);
PagerAPI.post('/api/settings/wifi/client/connect', opts)
.then(() => {
App.toast('Connecting to ' + opts.ssid + '\u2026');
state.status = null;
pollConnected(opts.ssid);
})
.catch((e) => {
state.connecting = false;
setBusy(false);
App.toast(e.message || 'Failed to connect', 'error');
refresh();
});
}
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(() => {
state.connecting = false;
setBusy(false);
App.toast('Failed to check connection status', 'error');
});
};
function finish(message, kind) {
state.connecting = false;
setBusy(false);
App.toast(message, kind);
App.checkInternet(false);
}
setTimeout(tick, 2000);
}
function doDisconnect() {
if (state.connecting) 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'));
}
function scan() {
if (state.connecting) return;
netsEl.textContent = 'Scanning\u2026';
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') }));
});
}
function setBusy(busy) {
const buttons = actionsEl.querySelectorAll('button');
buttons.forEach((b) => { b.disabled = 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)));
user.appendChild(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; }
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');
}).catch(apiError('Failed to update password'));
}));
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 tzActions = h('div', { class: 'settings-actions' },
btn('Update Timezone', () => {
const opt = tzSel.options[tzSel.selectedIndex];
PagerAPI.post('/api/settings/timezone', { timezone: tzSel.value, zonename: opt.getAttribute('data-zone') || '' })
.then(() => App.toast('Timezone updated')).catch(apiError('Failed to update timezone'));
}),
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');
PagerAPI.post('/api/settings/synctime', { timestamp: stamp })
.then(() => App.toast('Pager time synchronized')).catch(apiError('Failed to synchronize time'));
}, 'ghost'));
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));
ntp.appendChild(btn('Save', () => 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')).catch(apiError('Failed to save network time'))));
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'));
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; }
save.disabled = true;
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'); })
.catch(apiError('Failed to save management WiFi')).finally(() => { save.disabled = false; });
});
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 (111)', lcd), h('label', {}, 'Dim brightness (011)', 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) }; }
card.appendChild(btn('Save', () => PagerAPI.post('/api/settings/hardware', values())
.then(() => App.toast('Pager hardware preferences saved')).catch(apiError('Failed to save hardware preferences'))));
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));
updates.appendChild(btn('Set Update Channel', () => PagerAPI.post('/api/settings/advanced', { update_channel: channel.value })
.then(() => App.toast('Update channel saved')).catch(apiError('Failed to save update channel'))));
const host = settingsCard(box, 'Hostname', 'The hostname appears in SSH and DHCP client requests.');
const hostname = h('input', {});
host.appendChild(h('label', {}, 'Hostname', hostname));
host.appendChild(btn('Save', () => PagerAPI.post('/api/settings/hostname', { hostname: hostname.value.trim() })
.then(() => App.toast('Hostname saved')).catch(apiError('Failed to save hostname'))));
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', () => {
generate.disabled = true; output.textContent = 'Generating diagnostics…';
PagerAPI.get('/api/settings/diagnostics').then((r) => { report = r.data.report || ''; output.textContent = report; })
.catch(() => { output.textContent = 'Diagnostics failed.'; App.toast('Failed to generate diagnostics', 'error'); })
.finally(() => { generate.disabled = false; });
});
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: () => {} };
};