feat(observer): Settings > Observer tab UI

New Observer tab: master switch, IATA location code, flood advert
interval, live status line with packet counters, broker list with
connection badges (green/red + error tooltip), enable switches and a
stacked add/edit modal (blank password on edit keeps the stored one).
Live badge/counter updates via the observer_status socket event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-07-14 19:07:09 +02:00
parent a60ff138ff
commit 668d8d5c79
2 changed files with 415 additions and 0 deletions
+306
View File
@@ -484,6 +484,11 @@ function connectChatSocket() {
chatSocket.on('device_status', (data) => {
updateStatus(data.connected ? 'connected' : 'disconnected');
});
// Observer live status (Settings > Observer tab badges + counters)
chatSocket.on('observer_status', (data) => {
applyObserverLiveStatus(data || {});
});
}
document.addEventListener('DOMContentLoaded', async function() {
@@ -2572,6 +2577,7 @@ document.addEventListener('DOMContentLoaded', () => {
loadContactsSettings();
loadRegions();
loadAnalyzers();
loadObserverTab();
});
settingsModal.addEventListener('shown.bs.modal', () => {
settingsModal.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
@@ -2631,6 +2637,29 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
// Observer tab: settings + broker add/edit form
document.getElementById('observerEnabledToggle')?.addEventListener('change', (e) => {
saveObserverSettings({ enabled: e.target.checked });
});
document.getElementById('observerIataInput')?.addEventListener('change', (e) => {
const iata = (e.target.value || '').trim();
if (iata && !/^[A-Za-z]{3}$/.test(iata)) {
showNotification('Location code must be empty or exactly 3 letters', 'warning');
return;
}
saveObserverSettings({ iata });
});
document.getElementById('observerAdvertIntervalInput')?.addEventListener('change', (e) => {
saveObserverSettings({ advert_interval_hours: parseInt(e.target.value || '0', 10) || 0 });
});
document.getElementById('addObserverBrokerBtn')?.addEventListener('click', () => {
openObserverBrokerModal(null);
});
document.getElementById('observerBrokerEditForm')?.addEventListener('submit', (e) => {
e.preventDefault();
saveObserverBrokerFromForm();
});
// Preload analyzers so the first click on a chart icon doesn't need a round-trip.
loadAnalyzers();
@@ -3466,6 +3495,283 @@ function openAnalyzerChooser(packetHash, enabled) {
modal.show();
}
// ================================================================
// Observer (Settings > Observer): MQTT packet capture
// ================================================================
window.observerCache = window.observerCache || {
status: null,
brokers: [],
};
async function loadObserverTab() {
try {
const resp = await fetch('/api/observer/status');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (!data.success) throw new Error(data.error || 'Failed');
window.observerCache.status = data.status;
window.observerCache.brokers = data.status.brokers || [];
renderObserverSettings(data.status);
renderObserverBrokers();
renderObserverStatusLine(data.status);
} catch (e) {
console.error('Error loading observer status:', e);
const listEl = document.getElementById('observerBrokersList');
if (listEl) {
listEl.innerHTML = '<div class="text-center text-danger small py-2">Failed to load observer status</div>';
}
}
}
function renderObserverSettings(status) {
const s = status.settings || {};
const enabledEl = document.getElementById('observerEnabledToggle');
const iataEl = document.getElementById('observerIataInput');
const advertEl = document.getElementById('observerAdvertIntervalInput');
if (enabledEl) enabledEl.checked = !!s.enabled;
// Don't clobber a field the user is typing in
if (iataEl && document.activeElement !== iataEl) iataEl.value = (s.iata || '').toUpperCase();
if (advertEl && document.activeElement !== advertEl) advertEl.value = s.advert_interval_hours || 0;
}
function renderObserverStatusLine(status) {
const el = document.getElementById('observerStatusLine');
if (!el || !status) return;
if (!status.enabled) {
el.innerHTML = 'Observer is <strong>off</strong>.';
return;
}
const state = status.running
? '<span class="badge bg-success">running</span>'
: `<span class="badge bg-warning text-dark">waiting</span> ${escapeHtml(status.reason || '')}`;
el.innerHTML = `${state} &mdash; packets captured: <strong>${status.packets_seen ?? 0}</strong>,`
+ ` published: <strong>${status.packets_published ?? 0}</strong>`;
}
function observerBrokerBadgeParts(b) {
if (b.connected) return { cls: 'bg-success', txt: 'connected', title: '' };
if (b.last_error) return { cls: 'bg-danger', txt: 'error', title: b.last_error };
return { cls: 'bg-secondary', txt: 'offline', title: '' };
}
function renderObserverBrokers() {
const listEl = document.getElementById('observerBrokersList');
if (!listEl) return;
const brokers = window.observerCache.brokers || [];
if (brokers.length === 0) {
listEl.innerHTML =
'<div class="text-center text-muted small py-3">No brokers configured. Click "Add broker" to add one.</div>';
return;
}
const rows = brokers.map(b => {
const enabled = !b.is_disabled;
const nameClass = enabled ? '' : 'text-muted text-decoration-line-through';
const safeName = escapeHtml(b.name);
const tlsBadge = b.use_tls ? '<span class="badge bg-info text-dark ms-1">TLS</span>' : '';
const badge = b.is_disabled
? '<span class="badge bg-secondary ms-1">Disabled</span>'
: (() => {
const p = observerBrokerBadgeParts(b);
return `<span class="badge ${p.cls} ms-1" id="observerBrokerBadge_${b.id}" title="${escapeHtml(p.title)}">${p.txt}</span>`;
})();
const userInfo = b.username ? `${escapeHtml(b.username)}@` : '';
return `
<div class="list-group-item d-flex align-items-center gap-2 py-2">
<div class="flex-grow-1" style="min-width: 0;">
<div class="${nameClass}"><strong>${safeName}</strong>${tlsBadge}${badge}</div>
<code class="small text-muted text-break" style="word-break: break-all;">${userInfo}${escapeHtml(b.host)}:${b.port}</code>
</div>
<div class="form-check form-switch mb-0" title="${enabled ? 'Enabled' : 'Disabled'}">
<input class="form-check-input" type="checkbox" ${enabled ? 'checked' : ''}
onchange="toggleObserverBrokerDisabled(${b.id}, !this.checked)">
</div>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="openObserverBrokerModal(${b.id})" title="Edit">
<i class="bi bi-pencil"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-danger"
onclick="deleteObserverBroker(${b.id}, '${safeName.replace(/'/g, "\\'")}')" title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
`;
}).join('');
listEl.innerHTML = rows;
}
// Live updates pushed by the backend (observer_status socket event).
// Payload has no settings/broker rows — only counters + per-client state.
function applyObserverLiveStatus(live) {
renderObserverStatusLine({ ...live, settings: undefined });
(live.brokers || []).forEach(b => {
const badge = document.getElementById(`observerBrokerBadge_${b.id}`);
if (!badge) return;
const p = observerBrokerBadgeParts(b);
badge.className = `badge ${p.cls} ms-1`;
badge.textContent = p.txt;
badge.title = p.title;
});
}
function openObserverBrokerModal(id) {
const modalEl = document.getElementById('observerBrokerEditModal');
if (!modalEl) return;
const titleEl = document.getElementById('observerBrokerEditModalTitle');
const idEl = document.getElementById('observerBrokerEditId');
const nameEl = document.getElementById('observerBrokerEditName');
const hostEl = document.getElementById('observerBrokerEditHost');
const portEl = document.getElementById('observerBrokerEditPort');
const userEl = document.getElementById('observerBrokerEditUsername');
const passEl = document.getElementById('observerBrokerEditPassword');
const passHintEl = document.getElementById('observerBrokerPasswordHint');
const tlsEl = document.getElementById('observerBrokerEditTls');
const tlsVerifyEl = document.getElementById('observerBrokerEditTlsVerify');
const enabledEl = document.getElementById('observerBrokerEditEnabled');
const errorEl = document.getElementById('observerBrokerEditError');
errorEl.classList.add('d-none');
errorEl.textContent = '';
passEl.value = '';
if (id) {
const b = (window.observerCache.brokers || []).find(x => x.id === id);
if (!b) return;
titleEl.textContent = 'Edit broker';
idEl.value = String(b.id);
nameEl.value = b.name || '';
hostEl.value = b.host || '';
portEl.value = b.port || 1883;
userEl.value = b.username || '';
tlsEl.checked = !!b.use_tls;
tlsVerifyEl.checked = !!b.tls_verify;
enabledEl.checked = !b.is_disabled;
passHintEl.classList.toggle('d-none', !b.has_password);
} else {
titleEl.textContent = 'Add broker';
idEl.value = '';
nameEl.value = '';
hostEl.value = '';
portEl.value = 1883;
userEl.value = '';
tlsEl.checked = false;
tlsVerifyEl.checked = true;
enabledEl.checked = true;
passHintEl.classList.add('d-none');
}
modalEl.addEventListener('shown.bs.modal', _bumpAnalyzerBackdrop, { once: true });
bootstrap.Modal.getOrCreateInstance(modalEl).show();
}
function showObserverBrokerFormError(msg) {
const errorEl = document.getElementById('observerBrokerEditError');
if (!errorEl) return;
errorEl.textContent = msg;
errorEl.classList.remove('d-none');
}
async function saveObserverBrokerFromForm() {
const id = document.getElementById('observerBrokerEditId').value
? parseInt(document.getElementById('observerBrokerEditId').value, 10) : null;
const name = (document.getElementById('observerBrokerEditName').value || '').trim();
const host = (document.getElementById('observerBrokerEditHost').value || '').trim();
const port = parseInt(document.getElementById('observerBrokerEditPort').value || '1883', 10);
const username = (document.getElementById('observerBrokerEditUsername').value || '').trim();
const password = document.getElementById('observerBrokerEditPassword').value;
const use_tls = document.getElementById('observerBrokerEditTls').checked;
const tls_verify = document.getElementById('observerBrokerEditTlsVerify').checked;
const is_disabled = !document.getElementById('observerBrokerEditEnabled').checked;
if (!name) { showObserverBrokerFormError('Name is required'); return; }
if (!host) { showObserverBrokerFormError('Host is required'); return; }
if (!(port >= 1 && port <= 65535)) { showObserverBrokerFormError('Port must be 1-65535'); return; }
const body = { name, host, port, username, use_tls, tls_verify, is_disabled };
// Edit mode: an empty password field means "keep the stored password"
if (!id || password !== '') body.password = password;
try {
const url = id ? `/api/observer/brokers/${id}` : '/api/observer/brokers';
const resp = await fetch(url, {
method: id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.success) {
showObserverBrokerFormError(data.error || 'Failed to save broker');
return;
}
bootstrap.Modal.getInstance(document.getElementById('observerBrokerEditModal'))?.hide();
// Reload once now and once after the reconnect settles
await loadObserverTab();
setTimeout(loadObserverTab, 1500);
} catch (e) {
console.error('Error saving observer broker:', e);
showObserverBrokerFormError('Network error saving broker');
}
}
async function deleteObserverBroker(id, name) {
if (!confirm(`Delete broker "${name}"?`)) return;
try {
const resp = await fetch(`/api/observer/brokers/${id}`, { method: 'DELETE' });
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.success) {
showNotification(data.error || 'Failed to delete broker', 'danger');
return;
}
await loadObserverTab();
} catch (e) {
console.error('Error deleting observer broker:', e);
showNotification('Network error deleting broker', 'danger');
}
}
async function toggleObserverBrokerDisabled(id, disabled) {
try {
const resp = await fetch(`/api/observer/brokers/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_disabled: !!disabled }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.success) {
showNotification(data.error || 'Failed to update broker', 'danger');
}
} catch (e) {
console.error('Error toggling observer broker:', e);
showNotification('Network error updating broker', 'danger');
}
await loadObserverTab();
setTimeout(loadObserverTab, 1500);
}
async function saveObserverSettings(patch) {
try {
const resp = await fetch('/api/observer/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.success) {
showNotification(data.error || 'Failed to save observer settings', 'danger');
await loadObserverTab();
return;
}
// The reload runs on a backend thread; refresh after it settles
setTimeout(loadObserverTab, 1500);
} catch (e) {
console.error('Error saving observer settings:', e);
showNotification('Network error saving observer settings', 'danger');
}
}
// ================================================================
// Per-channel region picker (Manage Channels > row > pin icon)
// ================================================================
+109
View File
@@ -459,6 +459,9 @@
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsAnalyzer" type="button">Analyzer</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsObserver" type="button">Observer</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsNotifications" type="button">Notifications</button>
</li>
@@ -1009,6 +1012,45 @@
Disabled analyzers stay in the list but are hidden from the chooser.
</div>
</div>
<div class="tab-pane fade" id="tabSettingsObserver">
<div class="alert alert-info py-2 small mb-3 mt-2">
<i class="bi bi-info-circle"></i> Publish every mesh packet this device overhears to
MQTT brokers, in the <code>meshcore-packet-capture</code> format
(topics <code>meshcore/IATA/PUBKEY/packets</code>). Compatible with
letsmesh-style packet analyzers.
</div>
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="observerEnabledToggle">
<label class="form-check-label" for="observerEnabledToggle">Enable observer</label>
</div>
<div class="row g-2 mb-1">
<div class="col-6">
<label class="form-label small mb-1" for="observerIataInput">Location code (IATA)</label>
<input type="text" class="form-control form-control-sm" id="observerIataInput"
maxlength="3" placeholder="e.g. KRK" autocomplete="off"
style="text-transform: uppercase;">
</div>
<div class="col-6">
<label class="form-label small mb-1" for="observerAdvertIntervalInput">Flood advert interval (h)</label>
<input type="number" class="form-control form-control-sm" id="observerAdvertIntervalInput"
min="0" max="8760" step="1" placeholder="0 = off">
</div>
</div>
<div class="form-text small mb-2">
The 3-letter location code is used in MQTT topics; leave it empty for the flat
<code>meshcore/packets</code> topics. Advert interval 0 disables scheduled flood adverts.
</div>
<div id="observerStatusLine" class="small text-muted mb-3"></div>
<h6 class="mb-2">MQTT Brokers</h6>
<div id="observerBrokersList" class="list-group mb-3">
<div class="text-center text-muted py-3 small">
<div class="spinner-border spinner-border-sm"></div> Loading...
</div>
</div>
<button type="button" class="btn btn-primary btn-sm" id="addObserverBrokerBtn">
<i class="bi bi-plus-circle"></i> Add broker
</button>
</div>
<div class="tab-pane fade" id="tabSettingsNotifications">
<p class="text-muted small mb-3">Browser notifications appear when the app is hidden or in the background.</p>
<button id="notificationsToggle" class="list-group-item list-group-item-action d-flex align-items-center gap-3 border rounded" type="button" style="width: 100%;">
@@ -1085,6 +1127,73 @@
</div>
</div>
<!-- Observer Broker Edit Modal (create + edit) -->
<div class="modal fade" id="observerBrokerEditModal" tabindex="-1" style="z-index: 1080;">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-broadcast"></i> <span id="observerBrokerEditModalTitle">Add broker</span></h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="observerBrokerEditForm">
<div class="modal-body">
<input type="hidden" id="observerBrokerEditId" value="">
<div class="mb-2">
<label class="form-label small mb-1" for="observerBrokerEditName">Name</label>
<input type="text" class="form-control form-control-sm" id="observerBrokerEditName"
placeholder="e.g. My MQTT server" required maxlength="60" autocomplete="off">
</div>
<div class="row g-2 mb-1">
<div class="col-8">
<label class="form-label small mb-1" for="observerBrokerEditHost">Host</label>
<input type="text" class="form-control form-control-sm" id="observerBrokerEditHost"
placeholder="mqtt.example.com" required autocomplete="off">
</div>
<div class="col-4">
<label class="form-label small mb-1" for="observerBrokerEditPort">Port</label>
<input type="number" class="form-control form-control-sm" id="observerBrokerEditPort"
min="1" max="65535" value="1883" required>
</div>
</div>
<div class="form-text small mb-2">Typical ports: 1883 (plain), 8883 (TLS).</div>
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label small mb-1" for="observerBrokerEditUsername">Username</label>
<input type="text" class="form-control form-control-sm" id="observerBrokerEditUsername"
autocomplete="off">
</div>
<div class="col-6">
<label class="form-label small mb-1" for="observerBrokerEditPassword">Password</label>
<input type="password" class="form-control form-control-sm" id="observerBrokerEditPassword"
autocomplete="new-password">
</div>
</div>
<div class="form-text small mb-2 d-none" id="observerBrokerPasswordHint">
Leave the password blank to keep the current one.
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="observerBrokerEditTls">
<label class="form-check-label small" for="observerBrokerEditTls">Use TLS</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="observerBrokerEditTlsVerify" checked>
<label class="form-check-label small" for="observerBrokerEditTlsVerify">Verify TLS certificate</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="observerBrokerEditEnabled" checked>
<label class="form-check-label small" for="observerBrokerEditEnabled">Enabled</label>
</div>
<div id="observerBrokerEditError" class="alert alert-danger py-1 small mt-3 d-none"></div>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary btn-sm">Save</button>
</div>
</form>
</div>
</div>
</div>
<!-- Coordinate Picker Map Modal -->
<div class="modal fade" id="coordPickerModal" tabindex="-1" style="z-index: 1080;">
<div class="modal-dialog modal-lg modal-dialog-centered">