# PineAP Pages — Mark VII Layout 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 PineAP section's 8 tabs to the Mark VII `pineap-*` title-card layout (20px card titles, clickable title links, centered 24px values, full-width mode button group) without changing any behavior. **Architecture:** Frontend-only. Add the Mk7 `.pineap-*` CSS vocabulary to `app.css`, add two small DOM helpers (`pineapCard`, `pineapTitleCard`) to `views.js`, then rebuild the markup of each of the 8 PineAP tab renderers to use them. All data fetches, toggles, presets, polling, and error handling are preserved verbatim — only the DOM structure/classes change. **Tech Stack:** Vanilla JS (`h()` helper, `PagerAPI`, `btn`, `table`, `pineapShell`, `tabBar`), plain CSS, existing design tokens (`var(--surface)`, `var(--shadow)`, `var(--muted)`). ## Global Constraints - No backend changes. No changes to `server.py` or `tests/`. - No behavior changes: every fetch, toggle, preset (`enable`+`mimic` only), `karmaOn` tracking, `modePending` guard, polling interval (5s; APs 10s), and `destroy()` stays exactly as it is today. - JS verification uses the Python delimiter-balance checker at `C:\Users\root\AppData\Local\Temp\opencode\js_balance.py` (no node available). - Python for the unittest loop: `$env:LOCALAPPDATA\Programs\Python\Python311\python.exe`. - Deploy: from repo root, `powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\deploy.ps1 -Password ""`, then over sshpass restart the service (`/etc/init.d/pagerwebui restart`). - Commit messages follow repo style (`feat:`, `fix:`, `docs:`, `test:`). - The 8-tab `tabBar` remains the PineAP navigation. --- ### Task 1: Mk7 layout CSS + DOM helpers + PineAP overview rebuild **Files:** - Modify: `payload/user/general/pager-webui/www/css/app.css` (append `.pineap-*` classes) - Modify: `payload/user/general/pager-webui/www/js/views.js` (helpers + full `views.pineap` replacement) **Interfaces:** - Consumes: `h(tag, attrs, ...children)` (supports `class`, `text`, `on*` handlers), `btn(label, onclk, cls)`, `PagerAPI`, `App.go`, `App.toast`, `pineapShell(root, hash)`, `tabBar(box, items, hash)`, `table(columns, rows, rowAttrs)`. - Produces: `pineapCard(title)` and `pineapTitleCard(titleText, linkHash, valueNode)` helpers used by Tasks 2-3; `.pineap-*` CSS classes; a rebuilt `views.pineap` overview. - [ ] **Step 1: Append the Mk7 layout CSS to `app.css`** Append to the end of `payload/user/general/pager-webui/www/css/app.css`: ```css /* ---- PineAP Mark VII layout ---- */ .pineap-title-card-container { display: flex; width: 100%; flex-wrap: wrap; justify-content: space-between; gap: 30px; margin: 8px 0 16px; } .pineap-title-card { flex: 1; min-width: 220px; background: var(--surface); border-radius: 2px; box-shadow: var(--shadow); padding: 14px 16px; margin-bottom: 16px; } .pineap-card-title { font-size: 20px; display: flex; align-items: center; margin-bottom: 10px; } .pineap-card-title-flex { display: flex; align-items: center; font-size: 20px; margin-bottom: 15px; } .pineap-card-title-link { color: inherit; text-decoration: none; cursor: pointer; } .pineap-card-title-link:visited { color: inherit; } .pineap-card-title-link:hover { text-decoration: underline; } .pineap-card-title-content { display: flex; justify-content: center; align-items: center; font-size: 24px; } .pineap-card-button-group { width: 100%; height: 30px; display: flex; } .pineap-card-button-group .seg { flex: 1; height: 100%; } .pineap-card-button-group .seg-btn { flex: 1; } .pineap-card-settings, .pineap-card-pool, .pineap-card-handshakes, .pineap-card-inject { flex: 1; } .pineap-handshakes-none { display: flex; justify-content: center; font-style: italic; color: var(--muted); } ``` - [ ] **Step 2: Add the two DOM helpers and remove the now-unused `pineapSetCard`** In `payload/user/general/pager-webui/www/js/views.js`, replace the `pineapSetCard` helper (currently lines ~182-185) with: ```js function pineapCard(title) { return h('div', { class: 'pineap-title-card' }, h('div', { class: 'pineap-card-title' }, title)); } function pineapTitleCard(titleText, linkHash, valueNode) { const title = linkHash ? h('a', { class: 'pineap-card-title-link', onclick: (e) => { e.preventDefault(); App.go(linkHash); } }, titleText) : titleText; return h('div', { class: 'pineap-title-card' }, h('div', { class: 'pineap-card-title' }, title), h('div', { class: 'pineap-card-title-content' }, valueNode)); } ``` - [ ] **Step 3: Replace the whole `views.pineap` function** Replace everything from `views.pineap = (root) => {` through the closing `};` of that function (current lines ~187-328, i.e. just before `views.pineap_open`) with: ```js views.pineap = (root) => { const box = pineapShell(root, '#/pineap'); const stats = {}; const statWrap = h('div', { class: 'pineap-title-card-container' }); const statDefs = [ ['ssids', 'Total SSIDs in Pool', '#/pineap/impersonation'], ['clients', 'Clients Connected', '#/pineap/clients'], ['handshakes', 'Handshakes Captured', '#/pineap/evilwpa'] ]; statDefs.forEach(([k, label, hash]) => { const val = h('span', { text: '—' }); stats[k] = val; statWrap.appendChild(pineapTitleCard(label, hash, val)); }); box.appendChild(statWrap); const mode = h('span', { class: 'badge', text: '—' }); const segBtns = {}; const modeBar = h('div', { class: 'seg' }); ['passive', 'active', 'advanced'].forEach((m) => { const b = h('button', { class: 'seg-btn', text: m[0].toUpperCase() + m.slice(1) }); b.addEventListener('click', () => applyMode(m)); modeBar.appendChild(b); segBtns[m] = b; }); const modeInfo = h('div', { class: 'muted', style: 'margin-top:8px;font-size:12px' }); const modeCard = h('div', { class: 'pineap-title-card' }, h('div', { class: 'pineap-card-title-flex' }, mode), h('div', { class: 'pineap-card-button-group' }, modeBar), modeInfo); const quick = { collect: h('input', { type: 'checkbox', id: 'po-collect' }), advertise: h('input', { type: 'checkbox', id: 'po-advertise' }) }; const quickCard = h('div', { class: 'pineap-title-card pineap-card-settings' }, h('div', { class: 'pineap-card-title' }, 'Quick Settings')); quickCard.appendChild(h('label', { class: 'toggle' }, quick.collect, ' Capture SSIDs to Pool')); quickCard.appendChild(h('label', { class: 'toggle' }, quick.advertise, ' Advertise AP Impersonation Pool')); quickCard.appendChild(h('div', { class: 'muted', style: 'margin-top:8px;font-size:12px' }, 'Client connect/disconnect notifications are handled by the Pager alert payload system.')); const modeRow = h('div', { class: 'pineap-title-card-container' }); modeRow.appendChild(modeCard); modeRow.appendChild(quickCard); box.appendChild(modeRow); const cards = { karma: {}, open: {}, wpa: {}, ent: {} }; const cardWrap = h('div', { class: 'pineap-title-card-container' }); const statusDefs = [ ['karma', 'Karma', '#/pineap/open'], ['open', 'Open Network', '#/pineap/open'], ['wpa', 'Evil WPA', '#/pineap/evilwpa'], ['ent', 'Evil Enterprise', '#/pineap/enterprise'] ]; statusDefs.forEach(([k, label, hash]) => { const val = h('span', { text: '—' }); cards[k].value = val; cardWrap.appendChild(pineapTitleCard(label, hash, val)); }); box.appendChild(cardWrap); let karmaOn = null; function bind(cb, on) { cb.addEventListener('change', () => on(cb.checked).then(load).catch(() => { cb.checked = !cb.checked; App.toast('Failed', 'error'); })); } bind(quick.collect, (v) => PagerAPI.post('/api/pineap/ssidpool/collect', { enable: v })); bind(quick.advertise, (v) => PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: v })); function setMode(m, disabled) { Object.keys(segBtns).forEach((k) => segBtns[k].classList.toggle('active', k === m)); modeInfo.textContent = disabled ? 'PineAP is off. Enable it from the Open AP tab or a mode preset to begin impersonating networks.' : { passive: 'PineAP is on; network impersonation (Karma) is off.', active: 'PineAP is on; Karma is expected to be enabled, impersonating open networks.', advanced: 'All PineAP features are enabled and customizable.' }[m] || ''; } let modePending = false; function applyMode(m) { const btn = segBtns[m]; if (!btn || btn.classList.contains('active') || modePending) return; const on = m !== 'passive'; modePending = true; btn.classList.add('busy'); Promise.all([ PagerAPI.post('/api/pineap/enable', { enable: true }), PagerAPI.post('/api/pineap/mimic', { enable: on }) ]).then(() => { karmaOn = on; setMode(m); App.toast('Mode: ' + m[0].toUpperCase() + m.slice(1)); load(); }).catch(() => { karmaOn = null; App.toast('Failed', 'error'); }) .finally(() => { modePending = false; btn.classList.remove('busy'); }); } function load() { Promise.all([ PagerAPI.get('/api/pineap/get_config').catch(() => ({ data: {} })), PagerAPI.get('/api/pineap/hostapd').catch(() => ({ data: {} })), PagerAPI.post('/api/pineap/wifi/get_ap').catch(() => ({ data: {} })), PagerAPI.get('/api/pineap/ssids').catch(() => ({ data: {} })), PagerAPI.get('/api/pineap/clients').catch(() => ({ data: {} })), PagerAPI.get('/api/pineap/handshakes').catch(() => ({ data: {} })) ]).then(([cfg, host, ap, ss, cl, hs]) => { const c = cfg.data || {}, hh = host.data || {}, a = ap.data || {}; const disabled = !!hh.pineap_disabled; const wpa = a.wpa || {}, ent = a.enterprise || {}; const advanced = !disabled && (wpa.enabled || ent.enabled); const computed = disabled ? 'passive' : (karmaOn === false ? 'passive' : (advanced ? 'advanced' : 'active')); mode.textContent = computed[0].toUpperCase() + computed.slice(1); mode.className = 'badge ' + (disabled ? 'off' : 'on'); setMode(computed, disabled); quick.collect.checked = !!c.autossidpool; const pool = a.pool || {}; quick.advertise.checked = pool.disabled === false; stats.ssids.textContent = (ss.data && Array.isArray(ss.data.ssids)) ? ss.data.ssids.length : '—'; stats.clients.textContent = (cl.data && typeof cl.data.count === 'number') ? cl.data.count : '—'; stats.handshakes.textContent = (hs.data && Array.isArray(hs.data.files)) ? hs.data.files.length : '—'; cards.karma.value.textContent = karmaOn == null ? '—' : (karmaOn ? 'On' : 'Off'); const open = a.open || {}; cards.open.value.textContent = open.enabled == null ? '—' : (open.enabled ? 'On' : 'Off'); cards.wpa.value.textContent = wpa.enabled ? 'On' : 'Off'; cards.ent.value.textContent = ent.enabled ? 'On' : 'Off'; }); } load(); const iv = setInterval(load, 5000); return { destroy: () => clearInterval(iv) }; }; ``` - [ ] **Step 4: Run the JS delimiter balance check** Run: `& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" "C:\Users\root\AppData\Local\Temp\opencode\js_balance.py" "C:\Users\root\Documents\Pineapple\pager-webui\payload\user\general\pager-webui\www\js\views.js"` Expected: `...views.js: delimiter balance OK` - [ ] **Step 5: Run the 13-module unittest loop (backend must stay green)** Run from `C:\Users\root\Documents\Pineapple\pager-webui`: ```powershell $py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"; Get-ChildItem tests\test_*.py | ForEach-Object { $mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name); $out = & $py -m unittest $mod 2>&1; ($out | Select-Object -Last 1) } ``` Expected: every module reports `OK` (recon may show `skipped=1`). - [ ] **Step 6: Commit** ```bash git add payload/user/general/pager-webui/www/css/app.css payload/user/general/pager-webui/www/js/views.js git commit -m "feat: Mark VII title-card layout for PineAP overview" ``` --- ### Task 2: Open AP, Evil WPA, Enterprise tabs **Files:** - Modify: `payload/user/general/pager-webui/www/js/views.js` (`views.pineap_open`, `views.pineap_evilwpa`, `views.pineap_enterprise`) **Interfaces:** - Consumes: `pineapCard(title)` (Task 1), `h()`, `btn()`, `table()`, `PagerAPI`, `App.toast`, `EVIL_ENC`, `pineapShell`. - Produces: the three tab renderers rebuilt on `.pineap-title-card` anatomy. `views.pineap_enterprise.tableBox` now returns `{ body, endpoint }` where `body` is a child div (not the card itself) — the `load()` body-clearing code keeps using `t.body`. - [ ] **Step 1: Replace `views.pineap_open`** Replace the whole function (current lines ~330-377) with the same code except the `wrap` construction — change: ```js const wrap = h('div', { class: 'section' }, h('h2', {}, 'Open AP')); ``` to: ```js const wrap = h('div', { class: 'pineap-title-card pineap-card-settings' }, h('div', { class: 'pineap-card-title' }, 'Open AP')); ``` Everything else in `views.pineap_open` (the `defs` array, the `forEach`, the `info` line, `saveCfg`, `load`) stays byte-for-byte identical. - [ ] **Step 2: Replace `views.pineap_evilwpa`** Replace the whole function (current lines ~383-450) with: ```js views.pineap_evilwpa = (root) => { const box = pineapShell(root, '#/pineap/evilwpa'); const cfg = h('div', { class: 'pineap-title-card' }, h('div', { class: 'pineap-card-title' }, 'Evil WPA')); box.appendChild(cfg); const ssidIn = h('input', { id: 'ew-ssid' }); const pskIn = h('input', { id: 'ew-psk' }); const encSel = h('select', { id: 'ew-enc' }); EVIL_ENC.forEach(([v, l]) => encSel.appendChild(h('option', { value: v, text: l }))); const hiddenCb = h('input', { type: 'checkbox', id: 'ew-hidden' }); const enabledCb = h('input', { type: 'checkbox', id: 'ew-enabled' }); cfg.appendChild(h('label', {}, 'SSID', ssidIn)); cfg.appendChild(h('label', {}, 'Passphrase', pskIn)); cfg.appendChild(h('label', {}, 'Encryption', encSel)); cfg.appendChild(h('label', { class: 'toggle' }, hiddenCb, ' Hidden')); cfg.appendChild(h('label', { class: 'toggle' }, enabledCb, ' Enabled')); cfg.appendChild(h('div', { class: 'row' }, h('div', {}, btn('Save', () => { PagerAPI.post('/api/pineap/wifi/set_ap', { wpa: { ssid: ssidIn.value, passphrase: pskIn.value, enctype: encSel.value, hidden: hiddenCb.checked, enabled: enabledCb.checked } }).then(() => { App.toast('Evil WPA saved'); load(); }).catch(() => App.toast('Failed', 'error')); })), h('div', { class: 'muted', style: 'align-self:center;font-size:12px' }, 'Applying reconfigures the radio — you may be disconnected briefly.'))); const capBox = h('div', { class: 'pineap-title-card' }, h('div', { class: 'pineap-card-title' }, 'Handshake Capture')); box.appendChild(capBox); const bssidIn = h('input', { id: 'ew-bssid', placeholder: 'BSSID' }); const secsIn = h('input', { id: 'ew-secs', type: 'number', value: '30', style: 'max-width:80px' }); capBox.appendChild(h('div', { class: 'row' }, h('div', {}, h('label', {}, 'BSSID', bssidIn)), h('div', {}, h('label', {}, 'Seconds', secsIn)), h('div', {}, btn('Examine', () => { const b = bssidIn.value.trim(); if (!b) { App.toast('BSSID required', 'error'); return; } PagerAPI.post('/api/pineap/examine', { bssid: b, seconds: parseInt(secsIn.value, 10) || 30 }) .then(() => App.toast('Examining ' + b)).catch(() => App.toast('Failed', 'error')); })), h('div', {}, btn('Stop', () => PagerAPI.post('/api/pineap/examine', { reset: true }).then(() => App.toast('Stopped')), 'danger')))); const hsBody = h('div', {}); const hsBox = h('div', { class: 'pineap-title-card pineap-card-handshakes' }, h('div', { class: 'pineap-card-title' }, 'Captured Handshakes'), hsBody); box.appendChild(hsBox); function load() { PagerAPI.post('/api/pineap/wifi/get_ap').then((r) => { const w = (r.data || {}).wpa || {}; ssidIn.value = w.ssid || ''; pskIn.value = w.passphrase || ''; if (w.enctype) encSel.value = w.enctype; hiddenCb.checked = !!w.hidden; enabledCb.checked = !!w.enabled; }).catch(() => {}); PagerAPI.get('/api/pineap/handshakes').then((r) => { hsBody.innerHTML = ''; const rows = (r.data.handshakes || []).map((x) => ({ name: x.name || '--', ap: x.ap || '--', client: x.client || '--', type: x.type || '--' })); hsBody.appendChild(table( [{ label: 'File', key: 'name' }, { label: 'AP', key: 'ap' }, { label: 'Client', key: 'client' }, { label: 'Type', key: 'type' }], rows)); if (!rows.length) hsBody.appendChild(h('div', { class: 'pineap-handshakes-none', text: 'No handshakes captured yet.' })); }).catch(() => {}); } load(); const iv = setInterval(load, 5000); return { destroy: () => clearInterval(iv) }; }; ``` - [ ] **Step 3: Replace `views.pineap_enterprise`** Replace the whole function (current lines ~452-494) with: ```js views.pineap_enterprise = (root) => { const box = pineapShell(root, '#/pineap/enterprise'); const cfg = h('div', { class: 'pineap-title-card' }, h('div', { class: 'pineap-card-title' }, 'Evil Enterprise')); box.appendChild(cfg); const enabledCb = h('input', { type: 'checkbox', id: 'ee-enabled' }); const authCb = h('input', { type: 'checkbox', id: 'ee-auth' }); cfg.appendChild(h('label', { class: 'toggle' }, enabledCb, ' Enabled')); cfg.appendChild(h('label', { class: 'toggle' }, authCb, ' Auth Pass Capture')); enabledCb.addEventListener('change', () => PagerAPI.post('/api/pineap/hostapd', { pineape_disabled: !enabledCb.checked }).then(load).catch(() => { enabledCb.checked = !enabledCb.checked; App.toast('Failed', 'error'); })); authCb.addEventListener('change', () => PagerAPI.post('/api/pineap/hostapd', { pineape_auth_pass: authCb.checked }).then(load).catch(() => { authCb.checked = !authCb.checked; App.toast('Failed', 'error'); })); function tableBox(name, endpoint, clearTable) { const body = h('div', {}); const tb = h('div', { class: 'pineap-title-card pineap-card-inject' }, h('div', { class: 'pineap-card-title-flex' }, h('span', { text: name }), h('span', { class: 'toolbar-spacer' }), btn('Clear', () => PagerAPI.post('/api/pineap/enterprise/clear', { table: clearTable }).then(load), 'danger')), body); box.appendChild(tb); return { body, endpoint }; } const basic = tableBox('Basic Data', '/api/pineap/enterprise/basic', 'basic'); const chall = tableBox('Challenge Data', '/api/pineap/enterprise/challenge', 'challenge'); function load() { PagerAPI.get('/api/pineap/hostapd').then((r) => { const hh = r.data || {}; enabledCb.checked = !hh.pineape_disabled; authCb.checked = !!hh.pineape_auth_pass; }).catch(() => {}); [basic, chall].forEach((t) => { PagerAPI.get(t.endpoint).then((r) => { const rows = (r.data.rows || []).slice(); t.body.innerHTML = ''; const cols = rows.length ? Object.keys(rows[0]).map((k) => ({ label: k, key: k })) : [{ label: '—', key: '_none' }]; t.body.appendChild(table(cols, rows)); if (!rows.length) t.body.appendChild(h('div', { class: 'empty', text: 'No data captured.' })); }).catch(() => {}); }); } load(); const iv = setInterval(load, 5000); return { destroy: () => clearInterval(iv) }; }; ``` - [ ] **Step 4: Run the JS delimiter balance check** Run: `& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" "C:\Users\root\AppData\Local\Temp\opencode\js_balance.py" "C:\Users\root\Documents\Pineapple\pager-webui\payload\user\general\pager-webui\www\js\views.js"` Expected: `...views.js: delimiter balance OK` - [ ] **Step 5: Run the 13-module unittest loop** Run from `C:\Users\root\Documents\Pineapple\pager-webui`: ```powershell $py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"; Get-ChildItem tests\test_*.py | ForEach-Object { $mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name); $out = & $py -m unittest $mod 2>&1; ($out | Select-Object -Last 1) } ``` Expected: every module reports `OK` (recon may show `skipped=1`). - [ ] **Step 6: Commit** ```bash git add payload/user/general/pager-webui/www/js/views.js git commit -m "feat: Mark VII layout for Open AP, Evil WPA, Enterprise tabs" ``` --- ### Task 3: Impersonation, Clients, Filtering, APs tabs **Files:** - Modify: `payload/user/general/pager-webui/www/js/views.js` (`views.pineap_impersonation`, `views.pineap_clients`, `views.pineap_filtering`, `views.pineap_aps`) **Interfaces:** - Consumes: `pineapCard`/`pineapTitleCard` (Task 1), `h()`, `btn()`, `table()`, `PagerAPI`, `App.toast`, `pineapShell`. - Produces: the four tab renderers rebuilt. `views.pineap_impersonation` gains a `poolCount` span (updated in `render()`); `views.pineap_clients` gains a `count` span (updated in `load()`). - [ ] **Step 1: Replace `views.pineap_impersonation`** Replace the whole function (current lines ~496-536) with: ```js views.pineap_impersonation = (root) => { const box = pineapShell(root, '#/pineap/impersonation'); const poolCount = h('span', { text: '—' }); const countRow = h('div', { class: 'pineap-title-card-container' }, pineapTitleCard('Total SSIDs in Pool', '#/pineap/impersonation', poolCount)); box.appendChild(countRow); const input = h('input', { id: 'imp-ssid' }); const list = h('div', {}); const advCb = h('input', { type: 'checkbox', id: 'imp-advertise' }); const colCb = h('input', { type: 'checkbox', id: 'imp-collect' }); const poolBox = h('div', { class: 'pineap-title-card pineap-card-pool' }, h('div', { class: 'pineap-card-title' }, 'SSID Pool')); poolBox.appendChild(h('div', { class: 'row' }, h('div', {}, h('label', {}, 'SSID', input)), h('div', {}, btn('Add', () => { const v = input.value.trim(); if (!v) return; PagerAPI.post('/api/pineap/ssids', { action: 'add', ssid: v }).then((r) => { input.value = ''; render(r.data.ssids); App.toast('Added'); }); })), h('div', {}, btn('Clear', () => PagerAPI.post('/api/pineap/ssids', { action: 'clear' }).then((r) => render(r.data.ssids)), 'danger')))); poolBox.appendChild(h('label', { class: 'toggle' }, advCb, ' Advertise AP Impersonation Pool')); poolBox.appendChild(h('label', { class: 'toggle' }, colCb, ' Capture SSIDs to Pool')); poolBox.appendChild(list); box.appendChild(poolBox); advCb.addEventListener('change', () => PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: advCb.checked }).then(load).catch(() => { advCb.checked = !advCb.checked; App.toast('Failed', 'error'); })); colCb.addEventListener('change', () => PagerAPI.post('/api/pineap/ssidpool/collect', { enable: colCb.checked }).then(load).catch(() => { colCb.checked = !colCb.checked; App.toast('Failed', 'error'); })); function render(ssids) { poolCount.textContent = Array.isArray(ssids) ? ssids.length : 0; list.innerHTML = ''; list.appendChild(table( [{ label: 'SSID', key: 'ssid' }, { label: '', render: () => '' }], (ssids || []).map((s) => ({ ssid: s })), (r) => ({ onclick: () => { if (confirm('Remove ' + r.ssid + '?')) PagerAPI.post('/api/pineap/ssids', { action: 'remove', ssid: r.ssid }).then((x) => render(x.data.ssids)); } }))); list.querySelectorAll('.tbl th').forEach((th, i) => { if (i === 1) th.textContent = 'Remove'; }); if (!ssids || !ssids.length) list.appendChild(h('div', { class: 'empty', text: 'No SSIDs in pool.' })); } function load() { PagerAPI.get('/api/pineap/ssids').then((r) => render(r.data.ssids)).catch(() => {}); PagerAPI.post('/api/pineap/wifi/get_ap').then((r) => { const p = (r.data || {}).pool || {}; advCb.checked = p.disabled === false; colCb.checked = !!p.collecting; }).catch(() => {}); } load(); return { destroy: () => {} }; }; ``` - [ ] **Step 2: Replace `views.pineap_clients`** Replace the whole function (current lines ~538-561) with: ```js views.pineap_clients = (root) => { const box = pineapShell(root, '#/pineap/clients'); const state = { clients: [] }; const count = h('span', { text: '—' }); const countRow = h('div', { class: 'pineap-title-card-container' }, pineapTitleCard('Clients Connected', '#/pineap/clients', count)); box.appendChild(countRow); const body = h('div', {}); const tableCard = h('div', { class: 'pineap-title-card' }, h('div', { class: 'pineap-card-title-flex' }, h('span', { text: 'Connected Clients' }), h('span', { class: 'toolbar-spacer' }), btn('Refresh', load, 'ghost')), body); box.appendChild(tableCard); function render() { body.innerHTML = ''; body.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']; body.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; count.textContent = (r.data && typeof r.data.count === 'number') ? r.data.count : state.clients.length; render(); }); } load(); const iv = setInterval(load, 5000); return { destroy: () => clearInterval(iv) }; }; ``` - [ ] **Step 3: Replace `views.pineap_filtering`** Replace the whole function (current lines ~563-603) with: ```js views.pineap_filtering = (root) => { const box = pineapShell(root, '#/pineap/filtering'); function filterCard(title) { const body = h('div', {}); const card = h('div', { class: 'pineap-title-card' }, h('div', { class: 'pineap-card-title' }, title), body); box.appendChild(card); return body; } const cfBox = filterCard('Client Filter'); const sfBox = filterCard('SSID Filter'); function renderFilter(dom, kind) { dom.innerHTML = ''; const path = '/api/pineap/filters/' + kind; const modeSel = h('select', { id: 'fm-' + kind }, h('option', { value: 'allow', text: 'Allow list' }), h('option', { value: 'deny', text: 'Deny list' })); const valueIn = h('input', { id: 'fv-' + kind }); dom.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('fv-' + kind).value.trim(); if (!v) return; PagerAPI.post(path, { action: 'add', value: v }).then(() => refresh()).catch(() => App.toast('Failed', 'error')); })), h('div', {}, btn('Clear', () => PagerAPI.post(path, { action: 'clear' }).then(refresh), 'danger')))); modeSel.addEventListener('change', () => PagerAPI.post(path, { action: 'set_mode', mode: modeSel.value }).then(refresh)); const list = h('div', {}); dom.appendChild(list); PagerAPI.get(path).then((r) => { modeSel.value = r.data.mode; list.innerHTML = ''; list.appendChild(table( [{ label: kind === 'client' ? 'MAC' : 'SSID', key: 'value' }, { label: '', render: () => '' }], (r.data.entries || []).map((e) => ({ value: e })), (row) => ({ onclick: () => { if (confirm('Delete ' + row.value + '?')) PagerAPI.post(path, { action: 'delete', value: row.value }).then(refresh); } }))); list.querySelectorAll('.tbl th').forEach((th, i) => { if (i === 1) th.textContent = 'Delete'; }); if (!r.data.entries || !r.data.entries.length) list.appendChild(h('div', { class: 'pineap-handshakes-none', text: 'No entries.' })); }).catch(() => {}); } function refresh() { renderFilter(cfBox, 'client'); renderFilter(sfBox, 'ssid'); } refresh(); return { destroy: () => {} }; }; ``` - [ ] **Step 4: Replace `views.pineap_aps`** Replace the whole function (current lines ~605-631) with: ```js views.pineap_aps = (root) => { const box = pineapShell(root, '#/pineap/aps'); const body = h('div', {}); const b = h('div', { class: 'pineap-title-card pineap-card-inject' }, h('div', { class: 'pineap-card-title-flex' }, h('span', { text: 'Access Points' }), h('span', { class: 'toolbar-spacer' }), btn('Refresh', load, 'ghost')), body); box.appendChild(b); function load() { body.innerHTML = ''; 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 || '--' })); body.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) body.appendChild(h('div', { class: 'pineap-handshakes-none', text: 'No access points found.' })); }).catch(() => body.appendChild(h('div', { class: 'pineap-handshakes-none', text: 'Scan failed.' }))); } load(); const iv = setInterval(load, 10000); return { destroy: () => clearInterval(iv) }; }; ``` - [ ] **Step 5: Run the JS delimiter balance check** Run: `& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" "C:\Users\root\AppData\Local\Temp\opencode\js_balance.py" "C:\Users\root\Documents\Pineapple\pager-webui\payload\user\general\pager-webui\www\js\views.js"` Expected: `...views.js: delimiter balance OK` - [ ] **Step 6: Run the 13-module unittest loop** Run from `C:\Users\root\Documents\Pineapple\pager-webui`: ```powershell $py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"; Get-ChildItem tests\test_*.py | ForEach-Object { $mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name); $out = & $py -m unittest $mod 2>&1; ($out | Select-Object -Last 1) } ``` Expected: every module reports `OK` (recon may show `skipped=1`). - [ ] **Step 7: Commit** ```bash git add payload/user/general/pager-webui/www/js/views.js git commit -m "feat: Mark VII layout for Impersonation, Clients, Filtering, APs tabs" ``` --- ### Task 4: Deploy and verify on device **Files:** none (verification only; no commit). **Interfaces:** - Consumes: Tasks 1-3 output (deployed via `scripts/deploy.ps1`). - [ ] **Step 1: Deploy the payload and restart the webui** Run from `C:\Users\root\Documents\Pineapple\pager-webui`: ```powershell powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\deploy.ps1 -Password "" ``` Then restart and confirm the port is up (over sshpass SSH): ```bash sshpass -p "" ssh root@172.16.52.1 "/etc/init.d/pagerwebui restart; sleep 4; curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/api/api_ping" ``` Expected: `401` (auth required = running). - [ ] **Step 2: Confirm the deployed files contain the new classes** ```bash sshpass -p "" ssh root@172.16.52.1 "grep -c 'pineap-title-card' /root/payloads/user/general/pager-webui/www/css/app.css; grep -c 'pineapTitleCard' /root/payloads/user/general/pager-webui/www/js/views.js" ``` Expected: both counts greater than zero. - [ ] **Step 3: Report for user UI walk** Tell the user all 8 PineAP tabs now use the Mark VII `pineap-*` title-card layout: the overview has three rows of title cards (stats with clickable title links → mode/quick-settings → status cards), the other tabs use 20px title cards with the `.pineap-card-title-flex` action rows and `.pineap-handshakes-none` empty states. Ask them to refresh `http://172.16.52.1:8080/#/pineap` and walk the tabs to confirm.