From d7ef0624f45392e535be0d530b7d20532f461678 Mon Sep 17 00:00:00 2001 From: c4ch3c4d3 Date: Tue, 18 Aug 2026 23:49:49 -0500 Subject: [PATCH] ui: recon card redesign, channel-map hover, Send to PineAP prefill, recon hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Top 5 recon cards restyled as stat cards: value + sub-line + mini chart, consistent 190px anatomy; Handshakes links to the handshakes page; Previous Scans shows count/latest plus a compact picker + action row - Channel map records lobe geometry (canvas.__reconLobes / __reconLobesHit); hovering lists the networks under the cursor, click pins the tooltip, mouseleave hides it; empty states hide the tooltip too - Recon focus sidebar gains 'Send to PineAP — Twin this network': routes to Evil WPA (psk2/sae/owe from recon encryption) or OpenAP (with BSSID) and prefills the form; one-shot PineAPPrefill is consumed by the attack launcher and never auto-deploys or leaks into manual forms - Existing Actions verified end-to-end and hardened: capture/stop handshake sync the auto-collect toggle, examine buttons disable while pending - Recon resilience: loadDetail retries after failed render or 503 (no more permanently blank page), scan-list errors surface in the scan bar, poll guard prevents overlapping loads, chart draws are individually isolated - Auto-collect toggle re-syncs from get_config on the slow poll so the UI tracks the pager's own settings --- .../specs/2026-08-18-ui-hardening-design.md | 123 +++++ .../remote_access/pager-webui/www/css/app.css | 56 +- .../remote_access/pager-webui/www/index.html | 8 +- .../remote_access/pager-webui/www/js/chart.js | 18 + .../remote_access/pager-webui/www/js/views.js | 480 +++++++++++++----- 5 files changed, 537 insertions(+), 148 deletions(-) create mode 100644 docs/superpowers/specs/2026-08-18-ui-hardening-design.md diff --git a/docs/superpowers/specs/2026-08-18-ui-hardening-design.md b/docs/superpowers/specs/2026-08-18-ui-hardening-design.md new file mode 100644 index 0000000..cdecb44 --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-ui-hardening-design.md @@ -0,0 +1,123 @@ +# Mark VIII UI Hardening + Recon Polish — Design + +Date: 2026-08-18 +Status: Approved in advance by user (auto-approve; user unavailable for review) + +## Context + +Mark VIII (web UI for the WiFi Pineapple Pager) has been through major dev +changes. The user runs a live test tomorrow and needs the UI rock solid. +Verified on device: + +- MCP server works: `tools/list` returns 16 tools; `device.state`, + `recon.aps` respond correctly. +- API endpoints used by the Recon "Actions" buttons work + (`/api/recon/examine`, `/api/pineap/set_config`). +- Recon detail endpoint is slow (~1.5 s) and can 503 when the sqlite DB is + busy; the UI swallows errors silently and can stay blank. + +## Findings + +1. **Recon top cards (5)**: equal 200 px cards with 20 px titles, centered + content, charts sized for wide cards. "Previous Scans" crams 5 icon + buttons + a 50+-option select into a ~220 px card → buttons stack into + three rows. "Channel Distribution" bar chart is unreadable at card width. + Handshakes toggle text wraps awkwardly. +2. **Channel map**: canvas lobes render but have zero interactivity — no way + to see which networks are under the cursor. +3. **Focus sidebar Actions**: "Capture WPA Handshakes" / "Stop Handshake + Capture" / "Examine BSSID" / "Examine Channel" work at the API level but + give weak feedback; there is no path from a recon target to the PineAP + evil-twin form. +4. **Robustness**: `loadDetail()` in the recon view swallows render/network + errors with `.catch(() => {})`; if rendering throws after `detailId` is + set, the page stays blank forever (guard short-circuits). `load()` has no + pending guard → overlapping polls. `hsAuto` (loghandshake) checkbox is + only synced at view creation — it drifts from the pager's own settings + when changed elsewhere (pager UI / another browser). + +## Changes + +### 1. Recon title cards — redesign (`views.js` + `app.css`) + +New card anatomy (all five consistent): +- Compact header: 12 px uppercase muted title (clickable where it links). +- Primary value: 28 px bold. +- Sub-line: 12 px muted context text. +- Optional mini chart: fixed 96 px tall. + +Cards (`flex: 1 1 0; min-width: 170px; height: 190px` — five fit one row +even in a ~1000 px pane): +1. **Wireless Landscape** — value: total networks (APs); sub: "N clients · + M unassociated"; mini doughnut (APs/Clients/Unassociated). +2. **Channel Distribution** — value: busiest channel "CH 6"; sub: "N of K + APs on CH 6 · channels seen"; mini bar chart of channel counts. +3. **Encryption Landscape** — value: top encryption bucket; sub: "N of K + APs"; mini doughnut of buckets. +4. **Handshakes** — value: handshake count (links to + `#/recon/handshakes`); sub: "captured"; auto-collect toggle (restyled). +5. **Previous Scans** — value: scan count; sub: latest scan start time; + compact select row + single row of icon buttons. + +Empty states get copy that matches the card ("No landscape data yet — +run a scan"). + +### 2. Channel map hover (`chart.js` + `views.js` + `app.css`) + +- `MiniChart.channelMap` records per-lobe geometry on the canvas: + `canvas.__reconLobes = [{ ap, cx, half, topPx, basePx }]` (CSS px). +- `views.js` attaches `mousemove`/`mouseleave`/`click` to the map canvas: + - hit test: `t=(mx-cx)/half`, lift = `0.5+0.5·cos(πt)`, hovered if + `my >= basePx - lift·peakPx - 4` and `my <= basePx + 8`; + - tooltip div (absolute, inside `.recon-map-box`) lists every network + under the cursor: SSID/MAC/channel/freq/signal/encryption/vendor; + - click pins the tooltip until the next move or click; + - `mouseleave` hides it. +- Lobe geometry regenerates on every `renderChannelMap()` (redraw), so + stale geometry is impossible. + +### 3. Focus sidebar: Actions validation + Send to PineAP (`views.js`) + +- Existing buttons stay; toast feedback improved (already verified working + at API level; re-verified end-to-end in browser). +- New primary button **"Send to PineAP — Twin this network"**: + - encryption bucket `Open` → navigate `#/pineap/open` with prefill + `{ssid, hidden, channel, bssid}`; + - anything else → navigate `#/pineap/evilwpa` with prefill + `{ssid, hidden, channel, enctype}` where enctype maps from recon + encryption: SAE→`sae`, OWE→`owe`, WPA3-only→`sae`, else `psk2`; + - Enterprise networks still go to Evil WPA (psk2) — noted in the prefill + banner. +- Prefill mechanism: `window.PineAPPrefill = { set, consume }` (module + singleton in `views.js`); `consume()` clears after use so a stale prefill + never leaks into a manually opened form. +- `attackLauncher()` consumes the prefill when building the form (SSID, + hidden, channel via `chanSelect`, enctype, BSSID) and renders a muted + banner: "Prefilled from Recon — verify, set the passphrase, then Deploy." + Deploy is never triggered automatically (no attacks without an explicit + user action). + +### 4. Robustness / sync hardening (`views.js`) + +- `loadDetail()`: on fetch failure keep `detailId` unset so the poll + retries; surface "Scan data unavailable — retrying…" in the scan status + line; wrap the render body so one chart's exception cannot blank the + table (each chart draw also wrapped individually). +- `drawCharts()`: wrap each chart section in try/catch. +- `load()`: `loadPending` guard against overlapping polls; surface scan-list + errors in the status line. +- `hsAuto` re-syncs from `/api/pineap/get_config` on the 30 s slow poll + (stays in sync with the pager's own UI/settings changes). +- Version bumps in `index.html` for `app.css`, `chart.js`, `views.js`. + +### 5. Verification + +- Backend unchanged → existing `tests/` still pass (run the suite). +- Deploy via `scripts/deploy.sh --password` (device: 172.16.52.1). +- End-to-end in browser (device UI): + - login; recon page: cards populated with scan data; no blank-page state; + - click an AP row → focus sidebar → each Action button verified by + reading back state (`get_config`) and API responses; + - Send to PineAP → form pre-filled on the right tab (open vs WPA target); + - channel map click → tooltip shows networks under cursor; + - reboot-resilience spot check via service restart (pagerwebui restart). diff --git a/payload/user/remote_access/pager-webui/www/css/app.css b/payload/user/remote_access/pager-webui/www/css/app.css index 852cbff..82242eb 100644 --- a/payload/user/remote_access/pager-webui/www/css/app.css +++ b/payload/user/remote_access/pager-webui/www/css/app.css @@ -301,23 +301,39 @@ html.dark .sel { background: #424242; border-color: #545454; color: #fff; } html.dark .muted { color: #bdbdbd; } /* ---- Recon (Mark VII parity) ---- */ -.recon-title-card-container { display: flex; width: 100%; flex-wrap: wrap; justify-content: space-between; gap: 10px; margin: 8px 0 16px; } -.recon-title-card { flex: 1 1 220px; min-width: 220px; margin-bottom: 1em; } -.recon-card { background: var(--surface); border-radius: 2px; box-shadow: var(--shadow); height: 200px; padding: 12px 16px; display: flex; flex-direction: column; } -.recon-title-card-title { font-size: 20px; margin-bottom: 15px; display: flex; align-items: center; color: var(--text); } +.recon-title-card-container { display: flex; width: 100%; flex-wrap: wrap; gap: 10px; margin: 8px 0 16px; } +.recon-card { + flex: 1 1 0; min-width: 170px; height: 190px; + background: var(--surface); border-radius: 2px; box-shadow: var(--shadow); + padding: 12px 14px; display: flex; flex-direction: column; +} +.recon-card-title { + font-size: 12px; text-transform: uppercase; letter-spacing: .05em; + color: var(--muted); margin-bottom: 6px; display: flex; align-items: center; +} .recon-card-title-link { color: inherit; text-decoration: none; } .recon-card-title-link:visited { color: inherit; } -.recon-card-title-link:hover { text-decoration: underline; } -.recon-title-card-content { display: flex; justify-content: center; align-items: center; height: 70%; } -.recon-chart-box { width: 100%; height: 150px; position: relative; } -.recon-chart-box canvas { width: 100%; height: 100%; } -.recon-no-data { font-style: italic; color: #787878; display: flex; justify-content: center; padding: 12px; } -.recon-hs-col { display: flex; flex-direction: column; justify-content: center; align-items: center; } +.recon-card-title-link:hover { color: var(--primary); text-decoration: underline; } +.recon-card-body { display: flex; flex-direction: column; flex: 1; min-height: 0; } +.recon-card-value { + font-size: 26px; font-weight: 700; line-height: 1.15; color: var(--text); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.recon-card-sub { + font-size: 12px; color: var(--muted); margin: 1px 0 6px; min-height: 16px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.recon-chart-box { position: relative; flex: 1; min-height: 0; margin-top: auto; } +.recon-chart-box canvas { position: absolute; inset: 0; width: 100%; height: 100%; } +.recon-no-data { font-style: italic; color: #787878; display: flex; justify-content: center; align-items: center; padding: 8px; text-align: center; } .recon-hs-count { font-size: 32px; font-weight: 700; line-height: 1.1; } -.recon-hs-label { color: grey; margin: 2px 0 10px; } -.recon-toggle { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text); margin: 0; cursor: pointer; } -.recon-ps-row { display: flex; align-items: center; width: 100%; gap: 4px; } -.recon-ps-row .sel { width: 100%; } +.recon-hs-label { color: var(--muted); font-size: 12px; margin: 1px 0 8px; } +.recon-toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text); margin: 0; cursor: pointer; } +.recon-ps-row { display: flex; align-items: center; width: 100%; gap: 4px; margin-top: 2px; } +.recon-ps-row .sel { width: 100%; font-size: 12px; padding: 4px 6px; } +.recon-ps-actions { display: flex; align-items: center; gap: 2px; margin-top: 4px; } +.recon-ps-actions .icon-btn { width: 28px; height: 28px; } +.recon-ps-actions .icon-btn svg { width: 18px; height: 18px; } .icon-btn { background: transparent; color: var(--muted); border: 0; border-radius: 50%; width: 36px; height: 36px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 0; } .icon-btn:hover { background: var(--surface-alt); color: var(--text); } .icon-btn:disabled { opacity: .38; cursor: default; } @@ -362,6 +378,8 @@ html.dark .recon-row-compare td { background: rgba(25, 118, 210, .18); } .recon-focus-body { margin-top: 18px; } .recon-focus-body-title { font-size: 16px; margin-bottom: 8px; color: var(--text); } .recon-focus-action-button { width: 100%; margin-bottom: 5px; } +.recon-focus-twin { background: var(--ok); } +.recon-focus-twin:hover { background: #689f38; } .recon-focus-detail { display: flex; justify-content: space-between; gap: 10px; padding: 3px 0; border-bottom: 1px solid var(--border); font-size: 13px; } .recon-focus-detail-label { color: var(--muted); flex: none; } .recon-sort-arrow { color: var(--muted); font-size: 11px; } @@ -410,6 +428,16 @@ html.dark .recon-pill.on { background: #1b3a23; color: #81c784; } .recon-map-box { position: relative; } .recon-map-box canvas { display: block; } .recon-map-box .recon-no-data { min-height: 60px; } +.recon-map-tip { + position: absolute; z-index: 20; min-width: 220px; max-width: 260px; + background: var(--surface); border: 1px solid var(--border); border-radius: 3px; + box-shadow: var(--shadow); padding: 8px 10px; pointer-events: none; font-size: 12px; + color: var(--text); +} +.recon-map-tip-row { padding: 3px 0; border-bottom: 1px solid var(--border); } +.recon-map-tip-row:last-child { border-bottom: 0; } +.recon-map-tip-ssid { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.recon-map-tip-meta { color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* ---- Reports view ---- */ .wigle-warn { color: #ef6c00; font-size: 12px; } diff --git a/payload/user/remote_access/pager-webui/www/index.html b/payload/user/remote_access/pager-webui/www/index.html index 30d1822..8d9e1d6 100644 --- a/payload/user/remote_access/pager-webui/www/index.html +++ b/payload/user/remote_access/pager-webui/www/index.html @@ -6,7 +6,7 @@ WiFi Pineapple - + @@ -263,12 +263,12 @@ - + - - + + diff --git a/payload/user/remote_access/pager-webui/www/js/chart.js b/payload/user/remote_access/pager-webui/www/js/chart.js index c0ca720..6c760b6 100644 --- a/payload/user/remote_access/pager-webui/www/js/chart.js +++ b/payload/user/remote_access/pager-webui/www/js/chart.js @@ -161,6 +161,8 @@ const MiniChart = (() => { // Channel map: each AP is a raised-cosine lobe at its reported center // frequency with the peak at its signal strength. The radio does not report // channel width, so every lobe assumes 20 MHz (half-width +-10 MHz). + // Lobe geometry (CSS px) is recorded on the canvas so the caller can + // hit-test pointer position against the networks under the cursor. function channelMap(canvas, aps, opts) { const o = opts || {}; const dpr = window.devicePixelRatio || 1; @@ -171,6 +173,7 @@ const MiniChart = (() => { ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const w = canvas.clientWidth, h = H; ctx.clearRect(0, 0, w, h); + canvas.__reconLobes = []; if (!aps || !aps.length) return; const pts = aps .map((a) => ({ @@ -211,6 +214,7 @@ const MiniChart = (() => { const topY = y(a.signal); const peakH = Math.max(2, baseY - topY); const half = Math.max(4, (10 / (fMax - fMin)) * plotW); + canvas.__reconLobes.push({ ap: a, cx: cx, half: half, topY: topY, baseY: baseY }); ctx.beginPath(); for (let i = 0; i <= 28; i++) { const t = -1 + i / 14; @@ -227,6 +231,20 @@ const MiniChart = (() => { ctx.lineWidth = 1.5; ctx.stroke(); }); + // Hit test: list networks whose lobe is under (mx, my) in CSS px. + canvas.__reconLobesHit = function (mx, my, padPx) { + const pad = padPx == null ? 4 : padPx; + const hits = []; + (this.__reconLobes || []).forEach((l) => { + const dx = mx - l.cx; + if (Math.abs(dx) > l.half + 2) return; + const t = Math.min(1, Math.max(-1, dx / l.half)); + const lift = Math.max(0, 0.5 + 0.5 * Math.cos(Math.PI * t)); + const lobeTop = l.baseY - lift * (l.baseY - l.topY); + if (my >= lobeTop - pad && my <= l.baseY + pad) hits.push(l.ap); + }); + return hits; + }; let lastLabelX = -Infinity; ctx.textAlign = 'center'; aps.forEach((a) => { diff --git a/payload/user/remote_access/pager-webui/www/js/views.js b/payload/user/remote_access/pager-webui/www/js/views.js index de90387..76b76e0 100644 --- a/payload/user/remote_access/pager-webui/www/js/views.js +++ b/payload/user/remote_access/pager-webui/www/js/views.js @@ -1,6 +1,20 @@ 'use strict'; const views = {}; +// One-shot prefill passed between views (Recon target -> PineAP twin form). +// consume() clears the value so a stale prefill can never leak into a +// manually opened form. +const PineAPPrefill = { + data: null, + set(d) { PineAPPrefill.data = d || null; }, + consume() { + const d = PineAPPrefill.data; + PineAPPrefill.data = null; + return d; + } +}; +window.PineAPPrefill = PineAPPrefill; + const h = (tag, attrs, ...children) => { const n = document.createElement(tag); if (attrs) { @@ -985,6 +999,31 @@ function attackLauncher(kind, opts) { f.appendChild(h('label', { class: 'switch' }, hiddenCb, h('span', { class: 'track' }), 'Hidden')); f.appendChild(h('label', {}, 'Channel', chanSel)); f.appendChild(bandHint); + + // Consume a Recon -> PineAP prefill (set by the Recon focus sidebar's + // "Send to PineAP" button). The form is filled but never auto-deploys: + // an attack always requires an explicit user action. + const prefill = PineAPPrefill.consume(); + if (prefill && prefill.ssid) { + ssidIn.value = prefill.ssid; + hiddenCb.checked = !!prefill.hidden; + if (prefill.channel != null && prefill.channel !== '') { + const chanOpts = Array.prototype.slice.call(chanSel.options); + const hit = chanOpts.find((o) => Number(o.value) === Number(prefill.channel)); + if (hit) chanSel.value = hit.value; + chanSel.dispatchEvent(new Event('change')); + } + if (encSel && prefill.enctype) { + const encOpts = Array.prototype.slice.call(encSel.options); + const hit = encOpts.find((o) => o.value === prefill.enctype); + if (hit) encSel.value = hit.value; + } + if (pskIn) pskIn.placeholder = 'Passphrase for ' + prefill.ssid; + if (bssidIn && prefill.bssid) bssidIn.value = prefill.bssid; + f.appendChild(h('div', { class: 'pineap-infobox info', style: 'margin:10px 0 0;font-size:12px', + text: 'Prefilled from Recon (' + (prefill.source || 'target') + '). Set the passphrase, verify the settings, then Deploy.' })); + } + f.appendChild(h('div', { class: 'row', style: 'margin-top:10px' }, h('div', {}, btn('Deploy Attack', () => { const body = { @@ -1372,6 +1411,14 @@ function reconEncBucket(enc) { return s || 'Unknown'; } +// Map a recon AP's encryption string to the Evil WPA form's enctype. +function reconPrefillEnc(enc) { + const s = (enc || '').toLowerCase(); + if (s.indexOf('owe') !== -1) return 'owe'; + if (s.indexOf('sae') !== -1) return 'sae'; + return 'psk2'; +} + function reconPer(key, def) { const v = parseInt(localStorage.getItem('pw_recon_per_' + key), 10); return [10, 25, 50].indexOf(v) !== -1 ? v : def; @@ -1403,65 +1450,86 @@ views.recon = (root) => { apBand: 'all', apEnc: 'all', gps: null, wigle: null, compare: [], history: {}, mapBand: null, archive: null, archives: [], scanRemaining: null, - hopperOnline: null, historyReset: false }; + hopperOnline: null, historyReset: false, scanErr: null }; const cols = reconLoadCols(); - // ---- title cards ---- + // ---- title cards (stat cards with optional mini charts) ---- const cardWrap = h('div', { class: 'recon-title-card-container' }); root.appendChild(cardWrap); function titleCard(titleText, link) { - const 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 head = h('div', { class: 'recon-card-title' }); + head.appendChild(link + ? h('a', { class: 'recon-card-title-link', href: link, text: titleText }) + : h('span', { text: titleText })); + card.appendChild(head); + const body = h('div', { class: 'recon-card-body' }); + card.appendChild(body); + cardWrap.appendChild(card); + return body; } - const landContent = titleCard('Wireless Landscape', false); + const landBody = titleCard('Wireless Landscape', null); + const landValue = h('div', { class: 'recon-card-value', text: '—' }); + const landSub = h('div', { class: 'recon-card-sub', text: '' }); + landBody.appendChild(landValue); + landBody.appendChild(landSub); const landBox = h('div', { class: 'recon-chart-box' }); - landContent.appendChild(landBox); + landBody.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.' }); + const landEmpty = h('div', { class: 'recon-no-data', text: 'No landscape data yet — run a scan.' }); landBox.appendChild(landEmpty); - const chanContent = titleCard('Channel Distribution', false); + const chanBody = titleCard('Channel Distribution', null); + const chanValue = h('div', { class: 'recon-card-value', text: '—' }); + const chanSub = h('div', { class: 'recon-card-sub', text: '' }); + chanBody.appendChild(chanValue); + chanBody.appendChild(chanSub); const chanBox = h('div', { class: 'recon-chart-box' }); - chanContent.appendChild(chanBox); + chanBody.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.' }); + const chanEmpty = h('div', { class: 'recon-no-data', text: 'No channel data yet — run a scan.' }); chanBox.appendChild(chanEmpty); - const encContent = titleCard('Encryption Landscape', false); + const encBody = titleCard('Encryption Landscape', null); + const encValue = h('div', { class: 'recon-card-value', text: '—' }); + const encSub = h('div', { class: 'recon-card-sub', text: '' }); + encBody.appendChild(encValue); + encBody.appendChild(encSub); const encBox = h('div', { class: 'recon-chart-box' }); - encContent.appendChild(encBox); + encBody.appendChild(encBox); const encCanvas = h('canvas', { id: 'recon-encryption' }); encBox.appendChild(encCanvas); - const encEmpty = h('div', { class: 'recon-no-data', text: 'No encryption data is available yet.' }); + const encEmpty = h('div', { class: 'recon-no-data', text: 'No encryption data yet — run a scan.' }); encBox.appendChild(encEmpty); - const hsContent = titleCard('Handshakes', true); - const hsCol = h('div', { class: 'recon-hs-col' }); - hsContent.appendChild(hsCol); + const hsBody = titleCard('Handshakes', '#/recon/handshakes'); const hsCount = h('span', { class: 'recon-hs-count', text: '0' }); - hsCol.appendChild(hsCount); - hsCol.appendChild(h('span', { class: 'recon-hs-label', text: 'Handshakes Captured' })); + hsBody.appendChild(hsCount); + hsBody.appendChild(h('span', { class: 'recon-hs-label', text: 'handshakes captured' })); const hsAuto = h('label', { class: 'recon-toggle' }, - h('input', { type: 'checkbox', id: 'recon-auto-hs' }), ' Automatically Collect Any Handshakes'); + h('input', { type: 'checkbox', id: 'recon-auto-hs' }), ' Auto-collect 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')); + const requested = hsAuto.querySelector('input').checked; + PagerAPI.post('/api/pineap/set_config', { loghandshake: requested }) + .then(() => App.toast('Settings saved')) + .catch(() => { + hsAuto.querySelector('input').checked = !requested; + App.toast('Failed to save', 'error'); + }); }); - hsCol.appendChild(hsAuto); + hsBody.appendChild(hsAuto); - const psContent = titleCard('Previous Scans', false); + const psBody = titleCard('Previous Scans', null); + const psValue = h('div', { class: 'recon-card-value', text: '—' }); + const psSub = h('div', { class: 'recon-card-sub', text: '' }); + psBody.appendChild(psValue); + psBody.appendChild(psSub); + const psRow = h('div', { class: 'recon-ps-row' }); + psBody.appendChild(psRow); let pickerOptions = []; const sel = h('select', { class: 'sel', id: 'recon-scan-select' }); sel.addEventListener('change', () => { @@ -1473,6 +1541,9 @@ views.recon = (root) => { state.detailId = null; state.detailArchive = null; loadDetail(); }); + psRow.appendChild(sel); + const psActions = h('div', { class: 'recon-ps-actions' }); + psBody.appendChild(psActions); function dlBase() { if (state.selected == null) return null; return state.archive @@ -1508,16 +1579,11 @@ views.recon = (root) => { }) .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' }); @@ -1586,8 +1652,9 @@ views.recon = (root) => { } 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)'); + if (state.scanErr) bits.push(state.scanErr); scanStatus.textContent = bits.join(' · '); - scanStatus.classList.toggle('warn', state.hopperOnline === false || state.historyReset); + scanStatus.classList.toggle('warn', state.hopperOnline === false || state.historyReset || !!state.scanErr); } scanToggle.addEventListener('change', () => { if (pendingScan) { scanToggle.checked = !scanToggle.checked; return; } @@ -1670,34 +1737,63 @@ views.recon = (root) => { const actions = h('div', { class: 'recon-focus-body' }); focusSidebar.appendChild(actions); actions.appendChild(h('div', { class: 'recon-focus-body-title', text: 'Actions' })); + const twin = h('button', { class: 'btn recon-focus-action-button recon-focus-twin', text: 'Send to PineAP — Twin this network' }); + twin.addEventListener('click', () => { + const open = reconEncBucket(ap.encryption) === 'Open'; + PineAPPrefill.set({ + ssid: ap.ssid || '', + hidden: !!ap.hidden, + channel: ap.channel, + bssid: open ? (ap.bssid || '') : '', + enctype: open ? null : reconPrefillEnc(ap.encryption), + source: ap.ssid || 'hidden network' + }); + App.go(open ? '#/pineap/open' : '#/pineap/evilwpa'); + App.toast('PineAP form prefilled for ' + (ap.ssid || 'the hidden network') + ' — verify, then Deploy'); + }); + actions.appendChild(twin); const capture = h('button', { class: 'btn recon-focus-action-button', text: 'Capture WPA Handshakes' }); capture.addEventListener('click', () => { + capture.disabled = true; 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')); + .then(() => { + App.toast('Handshake capture enabled (device-wide on Pager)'); + if (hsAuto) hsAuto.querySelector('input').checked = true; + }) + .catch(() => App.toast('Failed to enable handshake capture', 'error')) + .finally(() => { capture.disabled = false; }); }); actions.appendChild(capture); const stopHs = h('button', { class: 'btn danger recon-focus-action-button', text: 'Stop Handshake Capture' }); stopHs.addEventListener('click', () => { + stopHs.disabled = true; PagerAPI.post('/api/pineap/set_config', { loghandshake: false }) - .then(() => App.toast('Handshake capture disabled')) - .catch(() => App.toast('Failed to disable handshake capture', 'error')); + .then(() => { + App.toast('Handshake capture disabled'); + if (hsAuto) hsAuto.querySelector('input').checked = false; + }) + .catch(() => App.toast('Failed to disable handshake capture', 'error')) + .finally(() => { stopHs.disabled = false; }); }); actions.appendChild(stopHs); const exB = h('button', { class: 'btn recon-focus-action-button', text: 'Examine BSSID' }); exB.addEventListener('click', () => { if (!ap.bssid) return; + exB.disabled = true; PagerAPI.post('/api/recon/examine', { bssid: ap.bssid }) - .then(() => App.toast('Examining ' + ap.bssid)) - .catch(() => App.toast('Examine failed', 'error')); + .then(() => App.toast('Examining ' + ap.bssid + ' — check the Pager screen')) + .catch(() => App.toast('Examine failed', 'error')) + .finally(() => { exB.disabled = false; }); }); 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; } + exC.disabled = true; PagerAPI.post('/api/recon/examine', { channel: ap.channel }) - .then(() => App.toast('Examining channel ' + ap.channel)) - .catch(() => App.toast('Examine failed', 'error')); + .then(() => App.toast('Examining channel ' + ap.channel + ' — check the Pager screen')) + .catch(() => App.toast('Examine failed', 'error')) + .finally(() => { exC.disabled = false; }); }); actions.appendChild(exC); @@ -1798,7 +1894,7 @@ views.recon = (root) => { 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.' })); + mapCard.appendChild(h('div', { class: 'recon-map-sub', text: 'Access points placed at their reported center frequency. The radio does not report channel width, so every lobe assumes 20 MHz. Hover a lobe (or click to pin) to see the networks under it.' })); const mapChips = h('div', { class: 'recon-chips-row recon-map-chips' }); mapCard.appendChild(mapChips); const mapBox = h('div', { class: 'recon-map-box' }); @@ -1807,6 +1903,61 @@ views.recon = (root) => { mapBox.appendChild(mapCanvas); const mapEmpty = h('div', { class: 'recon-no-data', text: 'No access points with a known channel yet.' }); mapBox.appendChild(mapEmpty); + const mapTip = h('div', { class: 'recon-map-tip hidden' }); + mapBox.appendChild(mapTip); + let mapTipPinned = false; + + function renderMapTip(hits, x, y) { + mapTip.innerHTML = ''; + hits.forEach((a) => { + mapTip.appendChild(h('div', { class: 'recon-map-tip-row' }, + h('div', { class: 'recon-map-tip-ssid', text: a.ssid || '(hidden SSID)' }), + h('div', { class: 'recon-map-tip-meta', text: + (a.bssid || '--') + ' · CH ' + (a.channel == null ? '--' : a.channel) + + (a.freq ? ' · ' + a.freq + ' MHz' : '') + ' · ' + + (a.signal == null ? '--' : a.signal + ' dBm') }), + h('div', { class: 'recon-map-tip-meta', text: + (a.encryption || '--') + (a.vendor && a.vendor !== 'Unknown' ? ' · ' + a.vendor : '') }))); + }); + const bx = mapBox.getBoundingClientRect(); + const tx = Math.max(4, Math.min(x - bx.left + 14, bx.width - 250)); + const ty = Math.max(4, Math.min(y - bx.top + 14, bx.height - 80)); + mapTip.style.left = tx + 'px'; + mapTip.style.top = ty + 'px'; + mapTip.classList.remove('hidden'); + } + + function mapHitsAt(e) { + if (!mapCanvas.__reconLobes || !mapCanvas.__reconLobesHit) return []; + const rect = mapCanvas.getBoundingClientRect(); + return mapCanvas.__reconLobesHit(e.clientX - rect.left, e.clientY - rect.top, 8); + } + + mapCanvas.addEventListener('mousemove', (e) => { + const hits = mapHitsAt(e); + if (!hits.length) { + if (!mapTipPinned) mapTip.classList.add('hidden'); + mapCanvas.style.cursor = 'default'; + return; + } + mapCanvas.style.cursor = 'pointer'; + if (!mapTipPinned) renderMapTip(hits, e.clientX, e.clientY); + }); + mapCanvas.addEventListener('mouseleave', () => { + if (!mapTipPinned) mapTip.classList.add('hidden'); + mapCanvas.style.cursor = 'default'; + }); + mapCanvas.addEventListener('click', (e) => { + const hits = mapHitsAt(e); + if (!hits.length) { + mapTip.classList.add('hidden'); + mapTipPinned = false; + return; + } + mapTipPinned = !mapTipPinned; + if (mapTipPinned) renderMapTip(hits, e.clientX, e.clientY); + else mapTip.classList.add('hidden'); + }); function mapAps() { const d = state.detail || {}; @@ -1853,6 +2004,8 @@ views.recon = (root) => { if (!vis.length || !hasChan) { mapCanvas.classList.add('hidden'); mapEmpty.classList.remove('hidden'); + mapTip.classList.add('hidden'); + mapTipPinned = false; return; } mapEmpty.classList.add('hidden'); @@ -2078,64 +2231,94 @@ views.recon = (root) => { : 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 = {}; + const un = d.unassociated || 0; + + // Card headline values (compare-aware, matching the charts below). + landValue.textContent = n; + landSub.textContent = n + ? c + ' client' + (c === 1 ? '' : 's') + ' · ' + un + ' unassociated' + : ''; + const chCounts = {}; aps.forEach((a) => { const ch = a.channel == null ? '?' : a.channel; - counts[ch] = (counts[ch] || 0) + 1; + chCounts[ch] = (chCounts[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 chKeys = Object.keys(chCounts); + let busiest = null, busiestN = 0; + chKeys.forEach((k) => { + if (k === '?') return; + if (chCounts[k] > busiestN) { busiest = k; busiestN = chCounts[k]; } }); - 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'); - } - } + chanValue.textContent = busiest == null ? '—' : 'CH ' + busiest; + chanSub.textContent = busiest == null + ? '' + : busiestN + ' of ' + n + ' APs · ' + chKeys.filter((k) => k !== '?').length + ' channels'; const encCounts = {}; aps.forEach((a) => { const b = reconEncBucket(a.encryption); encCounts[b] = (encCounts[b] || 0) + 1; }); + let topEnc = null, topEncN = 0; + Object.keys(encCounts).forEach((k) => { + if (encCounts[k] > topEncN) { topEnc = k; topEncN = encCounts[k]; } + }); + encValue.textContent = topEnc || '—'; + encSub.textContent = topEnc ? topEncN + ' of ' + n + ' APs' : ''; + + const land = document.getElementById('recon-landscape'); + if (land && typeof MiniChart !== 'undefined' && MiniChart.doughnut) { + try { + 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: un, color: RECON_LANDSCAPE_COLORS[2] } + ]; + MiniChart.doughnut(land, segs, { legend: false, height: 90 }); + land.classList.remove('hidden'); + landEmpty.classList.add('hidden'); + } else { + land.classList.add('hidden'); + landEmpty.classList.remove('hidden'); + } + } catch (e) {} + } + const ch = document.getElementById('recon-channel'); + if (ch && typeof MiniChart !== 'undefined' && MiniChart.bar) { + try { + const keys = chKeys.sort((a, b) => { + if (a === '?') return 1; + if (b === '?') return -1; + return Number(a) - Number(b); + }); + if (keys.length) { + MiniChart.bar(ch, keys.map((k, i) => ({ + label: k, value: chCounts[k], color: RECON_CHANNEL_COLORS[i % RECON_CHANNEL_COLORS.length] + })), { height: 90 }); + ch.classList.remove('hidden'); + chanEmpty.classList.add('hidden'); + } else { + ch.classList.add('hidden'); + chanEmpty.classList.remove('hidden'); + } + } catch (e) {} + } const enc = document.getElementById('recon-encryption'); if (enc && typeof MiniChart !== 'undefined' && MiniChart.doughnut) { - 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'); - } + try { + 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: 66 }); + enc.classList.remove('hidden'); + encEmpty.classList.add('hidden'); + } else { + enc.classList.add('hidden'); + encEmpty.classList.remove('hidden'); + } + } catch (e) {} } } @@ -2158,42 +2341,63 @@ views.recon = (root) => { 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]); + try { + const isNewScan = state.detailId !== scanId || state.detailArchive !== arch; + state.detail = r.data; + state.detailId = scanId; + state.detailArchive = arch; + if (isNewScan) state.history = {}; + const aps = r.data.aps || []; + const seen = {}; + aps.forEach((a) => { if (a.bssid) seen[a.bssid] = true; }); + if (state.compare.some((b) => !seen[b])) { + state.compare = state.compare.filter((b) => seen[b]); + } + if (!arch) { + // Signal-over-time history only makes sense for the live database. + const nowT = Date.now() / 1000; + aps.forEach((a) => { + if (a.bssid == null || a.signal == null) return; + const hist = state.history[a.bssid] || (state.history[a.bssid] = []); + hist.push({ t: nowT, sig: a.signal }); + while (hist.length > RECON_MAX_HISTORY) hist.shift(); + }); + Object.keys(state.history).forEach((b) => { + if (!seen[b]) { + const hist = state.history[b]; + const recent = hist.filter((p) => nowT - p.t < 30); + if (!recent.length) delete state.history[b]; + else state.history[b] = recent; + } + }); + } + drawCharts(r.data); + renderTables(); + renderSelection(); + renderCompare(); + renderChannelMap(); + hsCount.textContent = (r.data.handshakes || []).length; + if (state.scanErr) { state.scanErr = null; renderScanBar(); } + } catch (err) { + // A render failure must never brick the page: reset the detail state + // so the next poll re-fetches and re-renders. + state.detail = null; + state.detailId = null; + state.detailArchive = null; + state.scanErr = 'Scan data failed to render — retrying…'; + renderScanBar(); } - 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; - } - }); + }).catch(() => { + // Transient failure (recon DB busy / 503 / timeout): keep detailId + // unset so the next poll retries, and tell the user. + if (state.selected === scanId && state.archive === arch) { + state.detail = null; + state.detailId = null; + state.detailArchive = null; + state.scanErr = 'Scan data unavailable — retrying…'; + renderScanBar(); } - drawCharts(r.data); - renderTables(); - renderSelection(); - renderCompare(); - renderChannelMap(); - hsCount.textContent = (r.data.handshakes || []).length; - }).catch(() => {}).finally(() => { + }).finally(() => { state.detailLoading = false; state.detailLoadingId = null; if (state.detailQueued) { @@ -2241,10 +2445,21 @@ views.recon = (root) => { if (idx !== -1) sel.value = String(idx); delBtn.disabled = state.archive !== null; delBtn.title = state.archive ? 'Archived scans are read-only' : 'Delete scan'; + const archCount = state.archives.reduce((m, a) => m + ((a.scans || []).length), 0); + const total = state.scans.length + archCount; + psValue.textContent = total ? String(total) : '—'; + const latest = state.scans[0]; + psSub.textContent = total + ? 'Latest: ' + fmtTime(latest ? latest.time : null) + (archCount ? ' · ' + archCount + ' archived' : '') + : 'No scans recorded yet'; } + let loadPending = false; function load() { + if (loadPending) return; + loadPending = true; PagerAPI.get('/api/recon/scans').then((r) => { + state.scanErr = null; state.scans = r.data.scans || []; const newest = state.scans[0] ? state.scans[0].id : null; let keep = null; @@ -2278,7 +2493,10 @@ views.recon = (root) => { } if (state.selected != null) loadDetail(); } - }).catch(() => {}); + }).catch(() => { + state.scanErr = 'Scan list unavailable — retrying…'; + renderScanBar(); + }).finally(() => { loadPending = false; }); PagerAPI.get('/api/recon/status').then((r) => { const scanning = !!r.data.scanning; const wasScanning = state.scanActive; @@ -2309,11 +2527,13 @@ views.recon = (root) => { state.archives = (r.data && r.data.archives) || []; renderPicker(); }).catch(() => {}); + // Keep the auto-collect toggle in sync with the device's actual + // loghandshake setting (the pager's own UI can change it). + PagerAPI.get('/api/pineap/get_config').then((r) => { + hsAuto.querySelector('input').checked = !!((r.data || {}).loghandshake); + }).catch(() => {}); } - PagerAPI.get('/api/pineap/get_config').then((r) => { - hsAuto.querySelector('input').checked = !!((r.data || {}).loghandshake); - }).catch(() => {}); load(); loadSlow(); let pollIv = null;