73 KiB
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-lighton 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 usesconst/arrow functions andasync/await). - No new
/api/*surface exceptGET /api/pineap/aps(read-only,iwinfo scan). - Auth/session mechanics unchanged:
AUTH_<serverid>HttpOnly cookie, login as userrootwith the device password. - Design tokens (from the old UI): content bg
#fafafa, cards#fffwith 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 stackRoboto, "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
unittestsuite 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 — returnswlan*/radio*iface names),server.ROUTER.add(method, pattern, handler)(exists), handler signature(ctx) -> (status, payload)wherectx.argsis a tuple. -
Produces:
server.parse_iwinfo_scan(text) -> list[dict]with keysbssid(upper-case MAC),ssid(str, may be''),channel(int orNone),signal(int orNone),encryption(str, may be'none').server.aps_data() -> list[dict]— concatenation ofparse_iwinfo_scan(out)per iface, each row augmented withiface.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:
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
& "$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:
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:
ROUTER.add('GET', r'/api/pineap/aps', h_pineap_aps)
- Step 4: Run the test to verify it passes
& "$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
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
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
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:
$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
'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:
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
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) andhtml.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.
- Shell:
-
Step 1: Replace
app.csswith the Material theme
Use CSS custom properties so light/dark swap via html.dark. Key values from Global Constraints. Structure:
: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 viahtml.darkor usevar(--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;
.xtermsizing 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:
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
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.cssclasses from Task 3, existingjs/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
<!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:
'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
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 inapp.jsitself, as today). -
Produces (used by
js/views.jsin Tasks 6–8):App.toast(msg, kind)— creates.toastin#toast-container, auto-removes after 4s.App.route()— readslocation.hash, looks upviews[name], callsviews[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 aftercampaigns,logging, and before the footer.App.key— current nav key derived from hash (parent segment, e.g.#/pineap/clients→pineap).App.go(hash)— setslocation.hash.Live.onTick(fn)— subscribes;Live.start()— WS/api/ws+ 5s poll fallback (keep existing logic).MiniChart.draw(canvas, series, opts)(inchart.js) — draws a 2-series line chart.window.THEMEhelpers:Theme.apply()setsdocument.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)
'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:
'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
$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
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 functionsh,table,fmtTime,fmtDur,badge,btnare 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, plustabBar(box, items, activeHash)which renders.tabbarwith.tabitems linking toactiveHashvalues and a blue.inkbar; and view functionsviews.dashboard,views.campaigns(this task) andviews.pineap_open…views.settings(Tasks 7–8). -
Step 1: Replace
views.jswith 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:
const btn = (label, onclk, cls) => h('button', { class: 'btn ' + (cls || ''), onclick: onclk, text: label });
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:
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:
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
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
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:
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):
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 .sections:
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):
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:
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
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
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):
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
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)
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)
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:
$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
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
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
& .\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
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:
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,tabBarare 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.godefined in Task 5;PineappleIconskeys (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.