Files
Mark-VIII/docs/superpowers/plans/2026-08-11-pineap-markvii-layout.md
T
2026-08-11 20:24:24 -07:00

32 KiB

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 "<PAGER_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:

/* ---- 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:

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:

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:

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

  const wrap = h('div', { class: 'section' }, h('h2', {}, 'Open AP'));

to:

  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:

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:

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:

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

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:

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:

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:

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:

$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
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 -NoProfile -ExecutionPolicy Bypass -File .\scripts\deploy.ps1 -Password "<PAGER_PASSWORD>"

Then restart and confirm the port is up (over sshpass SSH):

sshpass -p "<PAGER_PASSWORD>" 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
sshpass -p "<PAGER_PASSWORD>" 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.