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

43 KiB
Raw Blame History

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):

    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
& "$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):

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
& "$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
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
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:

'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
$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
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 PineappleIconsrefresh, 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):

  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
$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
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:

/* ---- 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
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
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):

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:

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:

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:

  const box = h('div', { class: 'section' }, h('h2', {}, 'Handshakes'));
  root.appendChild(box);

with:

  const box = h('div', { class: 'section recon-handshakes-card' });
  root.appendChild(box);

and replace the first two lines inside load():

      box.innerHTML = '';
      box.appendChild(h('h2', {}, 'Handshakes'));

with:

      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
$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
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
$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
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

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
& .\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
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

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.