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 e072ea5..8f90270 100644 --- a/payload/user/remote_access/pager-webui/www/css/app.css +++ b/payload/user/remote_access/pager-webui/www/css/app.css @@ -622,3 +622,37 @@ html.dark .pineap-infobox.info { background: #10263a; color: #9cc7f0; border-col .payload-filters, .payload-dev-grid { grid-template-columns: 1fr; } .payload-actions { justify-content: flex-start; } } + +/* ---- Mark VIII reliability panel ---- */ +.mk8-rel-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; } +.mk8-rel-head h2 { margin: 0; } +.mk8-counter-row { + display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 10px; margin-bottom: 14px; +} +.mk8-counter { background: var(--surface-alt); border-radius: 2px; padding: 8px 12px; } +.mk8-counter-value { font-size: 22px; font-weight: 500; font-variant-numeric: tabular-nums; } +.mk8-counter-label { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); } +.mk8-events-feed { max-height: 280px; overflow-y: auto; border-top: 1px solid var(--border); } +.mk8-event-row { + display: flex; align-items: baseline; gap: 10px; padding: 6px 2px; + border-bottom: 1px solid var(--border); font-size: 12px; +} +.mk8-event-time { flex: none; color: var(--muted); font-variant-numeric: tabular-nums; } +.mk8-event-kind { flex: none; min-width: 90px; font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--primary); } +.mk8-event-msg { flex: 1; min-width: 0; overflow-wrap: anywhere; } +.mk8-event-row.sev-warn .mk8-event-msg { color: #b26a00; } +.mk8-event-row.sev-error .mk8-event-msg { color: var(--danger); } +html.dark .mk8-event-row.sev-warn .mk8-event-msg { color: #ffb74d; } + +/* ---- Mark VIII RF plan chip + role card ---- */ +#rf-chip { white-space: nowrap; } +.rf-role-status { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 4px 0 10px; font-size: 12px; } +.rf-role-grid { + display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 4px 14px; align-items: end; +} +.mk8-rf-result { font-size: 12px; margin-top: 10px; min-height: 16px; overflow-wrap: anywhere; } +.mk8-rf-result.ok { color: #2e7d32; } +.mk8-rf-result.error { color: var(--danger); } +html.dark .mk8-rf-result.ok { color: #81c784; } diff --git a/payload/user/remote_access/pager-webui/www/index.html b/payload/user/remote_access/pager-webui/www/index.html index 62c598c..9b6bd57 100644 --- a/payload/user/remote_access/pager-webui/www/index.html +++ b/payload/user/remote_access/pager-webui/www/index.html @@ -6,7 +6,7 @@ WiFi Pineapple - + @@ -27,6 +27,7 @@ +
@@ -268,7 +269,7 @@ - - + + 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 4686072..696bf8b 100644 --- a/payload/user/remote_access/pager-webui/www/js/app.js +++ b/payload/user/remote_access/pager-webui/www/js/app.js @@ -448,6 +448,9 @@ const Live = (() => { let poll = null; let pollHealthTimer = null; let pollEventsTimer = null; + let pollRfTimer = null; + let rfState = null; + let rfChannel = null; const lastEvents = { hsSeen: {}, hsPrimed: false, creds: null, credsPrimed: false, pineapUp: null, mon0: null, mon1: null }; const subs = []; @@ -466,6 +469,10 @@ const Live = (() => { pollEventsTimer = setInterval(pollEvents, 15000); pollEvents(); } + if (!pollRfTimer) { + pollRfTimer = setInterval(pollRfplan, 15000); + pollRfplan(); + } try { ws = new WebSocket(App.wsUrl('/api/ws')); } catch (e) { fallback(); return; } ws.onopen = () => { ever = true; }; @@ -512,6 +519,47 @@ const Live = (() => { const n = (msg.clients || []).length; const el = document.getElementById('live-status'); if (el) el.textContent = 'BAT ' + (b.level == null ? '--' : b.level + '%' + (b.charging ? '+' : '')) + ' CLIENTS ' + n; + const wifi = (msg.status || {}).wifi; + if (Array.isArray(wifi)) { + const up = wifi.find((w) => w && w.iface === 'wlan1up'); + rfChannel = up && up.channel != null ? Number(up.channel) : null; + renderRfChip(); + } + } + function pollRfplan() { + fetch(App.apiBase + '/api/rfplan', { credentials: 'include' }).then((r) => r.json()) + .then((d) => { + rfState = d && typeof d.role === 'string' ? d : null; + renderRfChip(); + }).catch(() => {}); + } + function renderRfChip() { + const el = document.getElementById('rf-chip'); + if (!el) return; + if (!rfState) { + el.textContent = ''; + el.title = ''; + el.className = 'health-chip'; + return; + } + let text; + let cls = ''; + if (rfState.role === 'uplink') { + text = 'PHY1: UPLINK' + (rfState.assoc && rfChannel ? ' ch' + rfChannel : ''); + cls = rfState.assoc ? 'good' : 'warn'; + } else if (rfState.role === 'attack') { + text = 'PHY1: ATTACK'; + cls = 'warn'; + } else if (rfState.role === 'idle') { + text = 'PHY1: IDLE'; + } else { + text = 'PHY1: ' + String(rfState.role).toUpperCase(); + } + el.textContent = text; + el.className = 'health-chip' + (cls ? ' ' + cls : ''); + el.title = 'radio1 role: ' + rfState.role + + ' \u00b7 assoc ' + (rfState.assoc || 'none') + + ' \u00b7 hop ' + (rfState.hop_paused == null ? 'unknown' : rfState.hop_paused ? 'paused' : 'running'); } function pollHealth() { fetch(App.apiBase + '/api/health', { credentials: 'include' }).then((r) => r.json()) 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 b07c7d3..4eab10b 100644 --- a/payload/user/remote_access/pager-webui/www/js/views.js +++ b/payload/user/remote_access/pager-webui/www/js/views.js @@ -214,6 +214,40 @@ views.dashboard = (root) => { live.appendChild(card); liveCards[k] = card.querySelector('.card-value'); }); + + const counterDefs = [ + ['boots', 'Boots'], ['unexpected_boots', 'Unexpected Boots'], + ['rollbacks', 'Rollbacks'], ['restarts', 'Restarts'], ['guard_fixes', 'Guard Fixes'] + ]; + const counterVals = {}; + const counterRow = h('div', { class: 'mk8-counter-row' }); + counterDefs.forEach(([k, label]) => { + const val = h('div', { class: 'mk8-counter-value', text: '—' }); + counterRow.appendChild(h('div', { class: 'mk8-counter' }, + val, h('div', { class: 'mk8-counter-label', text: label }))); + counterVals[k] = val; + }); + const guardChip = h('span', { class: 'health-chip', text: 'GUARD —' }); + const feedBody = h('div', { class: 'mk8-events-feed' }, + h('div', { class: 'empty', text: 'No events recorded.' })); + root.appendChild(h('div', { class: 'section' }, + h('div', { class: 'mk8-rel-head' }, h('h2', {}, 'Reliability'), guardChip), + counterRow, + feedBody)); + function renderEvents(events) { + feedBody.innerHTML = ''; + if (!Array.isArray(events) || !events.length) { + feedBody.appendChild(h('div', { class: 'empty', text: 'No events recorded.' })); + return; + } + events.forEach((ev) => { + const sev = ev.sev === 'error' ? 'error' : ev.sev === 'warn' ? 'warn' : 'info'; + feedBody.appendChild(h('div', { class: 'mk8-event-row sev-' + sev }, + h('span', { class: 'mk8-event-time', text: fmtShortTime(ev.ts) }), + h('span', { class: 'mk8-event-kind', text: String(ev.kind || '?') }), + h('span', { class: 'mk8-event-msg', text: String(ev.msg || '') }))); + }); + } function loadLive() { PagerAPI.get('/api/attacks/status').then((r) => { const s = r.data || {}; @@ -242,6 +276,22 @@ views.dashboard = (root) => { (h2.pool_disabled ? ' · pool off' : '') + ((h2.env || {}).overall ? ' · env ' + h2.env.overall : ''); liveCards.health.style.color = h2.pineap_up ? '' : '#b71c1c'; + const rel = h2.reliability || {}; + Object.keys(counterVals).forEach((k) => { + counterVals[k].textContent = rel[k] == null ? '—' : String(rel[k]); + }); + const g = h2.guard || {}; + if (typeof g.in_sync !== 'boolean') { + guardChip.className = 'health-chip'; + guardChip.textContent = 'GUARD —'; + } else if (g.in_sync) { + guardChip.className = 'health-chip good'; + guardChip.textContent = 'GUARD IN SYNC'; + } else { + guardChip.className = 'health-chip warn'; + guardChip.textContent = 'GUARD PENDING' + (g.pending != null ? ' · ' + g.pending : ''); + } + renderEvents(h2.events); }).catch(() => {}); PagerAPI.get('/api/recon/status').then((r) => { const s = r.data || {}; @@ -420,6 +470,62 @@ views.pineap = (root) => { modeRow.appendChild(quickCard); box.appendChild(modeRow); + const rfSel = h('select', {}, + h('option', { value: 'uplink', text: 'Uplink (station)' }), + h('option', { value: 'attack', text: 'Attack' }), + h('option', { value: 'idle', text: 'Idle' })); + const rfSsid = h('input', { placeholder: 'Uplink network SSID', autocomplete: 'off' }); + const rfPsk = h('input', { type: 'password', placeholder: 'Leave blank for an open network', + autocomplete: 'new-password' }); + const rfStatusBadge = h('span', { class: 'badge unknown', text: '—' }); + const rfStatusInfo = h('span', { class: 'muted', text: '' }); + const rfResult = h('div', { class: 'mk8-rf-result', text: '' }); + function renderRfStatus(d) { + const role = d.role || 'idle'; + rfStatusBadge.textContent = role.toUpperCase(); + rfStatusBadge.className = 'badge ' + + (role === 'uplink' ? (d.assoc ? 'on' : 'warn') : role === 'attack' ? 'warn' : 'off'); + const parts = []; + if (role === 'uplink') parts.push(d.assoc ? 'associated to ' + d.assoc : 'not associated'); + if (d.hop_paused != null) parts.push('hop ' + (d.hop_paused ? 'paused' : 'running')); + rfStatusInfo.textContent = parts.join(' · '); + } + const rfApply = btn('Apply Role', () => { + const role = rfSel.value; + if (role === 'uplink' && !rfSsid.value.trim()) { + rfResult.textContent = 'SSID is required for the uplink role.'; + rfResult.className = 'mk8-rf-result error'; + return; + } + return PagerAPI.post('/api/rfplan/role', { role, ssid: rfSsid.value.trim(), psk: rfPsk.value }) + .then((r) => { + const d = r.data || {}; + rfResult.textContent = 'Role applied: ' + (d.role || role) + + (d.assoc ? ' — associated to ' + d.assoc : ''); + rfResult.className = 'mk8-rf-result ok'; + rfPsk.value = ''; + return PagerAPI.get('/api/rfplan').then((s) => renderRfStatus(s.data || {})); + }) + .catch((e) => { + rfResult.textContent = (e && e.message) || 'Role change failed.'; + rfResult.className = 'mk8-rf-result error'; + }); + }); + const rfCard = h('div', { class: 'pineap-title-card pineap-card-settings' }, + h('div', { class: 'pineap-card-title' }, 'RF Role (radio1)'), + h('p', { class: 'pineap-card-subtitle', + text: 'Radio1 is shared between an uplink client and attack work; applying a role makes them mutually exclusive.' })); + rfCard.appendChild(h('div', { class: 'rf-role-status' }, rfStatusBadge, rfStatusInfo)); + rfCard.appendChild(h('label', {}, 'Role', rfSel)); + rfCard.appendChild(h('div', { class: 'rf-role-grid' }, + h('label', {}, 'Uplink SSID', rfSsid), + h('label', {}, 'Uplink Password', rfPsk))); + rfCard.appendChild(rfApply); + rfCard.appendChild(rfResult); + const rfRow = h('div', { class: 'pineap-title-card-container' }); + rfRow.appendChild(rfCard); + box.appendChild(rfRow); + const cards = { karma: {}, open: {}, wpa: {} }; const cardWrap = h('div', { class: 'pineap-title-card-container' }); const statusDefs = [ @@ -562,7 +668,14 @@ views.pineap = (root) => { }).catch(() => { stats.clients.textContent = '0'; }), PagerAPI.get('/api/pineap/handshakes').then((hs) => { stats.handshakes.textContent = Array.isArray((hs.data || {}).files) ? hs.data.files.length : 'Unavailable'; - }).catch(() => { stats.handshakes.textContent = 'Unavailable'; }) + }).catch(() => { stats.handshakes.textContent = 'Unavailable'; }), + PagerAPI.get('/api/rfplan').then((rf) => { + renderRfStatus(rf.data || {}); + }).catch(() => { + rfStatusBadge.textContent = '—'; + rfStatusBadge.className = 'badge unknown'; + rfStatusInfo.textContent = 'RF plan unavailable'; + }) ]; Promise.allSettled([stateRequest].concat(statRequests)).finally(() => { loadPending = false; }); }