# Recon Mark VII Parity Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Rework the Pager WebUI Recon section into a faithful clone of the stock Hak5 WiFi Pineapple (Mark VII) Recon UI — Mark VII title cards, scan bar, APs/Clients tables with search + pagination, settings sidebar, and a two-tab Recon (Scanning + Handshakes, no Events) — using only data the Pager backend already exposes, plus one optional-body change to `POST /api/recon/start`. **Architecture:** All front-end changes live under `payload/user/general/pager-webui/www/` (vanilla JS SPA, no build step). The Mark VII layout/markup/colors were extracted from the old Angular bundle `main.ce5a318adf590e170f6d.js`. The scanning view is rewritten to mirror Mark VII's `.recon-title-card-container` structure; charts are extended hand-rolled `` renderers (bar + doughnut with legend); the Events tab/route/view is removed. One backend function (`h_recon_start`) forwards an optional `scan_time` to the daemon call. **Tech Stack:** Vanilla JS (ES6, `const`/arrow functions as used today), hand-rolled CSS via custom properties (light/dark), hand-rolled `` charts, Python `server.py` for the one backend change, `unittest` for the backend test. ## Global Constraints - Device runtime: `python3-light` on WiFi Pineapple Pager 24.10.1 — no third-party pip packages, no build step. - Front-end must stay ES6-compatible (matches existing code). - No new `/api/*` surface. Only `h_recon_start` body semantics change: optional `scan_time` (int seconds; `0` = continuous). When absent, behavior is byte-for-byte the current `body={}`. - Auth/session mechanics unchanged. - Design tokens (light): content `#fafafa`, cards `#fff`, toolbar `#424242`, primary `#1976d2`, text `#212121` / muted `#686868`, border `#e0e0e0`. Dark: surfaces `#303030`, cards `#424242`, border `#545454`. - Mark VII chart palettes (verbatim from the old bundle): - Landscape doughnut: `#2ecc71` (Access Points), `#2980b9` (Clients), `#8e44ad` (Unassociated). - Channel bar palette (cycle through for bars): `#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` - `localStorage` keys: `pw_scan_duration` (int string, default `'30'`), `pw_recon_cols` (JSON `{ap:{...},client:{...}}`). - Recon has exactly two tabs: `Scanning` (`#/recon`) and `Handshakes` (`#/recon/handshakes`). - No Band select (Pager cannot single-band scan). No graph/2D/3D view. No AP focus sidebars. - Existing Python `unittest` suite must stay green (`tests/` run per-file). - Commits follow repo style (`feat:`, `fix:`, `docs:`). - Deploy: `.\scripts\deploy.ps1 -SshKey "$HOME\.ssh\pager_key" -Password ""` (fall back to printed scp/ssh commands if no key/sshpass). --- ### Task 1: Backend — `h_recon_start` forwards optional `scan_time` **Files:** - Modify: `payload/user/general/pager-webui/server.py:912-916` (`h_recon_start`) - Modify: `tests/test_recon.py` (`DaemonSockTest`) **Interfaces:** - Consumes: `daemon_sock_call('POST', '/api/pineap/log/recon/start', body=...)` (exists, returns `(status, data)`); handler `ctx` may or may not have a `body` attribute (existing test builds `type('C', (), {'args': ()})()` with no `body`). - Produces: `h_recon_start(ctx)` → reads `getattr(ctx, 'body', None)`, forwards `{'scan_time': int}` when `scan_time` present, else `{}`. - [ ] **Step 1: Write the failing test** Add to `tests/test_recon.py`, inside `class DaemonSockTest` (after `test_start_stop_handlers_call_socket`): ```python def test_start_forwards_scan_time(self): calls = [] server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p, body)) or (200, {'success': True}) ctx = type('C', (), {'args': (), 'body': {'scan_time': 60}})() status, data = server.h_recon_start(ctx) self.assertEqual(status, 200) self.assertEqual(calls[0], ('POST', '/api/pineap/log/recon/start', {'scan_time': 60})) def test_start_defaults_empty_body(self): calls = [] server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p, body)) or (200, {'success': True}) server.h_recon_start(type('C', (), {'args': ()})()) self.assertEqual(calls[0], ('POST', '/api/pineap/log/recon/start', {})) ``` - [ ] **Step 2: Run the test to verify it fails** ```powershell & "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" -m unittest tests.test_recon.DaemonSockTest -v ``` Expected: `test_start_forwards_scan_time` FAIL (body is `{}`), `test_start_defaults_empty_body` FAIL (AttributeError on `ctx.body`). - [ ] **Step 3: Implement the change** Replace `h_recon_start` (currently lines 912-916): ```python def h_recon_start(ctx): body = {} scan_time = (getattr(ctx, 'body', None) or {}).get('scan_time') if scan_time is not None: body['scan_time'] = int(scan_time) status, data = daemon_sock_call('POST', '/api/pineap/log/recon/start', body=body) if status != 200 or not (data or {}).get('success'): return 502, {'error': 'daemon recon start failed'} return 200, {'ok': True} ``` - [ ] **Step 4: Run the test to verify it passes** ```powershell & "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" -m unittest tests.test_recon.DaemonSockTest -v ``` Expected: all DaemonSockTest tests PASS. - [ ] **Step 5: Run the full suite for regressions** ```powershell Get-ChildItem tests\test_*.py | ForEach-Object { $mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name) & "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" -m unittest $mod -v } ``` Expected: all modules PASS. - [ ] **Step 6: Commit** ```bash git add payload/user/general/pager-webui/server.py tests/test_recon.py git commit -m "feat: forward optional scan_time to daemon on recon start" ``` --- ### Task 2: Charts — doughnut with legend + bar chart in `chart.js` **Files:** - Modify: `payload/user/general/pager-webui/www/js/chart.js` **Interfaces:** - Produces (consumed by Task 5): - `MiniChart.doughnut(canvas, segments, opts)` — segments `[{label, value, color}]`; opts `{legend: bool, height: number, hole: number}`. Draws a ring doughnut (hole radius = `hole` × outer radius, default `0.65`) and, when `legend` is truthy, a legend row beneath (color dot + label, centered). Segments sum to 0 → draw empty ring and no legend. - `MiniChart.bar(canvas, items, opts)` — items `[{label, value, color}]`; opts `{height: number, grid: color}`. X axis labels = item labels (below chart), bars from baseline with item colors, Y gridlines, Y max = max value (min 1), no legend. - Unchanged: `MiniChart.draw` (dashboard line chart). - [ ] **Step 1: Rewrite `chart.js`** Replace the whole file with: ```js 'use strict'; const MiniChart = (() => { function draw(canvas, series, opts) { const o = opts || {}; const dpr = window.devicePixelRatio || 1; canvas.width = canvas.clientWidth * dpr; canvas.height = 140 * dpr; const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const w = canvas.clientWidth, h = 140; ctx.clearRect(0, 0, w, h); const max = Math.max(o.max || 10, ...series.map((s) => Math.max(...s.points, 0)), 1); const pad = 8; ctx.strokeStyle = o.grid || '#e0e0e0'; ctx.lineWidth = 1; for (let g = 0; g <= 4; g++) { const y = pad + (h - pad * 2) * g / 4; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } series.forEach((s) => { const pts = s.points; if (!pts || pts.length < 2) return; ctx.strokeStyle = s.color || '#1976d2'; ctx.lineWidth = 2; ctx.beginPath(); let started = false; pts.forEach((v, i) => { if (v == null) { started = false; return; } const x = pad + (w - pad * 2) * i / Math.max(pts.length - 1, 1); const y = h - pad - (h - pad * 2) * (v / max); if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y); }); ctx.stroke(); const last = pts[pts.length - 1]; if (last != null) { const x = pad + (w - pad * 2) * (pts.length - 1) / Math.max(pts.length - 1, 1); const y = h - pad - (h - pad * 2) * (last / max); ctx.fillStyle = s.color || '#1976d2'; ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fill(); } }); } function doughnut(canvas, segments, opts) { const o = opts || {}; const dpr = window.devicePixelRatio || 1; const legendH = o.legend ? 22 : 0; const H = (o.height || 160) + legendH; canvas.width = canvas.clientWidth * dpr; canvas.height = H * dpr; const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const w = canvas.clientWidth, h = o.height || 160; ctx.clearRect(0, 0, w, H); const cx = w / 2, cy = h / 2; const r = Math.min(w, h) / 2 - 8; const hole = (o.hole == null ? 0.65 : o.hole) * r; const total = segments.reduce((s, x) => s + x.value, 0); if (!total) { ctx.strokeStyle = '#e0e0e0'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke(); ctx.beginPath(); ctx.arc(cx, cy, hole, 0, Math.PI * 2); ctx.stroke(); return; } let a0 = -Math.PI / 2; segments.forEach((seg) => { const a1 = a0 + (seg.value / total) * Math.PI * 2; ctx.fillStyle = seg.color; ctx.beginPath(); ctx.arc(cx, cy, r, a0, a1); ctx.arc(cx, cy, hole, a1, a0, true); ctx.closePath(); ctx.fill(); a0 = a1; }); ctx.strokeStyle = o.stroke || '#ffffff'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke(); ctx.beginPath(); ctx.arc(cx, cy, hole, 0, Math.PI * 2); ctx.stroke(); if (o.legend) { ctx.font = '11px Roboto, "Segoe UI", Arial, sans-serif'; const dots = segments.filter((s) => s.value > 0); const text = dots.map((s) => s.label).join(' '); let tw = 0; dots.forEach((s) => { tw += 16 + ctx.measureText(s.label).width + 8; }); tw = Math.max(tw - 8, 0); let x = (w - tw) / 2; const ly = h + 13; dots.forEach((s) => { ctx.fillStyle = s.color; ctx.beginPath(); ctx.arc(x + 4, ly - 3, 4, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#686868'; ctx.textAlign = 'left'; ctx.fillText(s.label, x + 12, ly); x += 16 + ctx.measureText(s.label).width + 8; }); } } function bar(canvas, items, opts) { const o = opts || {}; const dpr = window.devicePixelRatio || 1; const H = o.height || 160; canvas.width = canvas.clientWidth * dpr; canvas.height = H * dpr; const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const w = canvas.clientWidth, h = H; ctx.clearRect(0, 0, w, h); if (!items || !items.length) return; const max = Math.max(1, ...items.map((i) => i.value)); const padB = 16, padT = 8, padL = 6, padR = 6; const plotW = w - padL - padR, plotH = h - padT - padB; const bw = plotW / items.length; ctx.strokeStyle = o.grid || '#e0e0e0'; ctx.lineWidth = 1; for (let g = 0; g <= 4; g++) { const y = padT + plotH * g / 4; ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(w - padR, y); ctx.stroke(); } items.forEach((it, i) => { const bh = it.value / max * plotH; const x = padL + bw * i + bw * 0.15; const wd = bw * 0.7; const y = padT + plotH - bh; ctx.fillStyle = it.color; ctx.fillRect(x, y, wd, bh); ctx.fillStyle = '#686868'; ctx.font = '10px Roboto, "Segoe UI", Arial, sans-serif'; ctx.textAlign = 'center'; ctx.fillText(String(it.label), padL + bw * i + bw / 2, h - 4); }); } return { draw, doughnut, bar }; })(); ``` - [ ] **Step 2: Sanity-check the file** ```powershell $c = Get-Content -Raw payload\user\general\pager-webui\www\js\chart.js if ($c -match 'function doughnut' -and $c -match 'function bar' -and $c -match 'return \{ draw, doughnut, bar \}') { 'chart.js OK' } ``` Expected: `chart.js OK`. - [ ] **Step 3: Commit** ```bash git add payload/user/general/pager-webui/www/js/chart.js git commit -m "feat: add doughnut legend and bar chart to MiniChart" ``` --- ### Task 3: Icons — Material path data for new buttons **Files:** - Modify: `payload/user/general/pager-webui/www/js/icons.js` **Interfaces:** - Produces (consumed by Task 5): new keys on `PineappleIcons` — `refresh`, `file_download`, `delete`, `settings`, `search`, `first_page`, `last_page`, `chevron_left`, `chevron_right`. Each is a full inline ``. - [ ] **Step 1: Append the new icons** Inside the `PineappleIcons` object (after the `receipt` line), add (note trailing commas between entries, last entry has none): ```js refresh: '', file_download: '', delete: '', settings: '', search: '', first_page: '', last_page: '', chevron_left: '', chevron_right: '' ``` - [ ] **Step 2: Verify** ```powershell $c = Get-Content -Raw payload\user\general\pager-webui\www\js\icons.js @('refresh','file_download','delete','settings','search','first_page','last_page','chevron_left','chevron_right') | ForEach-Object { if ($c -match $_ + ':') { "$_ OK" } else { "$_ MISSING" } } ``` Expected: all `OK`. - [ ] **Step 3: Commit** ```bash git add payload/user/general/pager-webui/www/js/icons.js git commit -m "feat: add Material action icons for recon rework" ``` --- ### Task 4: CSS — Mark VII recon styles **Files:** - Modify: `payload/user/general/pager-webui/www/css/app.css` (append; do not remove existing classes) **Interfaces:** - Consumes: existing custom properties (`--surface`, `--border`, `--muted`, `--primary`, `--shadow`, `--text`), `html.dark` overrides. - Produces (consumed by Task 5): classes `.recon-title-card-container`, `.recon-title-card`, `.recon-card`, `.recon-title-card-title`, `.recon-card-title-link`, `.recon-title-card-content`, `.recon-chart-box`, `.recon-no-data`, `.recon-hs-col`, `.recon-hs-count`, `.recon-hs-label`, `.recon-toggle`, `.recon-ps-row`, `.recon-scan-bar`, `.recon-table-head`, `.recon-search`, `.recon-paginator`, `.icon-btn`, `.recon-scan-results-card`, `.recon-table-body`, `.recon-settings-sidebar`, `.recon-settings-head`, `.recon-settings-title`, `.recon-settings-section`, `.recon-row-selected`. - [ ] **Step 1: Append the recon stylesheet block** Append to `app.css`: ```css /* ---- 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-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-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%; } .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; } .icon-btn svg { width: 22px; height: 22px; } .recon-scan-bar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; } .recon-scan-bar .sel { width: auto; } .recon-scan-results-card { } .recon-table-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; flex-wrap: wrap; } .recon-table-head h2 { margin: 0; } .recon-search { max-width: 180px; } .recon-paginator { display: flex; align-items: center; gap: 2px; font-size: 12px; } .recon-paginator .icon-btn { width: 30px; height: 30px; } .recon-paginator .icon-btn svg { width: 18px; height: 18px; } .recon-table-body { } .recon-row-selected td { background: #eaeaea; } html.dark .recon-row-selected td { background: #565656; } .recon-settings-sidebar { position: fixed; top: 64px; right: 0; bottom: 0; width: 270px; z-index: 50; background: var(--surface); box-shadow: -2px 0 6px rgba(0,0,0,.24); padding: 16px; overflow-y: auto; } .recon-settings-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } .recon-settings-title { font-size: 20px; } .recon-settings-section { font-size: 14px; font-weight: 500; margin: 14px 0 4px; color: var(--muted); } .recon-settings-sidebar .toggle { font-size: 13px; } .recon-handshakes-card .recon-table-head h2 { font-size: 20px; margin-bottom: 15px; } ``` - [ ] **Step 2: Sanity-check** ```powershell Select-String -Path payload\user\general\pager-webui\www\css\app.css -Pattern 'recon-title-card-container','recon-scan-bar','recon-settings-sidebar','recon-row-selected' ``` Expected: all four found. - [ ] **Step 3: Commit** ```bash git add payload/user/general/pager-webui/www/css/app.css git commit -m "feat: Mark VII recon styles (title cards, scan bar, tables, settings sidebar)" ``` --- ### Task 5: Views — rewrite `views.recon`, restyle handshakes, remove Events **Files:** - Modify: `payload/user/general/pager-webui/www/js/views.js` **Interfaces:** - Consumes: `h`, `table`, `fmtTime`, `btn`, `tabBar` (all existing module-level helpers), `PagerAPI`, `App.toast`, `App.apiBase`, `PineappleIcons` (Task 3), `MiniChart.doughnut` / `MiniChart.bar` (Task 2). - Produces: module-level `iconBtn(name, title, onclk)` helper; `RECON_TABS` (2 entries); `views.recon`; restyled `views.recon_handshakes`. **Deletes** `views.recon_events`. - [ ] **Step 1: Add the `iconBtn` helper** After the `btn` helper definition (near line 53): ```js const iconBtn = (name, title, onclk) => { const b = h('button', { class: 'icon-btn', title: title || '', onclick: onclk }); b.innerHTML = PineappleIcons[name] || ''; return b; }; ``` - [ ] **Step 2: Replace `RECON_TABS` and add constants** Replace the current `RECON_TABS` (3 entries) with: ```js const RECON_TABS = [ { label: 'Scanning', hash: '#/recon' }, { label: 'Handshakes', hash: '#/recon/handshakes' } ]; const RECON_LANDSCAPE_COLORS = ['#2ecc71', '#2980b9', '#8e44ad']; 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: 'channel', label: 'Channel', render: (a) => a.channel == null ? '--' : a.channel }, { key: 'signal', label: 'Signal', render: (a) => a.signal == null ? '--' : a.signal + ' dBm' }, { key: 'encryption', label: 'Encryption', render: (a) => a.encryption || '--' }, { 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 } ]; ``` - [ ] **Step 3: Replace `views.recon`** Replace the entire `views.recon = (root) => {...};` block (lines 364-525 in the current file) with: ```js function reconDefaultCols() { return { ap: { ssid: true, bssid: true, channel: true, signal: true, encryption: 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) 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)); } views.recon = (root) => { root.appendChild(h('h1', { class: 'page-title', text: 'Recon' })); tabBar(root, RECON_TABS, '#/recon'); const state = { scans: [], selected: null, detail: null, active: false, apPage: 0, apSearch: '', cliPage: 0, cliSearch: '' }; const cols = reconLoadCols(); function iconBtnView(name, title, onclk) { return iconBtn(name, title, onclk); } // ---- 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 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/settings', { collect_handshakes: 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); const psRow = h('div', { class: 'recon-ps-row' }); psContent.appendChild(psRow); const sel = h('select', { class: 'sel', id: 'recon-scan-select' }); sel.addEventListener('change', () => { state.selected = parseInt(sel.value, 10) || null; state.apPage = 0; state.cliPage = 0; loadDetail(); }); psRow.appendChild(sel); psRow.appendChild(iconBtnView('file_download', 'Download scan JSON', () => { if (state.selected != null) window.location = App.apiBase + '/api/recon/scans/' + state.selected + '/download/json'; })); psRow.appendChild(iconBtnView('delete', 'Delete scan', () => { if (state.selected == null) 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')); })); // ---- 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'], [0, 'Continuous']] .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); scanBar.appendChild(h('span', { class: 'toolbar-spacer' })); scanBar.appendChild(iconBtnView('settings', 'Recon settings', () => sidebar.classList.toggle('hidden'))); scanToggle.addEventListener('change', () => { const on = scanToggle.checked; scanToggle.disabled = true; PagerAPI.post(on ? '/api/recon/start' : '/api/recon/stop', on ? { scan_time: parseInt(durSel.value, 10) } : {}) .then(() => { App.toast(on ? 'Scan started' : 'Scan stopped'); load(); }) .catch(() => { scanToggle.checked = !on; App.toast('Recon control failed', 'error'); }) .finally(() => { scanToggle.disabled = false; }); }); // ---- 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: [['ssid', 'Show SSID'], ['bssid', 'Show MAC'], ['channel', 'Show Channel'], ['signal', 'Show Signal'], ['encryption', 'Show Encryption'], ['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); // ---- results tables ---- const apCard = h('div', { class: 'section recon-scan-results-card' }); root.appendChild(apCard); const cliCard = h('div', { class: 'section recon-scan-results-card' }); root.appendChild(cliCard); function buildPaginator(key) { const mk = (id, label, fn) => { const b = h('button', { class: 'icon-btn', id: key + '-' + id, title: label }); b.innerHTML = PineappleIcons[['first', 'last'].indexOf(id) !== -1 ? (id === 'first' ? 'first_page' : 'last_page') : (id === 'prev' ? 'chevron_left' : 'chevron_right')] || ''; b.addEventListener('click', fn); return b; }; return h('div', { class: 'recon-paginator' }, mk('first', 'First page', () => { state[key + 'Page'] = 0; renderTables(); }), mk('prev', 'Previous page', () => { state[key + 'Page'] = Math.max(0, state[key + 'Page'] - 1); renderTables(); }), h('span', { class: 'muted', id: key + '-range', text: '' }), mk('next', 'Next page', () => { state[key + 'Page'] = Math.min(reconPageCount(key) - 1, state[key + 'Page'] + 1); renderTables(); }), mk('last', 'Last page', () => { state[key + 'Page'] = Math.max(0, reconPageCount(key) - 1); renderTables(); })); } function reconPageCount(key) { const d = state.detail || {}; const rows = key === 'ap' ? (d.aps || []) : (d.clients || []); const colsArr = key === 'ap' ? RECON_AP_COLS : RECON_CLIENT_COLS; const q = key === 'ap' ? state.apSearch : state.cliSearch; return Math.max(1, Math.ceil(reconFiltered(rows, q, colsArr).length / 10)); } 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' }), buildPaginator(key)); box.appendChild(head); const input = head.querySelector('#' + searchId); input.addEventListener('input', onInput); return head; } 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.cliSearch = document.getElementById('cli-search').value; state.cliPage = 0; renderTables(); }); const cliBody = h('div', { class: 'recon-table-body' }); cliCard.appendChild(cliBody); function renderTable(box, key, rows, colsArr, selectedBssid, emptyMsg) { box.innerHTML = ''; const vis = colsArr.filter((c) => cols[key][c.key]); const page = state[key + 'Page']; const start = page * 10; const slice = rows.slice(start, start + 10); box.appendChild(table(vis, slice, key === 'ap' ? (r) => ({ style: 'cursor:pointer' + (r.bssid === selectedBssid ? ';background:var(--surface-alt)' : '') }) : undefined)); 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 + 10, rows.length) + ' of ' + rows.length : '0 of 0'; const p = reconPageCount(key); [['first', 0], ['prev', 0], ['next', p - 1], ['last', p - 1]].forEach(([id, limit]) => { const el = document.getElementById(key + '-' + id); if (el) el.disabled = page >= p - 1 && limit !== 0 ? true : (page <= 0 && (id === 'first' || id === 'prev')); }); } function renderTables() { const d = state.detail || { aps: [], clients: [], handshakes: [] }; const apRows = reconFiltered(d.aps || [], state.apSearch, RECON_AP_COLS); const cliRows = reconFiltered(d.clients || [], state.cliSearch, RECON_CLIENT_COLS); renderTable(apBody, 'ap', apRows, RECON_AP_COLS, '', 'No access points in this scan.'); renderTable(cliBody, 'client', cliRows, RECON_CLIENT_COLS, '', 'No clients in this scan.'); } function drawCharts(d) { const n = (d.aps || []).length; const c = (d.clients || []).length; const land = document.getElementById('recon-landscape'); if (land && typeof MiniChart !== 'undefined' && MiniChart.doughnut) { if (n + c > 0) { MiniChart.doughnut(land, [ { label: 'Access Points', value: n, color: RECON_LANDSCAPE_COLORS[0] }, { label: 'Clients', value: c, color: RECON_LANDSCAPE_COLORS[1] }, { label: 'Unassociated', value: 0, color: RECON_LANDSCAPE_COLORS[2] } ], { legend: true, height: 130 }); land.classList.remove('hidden'); landEmpty.classList.add('hidden'); } else { land.classList.add('hidden'); landEmpty.classList.remove('hidden'); } } const counts = {}; (d.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'); } } } function loadDetail() { if (state.selected == null) return; PagerAPI.get('/api/recon/scans/' + state.selected).then((r) => { state.detail = r.data; drawCharts(r.data); renderTables(); hsCount.textContent = (r.data.handshakes || []).length; }).catch(() => {}); } 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); sel.innerHTML = ''; state.scans.forEach((s) => { const opt = document.createElement('option'); opt.value = s.id; opt.textContent = 'Scan #' + s.id + ' — ' + fmtTime(s.time); sel.appendChild(opt); }); if (keep == null) { state.detail = null; drawCharts({ aps: [], clients: [], handshakes: [] }); renderTables(); hsCount.textContent = '0'; } if (keep != null) sel.value = keep; state.selected = keep; if (keep != null) loadDetail(); }).catch(() => {}); PagerAPI.get('/api/recon/status').then((r) => { state.active = !!r.data.active; scanToggle.checked = state.active; }).catch(() => {}); PagerAPI.get('/api/pineap/settings').then((r) => { hsAuto.querySelector('input').checked = !!((r.data.settings || {}).collect_handshakes); }).catch(() => {}); } load(); const iv = setInterval(load, 10000); return { destroy: () => clearInterval(iv) }; }; ``` - [ ] **Step 4: Replace `views.recon_handshakes` title row** In `views.recon_handshakes`, replace the line: ```js const box = h('div', { class: 'section' }, h('h2', {}, 'Handshakes')); root.appendChild(box); ``` with: ```js const box = h('div', { class: 'section recon-handshakes-card' }); root.appendChild(box); ``` and replace the first two lines inside `load()`: ```js box.innerHTML = ''; box.appendChild(h('h2', {}, 'Handshakes')); ``` with: ```js 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', () => App.toast('Handshakes settings are not available on the Pager'))); box.appendChild(head); ``` - [ ] **Step 5: Remove `views.recon_events`** Delete the entire `views.recon_events = (root) => {...};` block (lines 578-612 in the current file), including its `let all = []; let page = 0;` state and pager markup. - [ ] **Step 6: Verify definitions** ```powershell $v = Get-Content -Raw payload\user\general\pager-webui\www\js\views.js @('RECON_TABS','reconDefaultCols','reconLoadCols','reconFiltered','iconBtn','views.recon =','views.recon_handshakes =','RECON_CHANNEL_COLORS','RECON_AP_COLS','RECON_CLIENT_COLS') | ForEach-Object { if ($v -match [regex]::Escape($_) ) { "$_ OK" } else { "$_ MISSING" } } if ($v -match 'views\.recon_events') { 'recon_events STILL PRESENT (bad)' } else { 'recon_events REMOVED (good)' } ``` Expected: all definitions `OK`, `recon_events REMOVED (good)`. - [ ] **Step 7: Commit** ```bash git add payload/user/general/pager-webui/www/js/views.js git commit -m "feat: Mark VII recon scanning view, restyled handshakes, remove Events tab" ``` --- ### Task 6: Routing — drop the Events route **Files:** - Modify: `payload/user/general/pager-webui/www/js/app.js:180` **Interfaces:** - Consumes: `views.recon`, `views.recon_handshakes` (Task 5). `views.recon_events` no longer exists. - Produces: routes map without `#/recon/events`. - [ ] **Step 1: Remove the line** Delete line 180 (`'#/recon/events': 'recon_events',`) from the `routes` object. - [ ] **Step 2: Verify** ```powershell $c = Get-Content -Raw payload\user\general\pager-webui\www\js\app.js if ($c -match "'#/recon/events'") { 'events route STILL PRESENT (bad)' } else { 'events route REMOVED (good)' } ``` Expected: `events route REMOVED (good)`. - [ ] **Step 3: Commit** ```bash git add payload/user/general/pager-webui/www/js/app.js git commit -m "fix: remove recon events route" ``` --- ### Task 7: Build, deploy, on-device verification **Files:** - No source changes. - [ ] **Step 1: Run the full backend test suite** ```powershell Get-ChildItem tests\test_*.py | ForEach-Object { $mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name) & "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" -m unittest $mod -v } ``` Expected: every module PASS. - [ ] **Step 2: Deploy to the Pager** ```powershell & .\scripts\deploy.ps1 -SshKey "$HOME\.ssh\pager_key" -Password "" ``` (If no key, rely on sshpass; otherwise run the printed scp/ssh commands manually.) - [ ] **Step 3: Verify assets serve** ```powershell curl.exe -s -o NUL -w "%{http_code} %{size_download}`n" http://172.16.52.1:8080/ curl.exe -s -o NUL -w "%{http_code} %{size_download}`n" http://172.16.52.1:8080/js/chart.js curl.exe -s -o NUL -w "%{http_code} %{size_download}`n" http://172.16.52.1:8080/js/views.js curl.exe -s -o NUL -w "%{http_code} %{size_download}`n" http://172.16.52.1:8080/css/app.css ``` Expected: all `200` with non-zero sizes. - [ ] **Step 4: On-device smoke pass** Log in at `http://172.16.52.1:8080/` and walk through: - `#/recon`: two tabs (Scanning, Handshakes); 4 title cards render (landscape doughnut + legend, channel bar chart, handshakes count, previous-scans select + download/delete icons). - Scan bar: Scan toggle, duration select (persist via reload), settings icon opens sidebar (column toggles persist; hide SSID column → table updates). - Table search filters APs/Clients; paginator first/last/prev/next + range label work; 10-per-page. - Previous-scan select switches detail + charts + tables; download icon fetches JSON; delete icon confirms + deletes. - Handshakes tab: "Captured WPA Handshakes" card, file table, Download/Delete row actions, Download all / Archive. - `#/recon/events` → "View not available." (route gone). Keyboard `r` → `#/recon`. - Dark theme (Settings → Theme) renders cards correctly. - Backend: start a scan with Duration = 1 Minute; confirm `/api/recon/start` returns 200 (daemon acceptance of `scan_time` is daemon-dependent; UI unaffected either way). - [ ] **Step 5: Commit any smoke fixes** ```bash git add -A git commit -m "fix: recon rework smoke-test fixes" ``` (Only if changes exist.) --- ## Self-Review Notes (run before handing off) - **Spec coverage:** §3.1 tabs → Tasks 5–6; §3.2 layout → Task 5; §3.3 handshakes → Task 5; §3.4 charts → Task 2; §3.5 icons → Task 3; §3.6 CSS → Task 4; §3.7 backend → Task 1; §3.8 routing → Task 6; §4 data flow → Task 5 (`pw_scan_duration`, `pw_recon_cols`); §5 testing → Tasks 1 & 7; §6 out of scope → enforced (no band select, no graph view, no focus sidebars, no `/api/recon/events` removal). - **Name consistency:** `MiniChart.doughnut`/`MiniChart.bar` defined in Task 2 and consumed in Task 5 with the documented signatures (`doughnut(canvas, [{label,value,color}], {legend,height})`, `bar(canvas, [{label,value,color}], {height})`). `iconBtn` defined in Task 5 Step 1, used in Steps 3–4. `RECON_*` constants defined in Step 2, used in Step 3. `reconFiltered`/`reconPageCount`/`renderTables`/`renderTable`/`drawCharts`/`loadDetail`/`load` all defined before first use inside `views.recon`. - **Guardrail:** Task 1 must not break `test_start_stop_handlers_call_socket` — that test asserts `body={}` and `h_recon_start` now reads `getattr(ctx, 'body', None)` (absent → `{}`). - **Placeholder scan:** no TBD/TODO; every code step ships the full content.