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