Files
Mark-VIII/docs/plans/2026-08-11-pineapple-ui-clone.md
2026-08-11 20:24:24 -07:00

1534 lines
73 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<canvas>` 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_<serverid>` 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 `<svg viewBox="0 0 24 24" ...>...</svg>` 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: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,16A3,3 0 0,1 9,13C9,11.88 9.61,10.9 10.5,10.39L20.21,4.77L14.68,14.35C14.18,15.33 13.17,16 12,16M12,3C13.81,3 15.5,3.5 16.97,4.32L14.87,5.53C14,5.19 13,5 12,5A8,8 0 0,0 4,13C4,15.21 4.89,17.21 6.34,18.65H6.35C6.74,19.04 6.74,19.67 6.35,20.06C5.96,20.45 5.32,20.45 4.93,20.07V20.07C3.12,18.26 2,15.76 2,13A10,10 0 0,1 12,3M22,13C22,15.76 20.88,18.26 19.07,20.07V20.07C18.68,20.45 18.05,20.45 17.66,20.06C17.27,19.67 17.27,19.04 17.66,18.65V18.65C19.11,17.2 20,15.21 20,13C20,12 19.81,11 19.46,10.1L20.67,8C21.5,9.5 22,11.18 22,13Z"/></svg>',
campaigns: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 11.11V7A2 2 0 0 0 19 5H15V3A2 2 0 0 0 13 1H9A2 2 0 0 0 7 3V5H3A2 2 0 0 0 1 7V18A2 2 0 0 0 3 20H10.26A7 7 0 1 0 21 11.11M9 3H13V5H9M19 20A5 5 0 0 1 13 20A5 5 0 1 1 19 20M15 13H16.5V15.82L18.94 17.23L18.19 18.53L15 16.69V13"/></svg>',
pineap: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,21L15.6,16.2C16.2,15.4 16.8,14.5 17.2,13.6C18.1,11.5 18,9 18,9C18,6.5 16.5,4.3 15,3.5C13.5,2.7 10.5,2.7 9,3.5C7.5,4.3 6,6.5 6,9C6,9 5.9,11.5 6.8,13.6C7.2,14.5 7.8,15.4 8.4,16.2L12,21M12,5.5C13.4,5.5 14.5,6.6 14.5,8C14.5,9.4 13.4,10.5 12,10.5C10.6,10.5 9.5,9.4 9.5,8C9.5,6.6 10.6,5.5 12,5.5M7.1,13.1C7.1,13.1 8.2,14 12,14C15.8,14 16.9,13.1 16.9,13.1L15.9,12.1C15.9,12.1 14.8,12.8 12,12.8C9.2,12.8 8.1,12.1 8.1,12.1L7.1,13.1M12,17C10,17 9,17.6 9,17.6L10.3,19.3C10.3,19.3 11.1,19 12,19C12.9,19 13.7,19.3 13.7,19.3L15,17.6C15,17.6 14,17 12,17Z"/></svg>',
recon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11,6H13V13H11V6M9,20A1,1 0 0,1 8,21H5A1,1 0 0,1 4,20V15L6,6H10V13A1,1 0 0,1 9,14V20M10,5H7V3H10V5M15,20V14A1,1 0 0,1 14,13V6H18L20,15V20A1,1 0 0,1 19,21H16A1,1 0 0,1 15,20M14,5V3H17V5H14Z"/></svg>',
logging: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M14,17H4V15H14V17M14,13H4V11H14V13M14,9H4V7H14V9M18,13V11H16V9H18V7H20V9H22V11H20V13H18M20,3H2A2,2 0 0,0 0,5V19A2,2 0 0,0 2,21H20A2,2 0 0,0 22,19V17H20V19H2V5H20V3Z"/></svg>',
modules: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.5,11H19V7C19,5.89 18.1,5 17,5H13V3.5A2.5,2.5 0 0,0 10.5,1A2.5,2.5 0 0,0 8,3.5V5H4A2,2 0 0,0 2,7V10.8H3.5C5,10.8 6.2,12 6.2,13.5C6.2,15 5,16.2 3.5,16.2H2V20A2,2 0 0,0 4,22H7.8V20.5C7.8,19 9,17.8 10.5,17.8C12,17.8 13.2,19 13.2,20.5V22H17A2,2 0 0,0 19,20V16H20.5A2.5,2.5 0 0,0 23,13.5A2.5,2.5 0 0,0 20.5,11Z"/></svg>',
settings: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 11.11V7A2 2 0 0 0 19 5H15V3A2 2 0 0 0 13 1H9A2 2 0 0 0 7 3V5H3A2 2 0 0 0 1 7V18A2 2 0 0 0 3 20H10.26A7 7 0 1 0 21 11.11M9 3H13V5H9M19 20A5 5 0 0 1 13 20A5 5 0 1 1 19 20M15 13H16.5V15.82L18.94 17.23L18.19 18.53L15 16.69V13"/></svg>',
chevron: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7.41,15.41L12,10.83L16.59,15.41L18,14L12,8L6,14L7.41,15.41Z"/></svg>',
terminal: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M4,4H20A2,2 0 0,1 22,6V18A2,2 0 0,1 20,20H4A2,2 0 0,1 2,18V6A2,2 0 0,1 4,4M5,7V9H7V7H5M9,7V11H13V7H9M15,7V9H19V7H15M5,11V13H7V11H5M5,15V17H7V15H5M9,15V19H13V15H9M15,15V17H19V15H15M15,19V21H19V19H15Z"/></svg>',
wifi: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,21L15.6,16.2C16.2,15.4 16.8,14.5 17.2,13.6C18.1,11.5 18,9 18,9C18,6.5 16.5,4.3 15,3.5C13.5,2.7 10.5,2.7 9,3.5C7.5,4.3 6,6.5 6,9C6,9 5.9,11.5 6.8,13.6C7.2,14.5 7.8,15.4 8.4,16.2L12,21Z"/></svg>',
extension: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.5,11H19V7C19,5.89 18.1,5 17,5H13V3.5A2.5,2.5 0 0,0 10.5,1A2.5,2.5 0 0,0 8,3.5V5H4A2,2 0 0,0 2,7V10.8H3.5C5,10.8 6.2,12 6.2,13.5C6.2,15 5,16.2 3.5,16.2H2V20A2,2 0 0,0 4,22H7.8V20.5C7.8,19 9,17.8 10.5,17.8C12,17.8 13.2,19 13.2,20.5V22H17A2,2 0 0,0 19,20V16H20.5A2.5,2.5 0 0,0 23,13.5A2.5,2.5 0 0,0 20.5,11Z"/></svg>',
receipt: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M14,17H4V15H14V17M14,13H4V11H14V13M14,9H4V7H14V9M18,13V11H16V9H18V7H20V9H22V11H20V13H18M20,3H2A2,2 0 0,0 0,5V19A2,2 0 0,0 2,21H20A2,2 0 0,0 22,19V17H20V19H2V5H20V3Z"/></svg>'
};
```
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 `<link rel="icon" type="image/png" href="assets/logo.png">` inside the `<head>` 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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>WiFi Pineapple</title>
<link rel="icon" type="image/png" href="assets/logo.png">
<link rel="stylesheet" href="css/app.css">
<link rel="stylesheet" href="js/xterm.css">
</head>
<body>
<section id="login-screen">
<form id="login-form" class="login-card">
<img class="login-logo" src="assets/logo.png" alt="WiFi Pineapple">
<h2 class="login-title">WiFi Pineapple</h2>
<input id="login-password" class="login-field" type="password" placeholder="Password" autocomplete="current-password" required>
<p id="login-error" class="login-error"></p>
<button id="login-button" class="btn" type="submit">Login</button>
</form>
</section>
<div id="app" class="hidden">
<header id="topbar">
<img id="brand-logo" class="brand-logo" src="assets/logo.png" alt="WiFi Pineapple">
<span id="brand-text" class="brand-text">WiFi Pineapple</span>
<span class="toolbar-spacer"></span>
<span id="live-status"></span>
<button id="terminal-btn" class="btn ghost">Terminal</button>
</header>
<div id="layout">
<nav id="rail"></nav>
<main id="content"></main>
</div>
<div id="terminal-panel" class="hidden">
<div id="terminal-bar">
<span>Terminal</span>
<button id="terminal-close" class="btn ghost">×</button>
</div>
<div id="terminal"></div>
</div>
</div>
<div id="toast-container"></div>
<script src="js/config.js"></script>
<script src="js/icons.js"></script>
<script src="js/api.js"></script>
<script src="js/chart.js"></script>
<script src="js/xterm.min.js"></script>
<script src="js/xterm-addon-fit.min.js"></script>
<script src="js/terminal.js"></script>
<script src="js/views.js"></script>
<script src="js/app.js"></script>
</body>
</html>
```
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 68):
- `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 = '<span class="entry-icon">' + PineappleIcons[it.icon] + '</span><span class="entry-text">' + it.label + '</span>';
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 = '<span class="entry-icon">' + PineappleIcons.chevron + '</span><span class="entry-text">Open Menu</span>';
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 78): 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 78).
- [ ] **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 78). Keep the file loading without errors — every `views.*` referenced by `App.routes` must exist by the end of Task 8; for now, Tasks 78 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 "<PAGER_PASSWORD>"
```
(If no key, use `-Password "<PAGER_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 45; §3.4 login → Tasks 45; §3.5 views → Tasks 68; §3.5 APs endpoint → Task 1; §3.6 data flow → Tasks 56; §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 48.
- **Type/name consistency:** `h`, `table`, `fmtTime`, `fmtDur`, `btn`, `badge`, `tabBar` are defined once (Task 6) and reused by Tasks 78; `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 `<canvas>` 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.