Files
Mark-VIII/docs/superpowers/plans/2026-08-11-recon-markvii.md
T
2026-08-11 20:24:24 -07:00

909 lines
43 KiB
Markdown
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.
# 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 `<canvas>` 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 `<canvas>` 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 "<PAGER_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 `<svg viewBox="0 0 24 24" fill="currentColor"><path d="..."/></svg>`.
- [ ] **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: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z"/></svg>',
file_download: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19,9H15V3H9V9H5L12,16L19,9M11,18H13V22H11V18Z"/></svg>',
delete: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6,19C6,20.1 6.9,21 8,21H16C17.1,21 18,20.1 18,19V7H6V19M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19V4Z"/></svg>',
settings: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14,12.94C19.18,12.64 19.2,12.33 19.2,12C19.2,11.68 19.18,11.36 19.13,11.06L21.16,9.48C21.34,9.34 21.39,9.07 21.28,8.87L19.36,5.55C19.24,5.33 18.99,5.26 18.77,5.33L16.38,6.29C15.88,5.91 15.35,5.59 14.76,5.35L14.4,2.81C14.36,2.57 14.16,2.4 13.92,2.4H10.08C9.84,2.4 9.65,2.57 9.61,2.81L9.25,5.35C8.66,5.59 8.12,5.91 7.63,6.29L5.24,5.33C5.02,5.26 4.77,5.33 4.65,5.55L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48L4.89,11.06C4.84,11.36 4.8,11.67 4.8,12C4.8,12.33 4.82,12.64 4.87,12.94L2.84,14.52C2.66,14.66 2.61,14.93 2.72,15.13L4.64,18.45C4.76,18.67 5.01,18.74 5.23,18.67L7.62,17.71C8.12,18.09 8.65,18.41 9.24,18.65L9.6,21.19C9.65,21.43 9.84,21.6 10.08,21.6H13.92C14.16,21.6 14.36,21.43 14.4,21.19L14.76,18.65C15.35,18.41 15.88,18.09 16.38,17.71L18.77,18.67C18.99,18.74 19.24,18.67 19.36,18.45L21.28,15.13C21.39,14.93 21.34,14.66 21.16,14.52L19.14,12.94M12,15.6C10.02,15.6 8.4,13.98 8.4,12C8.4,10.02 10.02,8.4 12,8.4C13.98,8.4 15.6,10.02 15.6,12C15.6,13.98 13.98,15.6 12,15.6Z"/></svg>',
search: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.5,14H14.71L14.43,13.73C15.41,12.59 16,11.11 16,9.5C16,5.91 13.09,3 9.5,3C5.91,3 3,5.91 3,9.5C3,13.09 5.91,16 9.5,16C11.11,16 12.59,15.41 13.73,14.43L14,14.71V15.5L19,20.49L20.49,19L15.5,14M9.5,14C7.01,14 5,11.99 5,9.5C5,7.01 7.01,5 9.5,5C11.99,5 14,7.01 14,9.5C14,11.99 11.99,14 9.5,14Z"/></svg>',
first_page: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.41,16.59L13.82,12L18.41,7.41L17,6L11,12L17,18L18.41,16.59M6,6H8V18H6V6Z"/></svg>',
last_page: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5.59,7.41L10.18,12L5.59,16.59L7,18L13,12L7,6L5.59,7.41M16,6H18V18H16V6Z"/></svg>',
chevron_left: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z"/></svg>',
chevron_right: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z"/></svg>'
```
- [ ] **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 "<PAGER_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 56; §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 34. `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.