fix: repair recon scanning and add macOS deployment

Start scans via the Pager's native /api/pineap/recon/new so history
appends instead of rotating. Enforce finite durations (1-86400s,
default 30s), remove the unsupported Continuous mode, and refuse the
unsafe manual stop that left recon.db locked.

Rework scan list and detail reads into single-pass aggregate SQL,
shorten lock retries, and serve cached results during short exclusive
lock windows. Fall back to immutable read-only access when the
firmware leaves a stale lock after native completion.

Frontend auto-follows new scans, queues a single in-flight detail
refresh, keeps previous tables visible while a scan starts, and shows
completion toasts and daemon error details.

Add scripts/deploy.sh for macOS/Linux (zip packaging, scp/ssh install,
atomic payload replacement, service restart, portal refresh) with
README instructions, plus regression coverage for native start,
safe stop semantics, aggregate queries, and stale-lock fallback.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
2026-08-17 17:46:54 -05:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 3e1805dab8
commit 2bf39ecb9d
5 changed files with 431 additions and 86 deletions
@@ -1047,7 +1047,9 @@ views.recon = (root) => {
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 };
apSort: null, clientSort: null, focusAp: null, autoFollow: false,
scanActive: false, detailLoading: false, detailLoadingId: null,
detailQueued: false, detailId: null };
const cols = reconLoadCols();
// ---- title cards ----
@@ -1133,7 +1135,7 @@ views.recon = (root) => {
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'], [0, 'Continuous']]
[[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));
@@ -1150,18 +1152,22 @@ views.recon = (root) => {
.then(() => {
if (on) {
state.scanActive = true;
state.selected = null;
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(() => { scanToggle.checked = !on; App.toast('Recon control failed', 'error'); })
.catch((err) => {
scanToggle.checked = !on;
App.toast((err && err.message) || 'Recon control failed', 'error');
})
.finally(() => { pendingScan = false; scanToggle.disabled = false; });
});
@@ -1452,19 +1458,39 @@ views.recon = (root) => {
function loadDetail() {
if (state.selected == null) return;
PagerAPI.get('/api/recon/scans/' + state.selected).then((r) => {
const scanId = state.selected;
if (state.detailLoading) {
if (state.detailLoadingId !== scanId) state.detailQueued = true;
return;
}
if (!state.scanActive && state.detailId === scanId) return;
state.detailLoading = true;
state.detailLoadingId = scanId;
PagerAPI.get('/api/recon/scans/' + scanId).then((r) => {
if (state.selected !== scanId) return;
state.detail = r.data;
state.detailId = scanId;
drawCharts(r.data);
renderTables();
hsCount.textContent = (r.data.handshakes || []).length;
}).catch(() => {});
}).catch(() => {}).finally(() => {
state.detailLoading = false;
state.detailLoadingId = null;
if (state.detailQueued) {
state.detailQueued = false;
loadDetail();
}
});
}
function load() {
PagerAPI.get('/api/recon/scans').then((r) => {
state.scans = r.data.scans || [];
const keep = state.selected && state.scans.some((s) => s.id === state.selected)
? state.selected : (state.scans[0] ? state.scans[0].id : null);
const newest = state.scans[0] ? state.scans[0].id : null;
const keep = state.autoFollow
? newest
: (state.selected && state.scans.some((s) => s.id === state.selected)
? state.selected : newest);
sel.innerHTML = '';
state.scans.forEach((s) => {
const opt = document.createElement('option');
@@ -1482,17 +1508,23 @@ views.recon = (root) => {
state.selected = keep;
if (keep != null) loadDetail();
}).catch(() => {});
PagerAPI.get('/api/pineap/get_config').then((r) => {
hsAuto.querySelector('input').checked = !!((r.data || {}).loghandshake);
}).catch(() => {});
PagerAPI.get('/api/recon/status').then((r) => {
const scanning = !!r.data.scanning;
const wasScanning = state.scanActive;
const completed = wasScanning && !scanning;
state.scanActive = scanning;
if (!pendingScan) scanToggle.checked = scanning;
restartPoll();
if (wasScanning !== scanning) restartPoll();
if (completed) {
state.autoFollow = false;
App.toast('Scan complete');
}
}).catch(() => {});
}
PagerAPI.get('/api/pineap/get_config').then((r) => {
hsAuto.querySelector('input').checked = !!((r.data || {}).loghandshake);
}).catch(() => {});
load();
let pollIv = null;
const restartPoll = () => {