feat(ui): reliability panel events/counters, RF plan chip and controls
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>WiFi Pineapple</title>
|
||||
<link rel="icon" type="image/png" href="assets/logo.png">
|
||||
<link rel="stylesheet" href="css/app.css?v=20260820-4">
|
||||
<link rel="stylesheet" href="css/app.css?v=20260822-1">
|
||||
<link rel="stylesheet" href="js/xterm.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -27,6 +27,7 @@
|
||||
<span class="toolbar-spacer"></span>
|
||||
<span id="live-status"></span>
|
||||
<span id="health-status" class="health-chip"></span>
|
||||
<span id="rf-chip" class="health-chip"></span>
|
||||
<div class="toolbar-action">
|
||||
<button id="notifications-btn" class="toolbar-icon-btn" type="button" title="Notifications"
|
||||
aria-label="Notifications" aria-haspopup="menu" aria-controls="notifications-menu" aria-expanded="false"></button>
|
||||
@@ -268,7 +269,7 @@
|
||||
<script src="js/xterm-addon-fit.min.js"></script>
|
||||
<script src="js/terminal.js?v=20260820-4"></script>
|
||||
<script src="js/pager.js?v=20260820-4"></script>
|
||||
<script src="js/views.js?v=20260820-4"></script>
|
||||
<script src="js/app.js?v=20260820-4"></script>
|
||||
<script src="js/views.js?v=20260822-1"></script>
|
||||
<script src="js/app.js?v=20260822-1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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; });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user