diff --git a/payload/user/remote_access/pager-webui/server.py b/payload/user/remote_access/pager-webui/server.py index a9e294e..2faeaa0 100644 --- a/payload/user/remote_access/pager-webui/server.py +++ b/payload/user/remote_access/pager-webui/server.py @@ -3133,6 +3133,18 @@ def h_attacks_export_hc22000(ctx): 'hashcat': 'hashcat -m 22000 %s -a 0 wordlist.txt' % outname} +def h_attacks_download_hc22000(ctx): + name = _unquote_plus(ctx.args[0]) + if not re.match(r'^handshakes_\d+\.hc22000$', name): + return 400, {'error': 'invalid name'} + full = os.path.join('/root/loot/hc22000', name) + if not os.path.isfile(full): + return 404, {'error': 'not found'} + with open(full, 'rb') as f: + body = f.read() + return 200, Download(body, 'application/octet-stream', name) + + def h_attacks_deauth(ctx): body = ctx.body or {} bssid = (body.get('bssid') or '').strip().upper() @@ -4277,6 +4289,7 @@ ROUTER.add('POST', r'/api/attacks/stop', h_attacks_stop) ROUTER.add('GET', r'/api/attacks/status', h_attacks_status) ROUTER.add('POST', r'/api/attacks/capture', h_attacks_capture) ROUTER.add('GET', r'/api/attacks/export/hc22000', h_attacks_export_hc22000) +ROUTER.add('GET', r'/api/attacks/export/hc22000/([^/]+)', h_attacks_download_hc22000) ROUTER.add('POST', r'/api/attacks/deauth', h_attacks_deauth) ROUTER.add('GET', r'/api/attacks/clients', h_attacks_clients) ROUTER.add('GET', r'/api/health', h_health) diff --git a/payload/user/remote_access/pager-webui/www/css/app.css b/payload/user/remote_access/pager-webui/www/css/app.css index 4c68be2..8dc940d 100644 --- a/payload/user/remote_access/pager-webui/www/css/app.css +++ b/payload/user/remote_access/pager-webui/www/css/app.css @@ -185,6 +185,7 @@ body { .badge { display: inline-block; padding: 2px 10px; border-radius: 10px; font-size: 11px; } .badge.on { background: #e8f5e9; color: #2e7d32; } .badge.off { background: #fff3e0; color: #e65100; } +.badge.warn { background: #fff8e1; color: #f57f17; } .badge.unknown { background: #eeeeee; color: #616161; } .btn { background: var(--primary); color: #fff; border: 0; border-radius: 2px; @@ -468,6 +469,7 @@ html.dark .modal { background: #303030; } .pineap-infobox { border-radius: 2px; padding: 10px 12px; margin-top: 10px; font-size: 13px; display: flex; flex-direction: column; gap: 8px; } .pineap-infobox.error { background: #fdecea; color: #b71c1c; border: 1px solid #f5c6cb; } .pineap-infobox.info { background: #e3f2fd; color: #0d47a1; border: 1px solid #90caf9; } +.pineap-infobox.warn { background: #fff8e1; color: #f57f17; border: 1px solid #ffe082; } .pineap-infobox-actions { display: flex; gap: 8px; flex-wrap: wrap; } html.dark .pineap-infobox.error { background: #4a2020; color: #ffb4a9; border-color: #6b2d2d; } html.dark .pineap-infobox.info { background: #10263a; color: #9cc7f0; border-color: #1d3a54; } diff --git a/payload/user/remote_access/pager-webui/www/js/app.js b/payload/user/remote_access/pager-webui/www/js/app.js index 58b7671..d665200 100644 --- a/payload/user/remote_access/pager-webui/www/js/app.js +++ b/payload/user/remote_access/pager-webui/www/js/app.js @@ -29,6 +29,7 @@ const App = (() => { const railItems = [ { key: 'dashboard', label: 'Dashboard', hash: '#/dashboard', icon: 'dashboard' }, + { key: 'attacks', label: 'Attacks', hash: '#/attacks', icon: 'attack' }, { key: 'pineap', label: 'PineAP', hash: '#/pineap', icon: 'wifi' }, { key: 'recon', label: 'Recon', hash: '#/recon', icon: 'recon' }, { key: 'logging', label: 'Logging', hash: '#/logging', icon: 'logging' }, @@ -393,6 +394,10 @@ const App = (() => { const routes = { '#/dashboard': 'dashboard', + '#/attacks': 'attacks', + '#/attacks/wpa': 'attacks_wpa', + '#/attacks/open': 'attacks_open', + '#/attacks/enterprise': 'attacks_enterprise', '#/pineap': 'pineap', '#/pineap/open': 'pineap_open', '#/pineap/evilwpa': 'pineap_evilwpa', diff --git a/payload/user/remote_access/pager-webui/www/js/icons.js b/payload/user/remote_access/pager-webui/www/js/icons.js index abd2539..487fc93 100644 --- a/payload/user/remote_access/pager-webui/www/js/icons.js +++ b/payload/user/remote_access/pager-webui/www/js/icons.js @@ -1,6 +1,7 @@ 'use strict'; window.PineappleIcons = { + attack: '', dashboard: '', pineap: '', recon: '', diff --git a/payload/user/remote_access/pager-webui/www/js/views.js b/payload/user/remote_access/pager-webui/www/js/views.js index 17f4802..1addee9 100644 --- a/payload/user/remote_access/pager-webui/www/js/views.js +++ b/payload/user/remote_access/pager-webui/www/js/views.js @@ -1143,6 +1143,411 @@ function reconFiltered(rows, q, colsArr) { return rows.filter((r) => colsArr.some((c) => String(r[c.key] == null ? '' : r[c.key]).toLowerCase().indexOf(ql) !== -1)); } +// --------------------------------------------------------------------------- +// Attacks: one-click Evil WPA / Open / Enterprise launchers. +// --------------------------------------------------------------------------- + +const ATTACK_TABS = [ + { label: 'Overview', hash: '#/attacks' }, + { label: 'Evil WPA', hash: '#/attacks/wpa' }, + { label: 'Open AP', hash: '#/attacks/open' }, + { label: 'Evil Enterprise', hash: '#/attacks/enterprise' } +]; + +function attacksShell(root, activeHash) { + root.appendChild(h('h1', { class: 'page-title', text: 'Attacks' })); + tabBar(root, ATTACK_TABS, activeHash); + return h('div', {}, h('div', { class: 'muted', style: 'font-size:12px;margin:8px 0' }, + 'Targets: only networks you are authorized to test. The device is the source of truth — every change is verified against it.')); +} + +function attackBadge(ap) { + if (!ap) return badge(false); + if (!ap.enabled) return h('span', { class: 'badge off', text: 'OFF' }); + return h('span', { class: 'badge' + (ap.live ? ' on' : ' warn'), text: ap.live ? 'LIVE' : 'CONFIGURED' }); +} + +function attackStatusCard() { + const card = h('div', { class: 'pineap-title-card' }); + card.appendChild(h('div', { class: 'pineap-card-title' }, 'Status')); + const rows = h('div', { style: 'font-size:13px;line-height:1.9' }); + card.appendChild(rows); + const append = (label, node) => rows.appendChild( + h('div', { class: 'row' }, h('div', { style: 'min-width:130px', text: label }), node)); + return { card, rows, append }; +} + +function verifiedToast(result) { + if (result && result.verified) App.toast('Applied and verified on device'); + else if (result && result.ok) App.toast('Applied (verification pending — radio reloading)', 'error'); + else App.toast('Deploy failed', 'error'); +} + +views.attacks = (root) => { + const box = attacksShell(root, '#/attacks'); + const wrap = h('div', { class: 'pineap-title-card-container' }); + box.appendChild(wrap); + const kinds = [ + ['wpa', 'Evil WPA (PSK)', 'Clone a WPA2/WPA3-PSK network and capture the four-way handshake.', '#/attacks/wpa'], + ['open', 'Evil Open', 'Advertise an open network and watch who connects.', '#/attacks/open'], + ['enterprise', 'Evil Enterprise', 'Serve WPA2/3-Enterprise with PineAPE and harvest 802.1X credentials.', '#/attacks/enterprise'] + ]; + const statusEls = {}; + kinds.forEach(([kind, label, desc, hash]) => { + const card = h('div', { class: 'pineap-title-card' }); + const st = h('span', { class: 'badge', text: '—' }); + statusEls[kind] = st; + card.appendChild(h('div', { class: 'pineap-card-title' }, + h('a', { href: hash, style: 'color:var(--primary);cursor:pointer', + onclick: (e) => { e.preventDefault(); App.go(hash); } }, label))); + card.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin:6px 0', text: desc })); + card.appendChild(h('div', { class: 'row' }, st, + h('a', { class: 'btn ghost', href: hash, style: 'text-decoration:none', + onclick: (e) => { e.preventDefault(); App.go(hash); } }, 'Configure'))); + wrap.appendChild(card); + }); + function load() { + PagerAPI.get('/api/attacks/status').then((r) => { + const s = r.data || {}; + const live = (x) => !!(x && x.enabled); + const summary = { + wpa: live(s.wpa && s.wpa.radio0) || live(s.wpa && s.wpa.radio1) ? 'LIVE' : 'OFF', + open: live(s.open && s.open.radio0) || live(s.open && s.open.radio1) ? 'LIVE' : 'OFF', + enterprise: live(s.enterprise && s.enterprise.ap) ? 'LIVE' : 'OFF' + }; + Object.keys(summary).forEach((k) => { + statusEls[k].textContent = summary[k]; + statusEls[k].className = 'badge ' + (summary[k] === 'LIVE' ? 'on' : 'off'); + }); + }).catch(() => {}); + } + load(); + const iv = setInterval(load, 5000); + return { destroy: () => clearInterval(iv) }; +}; + +function attackLauncher(kind, opts) { + return (root) => { + const box = attacksShell(root, '#/attacks/' + kind); + const form = h('div', { class: 'pineap-title-card' }); + form.appendChild(h('div', { class: 'pineap-card-title' }, + opts.title + (opts.subtitle ? ' — ' + opts.subtitle : ''))); + box.appendChild(form); + const f = h('div', {}); + form.appendChild(f); + + const ssidIn = h('input', { id: 'atk-ssid' }); + const hiddenCb = h('input', { type: 'checkbox', id: 'atk-hidden' }); + const chanSel = h('select', { id: 'atk-channel' }); + chanSelect(chanSel, null); + const bandHint = h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px' }); + let pskIn = null, encSel = null; + chanSel.addEventListener('change', () => { + const b = bandOfChannel(chanSel.value); + bandHint.textContent = b === '6' ? '6 GHz requires WPA3 (SAE/OWE).' : ''; + if (encSel) { + Array.prototype.forEach.call(encSel.options, (o) => { + o.disabled = b === '6' && o.value !== 'sae' && o.value !== 'owe'; + }); + if (b === '6' && encSel.value === 'psk2') encSel.value = 'sae'; + } + }); + f.appendChild(h('label', {}, 'SSID (target network)', ssidIn)); + if (opts.passphrase) { + pskIn = h('input', { id: 'atk-psk', type: 'password', autocomplete: 'new-password' }); + encSel = h('select', { id: 'atk-enc' }); + (opts.encodings || EVIL_ENC).forEach(([v, l]) => encSel.appendChild(h('option', { value: v, text: l }))); + f.appendChild(h('label', {}, 'Passphrase', pskIn)); + f.appendChild(h('label', {}, 'Encryption', encSel)); + } + let bssidIn = null, coSel = null; + if (opts.bssid) { + bssidIn = h('input', { id: 'atk-bssid', placeholder: 'AA:BB:CC:DD:EE:FF' }); + f.appendChild(h('label', {}, 'BSSID (spoof, optional)', bssidIn)); + } + if (opts.country) { + coSel = h('select', { id: 'atk-country' }); + OPEN_COUNTRIES.forEach(([v, l]) => coSel.appendChild(h('option', { value: v, text: l }))); + f.appendChild(h('label', {}, 'Country', coSel)); + } + f.appendChild(h('label', { class: 'switch' }, hiddenCb, h('span', { class: 'track' }), 'Hidden')); + f.appendChild(h('label', {}, 'Channel', chanSel)); + f.appendChild(bandHint); + f.appendChild(h('div', { class: 'row', style: 'margin-top:10px' }, + h('div', {}, btn('Deploy Attack', () => { + const body = { + kind, ssid: ssidIn.value.trim(), + hidden: hiddenCb.checked, + channel: chanSel.value ? parseInt(chanSel.value, 10) : null + }; + if (pskIn) { body.passphrase = pskIn.value; body.enctype = encSel.value; } + if (bssidIn) body.bssid = bssidIn.value.trim(); + if (coSel) body.country = coSel.value; + PagerAPI.post('/api/attacks/deploy', body) + .then((r) => { verifiedToast(r.data || {}); load(); }) + .catch((e) => { App.toast(e.message || 'Deploy failed', 'error'); }); + })), + h('div', {}, btn('Stop Attack', () => { + PagerAPI.post('/api/attacks/stop', { kind }) + .then(() => { App.toast('Attack stopped'); load(); }) + .catch(() => App.toast('Stop failed', 'error')); + }, 'danger')), + h('div', { class: 'muted', style: 'align-self:center;font-size:12px' }, + 'Deploy reconfigures the radio — you may be disconnected briefly.'))); + + const status = attackStatusCard(); + box.appendChild(status.card); + + const hsBox = h('div', {}); + const captureBox = h('div', { class: 'pineap-title-card' }); + captureBox.appendChild(h('div', { class: 'pineap-card-title' }, 'Monitor Capture')); + const capBody = h('div', { style: 'font-size:13px' }); + captureBox.appendChild(capBody); + box.appendChild(captureBox); + + let capIface = 'wlan0mon'; + function capRow(st) { + capBody.innerHTML = ''; + const run = !!(st && st.running); + capBody.appendChild(h('div', { class: 'row' }, + h('span', { text: run ? ('Capturing on ' + (st.iface || capIface)) : 'Not capturing' }), + h('div', {}, btn(run ? 'Stop Capture' : 'Start Capture', () => { + PagerAPI.post('/api/attacks/capture', { + iface: capIface, action: run ? 'stop' : 'start' + }).then((r) => capRow(r.data || {})).catch(() => App.toast('Capture failed', 'error')); + })))); + capBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px', + text: run && st.path ? st.path : '' })); + } + + if (opts.export) { + const exp = h('div', { class: 'pineap-title-card' }); + exp.appendChild(h('div', { class: 'pineap-card-title' }, 'Hashcat Export')); + const expBody = h('div', { style: 'font-size:13px;line-height:1.8' }); + exp.appendChild(expBody); + expBody.appendChild(h('div', {}, btn('Export .hc22000', () => { + PagerAPI.get('/api/attacks/export/hc22000').then((r) => { + const d = r.data || {}; + if (d.file) { + expBody.appendChild(h('div', { class: 'row', style: 'margin-top:8px' }, + h('a', { class: 'btn ghost', href: '/api/attacks/export/hc22000/' + d.name, + style: 'text-decoration:none', download: d.name }, 'Download ' + d.name), + h('code', { style: 'font-size:12px', text: d.hashcat || '' }))); + App.toast('Export ready'); + } else App.toast('No captures to export', 'error'); + }).catch(() => App.toast('Export failed', 'error')); + }))); + box.appendChild(exp); + } + + let deauthCard = null; + if (opts.deauth) { + deauthCard = deauthPanel(); + box.appendChild(deauthCard); + } + + function load() { + PagerAPI.get('/api/attacks/status').then((r) => { + const s = r.data || {}; + const ap = s[kind] || {}; + const which = (ap.radio0 && ap.radio0.enabled) ? ap.radio0 + : (ap.radio1 && ap.radio1.enabled) ? ap.radio1 : null; + status.rows.innerHTML = ''; + status.append('Attack', attackBadge(which)); + status.append('SSID', h('span', { text: which && which.ssid ? which.ssid : '—' })); + status.append('Interface', h('span', { text: which ? which.iface : '—' })); + status.append('Band', h('span', { text: which && which.band ? which.band + ' GHz' : '—' })); + status.append('Channel', h('span', { text: which && which.channel != null ? which.channel : '—' })); + if (which && !which.live) { + status.append('Note', h('span', { class: 'pineap-infobox warn', + text: 'Configured but interface not live yet — radio reload may still be in progress.' })); + } + capIface = (which && (which.band === '5' || which.band === '6')) ? 'wlan1mon' : 'wlan0mon'; + if (opts.handshakes) { + hsBox.innerHTML = ''; + hsBox.appendChild(h('div', { class: 'pineap-card-title', text: 'Handshakes Captured (' + (s.handshakes || 0) + ')' })); + PagerAPI.get('/api/pineap/handshakes').then((hr) => { + const hd = (hr.data || {}).parsed || []; + const rows = hd.slice(-10).reverse().map((x) => ({ + ssid: x.ssid || '—', bssid: x.bssid || '—', + when: fmtTime(x.ts || 0), type: x.type || '' + })); + hsBox.appendChild(rows.length ? table( + [{ key: 'when', label: 'When' }, { key: 'ssid', label: 'SSID' }, + { key: 'bssid', label: 'BSSID' }, { key: 'type', label: 'Type' }], rows) + : h('div', { class: 'empty', text: 'No handshakes yet.' })); + }).catch(() => {}); + box.appendChild(hsBox); + } + }).catch(() => {}); + PagerAPI.post('/api/attacks/capture', { action: 'status' }).then((r) => capRow(r.data || {})).catch(() => {}); + } + load(); + const iv = setInterval(load, 5000); + return { destroy: () => clearInterval(iv) }; + }; +} + +function deauthPanel() { + const wrap = h('div', { class: 'pineap-title-card' }); + wrap.appendChild(h('div', { class: 'pineap-card-title' }, 'Deauth Targeting')); + const body = h('div', { style: 'font-size:13px' }); + wrap.appendChild(body); + const ssidBox = h('input', { placeholder: 'SSID to find clients for' }); + const apSel = h('select', {}); + const clTable = h('div', {}); + body.appendChild(h('div', { class: 'row' }, ssidBox, + h('div', {}, btn('Find', () => { + const q = ssidBox.value.trim(); + if (!q) return; + PagerAPI.get('/api/attacks/clients?ssid=' + encodeURIComponent(q)).then((r) => { + const d = r.data || {}; + apSel.innerHTML = ''; + (d.aps || []).forEach((a) => apSel.appendChild(h('option', { + value: (a.bssid || '') + '|' + (a.channel || 1), + text: (a.ssid || q) + ' — ' + (a.bssid || '?') + ' ch' + (a.channel || 1) + }))); + if (!(d.aps || []).length) apSel.appendChild(h('option', { value: '|1', text: 'No APs found — check SSID' })); + clTable.innerHTML = ''; + const cl = (d.clients || []).slice(0, 30); + if (!cl.length) { + clTable.appendChild(h('div', { class: 'empty', text: 'No devices in recon yet.' })); + return; + } + clTable.appendChild(table( + [{ key: 'mac', label: 'MAC' }, { key: 'freq', label: 'Freq (MHz)' }, + { key: 'signal', label: 'Signal' }, { key: 'packets', label: 'Packets' }, + { key: '_deauth', label: '' }], + cl.map((c) => { + const mac = c.mac || c.client_mac || ''; + return { mac: mac || '—', freq: c.freq || '', signal: c.signal || '', + packets: c.packets || '', _deauth: btn('Deauth', () => { + const parts = apSel.value.split('|'); + if (!mac || !parts[0]) { App.toast('Pick an AP and device first', 'error'); return; } + PagerAPI.post('/api/attacks/deauth', { + bssid: parts[0], client: mac, channel: parseInt(parts[1], 10) + }).then(() => App.toast('Deauth frames sent')) + .catch((e) => App.toast(e.message || 'Deauth failed', 'error')); + }, 'danger') }; + }))); + }).catch(() => App.toast('Lookup failed', 'error')); + })))); + body.appendChild(apSel); + body.appendChild(clTable); + body.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px', + text: 'Only deauth targets you are authorized to test.' })); + return wrap; +} + +views.attacks_wpa = attackLauncher('wpa', { + title: 'Evil WPA', + subtitle: 'WPA2-PSK / WPA3-SAE / WPA3-OWE evil twin with handshake capture', + passphrase: true, + encodings: EVIL_ENC, + handshakes: true, + export: true, + deauth: true +}); + +views.attacks_open = attackLauncher('open', { + title: 'Evil Open', + subtitle: 'Open network evil twin', + bssid: true, + country: true +}); + +views.attacks_enterprise = (root) => { + const box = attacksShell(root, '#/attacks/enterprise'); + const form = h('div', { class: 'pineap-title-card' }); + form.appendChild(h('div', { class: 'pineap-card-title' }, + 'Evil Enterprise — WPA2/3-Enterprise with PineAPE credential harvest')); + box.appendChild(form); + const f = h('div', {}); + form.appendChild(f); + const ssidIn = h('input', { id: 'ent-ssid' }); + const encSel = h('select', { id: 'ent-enc' }); + [['wpa2', 'WPA2 Enterprise'], ['wpa3', 'WPA3 Enterprise']] + .forEach(([v, l]) => encSel.appendChild(h('option', { value: v, text: l }))); + const pskIn = h('input', { id: 'ent-pass', type: 'password', autocomplete: 'new-password' }); + const hiddenCb = h('input', { type: 'checkbox', id: 'ent-hidden' }); + f.appendChild(h('label', {}, 'SSID', ssidIn)); + f.appendChild(h('label', {}, 'Encryption', encSel)); + f.appendChild(h('label', {}, 'Passphrase (EAP server secret)', pskIn)); + f.appendChild(h('label', { class: 'switch' }, hiddenCb, h('span', { class: 'track' }), 'Hidden')); + f.appendChild(h('div', { class: 'row', style: 'margin-top:10px' }, + h('div', {}, btn('Deploy Attack', () => { + PagerAPI.post('/api/attacks/deploy', { + kind: 'enterprise', ssid: ssidIn.value.trim(), + enctype: encSel.value, passphrase: pskIn.value, hidden: hiddenCb.checked + }).then((r) => { verifiedToast(r.data || {}); load(); }) + .catch((e) => App.toast(e.message || 'Deploy failed', 'error')); + })), + h('div', {}, btn('Stop Attack', () => { + PagerAPI.post('/api/attacks/stop', { kind: 'enterprise' }) + .then(() => { App.toast('Attack stopped'); load(); }) + .catch(() => App.toast('Stop failed', 'error')); + }, 'danger')))); + + const status = attackStatusCard(); + box.appendChild(status.card); + + const pApe = h('div', { class: 'pineap-title-card' }); + pApe.appendChild(h('div', { class: 'pineap-card-title' }, 'PineAPE')); + const pApeBody = h('div', {}); + pApe.appendChild(pApeBody); + box.appendChild(pApe); + const authCb = h('input', { type: 'checkbox', id: 'ent-auth' }); + authCb.addEventListener('change', () => PagerAPI.post('/api/pineap/hostapd', { pineape_auth_pass: authCb.checked }) + .then(load).catch(() => { authCb.checked = !authCb.checked; App.toast('Failed', 'error'); })); + + const tables = [['Basic Data', '/api/pineap/enterprise/basic', 'basic'], + ['Challenge Data', '/api/pineap/enterprise/challenge', 'challenge']] + .map(([name, endpoint, tableKey]) => { + const tb = h('div', { class: 'pineap-title-card' }); + const body = h('div', {}); + tb.appendChild(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: tableKey }).then(load), 'danger'))); + tb.appendChild(body); + box.appendChild(tb); + return { body, endpoint }; + }); + + function load() { + PagerAPI.get('/api/attacks/status').then((r) => { + const s = r.data || {}; + const ent = s.enterprise || {}; + const ap = ent.ap || null; + status.rows.innerHTML = ''; + status.append('Attack', attackBadge(ap)); + status.append('SSID', h('span', { text: ap && ap.ssid ? ap.ssid : '—' })); + status.append('Interface', h('span', { text: ap ? ap.iface : '—' })); + status.append('Band', h('span', { text: '2.4 GHz' })); + status.append('Channel', h('span', { text: ap && ap.channel != null ? ap.channel : '—' })); + status.append('Credentials', h('span', { text: String(ent.creds || 0) })); + pApeBody.innerHTML = ''; + pApeBody.appendChild(h('label', { class: 'switch' }, authCb, h('span', { class: 'track' }), 'Auth Pass Capture')); + if (ent.pineape) { + pApeBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px', + text: 'PineAPE ' + (ent.pineape.enabled ? 'enabled' : 'disabled') })); + } + }).catch(() => {}); + tables.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) }; +}; function reconEncBucket(enc) { const s = (enc || '').trim(); if (s === 'Open') return 'Open';