feat(ui): reliability panel events/counters, RF plan chip and controls

This commit is contained in:
2026-08-22 14:55:49 -06:00
parent bace45d6e4
commit 4c1144ab32
4 changed files with 200 additions and 4 deletions
@@ -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; });
}