# Pineapple UI Clone 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:** Restyle the Pager WebUI (`:8080`) into a faithful clone of the stock Hak5 WiFi Pineapple UI (`:1471`) — light Material look, old icon-rail navigation, old information architecture — while reusing the existing backend and API surface (plus one new read-only endpoint). **Architecture:** The SPA stays a no-build vanilla JS app running on the device's `python3-light` HTTP server. All front-end changes live under `payload/user/general/pager-webui/www/`. The design tokens, shell, navigation, and views are rewritten to mirror the old Angular/Material UI; the `Live` WS/poll channel, `PagerAPI` auth, and `server.py` session mechanics are untouched. One new backend route (`GET /api/pineap/aps`) runs `iwinfo scan` read-only. **Tech Stack:** Vanilla JS (no framework, no bundler), hand-rolled CSS (Material look via custom properties), hand-rolled `` line chart (no Chart.js — the old UI's Chart.js is inlined in its Angular bundle and no offline copy is available), Python `server.py` for the one new endpoint, `unittest` for backend tests. ## Global Constraints - Device runtime: `python3-light` on WiFi Pineapple Pager 24.10.1 — no third-party pip packages, no build step, ES5-compatible JS only (device browser = stock Chromium-era, but keep to ES6 that modern mobile browsers support; the existing code already uses `const`/arrow functions and `async/await`). - No new `/api/*` surface except `GET /api/pineap/aps` (read-only, `iwinfo scan`). - Auth/session mechanics unchanged: `AUTH_` HttpOnly cookie, login as user `root` with the device password. - Design tokens (from the old UI): content bg `#fafafa`, cards `#fff` with elevation shadow, toolbar + rail `#424242`, rail hover `#a9a9a9` (dark `#545454`), active indicator `#1976d2` (3px right border), primary `#1976d2`, danger `#d32f2f`, ok `#7cb342`, warn `#f9a825`, text `#212121` / muted `#686868`; font stack `Roboto, "Segoe UI", Arial, sans-serif`. Dark theme (opt-in): rail `#3a3a3a`, surfaces `#303030`, cards `#424242`. - Info architecture (sidebar order): Dashboard, Campaigns, PineAP, Recon, Logging, Modules & Packages, Settings. Sub-pages: PineAP → Open/Clients/Filtering/APs/Impersonation (no Enterprise); Recon → Overview/Handshakes; Logging → Overview/System. - Login: password only (username fixed `root`), old-style card. - Terminal stays bottom-docked (existing behavior), restyled. - Existing Python `unittest` suite must stay green (`tests/` run per-file). - Commits follow repo style (`feat:`, `fix:`, `docs:`). --- ### Task 1: Backend — `GET /api/pineap/aps` **Files:** - Modify: `payload/user/general/pager-webui/server.py` (add parser + handler + route next to the other pineap handlers/routes) - Test: `tests/test_pineap_aps.py` (new) **Interfaces:** - Consumes: `server.device_run(args, timeout=20)` (exists), `server.wifi_ifaces()` (exists — returns `wlan*`/`radio*` iface names), `server.ROUTER.add(method, pattern, handler)` (exists), handler signature `(ctx) -> (status, payload)` where `ctx.args` is a tuple. - Produces: - `server.parse_iwinfo_scan(text) -> list[dict]` with keys `bssid` (upper-case MAC), `ssid` (str, may be `''`), `channel` (int or `None`), `signal` (int or `None`), `encryption` (str, may be `'none'`). - `server.aps_data() -> list[dict]` — concatenation of `parse_iwinfo_scan(out)` per iface, each row augmented with `iface`. - `server.h_pineap_aps(ctx) -> (200, {'aps': [...], 'count': n})`. - Route: `ROUTER.add('GET', r'/api/pineap/aps', h_pineap_aps)`. - [ ] **Step 1: Write the failing test** Create `tests/test_pineap_aps.py`: ```python import os import sys import unittest sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'general', 'pager-webui')) import server SAMPLE = """Cell 01 - Address: AA:BB:CC:DD:EE:FF ESSID: "Hak5" Mode: Master Channel: 6 Signal: -45 dBm Quality: 70/100 Encryption: WPA2 PSK (CCMP) Cell 02 - Address: 00:11:22:33:44:55 ESSID: "" Mode: Master Channel: 11 Signal: -60 dBm Quality: 55/100 Encryption: none """ _orig_aps_data = server.aps_data class ParseTest(unittest.TestCase): def test_parses_cells(self): rows = server.parse_iwinfo_scan(SAMPLE) self.assertEqual(len(rows), 2) self.assertEqual(rows[0]['bssid'], 'AA:BB:CC:DD:EE:FF') self.assertEqual(rows[0]['ssid'], 'Hak5') self.assertEqual(rows[0]['channel'], 6) self.assertEqual(rows[0]['signal'], -45) self.assertEqual(rows[0]['encryption'], 'WPA2 PSK (CCMP)') self.assertEqual(rows[1]['ssid'], '') self.assertEqual(rows[1]['encryption'], 'none') def test_empty_text(self): self.assertEqual(server.parse_iwinfo_scan(''), []) class ApsHandlerTest(unittest.TestCase): def tearDown(self): server.aps_data = _orig_aps_data def test_handler(self): server.aps_data = lambda: [{'bssid': 'AA:BB:CC:DD:EE:FF', 'iface': 'wlan0mon'}] status, payload = server.h_pineap_aps(type('C', (), {'args': ()})()) self.assertEqual(status, 200) self.assertEqual(payload['count'], 1) self.assertEqual(payload['aps'][0]['bssid'], 'AA:BB:CC:DD:EE:FF') ``` - [ ] **Step 2: Run the test to verify it fails** ```powershell & "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" -m unittest tests.test_pineap_aps -v ``` Expected: FAIL with `AttributeError: module 'server' has no attribute 'parse_iwinfo_scan'`. - [ ] **Step 3: Implement the parser, data function, handler, and route** In `server.py`, after `assoc_clients()` (near the existing `wifi_ifaces`/`iwinfo` helpers), add: ```python def parse_iwinfo_scan(text): aps = [] cur = None for line in text.splitlines(): m = re.match(r'^Cell \d+ - Address:\s*([0-9A-Fa-f:]+)', line) if m: if cur is not None: aps.append(cur) cur = {'bssid': m.group(1).upper(), 'ssid': '', 'channel': None, 'signal': None, 'encryption': ''} continue if cur is None: continue m = re.search(r'ESSID:\s*"([^"]*)"', line) if m: cur['ssid'] = m.group(1) m = re.search(r'Channel:\s*(\d+)', line) if m: cur['channel'] = int(m.group(1)) m = re.search(r'Signal:\s*(-?\d+)', line) if m: cur['signal'] = int(m.group(1)) m = re.search(r'Encryption:\s*(.+)', line) if m: cur['encryption'] = m.group(1).strip() if cur is not None: aps.append(cur) return aps def aps_data(): aps = [] for name in wifi_ifaces(): rc, out, err = device_run(['iwinfo', name, 'scan']) if rc != 0: continue for ap in parse_iwinfo_scan(out): ap['iface'] = name aps.append(ap) return aps def h_pineap_aps(ctx): aps = aps_data() return 200, {'aps': aps, 'count': len(aps)} ``` In the `ROUTER.add` block (after the existing `POST /api/pineap/clients/kick` line), add: ```python ROUTER.add('GET', r'/api/pineap/aps', h_pineap_aps) ``` - [ ] **Step 4: Run the test to verify it passes** ```powershell & "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" -m unittest tests.test_pineap_aps -v ``` Expected: PASS (3 tests). - [ ] **Step 5: Run the existing suite to confirm no 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 existing modules PASS. - [ ] **Step 6: Commit** ```bash git add payload/user/general/pager-webui/server.py tests/test_pineap_aps.py git commit -m "feat: add read-only GET /api/pineap/aps (iwinfo scan)" ``` --- ### Task 2: Assets — fetch old-UI logo; add `icons.js` **Files:** - Create: `payload/user/general/pager-webui/www/assets/logo.png` (copied from the old UI) - Create: `payload/user/general/pager-webui/www/js/icons.js` - Modify: `payload/user/general/pager-webui/www/index.html` (favicon link) **Interfaces:** - Produces: `window.PineappleIcons` — a plain object mapping icon name → SVG markup string. Names used by later tasks: `dashboard`, `campaigns`, `pineap`, `recon`, `logging`, `modules`, `settings`, `chevron`, `terminal`, `wifi`, `extension`, `receipt`. Each string is a full inline `...` at 24×24. - [ ] **Step 1: Download the old-UI logo** ```powershell curl.exe -s -o "payload\user\general\pager-webui\www\assets\logo.png" "http://172.16.42.1:1471/assets/icons/logo.png" ``` Verify: file is ~41 KB PNG (`(Get-Item ...).Length`). - [ ] **Step 2: Extract the sidebar SVG paths from the old bundle** The Angular bundle was previously downloaded to `%TEMP%\opencode\oldui_main.js` (re-fetch with `curl.exe -s --compressed -u "root:hak5pineapple" -m 60 "http://172.16.42.1:1471/main.ce5a318adf590e170f6d.js" -o "$env:TEMP\opencode\oldui_main.js"` if missing). Extract the three inline nav icon paths: ```powershell $c = [IO.File]::ReadAllText("$env:TEMP\opencode\oldui_main.js") $i = $c.IndexOf('["class","sidenav-entry"],["matTooltip","Dashboard"]') $t = $c.Substring([Math]::Max(0, $i - 200), 30000) -replace '\s+', ' ' [regex]::Matches($t, '"d","([A-Za-z0-9,.\- ]+)"') | ForEach-Object { $_.Groups[1].Value } ``` Expected: three paths — `view-dashboard` (Dashboard), a gear (Campaigns), and a building/landmark (Recon). - [ ] **Step 3: Write `js/icons.js`** ```js 'use strict'; const PineappleIcons = { dashboard: '', campaigns: '', pineap: '', recon: '', logging: '', modules: '', settings: '', chevron: '', terminal: '', wifi: '', extension: '', receipt: '' }; ``` Note: `campaigns`, `settings`, and `modules`/`extension` intentionally share Material glyph shapes (gear / puzzle) exactly as the old bundle does. If Step 2's extraction produces different text than the literal strings above, prefer the bundle-extracted `d` attributes verbatim and update `icons.js` to match. - [ ] **Step 4: Reference the favicon in `index.html`** Add `` inside the `` of `payload/user/general/pager-webui/www/index.html`. - [ ] **Step 5: Verify assets serve locally** Serve the payload dir with the project dev server (or open the file path) and confirm `assets/logo.png` and `js/icons.js` load: ```powershell python -c "import http.server, functools, os; os.chdir(r'payload\user\general\pager-webui\www'); http.server.test(HandlerClass=http.server.SimpleHTTPRequestHandler, port=8000)" ``` Then `curl.exe -s -o NUL -w "%{http_code} %{size_download}" http://127.0.0.1:8000/assets/logo.png` and `.../js/icons.js` — both 200. - [ ] **Step 6: Commit** ```bash git add payload/user/general/pager-webui/www/assets/logo.png payload/user/general/pager-webui/www/js/icons.js payload/user/general/pager-webui/www/index.html git commit -m "feat: vendor pineapple logo and Material nav icons" ``` --- ### Task 3: Theme CSS — Material light/dark rewrite of `app.css` **Files:** - Rewrite: `payload/user/general/pager-webui/www/css/app.css` **Interfaces:** - Produces: CSS custom properties on `:root` (light) and `html.dark` (dark). Class names consumed by later tasks: - Shell: `#login-screen`, `.login-card`, `.login-logo`, `.login-title`, `.login-field`, `.login-error`, `.spinner`, `#topbar`, `.brand-logo`, `.brand-text`, `.toolbar-spacer`, `#live-status`, `#terminal-btn`, `#layout`, `#rail`, `#rail .entry`, `#rail .entry.active`, `#rail .entry .entry-icon`, `#rail .entry .entry-text`, `#rail .entry.divider`, `#rail .rail-footer`, `#rail.open`, `#content`, `#terminal-panel`, `#terminal-bar`. - Tabs: `.tabbar`, `.tabbar .tab`, `.tabbar .tab.active`, `.tabbar .ink`. - Content: `.page-title`, `.cards`, `.card`, `.card-label`, `.card-value`, `.section`, `.row`, `.tbl`, `.toggle`, `.badge`, `.badge.on`, `.badge.off`, `.btn`, `.btn.raised`, `.btn.ghost`, `.btn.danger`, `input`, `select`, `label`, `code`, `pre.logs`, `.empty`, `.toast`. - [ ] **Step 1: Replace `app.css` with the Material theme** Use CSS custom properties so light/dark swap via `html.dark`. Key values from Global Constraints. Structure: ```css :root { --bg: #fafafa; --surface: #fff; --surface-alt: #f5f5f5; --toolbar: #424242; --rail: #424242; --rail-hover: #545454; --text: #212121; --muted: #686868; --border: #e0e0e0; --primary: #1976d2; --primary-dark: #105694; --danger: #d32f2f; --ok: #7cb342; --warn: #f9a825; --terminal-bg: #000; --shadow: 0 2px 2px rgba(0,0,0,.24), 0 0 2px rgba(0,0,0,.12); --ink: #1976d2; } html.dark { --bg: #303030; --surface: #424242; --surface-alt: #3a3a3a; --toolbar: #424242; --rail: #3a3a3a; --rail-hover: #545454; --text: #fff; --muted: #bdbdbd; --border: #545454; --shadow: 0 2px 2px rgba(0,0,0,.5), 0 0 2px rgba(0,0,0,.3); } * { box-sizing: border-box; } html, body { margin: 0; height: 100%; } body { background: var(--bg); color: var(--text); font: 14px/1.45 Roboto, "Segoe UI", Arial, sans-serif; display: flex; flex-direction: column; } .hidden { display: none !important; } ``` Implement all of the following styles (light values, dark automatically via variables): - `#login-screen` — `flex:1; display:flex; align-items:center; justify-content:center; background:#9c9c9c;` - `.login-card` — `background:#fff; border-radius:2px; padding:32px; width:360px; box-shadow:0 8px 20px rgba(0,0,0,.4); text-align:center;` - `.login-logo` — `height:148px; margin-bottom:8px;` - `.login-title` — `margin:0 0 20px; font-size:26px; font-weight:400; color:#212121;` - `.login-field` — full-width Material-style inputs: `width:100%; padding:10px 12px; border:1px solid #ccc; border-radius:2px; margin:6px 0; font-size:14px;` - `.login-error` — `color:#d32f2f; font-size:13px; min-height:18px;` - `.spinner` — 18px ring loader (CSS border spinner, blue). - `#topbar` — `height:64px; background:var(--toolbar); color:#fff; display:flex; align-items:center; padding:0 16px; box-shadow:0 2px 4px rgba(0,0,0,.4);` - `.brand-logo` — `height:32px; margin-right:10px;` - `.brand-text` — `font-size:20px; font-weight:400; letter-spacing:.5px;` - `.toolbar-spacer` — `flex:1;` - `#live-status` — `color:#e0e0e0; font-size:12px; margin-right:16px; font-variant-numeric:tabular-nums;` - `#terminal-btn` — ghost white toolbar button. - `#layout` — `flex:1; display:flex; min-height:0;` - `#rail` — `width:60px; background:var(--rail); color:#fff; display:flex; flex-direction:column; overflow-y:auto; transition:width .2s;` - `#rail.open` — `width:220px;` - `#rail .entry` — `height:48px; display:flex; align-items:center; padding:0 0 0 18px; color:#e0e0e0; cursor:pointer; border-right:3px solid transparent; font-size:14px;` - `#rail .entry:hover` — `background:var(--rail-hover);` - `#rail .entry.active` — `border-right:3px solid #1976d2; background:rgba(255,255,255,.08); color:#fff;` - `.entry-icon` — `font-size:24px; width:24px; height:24px; display:flex; align-items:center; justify-content:center; margin-right:14px; flex:none;` - `.entry-icon svg` — `width:24px; height:24px; display:block;` - `#topbar .btn.ghost` — `color:#fff; border-color:#fff;` - `.entry-text` — `white-space:nowrap; opacity:0; transition:opacity .2s;` - `#rail.open .entry-text` — `opacity:1;` - `#rail .entry.divider` — `height:0; padding:0; margin:8px 0; border-top:1px solid #616161; flex:none;` - `.rail-footer` — `margin-top:auto;` - `#content` — `flex:1; overflow-y:auto; padding:20px 30px 5px 5px;` - `.tabbar` — `display:flex; border-bottom:2px solid #e0e0e0; margin-bottom:16px; position:relative;` - `.tabbar .tab` — `padding:10px 16px; cursor:pointer; color:var(--muted); font-size:14px; border-bottom:2px solid transparent; margin-bottom:-2px;` - `.tabbar .tab.active` — `color:var(--primary); border-bottom-color:var(--ink);` - `.page-title` — `font-size:24px; font-weight:400; margin:0 0 12px;` - `.cards` — `display:grid; grid-template-columns:repeat(auto-fill, minmax(170px,1fr)); gap:12px;` - `.card` — `background:var(--surface); border-radius:2px; box-shadow:var(--shadow); padding:14px;` - `.card-label` — `font-size:12px; color:var(--muted);` - `.card-value` — `font-size:28px; font-weight:400; margin-top:4px;` - `.section` — `background:var(--surface); border-radius:2px; box-shadow:var(--shadow); padding:16px; margin-bottom:16px;` - `.section h2` — `margin:0 0 12px; font-size:16px; font-weight:500;` - `.row` — `display:flex; gap:10px; align-items:flex-end; flex-wrap:wrap;` - `.row > div` — `flex:1; min-width:140px;` - `.tbl` — `width:100%; border-collapse:collapse;` - `.tbl th, .tbl td` — `text-align:left; padding:8px 10px; border-bottom:1px solid var(--border);` - `.tbl th` — `color:var(--muted); font-size:12px; font-weight:500;` - `.tbl tr:hover td` — `background:var(--surface-alt);` - `.toggle` — `display:flex; align-items:center; gap:8px; margin:6px 0;` - `.badge` — `display:inline-block; padding:2px 10px; border-radius:10px; font-size:11px;` - `.badge.on` — `background:#e8f5e9; color:#2e7d32;` (dark: override color via `html.dark` or use `var(--ok)` text) - `.badge.off` — `background:#fff3e0; color:#e65100;` - `.btn` — `background:var(--primary); color:#fff; border:0; border-radius:2px; padding:8px 14px; font-size:14px; cursor:pointer; box-shadow:0 1px 3px rgba(0,0,0,.3);` - `.btn:hover` — `background:var(--primary-dark);` - `.btn.ghost` — `background:transparent; color:var(--primary); box-shadow:none; border:1px solid var(--primary);` - `.btn.danger` — `background:var(--danger);` - `input, select` — `background:var(--surface); color:var(--text); border:1px solid var(--border); border-radius:2px; padding:8px 10px; width:100%;` - `label` — `display:block; font-size:12px; color:var(--muted); margin:8px 0 4px;` - `code` — `background:var(--surface-alt); padding:1px 5px; border-radius:2px;` - `pre.logs` — `background:var(--terminal-bg); color:#c9d1d9; padding:12px; overflow:auto; max-height:420px; font-size:12px; white-space:pre-wrap; border-radius:2px;` - `.empty` — `padding:24px; text-align:center; color:var(--muted);` - `#terminal-panel` — `border-top:1px solid var(--border); background:#000; height:280px; display:flex; flex-direction:column;` - `#terminal-bar` — `display:flex; justify-content:space-between; align-items:center; background:var(--toolbar); padding:4px 10px; font-size:12px; color:#e0e0e0;` - `#toast-container` — `position:fixed; right:16px; bottom:16px; display:flex; flex-direction:column; gap:8px; z-index:100;` - `.toast` — `padding:10px 14px; border-radius:2px; background:var(--surface); box-shadow:var(--shadow); color:var(--text);` - `.toast.error` — `border-left:4px solid var(--danger);` - `.toast.info` — `border-left:4px solid var(--primary);` - Terminal xterm stays black; `.xterm` sizing preserved (`#terminal .xterm { height:100%; }`). Write the full stylesheet — do not omit any listed selector. - [ ] **Step 2: Sanity-check the file** Verify the file parses as CSS (balanced braces) and contains both `:root` and `html.dark` blocks, plus `#rail` and `.tabbar` selectors: ```powershell Select-String -Path payload\user\general\pager-webui\www\css\app.css -Pattern ':root','html.dark','#rail','.tabbar' ``` Expected: all four patterns found. - [ ] **Step 3: Commit** ```bash git add payload/user/general/pager-webui/www/css/app.css git commit -m "feat: Material light/dark theme CSS for UI clone" ``` --- ### Task 4: Shell markup — rewrite `index.html` **Files:** - Rewrite: `payload/user/general/pager-webui/www/index.html` **Interfaces:** - Consumes: `js/icons.js` (`PineappleIcons`), `css/app.css` classes from Task 3, existing `js/xterm.min.js`, `js/xterm-addon-fit.min.js`, `js/xterm.css`. - Produces: element IDs wired by `js/app.js` (Task 5): `login-screen`, `login-form`, `login-password`, `login-error`, `login-button`, `app`, `topbar`, `brand-logo`, `brand-text`, `live-status`, `terminal-btn`, `layout`, `rail`, `content`, `terminal-panel`, `terminal-bar`, `terminal-close`, `terminal`, `toast-container`. - [ ] **Step 1: Replace `index.html`** ```html WiFi Pineapple
``` Note: `#rail` is rendered empty; Task 5 builds its entries from JS. `js/chart.js` is created in Task 5 — the script tag here is fine because it loads before `app.js` runs on `DOMContentLoaded`; until Task 5 lands, a missing file is expected and harmless in this interim commit only if you open the page before Task 5. To keep each task independently servable, create an empty placeholder `js/chart.js` in Task 4: ```js 'use strict'; window.MiniChart = window.MiniChart || { draw: function () {} }; ``` - [ ] **Step 2: Verify** `curl.exe -s http://127.0.0.1:8000/` (dev server from Task 2) returns 200 and contains `id="rail"`, `assets/logo.png`, `js/icons.js`, `js/chart.js`. - [ ] **Step 3: Commit** ```bash git add payload/user/general/pager-webui/www/index.html payload/user/general/pager-webui/www/js/chart.js git commit -m "feat: old-style app shell markup" ``` --- ### Task 5: Shell JS — routing, rail, theme, shortcuts, chart **Files:** - Rewrite: `payload/user/general/pager-webui/www/js/app.js` - Create: `payload/user/general/pager-webui/www/js/chart.js` - Modify: `payload/user/general/pager-webui/www/js/config.js` (no change needed — leave as-is) **Interfaces:** - Consumes: `PagerAPI` (`login`, `get`, `post`, `del`, `on401`, `setBase`), `PineappleIcons` (Task 2), DOM IDs from Task 4, `Live` (defined in `app.js` itself, as today). - Produces (used by `js/views.js` in Tasks 6–8): - `App.toast(msg, kind)` — creates `.toast` in `#toast-container`, auto-removes after 4s. - `App.route()` — reads `location.hash`, looks up `views[name]`, calls `views[name](rootEl)`, returns the view handle `{destroy}`. - `App.railItems` — the ordered nav list: `[{key:'dashboard', label:'Dashboard', hash:'#/dashboard', icon:'dashboard'}, {key:'campaigns', label:'Campaigns', hash:'#/campaigns', icon:'campaigns'}, {key:'pineap', label:'PineAP', hash:'#/pineap', icon:'pineap'}, {key:'recon', label:'Recon', hash:'#/recon', icon:'recon'}, {key:'logging', label:'Logging', hash:'#/logging', icon:'logging'}, {key:'modules', label:'Modules & Packages', hash:'#/modules', icon:'modules'}, {key:'settings', label:'Settings', hash:'#/settings', icon:'settings'}]` with dividers after `campaigns`, `logging`, and before the footer. - `App.key` — current nav key derived from hash (parent segment, e.g. `#/pineap/clients` → `pineap`). - `App.go(hash)` — sets `location.hash`. - `Live.onTick(fn)` — subscribes; `Live.start()` — WS `/api/ws` + 5s poll fallback (keep existing logic). - `MiniChart.draw(canvas, series, opts)` (in `chart.js`) — draws a 2-series line chart. - `window.THEME` helpers: `Theme.apply()` sets `document.documentElement.classList.toggle('dark', localStorage.pw_theme === 'dark')`; `Theme.toggle()`; `Theme.current()`. - Keyboard shortcuts: `d`→`#/dashboard`, `c`→`#/campaigns`, `p`→`#/pineap`, `r`→`#/recon`, `l`→`#/logging`, `m`→`#/modules`, `` ` ``→terminal toggle. Ignore when focus is in an input/textarea. - [ ] **Step 1: Write `js/chart.js` (mini canvas line chart)** ```js 'use strict'; const MiniChart = (() => { function draw(canvas, series, opts) { const o = opts || {}; const W = canvas.width = canvas.clientWidth * (window.devicePixelRatio || 1); const H = canvas.height = 140 * (window.devicePixelRatio || 1); const ctx = canvas.getContext('2d'); ctx.scale(window.devicePixelRatio || 1, window.devicePixelRatio || 1); 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; // gridlines 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(); } }); } return { draw }; })(); ``` - [ ] **Step 2: Rewrite `js/app.js`** Keep the existing `Live` implementation verbatim (WS + poll fallback + `updateBar`), but change `updateBar` to also write the brand text, and replace the app shell logic: ```js 'use strict'; const Theme = (() => { const KEY = 'pw_theme'; function apply() { document.documentElement.classList.toggle('dark', localStorage.getItem(KEY) === 'dark'); } function toggle() { localStorage.setItem(KEY, Theme.current() === 'dark' ? 'light' : 'dark'); apply(); } function current() { return document.documentElement.classList.contains('dark') ? 'dark' : 'light'; } return { apply, toggle, current, KEY }; })(); const App = (() => { const cfg = window.PAGER_CONFIG || {}; const API_BASE = cfg.apiBase || ''; const WS_BASE = (cfg.wsBase || location.origin).replace(/^http/, 'ws'); const TERMINAL_WS = cfg.terminalWs || ('ws://' + location.hostname + ':1471/api/terminal/openWs'); const els = {}; let currentView = null; const railItems = [ { key: 'dashboard', label: 'Dashboard', hash: '#/dashboard', icon: 'dashboard' }, { key: 'campaigns', label: 'Campaigns', hash: '#/campaigns', icon: 'campaigns' }, { key: 'pineap', label: 'PineAP', hash: '#/pineap', icon: 'pineap' }, { key: 'recon', label: 'Recon', hash: '#/recon', icon: 'recon' }, { key: 'logging', label: 'Logging', hash: '#/logging', icon: 'logging' }, { key: 'modules', label: 'Modules & Packages', hash: '#/modules', icon: 'modules' }, { key: 'settings', label: 'Settings', hash: '#/settings', icon: 'settings' } ]; const railDividers = new Set(['campaigns', 'logging']); function keyOf(hash) { const seg = (hash || '#/dashboard').split('/')[0].replace('#/', ''); return seg || 'dashboard'; } function buildRail() { const rail = els.rail; rail.innerHTML = ''; railItems.forEach((it) => { if (railDividers.has(it.key)) { const d = document.createElement('div'); d.className = 'entry divider'; rail.appendChild(d); } const a = document.createElement('a'); a.className = 'entry'; a.href = it.hash; a.title = it.label; a.innerHTML = '' + PineappleIcons[it.icon] + '' + it.label + ''; a.addEventListener('click', (e) => { e.preventDefault(); location.hash = it.hash; }); rail.appendChild(a); }); const foot = document.createElement('div'); foot.className = 'rail-footer'; const open = document.createElement('a'); open.className = 'entry'; open.title = 'Open Menu'; open.innerHTML = '' + PineappleIcons.chevron + 'Open Menu'; open.addEventListener('click', (e) => { e.preventDefault(); const openState = localStorage.getItem('pw_rail') === 'open'; localStorage.setItem('pw_rail', openState ? 'closed' : 'open'); els.rail.classList.toggle('open', !openState); }); foot.appendChild(open); rail.appendChild(foot); } function route() { const hash = location.hash || '#/dashboard'; const name = routes[hash]; if (currentView && currentView.destroy) currentView.destroy(); els.content.innerHTML = ''; if (!name || !views[name]) { const ph = document.createElement('div'); ph.className = 'section empty'; ph.textContent = 'View not available.'; els.content.appendChild(ph); currentView = null; } else { currentView = views[name](els.content); } const key = keyOf(hash); Array.prototype.forEach.call(els.rail.querySelectorAll('.entry'), (a) => { a.classList.toggle('active', a.getAttribute('href') === hash || keyOf(a.getAttribute('href')) === key); }); } function toast(msg, kind) { const d = document.createElement('div'); d.className = 'toast ' + (kind || 'info'); d.textContent = msg; els.toasts.appendChild(d); setTimeout(() => d.remove(), 4000); } function showApp() { els.login.classList.add('hidden'); els.app.classList.remove('hidden'); Theme.apply(); Live.start(); route(); } function showLogin() { els.app.classList.add('hidden'); els.login.classList.remove('hidden'); } function init() { els.login = document.getElementById('login-screen'); els.app = document.getElementById('app'); els.rail = document.getElementById('rail'); els.content = document.getElementById('content'); els.toasts = document.getElementById('toast-container'); document.getElementById('login-form').addEventListener('submit', (e) => { e.preventDefault(); const btn = document.getElementById('login-button'); const pw = document.getElementById('login-password').value; document.getElementById('login-error').textContent = ''; btn.disabled = true; PagerAPI.login('root', pw) .then(() => { document.getElementById('login-password').value = ''; showApp(); toast('Logged in'); }) .catch((err) => { document.getElementById('login-error').textContent = (err && err.message && err.message !== 'unauthorized') ? 'Login failed.' : 'Invalid credentials.'; }) .finally(() => { btn.disabled = false; }); }); document.getElementById('terminal-btn').addEventListener('click', () => { if (typeof Term === 'undefined') toast('Terminal not available yet'); else Term.toggle(); }); document.getElementById('terminal-close').addEventListener('click', () => { if (typeof Term === 'undefined') toast('Terminal not available yet'); else Term.toggle(); }); window.addEventListener('hashchange', route); document.addEventListener('keydown', (e) => { const t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return; if (e.ctrlKey || e.metaKey || e.altKey) return; const map = { d: '#/dashboard', c: '#/campaigns', p: '#/pineap', r: '#/recon', l: '#/logging', m: '#/modules' }; if (map[e.key.toLowerCase()]) { location.hash = map[e.key.toLowerCase()]; } else if (e.key === '`') { if (typeof Term !== 'undefined') Term.toggle(); } }); PagerAPI.setBase(API_BASE); PagerAPI.on401 = () => showLogin(); buildRail(); Theme.apply(); checkSession(); } function checkSession() { PagerAPI.get('/api/api_ping') .then(() => showApp()) .catch(() => showLogin()); } const routes = { '#/dashboard': 'dashboard', '#/campaigns': 'campaigns', '#/pineap': 'pineap', '#/pineap/open': 'pineap_open', '#/pineap/clients': 'pineap_clients', '#/pineap/filtering': 'pineap_filtering', '#/pineap/aps': 'pineap_aps', '#/pineap/impersonation': 'pineap_impersonation', '#/recon': 'recon', '#/recon/handshakes': 'recon_handshakes', '#/logging': 'logging', '#/logging/system': 'logging_system', '#/modules': 'modules', '#/settings': 'settings' }; return { init, route, toast, showLogin, wsUrl: (p) => WS_BASE + p, terminalWs: TERMINAL_WS, apiBase: API_BASE, keyOf, railItems, go: (h) => { location.hash = h; } }; })(); const Live = (() => { let ws = null; let ever = false; let poll = null; const subs = []; let timer = null; function stopPoll() { if (poll) { clearInterval(poll); poll = null; } } function start() { if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; stopPoll(); try { ws = new WebSocket(App.wsUrl('/api/ws')); } catch (e) { fallback(); return; } ws.onopen = () => { ever = true; }; ws.onmessage = (ev) => { let msg; try { msg = JSON.parse(ev.data); } catch (e) { return; } subs.forEach((fn) => fn(msg)); updateBar(msg); }; ws.onclose = () => { ws = null; clearTimeout(timer); if (ever) timer = setTimeout(start, 5000); else fallback(); }; ws.onerror = () => { try { ws.close(); } catch (e) {} }; } function fallback() { stopPoll(); poll = setInterval(async () => { try { const r = await PagerAPI.get('/api/status'); const msg = { type: 'tick', status: r.data, clients: r.data.clients }; subs.forEach((fn) => fn(msg)); updateBar(msg); } catch (e) {} }, 5000); } function updateBar(msg) { const b = (msg.status || {}).battery || {}; const n = (msg.clients || []).length; const el = document.getElementById('live-status'); if (el) el.textContent = 'BAT ' + (b.level == null ? '--' : b.level + '%' + (b.charging ? '+' : '')) + ' CLIENTS ' + n; } return { start, onTick: (fn) => subs.push(fn) }; })(); document.addEventListener('DOMContentLoaded', () => App.init()); ``` - [ ] **Step 3: Verify no syntax errors** ```powershell $c = Get-Content -Raw payload\user\general\pager-webui\www\js\app.js if ($c -match 'function|const' -and $c -match 'App.init') { 'app.js OK' } $d = Get-Content -Raw payload\user\general\pager-webui\www\js\chart.js if ($d -match 'MiniChart.draw') { 'chart.js OK' } ``` Also open `http://127.0.0.1:8000/` in a browser (dev server from Task 2) — the login card renders with the logo and no console errors; the `#rail` renders 7 entries + footer. - [ ] **Step 4: Commit** ```bash git add payload/user/general/pager-webui/www/js/app.js payload/user/general/pager-webui/www/js/chart.js git commit -m "feat: shell JS - routing, icon rail, keyboard shortcuts, theme" ``` --- ### Task 6: Views — Dashboard, Campaigns, tab-bar helper **Files:** - Modify: `payload/user/general/pager-webui/www/js/views.js` (replace everything; helper functions `h`, `table`, `fmtTime`, `fmtDur`, `badge`, `btn` are retained/restyled) **Interfaces:** - Consumes: `PagerAPI`, `App.toast`, `App.go`, `App.keyOf`, `Live.onTick`, `MiniChart.draw`, DOM helpers. - Produces (used by Tasks 7–8): shared helpers `h`, `table`, `fmtTime`, `fmtDur`, `btn`, `badge`, plus `tabBar(box, items, activeHash)` which renders `.tabbar` with `.tab` items linking to `activeHash` values and a blue `.ink` bar; and view functions `views.dashboard`, `views.campaigns` (this task) and `views.pineap_open` … `views.settings` (Tasks 7–8). - [ ] **Step 1: Replace `views.js` with helpers + Dashboard + Campaigns** Keep `h`, `table`, `fmtTime`, `fmtDur`, `badge`, `btn` from the current file (identical semantics) **except** `btn`, which must always carry the base `btn` class so Task 3's `.btn`/`.btn.ghost`/`.btn.danger` styles apply: ```js const btn = (label, onclk, cls) => h('button', { class: 'btn ' + (cls || ''), onclick: onclk, text: label }); ``` ```js const tabBar = (box, items, activeHash) => { const bar = h('div', { class: 'tabbar' }); items.forEach((it) => { const t = h('a', { class: 'tab' + (it.hash === activeHash ? ' active' : ''), href: it.hash, text: it.label }); t.addEventListener('click', (e) => { e.preventDefault(); location.hash = it.hash; }); bar.appendChild(t); }); box.appendChild(bar); return bar; }; ``` Replace `views.dashboard`: ```js views.dashboard = (root) => { const history = { clients: [], handshakes: [] }; const max = 60; const title = h('h1', { class: 'page-title', text: 'Dashboard' }); root.appendChild(title); const grid = h('div', { class: 'cards' }); root.appendChild(grid); const defs = [ ['clients', 'Clients Connected'], ['handshakes', 'Handshakes Captured'], ['disk', 'Disk Usage'], ['uptime', 'Uptime'] ]; const cards = {}; defs.forEach(([k, label]) => { const card = h('div', { class: 'card' }, h('div', { class: 'card-label', text: label }), h('div', { class: 'card-value', text: '--' })); grid.appendChild(card); cards[k] = card.querySelector('.card-value'); }); const chartBox = h('div', { class: 'section' }, h('h2', {}, 'Clients'), h('canvas', { id: 'dash-chart', style: 'width:100%;height:140px' })); root.appendChild(chartBox); const canvas = chartBox.querySelector('#dash-chart'); const update = (msg) => { const s = msg.status || {}; const n = (msg.clients || []).length; history.clients.push(n); history.handshakes.push(0); if (history.clients.length > max) { history.clients.shift(); history.handshakes.shift(); } cards.clients.textContent = n; const b = s.battery || {}; if (s.uptime != null) cards.uptime.textContent = fmtDur(s.uptime); if (s.disk) cards.disk.textContent = (s.disk.used / 1048576).toFixed(1) + ' / ' + (s.disk.size / 1048576).toFixed(1) + ' GB'; if (typeof MiniChart !== 'undefined') { MiniChart.draw(canvas, [ { label: 'Clients', color: '#1976d2', points: history.clients }, { label: 'Handshakes', color: '#7cb342', points: history.handshakes } ]); } }; Live.onTick(update); PagerAPI.get('/api/status').then((r) => update({ status: r.data, clients: r.data.clients })); PagerAPI.get('/api/pineap/handshakes').then((r) => { cards.handshakes.textContent = (r.data.files || []).length; history.handshakes = new Array(history.clients.length).fill((r.data.files || []).length); }).catch(() => {}); const clBox = h('div', { class: 'section' }, h('h2', {}, 'Connected Clients'), h('div', { id: 'dash-clients' })); root.appendChild(clBox); const hsBox = h('div', { class: 'section' }, h('h2', {}, 'Captured WPA Handshakes'), h('div', { id: 'dash-handshakes' })); root.appendChild(hsBox); function loadClients() { PagerAPI.get('/api/pineap/clients').then((r) => { document.getElementById('dash-clients').innerHTML = ''; document.getElementById('dash-clients').appendChild(table( [{ label: 'MAC', key: 'mac' }, { label: 'Interface', key: 'iface' }, { label: 'RSSI', key: 'rssi' }, { label: '', render: () => '' }], r.data.clients, (c) => ({ style: 'cursor:pointer', onclick: () => { if (confirm('Deauthenticate ' + c.mac + '?')) PagerAPI.post('/api/pineap/deauth/client', { mac: c.mac }).then(() => App.toast('Deauthenticated')).then(loadClients); } }))); const cols = ['MAC', 'Interface', 'RSSI']; document.getElementById('dash-clients').querySelectorAll('.tbl th').forEach((th, i) => { if (i >= cols.length) th.textContent = 'Deauth'; }); }).catch(() => {}); } function loadHandshakes() { PagerAPI.get('/api/pineap/handshakes').then((r) => { document.getElementById('dash-handshakes').innerHTML = ''; document.getElementById('dash-handshakes').appendChild(table( [{ label: 'File', key: 'name' }, { label: 'Size', key: 'size' }, { label: 'Modified', key: 'mtime' }], (r.data.files || []).map((f) => ({ name: f.name, size: f.size, mtime: fmtTime(f.mtime) })))); }).catch(() => {}); } loadClients(); loadHandshakes(); const iv = setInterval(loadClients, 10000); return { destroy: () => clearInterval(iv) }; }; ``` Replace `views.payloads`... no — add `views.campaigns`: ```js views.campaigns = (root) => { root.appendChild(h('h1', { class: 'page-title', text: 'Campaigns' })); root.appendChild(h('div', { class: 'section empty' }, 'Campaigns are not supported on the WiFi Pineapple Pager.')); return { destroy: () => {} }; }; ``` Remove the old `views.payloads`, `views.logs`, `views.settings`, `views.pineap`, `views.recon`, `views.handshakes` definitions from the file (they are re-added in Tasks 7–8). Keep the file loading without errors — every `views.*` referenced by `App.routes` must exist by the end of Task 8; for now, Tasks 7–8 add the rest. - [ ] **Step 2: Verify** With the dev server running, log in and navigate to `#/dashboard`: the page renders the title, 4 cards, a chart canvas, and the two tables; no console errors. Navigate to `#/campaigns` → placeholder card. - [ ] **Step 3: Commit** ```bash git add payload/user/general/pager-webui/www/js/views.js git commit -m "feat: dashboard (cards+chart+tables) and campaigns placeholder" ``` --- ### Task 7: Views — PineAP Suite (5 tabs) **Files:** - Modify: `payload/user/general/pager-webui/www/js/views.js` **Interfaces:** - Consumes: `tabBar`, helpers, `PagerAPI`, `App.toast`, `App.go`. - Produces: `views.pineap` (wrapper that renders the tab bar and delegates to the active sub-view), `views.pineap_open`, `views.pineap_clients`, `views.pineap_filtering`, `views.pineap_aps`, `views.pineap_impersonation`. - [ ] **Step 1: Add the PineAP views** ```js const PINEAP_TABS = [ { label: 'Open', hash: '#/pineap/open' }, { label: 'Clients', hash: '#/pineap/clients' }, { label: 'Filtering', hash: '#/pineap/filtering' }, { label: 'APs', hash: '#/pineap/aps' }, { label: 'Impersonation', hash: '#/pineap/impersonation' } ]; function pineapShell(root, activeHash, inner) { root.appendChild(h('h1', { class: 'page-title', text: 'PineAP Suite' })); tabBar(root, PINEAP_TABS, activeHash); const box = h('div', {}); root.appendChild(box); return inner(box); } views.pineap = (root) => { const hash = location.hash || '#/pineap'; if (hash === '#/pineap') { location.hash = '#/pineap/open'; return { destroy: () => {} }; } const view = hash.split('/')[2] || 'open'; const map = { open: views.pineap_open, clients: views.pineap_clients, filtering: views.pineap_filtering, aps: views.pineap_aps, impersonation: views.pineap_impersonation }; if (!map[view]) { location.hash = '#/pineap/open'; return { destroy: () => {} }; } return pineapShell(root, hash, (box) => map[view](box)); }; ``` `views.pineap_open` = current `views.pineap` settings content (settings toggles + bands + reload) with the old labels: ```js views.pineap_open = (root) => { const settings = {}; const toggleDefs = [ ['mimic', 'Mimic'], ['advertise', 'Advertise'], ['collect_probes', 'Collect probes'], ['collect_handshakes', 'Collect handshakes'], ['random_mac', 'Random MAC'], ['wigle', 'WiGLE'] ]; const box = h('div', { class: 'section' }, h('h2', {}, 'PineAP Settings')); root.appendChild(box); toggleDefs.forEach(([k, label]) => { const cb = h('input', { type: 'checkbox', id: 'set-' + k }); const wrap = h('label', { class: 'toggle' }, cb, ' ' + label); settings[k] = cb; cb.addEventListener('change', () => saveSettings()); box.appendChild(wrap); }); const bandsSel = h('select', { id: 'set-bands' }, h('option', { value: '2.4', text: '2.4 GHz' }), h('option', { value: '5', text: '5 GHz' }), h('option', { value: 'dual', text: 'Dual' })); bandsSel.addEventListener('change', () => saveSettings()); box.appendChild(h('label', {}, 'Bands', bandsSel)); box.appendChild(h('div', { class: 'row' }, h('div', {}, btn('Reload config', () => { PagerAPI.post('/api/pineap/settings', {}).then(loadSettings); }, 'ghost')))); function saveSettings() { const body = {}; toggleDefs.forEach(([k]) => { body[k] = settings[k].checked; }); body.bands = bandsSel.value; PagerAPI.post('/api/pineap/settings', body).then(() => App.toast('Settings saved')); } function loadSettings() { PagerAPI.get('/api/pineap/settings').then((r) => { const s = r.data.settings || {}; toggleDefs.forEach(([k]) => { settings[k].checked = !!s[k]; }); bandsSel.value = s.bands || '2.4'; }); } loadSettings(); return { destroy: () => {} }; }; ``` `views.pineap_clients` = current connected-clients list (from the old flat PineAP view): ```js views.pineap_clients = (root) => { const box = h('div', { class: 'section' }, h('h2', {}, 'Connected Clients')); root.appendChild(box); function render() { box.innerHTML = ''; box.appendChild(h('h2', {}, 'Connected Clients')); box.appendChild(btn('Refresh', load, 'ghost')); box.appendChild(table( [{ label: 'MAC', key: 'mac' }, { label: 'Interface', key: 'iface' }, { label: 'RSSI', key: 'rssi' }, { label: '', render: () => '' }], state.clients, (r) => ({ style: 'cursor:pointer', onclick: () => { if (confirm('Kick ' + r.mac + '?')) PagerAPI.post('/api/pineap/clients/kick', { mac: r.mac }).then(() => App.toast('Kicked')).then(load); } }))); const cols = ['MAC', 'Interface', 'RSSI']; box.querySelectorAll('.tbl th').forEach((th, i) => { if (i >= cols.length) th.textContent = 'Kick'; }); } function load() { PagerAPI.get('/api/pineap/clients').then((r) => { state.clients = r.data.clients; render(); }); } const state = { clients: [] }; load(); const iv = setInterval(load, 10000); return { destroy: () => clearInterval(iv) }; }; ``` `views.pineap_filtering` = current filter sections (Client Filter + SSID Filter) rendered side by side in two `.section`s: ```js views.pineap_filtering = (root) => { const state = { cMode: '', cEntries: [], sMode: '', sEntries: [] }; const cfBox = h('div', { class: 'section' }, h('h2', {}, 'PineAP Client Filter')); const sfBox = h('div', { class: 'section' }, h('h2', {}, 'PineAP SSID Filter')); root.appendChild(cfBox); root.appendChild(sfBox); function renderFilter(box, kind) { box.innerHTML = ''; box.appendChild(h('h2', {}, kind === 'client' ? 'PineAP Client Filter' : 'PineAP SSID Filter')); const list = kind === 'client' ? state.cEntries : state.sEntries; const mode = kind === 'client' ? state.cMode : state.sMode; const path = '/api/pineap/filters/' + kind; const modeSel = h('select', { id: 'mode-' + kind }, h('option', { value: 'allow', text: 'Allow list' }), h('option', { value: 'deny', text: 'Deny list' }), h('option', { value: 'off', text: 'Off' })); modeSel.value = mode; modeSel.addEventListener('change', () => { PagerAPI.post(path, { action: 'set_mode', mode: modeSel.value }).then(() => refresh()); }); const valueIn = h('input', { id: 'val-' + kind }); box.appendChild(h('div', { class: 'row' }, h('div', {}, h('label', {}, 'Mode', modeSel)), h('div', {}, h('label', {}, 'Value', valueIn)), h('div', {}, btn('Add', () => { const v = document.getElementById('val-' + kind).value.trim(); if (!v) return; PagerAPI.post(path, { action: 'add', value: v }).then(() => { refresh(); App.toast('Added'); }); })), h('div', {}, btn('Clear', () => PagerAPI.post(path, { action: 'clear' }).then(refresh), 'danger')))); box.appendChild(table( [{ label: kind === 'client' ? 'MAC' : 'SSID', key: 'value' }], list.map((e) => ({ value: e })), (r) => ({ style: 'cursor:pointer', onclick: () => { if (confirm('Delete ' + r.value + '?')) PagerAPI.post(path, { action: 'delete', value: r.value }).then(refresh); } }))); } function refresh() { PagerAPI.get('/api/pineap/filters/client').then((r) => { state.cMode = r.data.mode; state.cEntries = r.data.entries; renderFilter(cfBox, 'client'); }); PagerAPI.get('/api/pineap/filters/ssid').then((r) => { state.sMode = r.data.mode; state.sEntries = r.data.entries; renderFilter(sfBox, 'ssid'); }); } refresh(); return { destroy: () => {} }; }; ``` `views.pineap_aps` (uses the Task 1 endpoint): ```js views.pineap_aps = (root) => { const box = h('div', { class: 'section' }, h('h2', {}, 'Access Points')); root.appendChild(box); function load() { box.innerHTML = ''; box.appendChild(h('h2', {}, 'Access Points')); box.appendChild(btn('Refresh', load, 'ghost')); PagerAPI.get('/api/pineap/aps').then((r) => { const rows = (r.data.aps || []).map((a) => ({ bssid: a.bssid || '--', ssid: a.ssid || '--', channel: a.channel == null ? '--' : a.channel, signal: a.signal == null ? '--' : a.signal + ' dBm', encryption: a.encryption || '--', iface: a.iface || '--' })); box.appendChild(table( [{ label: 'BSSID', key: 'bssid' }, { label: 'SSID', key: 'ssid' }, { label: 'Channel', key: 'channel' }, { label: 'Signal', key: 'signal' }, { label: 'Encryption', key: 'encryption' }, { label: 'Interface', key: 'iface' }], rows)); if (!rows.length) box.appendChild(h('div', { class: 'empty', text: 'No access points found.' })); }).catch(() => box.appendChild(h('div', { class: 'empty', text: 'Scan failed.' }))); } load(); const iv = setInterval(load, 30000); return { destroy: () => clearInterval(iv) }; }; ``` `views.pineap_impersonation` = the current SSID pool section: ```js views.pineap_impersonation = (root) => { const state = { ssids: [] }; const box = h('div', { class: 'section' }, h('h2', {}, 'SSID Impersonation')); root.appendChild(box); function render() { box.innerHTML = ''; box.appendChild(h('h2', {}, 'SSID Impersonation')); const row = h('div', { class: 'row' }, h('div', {}, h('label', {}, 'SSID', h('input', { id: 'pool-ssid' }))), h('div', {}, btn('Add', () => { const v = document.getElementById('pool-ssid').value.trim(); if (!v) return; PagerAPI.post('/api/pineap/ssids', { action: 'add', ssid: v }).then((r) => { state.ssids = r.data.ssids; render(); App.toast('Added'); }); })), h('div', {}, btn('Clear', () => PagerAPI.post('/api/pineap/ssids', { action: 'clear' }).then((r) => { state.ssids = r.data.ssids; render(); }), 'danger'))); const actions = h('div', { class: 'row', style: 'margin-top:10px' }, h('div', {}, btn('Start', () => PagerAPI.post('/api/pineap/ssidpool/start'))), h('div', {}, btn('Stop', () => PagerAPI.post('/api/pineap/ssidpool/stop'))), h('div', {}, btn('Collect start', () => PagerAPI.post('/api/pineap/ssidpool/collect_start'))), h('div', {}, btn('Collect stop', () => PagerAPI.post('/api/pineap/ssidpool/collect_stop')))); box.appendChild(row); box.appendChild(actions); box.appendChild(table([{ label: 'SSID', key: 'ssid' }], state.ssids.map((s) => ({ ssid: s })))); document.querySelectorAll('#pool-ssid').forEach((x) => { x.value = ''; }); } function load() { PagerAPI.get('/api/pineap/ssids').then((r) => { state.ssids = r.data.ssids; render(); }); } load(); return { destroy: () => {} }; }; ``` - [ ] **Step 2: Verify** Navigate `#/pineap`, `#/pineap/open`, `#/pineap/clients`, `#/pineap/filtering`, `#/pineap/aps`, `#/pineap/impersonation` — tab bar shows 5 tabs, active tab matches hash, each sub-view renders, APs table shows scan results or "No access points found." - [ ] **Step 3: Commit** ```bash git add payload/user/general/pager-webui/www/js/views.js git commit -m "feat: PineAP Suite with Open/Clients/Filtering/APs/Impersonation tabs" ``` --- ### Task 8: Views — Recon, Logging, Modules & Packages, Settings **Files:** - Modify: `payload/user/general/pager-webui/www/js/views.js` **Interfaces:** - Consumes: `tabBar`, helpers, `PagerAPI`, `App.toast`. - Produces: `views.recon`, `views.recon_handshakes`, `views.logging`, `views.logging_system`, `views.modules`, `views.settings`. - [ ] **Step 1: Add Recon views** ```js const RECON_TABS = [ { label: 'Overview', hash: '#/recon' }, { label: 'Handshakes', hash: '#/recon/handshakes' } ]; const LOGGING_TABS = [ { label: 'Overview', hash: '#/logging' }, { label: 'System', hash: '#/logging/system' } ]; views.recon = (root) => { root.appendChild(h('h1', { class: 'page-title', text: 'Recon' })); tabBar(root, RECON_TABS, '#/recon'); const listBox = h('div', { class: 'section' }, h('h2', {}, 'Scans')); const detailBox = h('div', { class: 'section hidden' }, h('h2', {}, 'Scan Detail')); root.appendChild(listBox); root.appendChild(detailBox); function loadScans() { PagerAPI.get('/api/recon/scans').then((r) => { listBox.innerHTML = ''; listBox.appendChild(h('h2', {}, 'Scans')); const row = h('div', { class: 'row' }, h('div', {}, btn('New scan', () => PagerAPI.post('/api/recon/start').then(() => App.toast('Scan started')))), h('div', {}, btn('Stop', () => PagerAPI.post('/api/recon/stop').then(() => App.toast('Scan stopped')), 'danger')), h('div', {}, btn('Refresh', loadScans, 'ghost'))); listBox.appendChild(row); listBox.appendChild(table( [{ label: 'ID', key: 'id' }, { label: 'Timestamp', key: 'timestamp' }], (r.data.scans || []).map((s) => ({ id: s.id, timestamp: fmtTime(s.timestamp) })), (s) => ({ style: 'cursor:pointer', onclick: () => loadDetail(s.id) }))); }); } function loadDetail(id) { PagerAPI.get('/api/recon/scans/' + id).then((r) => { detailBox.classList.remove('hidden'); detailBox.innerHTML = ''; detailBox.appendChild(h('h2', {}, 'Scan #' + id)); const aps = r.data.aps || [], hs = r.data.handshakes || []; detailBox.appendChild(h('h3', { style: 'margin:8px 0 4px' }, 'APs (' + aps.length + ')')); detailBox.appendChild(table( [{ label: 'BSSID', key: 'bssid' }, { label: 'SSID', key: 'ssid' }], aps.map((a) => ({ bssid: a.bssid || a.mac || a.essid || a.wifi_device || '--', ssid: a.ssid || '--' })))); detailBox.appendChild(h('h3', { style: 'margin:8px 0 4px' }, 'Handshakes (' + hs.length + ')')); detailBox.appendChild(table( [{ label: 'BSSID', key: 'bssid' }, { label: 'Client', key: 'client' }], hs.map((x) => ({ bssid: x.bssid || x.ap_mac || '--', client: x.client || x.client_mac || '--' })))); }); } loadScans(); return { destroy: () => {} }; }; ``` `views.recon_handshakes` = the loot files table (from the old flat `views.handshakes`): ```js views.recon_handshakes = (root) => { root.appendChild(h('h1', { class: 'page-title', text: 'Recon' })); tabBar(root, RECON_TABS, '#/recon/handshakes'); const box = h('div', { class: 'section' }, h('h2', {}, 'Handshakes')); root.appendChild(box); function load() { PagerAPI.get('/api/pineap/handshakes').then((r) => { box.innerHTML = ''; box.appendChild(h('h2', {}, 'Handshakes')); const row = h('div', { class: 'row' }, h('div', {}, btn('Download all (zip)', () => { window.location = App.apiBase + '/api/loot/zip'; })), h('div', {}, btn('Archive', () => PagerAPI.post('/api/loot/archive').then(() => App.toast('Archived')))), h('div', {}, btn('Refresh', load, 'ghost'))); box.appendChild(row); box.appendChild(table( [{ label: 'File', key: 'name' }, { label: 'Size', key: 'size' }, { label: 'Modified', key: 'mtime' }, { label: '', render: () => '' }], (r.data.files || []).map((f) => ({ name: f.name, size: f.size, mtime: fmtTime(f.mtime) })), (f) => ({ style: 'cursor:pointer', onclick: () => { if (confirm('Delete ' + f.name + '?')) PagerAPI.del('/api/pineap/handshakes', { name: f.name }).then(load); } }))); box.querySelectorAll('.tbl th')[3].textContent = 'Delete'; }); } load(); return { destroy: () => {} }; }; ``` - [ ] **Step 2: Add Logging views** ```js views.logging = (root) => { root.appendChild(h('h1', { class: 'page-title', text: 'Logging' })); tabBar(root, LOGGING_TABS, '#/logging'); const sysBox = h('div', { class: 'section' }, h('h2', {}, 'System Log')); const pineBox = h('div', { class: 'section' }, h('h2', {}, 'PineAP Log')); root.appendChild(sysBox); root.appendChild(pineBox); function render(box, kind) { PagerAPI.get('/api/logging/' + kind + '?lines=200').then((r) => { box.innerHTML = ''; box.appendChild(h('h2', {}, kind === 'system' ? 'System Log' : 'PineAP Log')); box.appendChild(btn('Refresh', () => render(box, kind), 'ghost')); box.appendChild(h('pre', { class: 'logs' }, (r.data.lines || []).join('\n'))); }).catch(() => {}); } render(sysBox, 'system'); render(pineBox, 'pineap'); const iv = setInterval(() => render(sysBox, 'system'), 10000); return { destroy: () => clearInterval(iv) }; }; views.logging_system = (root) => { root.appendChild(h('h1', { class: 'page-title', text: 'Logging' })); tabBar(root, LOGGING_TABS, '#/logging/system'); const box = h('div', { class: 'section' }, h('h2', {}, 'System Log')); root.appendChild(box); function render() { PagerAPI.get('/api/logging/system?lines=400').then((r) => { box.innerHTML = ''; box.appendChild(h('h2', {}, 'System Log')); box.appendChild(btn('Refresh', render, 'ghost')); box.appendChild(h('pre', { class: 'logs' }, (r.data.lines || []).join('\n'))); }).catch(() => {}); } render(); const iv = setInterval(render, 10000); return { destroy: () => clearInterval(iv) }; }; ``` - [ ] **Step 3: Add Modules & Packages view** (payload portal, from the old flat `views.payloads`, plus a search filter) ```js views.modules = (root) => { root.appendChild(h('h1', { class: 'page-title', text: 'Modules & Packages' })); const box = h('div', { class: 'section' }, h('h2', {}, 'Payloads')); root.appendChild(box); function load(filter) { PagerAPI.get('/api/payloads/index').then((r) => { box.innerHTML = ''; box.appendChild(h('h2', {}, 'Payloads')); const search = h('input', { id: 'mod-search', placeholder: 'Search…', style: 'max-width:280px' }); box.appendChild(search); box.appendChild(btn('Refresh', () => load(search.value), 'ghost')); const payloads = (r.data.payloads || r.data || []) .filter((p) => { const q = (filter || '').toLowerCase(); if (!q) return true; return ((p.name || p.title || p.key || '') + ' ' + (p.desc || '')).toLowerCase().indexOf(q) !== -1; }); box.appendChild(table( [{ label: 'Name', key: 'name' }, { label: 'Description', key: 'desc' }, { label: '', render: () => '' }], payloads.map((p) => ({ name: p.name || p.title || p.key, desc: p.desc || '', key: p.key || p.id || p.name })), (p) => ({ onclick: (e) => e.stopPropagation() }))); const rows = box.querySelectorAll('.tbl tbody tr'); rows.forEach((tr, i) => { const p = payloads[i]; const td = tr.cells[2]; td.appendChild(btn('Install', () => PagerAPI.post('/api/payloads/install', { key: p.key }).then(() => App.toast('Installing')))); td.appendChild(btn('Remove', () => PagerAPI.post('/api/payloads/remove', { key: p.key }).then(() => App.toast('Removed')), 'danger')); }); search.addEventListener('input', () => load(search.value)); }); } load(''); return { destroy: () => {} }; }; ``` - [ ] **Step 4: Add Settings view** (from the old flat `views.settings`, with a theme selector added) ```js views.settings = (root) => { root.appendChild(h('h1', { class: 'page-title', text: 'Settings' })); const general = h('div', { class: 'section' }, h('h2', {}, 'General')); const ntpBox = h('div', { class: 'section' }, h('h2', {}, 'Time (NTP)')); const passBox = h('div', { class: 'section' }, h('h2', {}, 'Device Password')); const webui = h('div', { class: 'section' }, h('h2', {}, 'WebUI Preferences')); root.appendChild(general); root.appendChild(ntpBox); root.appendChild(passBox); root.appendChild(webui); PagerAPI.get('/api/status').then((r) => { const s = r.data; general.appendChild(h('p', {}, 'Firmware: ', h('code', { text: s.firmware || '--' }))); general.appendChild(h('p', {}, 'Model: ', h('code', { text: 'WiFi Pineapple Pager' }))); general.appendChild(h('p', {}, 'Uptime: ', h('code', { text: fmtDur(s.uptime) }))); general.appendChild(h('p', {}, 'Disk: ', h('code', { text: s.disk ? (s.disk.used / 1048576).toFixed(1) + ' / ' + (s.disk.size / 1048576).toFixed(1) + ' GB' : '--' }))); }); const hnInput = h('input', { id: 'set-hostname' }); PagerAPI.get('/api/settings/hostname').then((r) => { hnInput.value = r.data.hostname || ''; }); general.appendChild(h('div', { class: 'row' }, h('div', {}, h('label', {}, 'Hostname', hnInput)), h('div', {}, btn('Save', () => PagerAPI.post('/api/settings/hostname', { hostname: hnInput.value }).then(() => App.toast('Saved')))))); PagerAPI.get('/api/settings/service').then((r) => { general.appendChild(h('p', {}, 'WebUI service: ', h('span', { class: 'badge ' + (r.data.running ? 'on' : 'off'), text: r.data.running ? 'RUNNING' : 'STOPPED' }), ' ', h('span', { class: 'muted', text: r.data.background ? '(background mode)' : '(foreground mode)' }))); }); const ntpEnabled = h('input', { type: 'checkbox', id: 'ntp-enabled' }); const ntpServers = h('input', { id: 'ntp-servers' }); PagerAPI.get('/api/settings/ntp').then((r) => { ntpEnabled.checked = !!r.data.enabled; ntpServers.value = (r.data.servers || []).join(', '); }); ntpBox.appendChild(h('label', { class: 'toggle' }, ntpEnabled, ' Enabled')); ntpBox.appendChild(h('label', {}, 'NTP servers (comma separated)', ntpServers)); ntpBox.appendChild(btn('Save', () => PagerAPI.post('/api/settings/ntp', { enabled: ntpEnabled.checked, servers: ntpServers.value.split(',').map((s) => s.trim()).filter(Boolean) }).then(() => App.toast('NTP saved')), 'ghost')); const newPw = h('input', { type: 'password', id: 'set-password' }); passBox.appendChild(h('label', {}, 'New device password', newPw)); passBox.appendChild(btn('Change password', () => { if (newPw.value.length < 4) { App.toast('Password too short', 'error'); return; } PagerAPI.post('/api/settings/password', { password: newPw.value }).then(() => { App.toast('Password changed'); newPw.value = ''; }); }, 'danger')); const pollInput = h('input', { type: 'number', id: 'pref-poll', min: '2', max: '120' }); pollInput.value = localStorage.getItem('pw-poll') || '5'; pollInput.addEventListener('change', () => { localStorage.setItem('pw-poll', pollInput.value); App.toast('Poll interval saved (applies on next load)'); }); const themeSel = h('select', { id: 'pref-theme' }, h('option', { value: 'light', text: 'Light' }), h('option', { value: 'dark', text: 'Dark' })); themeSel.value = Theme.current(); themeSel.addEventListener('change', () => { localStorage.setItem(Theme.KEY, themeSel.value); Theme.apply(); App.toast('Theme updated'); }); webui.appendChild(h('label', {}, 'Poll interval (s)', pollInput)); webui.appendChild(h('label', {}, 'Theme', themeSel)); return { destroy: () => {} }; }; ``` - [ ] **Step 5: Remove leftover/duplicate definitions** Ensure the file defines exactly these views (no old flat `views.pineap`, `views.recon`, `views.logs`, `views.payloads`, `views.handshakes`, or the old `views.settings` in addition): `dashboard`, `campaigns`, `pineap`, `pineap_open`, `pineap_clients`, `pineap_filtering`, `pineap_aps`, `pineap_impersonation`, `recon`, `recon_handshakes`, `logging`, `logging_system`, `modules`, `settings`. Verify with: ```powershell $v = Get-Content -Raw payload\user\general\pager-webui\www\js\views.js @('dashboard','campaigns','pineap','pineap_open','pineap_clients','pineap_filtering','pineap_aps','pineap_impersonation','recon','recon_handshakes','logging','logging_system','modules','settings') | ForEach-Object { if ($v -match 'views\.' + $_ + ' ?=') { "$_ OK" } else { "$_ MISSING" } } ``` Expected: all `OK`. - [ ] **Step 6: Verify every route** With the dev server running and logged in, visit each hash route and confirm the page renders without console errors: `#/dashboard`, `#/campaigns`, `#/pineap/open`, `#/pineap/clients`, `#/pineap/filtering`, `#/pineap/aps`, `#/pineap/impersonation`, `#/recon`, `#/recon/handshakes`, `#/logging`, `#/logging/system`, `#/modules`, `#/settings`. Test the theme selector toggles dark mode. Test keyboard shortcuts `d`, `p`, `r`, `l`, `m`, and backquote opens the terminal. - [ ] **Step 7: Commit** ```bash git add payload/user/general/pager-webui/www/js/views.js git commit -m "feat: Recon, Logging, Modules, Settings views with old IA tabs" ``` --- ### Task 9: Build, deploy, and 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 (incl. new `test_pineap_aps`). - [ ] **Step 2: Build and deploy to the Pager** ```powershell & .\scripts\deploy.ps1 -SshKey "$HOME\.ssh\pager_key" -Password "" ``` (If no key, use `-Password ""` and rely on sshpass if installed; otherwise run the printed scp/ssh commands manually.) - [ ] **Step 3: Verify the payload serves the new UI** ```powershell curl.exe -s -o NUL -w "%{http_code} %{size_download}`n" http://172.16.52.1:8080/ # 200 curl.exe -s -o NUL -w "%{http_code} %{size_download}`n" http://172.16.52.1:8080/assets/logo.png # 200, ~41k curl.exe -s -o NUL -w "%{http_code} %{size_download}`n" http://172.16.52.1:8080/js/icons.js # 200 curl.exe -s -o NUL -w "%{http_code} %{size_download}`n" http://172.16.52.1:8080/js/chart.js # 200 curl.exe -s -o NUL -w "%{http_code} %{size_download}`n" http://172.16.52.1:8080/css/app.css # 200 curl.exe -s -o NUL -w "%{http_code}`n" "http://172.16.52.1:8080/api/pineap/aps" # 401 (auth required) ``` Then log in via the browser and confirm `/api/pineap/aps` returns `{"aps":[...],"count":n}` when authenticated. - [ ] **Step 4: On-device smoke pass (background + foreground modes)** Per spec v1 §8: run the payload in background mode, browse `http://172.16.52.1:8080/`, and walk every view in §Task 8 Step 6. Re-run in foreground mode (press `B` to stop) and repeat. Confirm: login card (gray bg, logo, password only), toolbar, rail expand/collapse + active indicator, tab bars, dashboard chart, theme toggle, keyboard shortcuts, terminal open/close, and that reboot persistence still holds (re-run payload, service auto-starts). - [ ] **Step 5: Final commit of any smoke fixes** If any fix was needed during verification, commit it: ```bash git add -A git commit -m "fix: UI clone smoke-test fixes" ``` (Only if changes exist.) --- ## Self-Review Notes (run before handing off) - **Spec coverage:** §3.2 tokens → Task 3; §3.3 shell → Tasks 4–5; §3.4 login → Tasks 4–5; §3.5 views → Tasks 6–8; §3.5 APs endpoint → Task 1; §3.6 data flow → Tasks 5–6; §3.7 error handling → retained toast/login-error logic; §4 testing → Task 9. Global Constraints (sidebar order, sub-pages, no Enterprise, password-only login, bottom-docked terminal) all enforced in Tasks 4–8. - **Type/name consistency:** `h`, `table`, `fmtTime`, `fmtDur`, `btn`, `badge`, `tabBar` are defined once (Task 6) and reused by Tasks 7–8; `MiniChart.draw(canvas, series, opts)` defined in Task 5 and consumed in Task 6; `App.railItems`/`App.keyOf`/`App.go` defined in Task 5; `PineappleIcons` keys (`dashboard`, `campaigns`, `pineap`, `recon`, `logging`, `modules`, `settings`, `chevron`, `wifi`, `extension`, `receipt`) match Task 2. The three Task-2 icons that Task 8 does not use (`wifi`, `extension`, `receipt`) are optional conveniences and may be dropped if unused. - **Chart.js deviation:** the old UI's Chart.js is inlined in its Angular bundle; no offline copy exists on the device or dev box, so the dashboard chart is a hand-rolled `` renderer (Task 5) delivering the same visual (2-series line chart). This was flagged to the user during design; it replaces the vendored-Chart.js idea from the design discussion. - **Git:** each task commits independently; the spec (`docs/specs/2026-08-11-pineapple-ui-clone-design.md`) is already committed.