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

73 KiB
Raw Blame History

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:

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

: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-screenflex:1; display:flex; align-items:center; justify-content:center; background:#9c9c9c;
  • .login-cardbackground:#fff; border-radius:2px; padding:32px; width:360px; box-shadow:0 8px 20px rgba(0,0,0,.4); text-align:center;
  • .login-logoheight:148px; margin-bottom:8px;
  • .login-titlemargin: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-errorcolor:#d32f2f; font-size:13px; min-height:18px;
  • .spinner — 18px ring loader (CSS border spinner, blue).
  • #topbarheight: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-logoheight:32px; margin-right:10px;
  • .brand-textfont-size:20px; font-weight:400; letter-spacing:.5px;
  • .toolbar-spacerflex:1;
  • #live-statuscolor:#e0e0e0; font-size:12px; margin-right:16px; font-variant-numeric:tabular-nums;
  • #terminal-btn — ghost white toolbar button.
  • #layoutflex:1; display:flex; min-height:0;
  • #railwidth:60px; background:var(--rail); color:#fff; display:flex; flex-direction:column; overflow-y:auto; transition:width .2s;
  • #rail.openwidth:220px;
  • #rail .entryheight: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:hoverbackground:var(--rail-hover);
  • #rail .entry.activeborder-right:3px solid #1976d2; background:rgba(255,255,255,.08); color:#fff;
  • .entry-iconfont-size:24px; width:24px; height:24px; display:flex; align-items:center; justify-content:center; margin-right:14px; flex:none;
  • .entry-icon svgwidth:24px; height:24px; display:block;
  • #topbar .btn.ghostcolor:#fff; border-color:#fff;
  • .entry-textwhite-space:nowrap; opacity:0; transition:opacity .2s;
  • #rail.open .entry-textopacity:1;
  • #rail .entry.dividerheight:0; padding:0; margin:8px 0; border-top:1px solid #616161; flex:none;
  • .rail-footermargin-top:auto;
  • #contentflex:1; overflow-y:auto; padding:20px 30px 5px 5px;
  • .tabbardisplay:flex; border-bottom:2px solid #e0e0e0; margin-bottom:16px; position:relative;
  • .tabbar .tabpadding:10px 16px; cursor:pointer; color:var(--muted); font-size:14px; border-bottom:2px solid transparent; margin-bottom:-2px;
  • .tabbar .tab.activecolor:var(--primary); border-bottom-color:var(--ink);
  • .page-titlefont-size:24px; font-weight:400; margin:0 0 12px;
  • .cardsdisplay:grid; grid-template-columns:repeat(auto-fill, minmax(170px,1fr)); gap:12px;
  • .cardbackground:var(--surface); border-radius:2px; box-shadow:var(--shadow); padding:14px;
  • .card-labelfont-size:12px; color:var(--muted);
  • .card-valuefont-size:28px; font-weight:400; margin-top:4px;
  • .sectionbackground:var(--surface); border-radius:2px; box-shadow:var(--shadow); padding:16px; margin-bottom:16px;
  • .section h2margin:0 0 12px; font-size:16px; font-weight:500;
  • .rowdisplay:flex; gap:10px; align-items:flex-end; flex-wrap:wrap;
  • .row > divflex:1; min-width:140px;
  • .tblwidth:100%; border-collapse:collapse;
  • .tbl th, .tbl tdtext-align:left; padding:8px 10px; border-bottom:1px solid var(--border);
  • .tbl thcolor:var(--muted); font-size:12px; font-weight:500;
  • .tbl tr:hover tdbackground:var(--surface-alt);
  • .toggledisplay:flex; align-items:center; gap:8px; margin:6px 0;
  • .badgedisplay:inline-block; padding:2px 10px; border-radius:10px; font-size:11px;
  • .badge.onbackground:#e8f5e9; color:#2e7d32; (dark: override color via html.dark or use var(--ok) text)
  • .badge.offbackground:#fff3e0; color:#e65100;
  • .btnbackground: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:hoverbackground:var(--primary-dark);
  • .btn.ghostbackground:transparent; color:var(--primary); box-shadow:none; border:1px solid var(--primary);
  • .btn.dangerbackground:var(--danger);
  • input, selectbackground:var(--surface); color:var(--text); border:1px solid var(--border); border-radius:2px; padding:8px 10px; width:100%;
  • labeldisplay:block; font-size:12px; color:var(--muted); margin:8px 0 4px;
  • codebackground:var(--surface-alt); padding:1px 5px; border-radius:2px;
  • pre.logsbackground:var(--terminal-bg); color:#c9d1d9; padding:12px; overflow:auto; max-height:420px; font-size:12px; white-space:pre-wrap; border-radius:2px;
  • .emptypadding:24px; text-align:center; color:var(--muted);
  • #terminal-panelborder-top:1px solid var(--border); background:#000; height:280px; display:flex; flex-direction:column;
  • #terminal-bardisplay:flex; justify-content:space-between; align-items:center; background:var(--toolbar); padding:4px 10px; font-size:12px; color:#e0e0e0;
  • #toast-containerposition:fixed; right:16px; bottom:16px; display:flex; flex-direction:column; gap:8px; z-index:100;
  • .toastpadding:10px 14px; border-radius:2px; background:var(--surface); box-shadow:var(--shadow); color:var(--text);
  • .toast.errorborder-left:4px solid var(--danger);
  • .toast.infoborder-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:

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

<!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 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/clientspineap).
    • 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)

'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 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_openviews.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:

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