# Handshakes Mark VII Parity 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:** Make the Pager WebUI Recon → Handshakes tab (`#/recon/handshakes`) look and behave like the stock Mark VII handshakes page (BSSID/Client/Source/Type/Captured/Message 1–4/Beacon Frame table, per-row Download & Delete, settings dialog with location + Delete All). **Architecture:** `server.py` synthesizes the Mark VII `{handshakes:[...]}` shape from loot filenames + one scoped recon.db correlation read (zero DB reads when the loot dir is empty). The vanilla-JS `views.recon_handshakes` view is rebuilt to render the Mark VII table with check/X/? glyphs, a success/error flash, and a settings modal. New icons and CSS are added. Dashboard keeps reading the unchanged `files` array. **Tech Stack:** Python 3.11 (Windows dev) / device `python3-light`; stdlib `unittest` with mocks; vanilla JS + hand-rolled `h()` DOM helpers. ## Global Constraints - **DB reads:** at most **1** `_db_rows` call per handshakes request, and only when the loot dir is non-empty. No polling added to the handshakes view. - **No new dependencies** — pure stdlib; reuse existing helpers `_db_rows`, `fmt_mac`, `hsType`, `iconBtn`, `btn`, `h`, `PagerAPI`, `App`. - **Dashboard unchanged:** `GET /api/pineap/handshakes` must keep returning a `files` array with the current shape (`{name,size,mtime}`). - **Router is first-match-wins:** the `location` GET route MUST be registered before `GET /api/pineap/handshakes/([^/]+)`. - Tests run per module in their own process (README): `& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" -m unittest tests.test_recon -v`. - Copy exact strings from the spec (Mark VII wording for tooltips/empty state, `pcap`→`PCAP`, `22000`→`Hashcat`). --- --- ### Task 1: Backend — filename parser + `handshakes_data()` Mark VII shape **Files:** - Modify: `payload/user/general/pager-webui/server.py` (`handshakes_data()` at ~line 1060; add `parse_hs_filename`, `_norm_mac`, `_hs_db_by_pair`, `_compose_hs` above it) - Test: `tests/test_recon.py` (add `HS_RE`/`parse_hs_filename`/`handshakes_data` tests) **Interfaces:** - Consumes: `LOOT_HS_DIR` (module global, str), `RECON_DB` (module global, str path), `_db_rows(db, sql)` (existing), `os.listdir`/`os.stat`. - Produces: - `parse_hs_filename(name) -> dict | None` with keys `ts` (int|None), `ap` (colon MAC str), `client` (colon MAC str), `kind` (`'full'|'partial'|'incomplete'`), `ext` (str). - `_norm_mac(m) -> str` — uppercased, dash→colon, 12-hex→colon form. - `handshakes_data() -> {'files': [...], 'handshakes': [...]}` where each handshake record has keys `mac, client, source, type, timestamp, in_db, part_mask, beacon, extension, name, location, file_exists`. - [ ] **Step 1: Write the failing tests** Append to `tests/test_recon.py` (add `import shutil` to the module imports at the top if not already present): ```python def make_hs_db(): db = make_db() conn = sqlite3.connect(db) conn.execute( "INSERT INTO handshake (hash, scan, stahash, aphash, time, beacon, hs1, hs2, hs3, hs4) " "VALUES (21, 1, 1, 2, 1786466650, X'BEACON', X'01', X'02', X'03', X'04')") conn.commit() conn.close() return db class ParseHsFilenameTest(unittest.TestCase): def test_parse_full_pcap(self): p = server.parse_hs_filename('1786466650_C8:9E:43:64:80:80_AE:77:C0:EB:31:41_handshake.pcap') self.assertEqual(p['ts'], 1786466650) self.assertEqual(p['ap'], 'C8:9E:43:64:80:80') self.assertEqual(p['client'], 'AE:77:C0:EB:31:41') self.assertEqual(p['kind'], 'full') self.assertEqual(p['ext'], 'pcap') def test_parse_partial_and_incomplete(self): p = server.parse_hs_filename('1_C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake_partial.22000') self.assertEqual(p['kind'], 'partial') self.assertEqual(p['ext'], '22000') p = server.parse_hs_filename('1_C8:9E:43:64:80:80_AE:77:C0:EB:31:41_handshake_incomplete.pcap') self.assertEqual(p['kind'], 'incomplete') self.assertEqual(p['ext'], 'pcap') def test_parse_dash_macs_and_no_ts(self): p = server.parse_hs_filename('C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake.pcap') self.assertIsNone(p['ts']) self.assertEqual(p['ap'], 'C8:9E:43:64:80:80') self.assertEqual(p['client'], 'AE:77:C0:EB:31:41') def test_parse_unrecognized(self): self.assertIsNone(server.parse_hs_filename('random.cap')) self.assertIsNone(server.parse_hs_filename('notes.txt')) self.assertIsNone(server.parse_hs_filename('')) self.assertIsNone(server.parse_hs_filename('123_mac1_mac2_handshake')) class HandshakesDataTest(unittest.TestCase): def setUp(self): self.db = make_hs_db() server.RECON_DB = self.db self.dir = tempfile.mkdtemp() server.LOOT_HS_DIR = self.dir def tearDown(self): shutil.rmtree(self.dir) os.unlink(self.db) def _write(self, name, ts): os.utime(os.path.join(self.dir, name), (ts, ts)) def test_empty_dir_skips_db(self): with mock.patch.object(server, '_db_rows', side_effect=AssertionError('db should not be touched')): data = server.handshakes_data() self.assertEqual(data, {'files': [], 'handshakes': []}) def test_correlation_composes_full_record(self): self._write('1786466650_C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake.pcap', 1786466650) data = server.handshakes_data() self.assertEqual(len(data['files']), 1) hs = data['handshakes'][0] self.assertEqual(hs['mac'], 'C8:9E:43:64:80:80') self.assertEqual(hs['client'], 'AE:77:C0:EB:31:41') self.assertEqual(hs['source'], 'Recon') self.assertEqual(hs['type'], 'full') self.assertEqual(hs['extension'], 'pcap') self.assertEqual(hs['timestamp'], 1786466650) self.assertTrue(hs['in_db']) self.assertEqual(hs['part_mask'], 15) self.assertTrue(hs['beacon']) self.assertEqual(hs['name'], '1786466650_C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake.pcap') self.assertTrue(hs['file_exists']) self.assertEqual(hs['location'], os.path.join(server.LOOT_HS_DIR, hs['name'])) def test_file_not_in_db_has_question_mark_fields(self): self._write('1786467000_AA-BB-CC-DD-EE-FF_00-11-22-33-44-55_handshake.pcap', 1786467000) hs = server.handshakes_data()['handshakes'][0] self.assertFalse(hs['in_db']) self.assertEqual(hs['part_mask'], 0) self.assertFalse(hs['beacon']) self.assertEqual(hs['timestamp'], 1786467000) def test_unparseable_file_still_listed_with_placeholders(self): self._write('random.cap', 1786467005) hs = server.handshakes_data()['handshakes'][0] self.assertEqual(hs['mac'], '--') self.assertEqual(hs['client'], '--') self.assertFalse(hs['in_db']) self.assertEqual(hs['extension'], 'cap') ``` `tests/test_recon.py` already imports `os, sqlite3, sys, tempfile, unittest, mock` and `import server`; add `import shutil` to the top-of-module imports. `re` is not needed. - [ ] **Step 2: Run tests to verify they fail** Run: ```powershell $py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" & $py -m unittest tests.test_recon -v ``` Expected: `AttributeError: module 'server' has no attribute 'parse_hs_filename'` (and failures on `handshakes_data` shape). - [ ] **Step 3: Implement parser + data composition** In `server.py`, replace the existing `handshakes_data()` (lines ~1060–1074) with: ```python HS_FILENAME_RE = re.compile( r'^(?:(\d+)_)?([0-9A-Fa-f]{2}(?:[:-][0-9A-Fa-f]{2}){5})_' r'([0-9A-Fa-f]{2}(?:[:-][0-9A-Fa-f]{2}){5})(?:_handshake)?' r'(?:_(full|partial|incomplete))?\.([A-Za-z0-9]+)$') def parse_hs_filename(name): m = HS_FILENAME_RE.match(name or '') if not m: return None ts, ap, client, kind, ext = m.groups() return {'ts': int(ts) if ts else None, 'ap': ap.replace('-', ':'), 'client': client.replace('-', ':'), 'kind': kind or 'full', 'ext': ext} def _norm_mac(m): m = (m or '').strip().upper().replace('-', ':') if len(m) == 12 and ':' not in m and all(c in '0123456789ABCDEF' for c in m): m = ':'.join(m[i:i + 2] for i in range(0, 12, 2)) return m def _hs_db_by_pair(min_ts): rows = _db_rows(RECON_DB, 'SELECT h.time, ' '(h.hs1 IS NOT NULL AND length(h.hs1) > 0) AS m1, ' '(h.hs2 IS NOT NULL AND length(h.hs2) > 0) AS m2, ' '(h.hs3 IS NOT NULL AND length(h.hs3) > 0) AS m3, ' '(h.hs4 IS NOT NULL AND length(h.hs4) > 0) AS m4, ' '(h.beacon IS NOT NULL AND length(h.beacon) > 0) AS beacon, ' 'w1.mac AS ap, w2.mac AS sta ' 'FROM handshake h ' 'JOIN wifi_device w1 ON w1.hash = h.aphash ' 'JOIN wifi_device w2 ON w2.hash = h.stahash ' 'WHERE h.time >= %d ORDER BY h.time' % min_ts) db = {} for r in rows: db[(_norm_mac(r.get('ap')), _norm_mac(r.get('sta')))] = { 'time': r.get('time'), 'part_mask': (1 if r.get('m1') else 0) | (2 if r.get('m2') else 0) | (4 if r.get('m3') else 0) | (8 if r.get('m4') else 0), 'beacon': bool(r.get('beacon')), } return db def _compose_hs(name, size, mtime, part, db): base = {'source': 'Recon', 'name': name, 'size': size, 'location': os.path.join(LOOT_HS_DIR, name), 'file_exists': True} if part is None: ext = name.rsplit('.', 1)[-1] if '.' in name else '' base.update({'mac': '--', 'client': '--', 'type': 'full', 'timestamp': mtime, 'in_db': False, 'part_mask': 0, 'beacon': False, 'extension': ext}) return base rec = db.get((_norm_mac(part['ap']), _norm_mac(part['client']))) base.update({ 'mac': part['ap'], 'client': part['client'], 'type': part['kind'], 'timestamp': (rec or {}).get('time') or part['ts'] or mtime, 'in_db': rec is not None, 'part_mask': (rec or {}).get('part_mask', 0), 'beacon': bool((rec or {}).get('beacon', False)), 'extension': part['ext']}) return base def handshakes_data(): files = [] parsed = [] min_ts = None try: names = sorted(os.listdir(LOOT_HS_DIR)) except OSError: names = [] for name in names: p = os.path.join(LOOT_HS_DIR, name) try: if not os.path.isfile(p) or name.startswith('.'): continue st = os.stat(p) except OSError: continue mtime = int(st.st_mtime) files.append({'name': name, 'size': st.st_size, 'mtime': mtime}) part = parse_hs_filename(name) if part is not None: ts = part['ts'] if part['ts'] is not None else mtime part['ts'] = ts if min_ts is None or ts < min_ts: min_ts = ts parsed.append((name, st.st_size, mtime, part)) handshakes = [] db = _hs_db_by_pair(min_ts) if parsed else {} for name, size, mtime, part in parsed: handshakes.append(_compose_hs(name, size, mtime, part, db)) return {'files': files, 'handshakes': handshakes} ``` - [ ] **Step 4: Run tests to verify they pass** Run: ```powershell $py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" & $py -m unittest tests.test_recon -v ``` Expected: all tests pass, including the pre-existing `HandshakeFileTest` and `ReconDataTest` cases. - [ ] **Step 5: Commit** ```bash git add tests/test_recon.py payload/user/general/pager-webui/server.py git commit -m "feat: handshakes endpoint returns Mark VII records from loot files + recon.db" ``` --- ### Task 2: Backend — `location` + `delete-all` routes **Files:** - Modify: `payload/user/general/pager-webui/server.py` (add `h_handshakes_location`, `h_handshakes_delete_all`; reorder/add route registrations at ~lines 1567–1569) - Test: `tests/test_recon.py` **Interfaces:** - Consumes: `LOOT_HS_DIR`, `handshakes_data()` (Task 1). - Produces: - `h_handshakes_location(ctx) -> (200, {'location': str})` - `h_handshakes_delete_all(ctx) -> (200, handshakes_data())` - Router registrations: `GET /api/pineap/handshakes/location` (BEFORE the `([^/]+)` file route), `DELETE /api/pineap/handshakes/all`. - [ ] **Step 1: Write the failing tests** Append to `tests/test_recon.py`: ```python class HandshakeRoutesTest(unittest.TestCase): def setUp(self): self.dir = tempfile.mkdtemp() server.LOOT_HS_DIR = self.dir def tearDown(self): shutil.rmtree(self.dir) def _write(self, name, data=b'data'): with open(os.path.join(self.dir, name), 'wb') as f: f.write(data) def test_location_returns_loot_dir(self): status, data = server.h_handshakes_location(type('C', (), {'args': ()})()) self.assertEqual(status, 200) self.assertEqual(data['location'], self.dir) def test_location_route_precedes_file_download(self): h, args = server.ROUTER.dispatch('GET', '/api/pineap/handshakes/location') self.assertIs(h, server.h_handshakes_location) def test_delete_all_removes_files(self): self._write('1_C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake.pcap') self._write('2_C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake.22000') status, data = server.h_handshakes_delete_all(type('C', (), {'args': ()})()) self.assertEqual(status, 200) self.assertEqual(data['files'], []) self.assertEqual(data['handshakes'], []) self.assertEqual(os.listdir(self.dir), []) def test_delete_all_empty_dir_is_ok(self): status, data = server.h_handshakes_delete_all(type('C', (), {'args': ()})()) self.assertEqual(status, 200) self.assertEqual(data['files'], []) ``` - [ ] **Step 2: Run tests to verify they fail** Run: ```powershell $py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" & $py -m unittest tests.test_recon -v ``` Expected: `AttributeError: module 'server' has no attribute 'h_handshakes_location'` etc. - [ ] **Step 3: Implement the handlers + routes** In `server.py`, after `h_handshakes_delete` (line ~1100), add: ```python def h_handshakes_location(ctx): return 200, {'location': LOOT_HS_DIR} def h_handshakes_delete_all(ctx): try: names = os.listdir(LOOT_HS_DIR) except OSError: names = [] for name in names: p = os.path.join(LOOT_HS_DIR, name) try: if os.path.isfile(p) and not name.startswith('.'): os.remove(p) except OSError: continue return 200, handshakes_data() ``` Replace the route block (lines ~1567–1569) with: ```python ROUTER.add('GET', r'/api/pineap/handshakes/location', h_handshakes_location) ROUTER.add('DELETE', r'/api/pineap/handshakes/all', h_handshakes_delete_all) ROUTER.add('GET', r'/api/pineap/handshakes', h_handshakes_get) ROUTER.add('GET', r'/api/pineap/handshakes/([^/]+)', h_handshake_file) ROUTER.add('DELETE', r'/api/pineap/handshakes', h_handshakes_delete) ``` (`location` MUST stay above the `([^/]+)` GET route — the Router is first-match-wins.) - [ ] **Step 4: Run tests to verify they pass** Run: ```powershell $py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" & $py -m unittest tests.test_recon -v ``` Expected: all pass. - [ ] **Step 5: Commit** ```bash git add tests/test_recon.py payload/user/general/pager-webui/server.py git commit -m "feat: handshakes location + delete-all endpoints" ``` --- ### Task 3: Frontend — glyph icons + CSS **Files:** - Modify: `payload/user/general/pager-webui/www/js/icons.js` - Modify: `payload/user/general/pager-webui/www/css/app.css` **Interfaces:** - Produces: `PineappleIcons.check`, `PineappleIcons.close`, `PineappleIcons.question_mark` (inline Material SVG strings); CSS classes `.hs-cell-center`, `.hs-ok`, `.hs-bad`, `.hs-na`, `.hs-actions`, `.hs-warn`, `.hs-flash`, `.hs-flash-ok`, `.hs-flash-error`, `.modal-overlay`, `.modal`, `.modal-title`, `.modal-body`, `.modal-actions`, `.hs-settings-row`, `.hs-settings-label`, `.hs-settings-value`. - Consumed by: Task 4 view. - [ ] **Step 1: Add the three icons** In `www/js/icons.js`, add inside the `window.PineappleIcons = { ... }` object (keep alphabetical-ish ordering; the file is a plain object): ```js check: '', close: '', question_mark: '', ``` - [ ] **Step 2: Add CSS** Append to `www/css/app.css` (after the existing recon rules): ```css /* ---- Mark VII handshakes table + settings dialog ---- */ .hs-cell-center { text-align: center; } .hs-ok, .hs-bad, .hs-na { display: inline-flex; vertical-align: middle; } .hs-ok svg, .hs-bad svg, .hs-na svg { width: 18px; height: 18px; } .hs-ok { color: #7cb342; } .hs-bad { color: #d32f2f; } .hs-na { color: #9e9e9e; } .hs-actions { display: inline-flex; align-items: center; gap: 4px; } .hs-warn { color: #d32f2f; } .hs-flash { font-size: 12px; margin-left: 8px; } .hs-flash-ok { color: #7cb342; } .hs-flash-error { color: #d32f2f; } .modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 100; display: flex; align-items: center; justify-content: center; } .modal { background: var(--surface); border: 1px solid var(--border); border-radius: 4px; min-width: 420px; max-width: 600px; box-shadow: 0 8px 24px rgba(0,0,0,.3); padding: 20px; } .modal-title { font-size: 20px; margin-bottom: 16px; } .modal-body { display: flex; flex-direction: column; gap: 12px; } .modal-actions { display: flex; justify-content: flex-end; margin-top: 16px; } .hs-settings-row { display: flex; justify-content: space-between; gap: 12px; font-size: 14px; } .hs-settings-label { color: var(--muted); } .hs-settings-value { font-family: Consolas, Menlo, monospace; word-break: break-all; } html.dark .modal { background: #303030; } ``` - [ ] **Step 3: Verify the file parses** There is no node on the dev box and no JS test harness, so do a brace/paren balance sanity check with Python instead of a real parse. From the repo root, run: ```powershell $py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" & $py -c "s=open(r'payload/user/general/pager-webui/www/js/icons.js',encoding='utf-8').read(); assert s.count('{')==s.count('}') and s.count('(')==s.count(')'), 'unbalanced'; print('icons.js balanced OK')" ``` Expected: prints `icons.js balanced OK`. (CSS has no build step — the Task 5 browser pass covers it, and the icons resolve in the browser console there.) - [ ] **Step 4: Commit** ```bash git add payload/user/general/pager-webui/www/js/icons.js payload/user/general/pager-webui/www/css/app.css git commit -m "feat: handshake glyph icons and modal/flash CSS" ``` --- ### Task 4: Frontend — rebuild `views.recon_handshakes` + settings dialog **Files:** - Modify: `payload/user/general/pager-webui/www/js/views.js` (replace `hsType`/`views.recon_handshakes`, lines ~904–952) **Interfaces:** - Consumes: `RECON_TABS`, `h`, `tabBar`, `iconBtn`, `btn`, `fmtTime`, `hsType`, `PagerAPI`, `App.apiBase`, `App.toast`, `PineappleIcons.*` (all existing); backend endpoints from Tasks 1–2. - Produces: rewritten `views.recon_handshakes` (no signature change — `(root) => ({destroy})`). - [ ] **Step 1: Replace `hsType` and `views.recon_handshakes`** Replace the `hsType` helper (lines ~904–909) and the whole `views.recon_handshakes` definition (lines ~911–952) in `www/js/views.js` with: ```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 recon-handshakes-card' }); root.appendChild(box); let flashTimer = null; function flash(ok, msg) { const head = box.querySelector('.recon-table-head'); if (!head) return; const old = head.querySelector('.hs-flash'); if (old) old.remove(); const el = h('span', { class: 'hs-flash ' + (ok ? 'hs-flash-ok' : 'hs-flash-error'), text: msg }); head.appendChild(el); clearTimeout(flashTimer); flashTimer = setTimeout(() => el.remove(), ok ? 3000 : 5000); } function hsIconBtn(name, title, cls, onclk) { const b = h('button', { class: 'icon-btn ' + (cls || ''), title: title, onclick: onclk }); b.innerHTML = PineappleIcons[name] || ''; return b; } function textCell(v) { return h('td', { class: 'mat-cell', text: v == null || v === '' ? '--' : String(v) }); } function naGlyph() { return h('td', { class: 'mat-cell hs-cell-center' }, h('span', { class: 'hs-na', title: "This information isn't available. This is common when a handshake file has been found, but the associated Recon scan has been lost or deleted." }, PineappleIcons.question_mark)); } function boolGlyph(v) { return h('td', { class: 'mat-cell hs-cell-center' }, h('span', { class: v ? 'hs-ok' : 'hs-bad' }, v ? PineappleIcons.check : PineappleIcons.close)); } function msgCell(inDb, present) { return inDb ? boolGlyph(present) : naGlyph(); } function load(done) { PagerAPI.get('/api/pineap/handshakes').then((r) => { box.innerHTML = ''; const head = h('div', { class: 'recon-table-head' }, h('h2', { text: 'Captured WPA Handshakes' }), h('span', { class: 'toolbar-spacer' }), iconBtn('settings', 'Handshakes settings', openSettings)); box.appendChild(head); box.appendChild(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')))); const hs = r.data.handshakes || []; if (!hs.length) { box.appendChild(h('div', { class: 'empty', text: 'No Handshakes Available' })); if (done) done(); return; } const t = h('table', { class: 'tbl' }); const thead = h('thead'); const th = h('tr'); ['BSSID', 'Client', 'Source', 'Type', 'Captured', 'Message 1', 'Message 2', 'Message 3', 'Message 4', 'Beacon Frame', ''] .forEach((c) => th.appendChild(h('th', { text: c }))); thead.appendChild(th); t.appendChild(thead); const tb = h('tbody'); hs.forEach((f) => { const trr = h('tr'); trr.appendChild(textCell(f.mac)); trr.appendChild(textCell(f.client)); trr.appendChild(textCell(f.source)); trr.appendChild(textCell(String(f.type).charAt(0).toUpperCase() + String(f.type).slice(1) + ' ' + hsType(f.name))); trr.appendChild(textCell(fmtTime(f.timestamp))); [1, 2, 4, 8].forEach((bit) => trr.appendChild(msgCell(f.in_db, (f.part_mask & bit) !== 0))); trr.appendChild(msgCell(f.in_db, !!f.beacon)); const act = h('td', { class: 'mat-cell' }, h('span', { class: 'hs-actions' }, hsIconBtn('file_download', 'Download', '', () => { window.location = App.apiBase + '/api/pineap/handshakes/' + encodeURIComponent(f.name); }), hsIconBtn('delete', 'Delete', 'hs-warn', () => { PagerAPI.del('/api/pineap/handshakes', { name: f.name }) .then(() => load(() => flash(true, 'Deleted ' + f.name))) .catch(() => flash(false, 'Failed to delete ' + f.name)); }))); trr.appendChild(act); tb.appendChild(trr); }); t.appendChild(tb); box.appendChild(t); if (done) done(); }).catch(() => flash(false, 'Failed to load handshakes')); } function openSettings() { PagerAPI.get('/api/pineap/handshakes/location').then((r) => { const loc = (r.data || {}).location || '--'; const overlay = h('div', { class: 'modal-overlay' }); function close() { overlay.remove(); } overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); const modal = h('div', { class: 'modal' }, h('div', { class: 'modal-title', text: 'Handshake Settings' }), h('div', { class: 'modal-body' }, h('div', { class: 'hs-settings-row' }, h('span', { class: 'hs-settings-label', text: 'Handshake Location' }), h('span', { class: 'hs-settings-value', text: loc })), btn('Delete All Handshakes', () => { PagerAPI.del('/api/pineap/handshakes/all') .then(() => { close(); load(() => flash(true, 'All handshakes deleted')); }) .catch(() => { close(); flash(false, 'Failed to delete all handshakes'); }); }, 'danger')), h('div', { class: 'modal-actions' }, btn('Close', close, 'ghost'))); overlay.appendChild(modal); document.body.appendChild(overlay); }).catch(() => App.toast('Failed to load handshake settings', 'error')); } load(); return { destroy: () => {} }; }; ``` Notes: - The old `hsType(name)` helper is still referenced (it maps `f.name` → `PCAP`/`Hashcat`/`Unknown`), so leave it in place. - `msgCell` renders a check/X/`?` glyph for Message 1–4 and Beacon Frame exactly like the Mark VII. - Delete is deliberately **no-confirm** to match the Mark VII; the flash + reload give feedback. - [ ] **Step 2: Verify the view wiring** Serve locally and log in: ```powershell & .\scripts\dev.ps1 -Tunnel ``` Browse `http://127.0.0.1:8000/#/recon/handshakes` and confirm the page renders without console errors (empty state "No Handshakes Available" is expected with no captured handshakes). Check the settings gear opens the dialog showing the location and the Delete All button. - [ ] **Step 3: Commit** ```bash git add payload/user/general/pager-webui/www/js/views.js git commit -m "feat: Mark VII handshakes table with per-row download/delete and settings dialog" ``` --- ### Task 5: Deploy + on-device verification **Files:** - None (deployment + manual smoke pass) **Interfaces:** - Consumes: all tasks 1–4. - [ ] **Step 1: Deploy to the Pager** Run (password auth via sshpass, per README): ```powershell & .\scripts\deploy.ps1 -Password "" ``` - [ ] **Step 2: Restart the webui service** ```powershell sshpass -p "" ssh -o StrictHostKeyChecking=no root@172.16.52.1 "/etc/init.d/pagerwebui restart" ``` - [ ] **Step 3: Smoke test the API** Log in and check the new shape + routes: ```powershell $tok = (curl.exe -s -X POST http://172.16.52.1:8080/api/login -H "Content-Type: application/json" -d "{\"username\":\"root\",\"password\":\"\"}" | ConvertFrom-Json).token curl.exe -s -b "AUTH_001337AEE050=$tok" http://172.16.52.1:8080/api/pineap/handshakes curl.exe -s -b "AUTH_001337AEE050=$tok" http://172.16.52.1:8080/api/pineap/handshakes/location ``` Expected: handshakes response has both `files` and `handshakes` keys; location returns `{"location":"/root/loot/handshakes"}`. (Use the `AUTH_` cookie name the login actually sets — it is `AUTH_` + the returned `serverid`, e.g. `AUTH_001337AEE050`.) - [ ] **Step 4: Browser verification checklist** Browse `http://172.16.52.1:8080/#/recon/handshakes` and verify: - [ ] Table headers exactly: BSSID, Client, Source, Type, Captured, Message 1–4, Beacon Frame, action. - [ ] Zip-all / Archive / Refresh row still present and working. - [ ] With no captures: "No Handshakes Available". - [ ] With a capture (or a manually placed file `1786466650_C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake.pcap` in `/root/loot/handshakes/` for testing): BSSID/Client/Source/Type/Captured render; M1–4/Beacon show ✓/✗ or `?`; download and delete work; delete flashes green; dark theme (`html.dark`) looks right. - [ ] Gear opens dialog with location + Delete All; Delete All empties the dir and reloads. - [ ] Dashboard (handshakes count + "Captured WPA Handshakes" table) still renders from `files`. - [ ] **Step 5: Clean up test file + commit any fixes** Remove any manually placed test file from `/root/loot/handshakes/`. If verification found defects, fix them in a new task-style cycle (test → fix → re-deploy), then commit the fixes. ```bash git add -A git commit -m "fix: on-device verification adjustments" ``` (Only if there were fixes; otherwise skip.) --- ## Self-Review **Spec coverage:** - §4.1 `handshakes_data()` shape + empty-dir skip + single batched read → Task 1. - §4.2 filename parser + fallback → Task 1 (parser + `_compose_hs` fallback branch). - §4.3 record composition (incl. `name` field) → Task 1. - §4.4 location + delete-all routes + ordering → Task 2. - §5.1 view rewrite (table, glyphs, download/delete, flash, empty state) → Task 4. - §5.2 settings dialog → Task 4. - §5.3 icons → Task 3. - §5.4 CSS → Task 3. - §6 compute mitigation → Task 1 (empty-dir skip), Task 1/4 (no polling). - §8 testing → Tasks 1–2, §5 smoke → Task 5. **Placeholder scan:** No TBD/TODO/“similar to” — every code step contains full code. **Type consistency:** `parse_hs_filename`, `_norm_mac`, `_hs_db_by_pair`, `_compose_hs`, `handshakes_data`, `h_handshakes_location`, `h_handshakes_delete_all` — names/return shapes consistent between Task 1, Task 2, and the view in Task 4 (`f.name`, `f.mac`, `f.client`, `f.type`, `f.timestamp`, `f.in_db`, `f.part_mask`, `f.beacon`). `hsType(f.name)` reuses the existing helper. `AUTH_` cookie note in Task 5 matches server.py `h_login`.