release: Mark VIII 1.0
This commit is contained in:
@@ -0,0 +1,908 @@
|
||||
# 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 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.
|
||||
Reference in New Issue
Block a user