const q = selector => document.querySelector(selector); const pendingActions = new Set(); let latestState = null; let latestConfig = null; let configRevision = -1; let editorStructure = ''; const expandedPackets = new Set(); const packetRows = new Map(); let logSession = null; let latestLogId = -1; function setText(element, value) { const text = value ?? ''; if (element.textContent !== String(text)) element.textContent = text; } async function responseValue(response) { const type = response.headers.get('content-type') || ''; if (!type.includes('application/json')) return response.text(); try { return await response.json(); } catch (_) { throw Error(`HTTP ${response.status}: invalid JSON response`); } } async function api(path, options = {}) { const response = await fetch(path, options); const value = await responseValue(response); if (!response.ok) { const detail = typeof value === 'object' && value !== null ? value.error : value; throw Error(detail || `Request failed with HTTP ${response.status}`); } return value; } function post(path, body) { return api(path, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: body === undefined ? undefined : JSON.stringify(body), }); } function showError(message) { setText(q('#connect-error'), message); } async function runAction(id, action) { pendingActions.add(id); renderPermissions(); try { const value = await action(); showError(''); return value; } catch (error) { showError(error.message); return null; } finally { pendingActions.delete(id); renderPermissions(); } } q('#connect-form').addEventListener('submit', event => { event.preventDefault(); runAction('connect', () => post('/api/connect', {path: q('#path').value})); }); q('#disconnect').addEventListener('click', () => { runAction('disconnect', () => post('/api/disconnect')); }); q('#draft-fields').addEventListener('submit', event => event.preventDefault()); q('#save').addEventListener('click', () => { runAction('save', () => post('/api/config/save')); }); function fieldStructure(field) { return [ field.id, field.key, field.label, field.group, field.unit, field.kind, field.minimum, field.maximum, field.factory, field.help, field.options, ]; } function fieldControl(field) { let control; if (field.options.length !== 0) { control = document.createElement('select'); for (const choice of field.options) { const option = document.createElement('option'); option.value = String(choice.value); option.textContent = choice.label; control.append(option); } } else { control = document.createElement('input'); } control.name = String(field.id); control.dataset.fieldId = String(field.id); if (field.options.length === 0) { if (field.kind === 'bool') { control.type = 'checkbox'; } else { control.type = 'number'; control.step = field.kind === 'decimal' ? 'any' : '1'; control.inputMode = field.kind === 'decimal' ? 'decimal' : 'numeric'; if (field.minimum !== null) control.min = String(field.minimum); if (field.maximum !== null) control.max = String(field.maximum); } } control.addEventListener('change', async () => { let value = control.value; if (field.kind === 'bool') { value = field.options.length === 0 ? control.checked : control.value === 'true'; } const action = `edit:${field.id}`; const config = await runAction(action, () => post('/api/config/edit', { field_id: field.id, value, })); if (config !== null) renderConfig(config); }); return control; } function buildEditor(fields) { const groups = new Map(); for (const field of fields) { if (!groups.has(field.group)) groups.set(field.group, []); groups.get(field.group).push(field); } const sections = []; for (const [group, groupFields] of groups) { const section = document.createElement('fieldset'); const legend = document.createElement('legend'); legend.textContent = group; section.append(legend); for (const field of groupFields) { const label = document.createElement('label'); const title = document.createElement('span'); title.textContent = `${field.label}${field.unit ? ` (${field.unit})` : ''}`; const key = document.createElement('code'); key.className = 'field-key'; key.textContent = `${field.key} [${field.id}]`; const control = fieldControl(field); label.append(title, key, control); const bounds = field.minimum !== null || field.maximum !== null ? `Inclusive bounds: ${field.minimum ?? 'unbounded'} to ${field.maximum ?? 'unbounded'}.` : ''; const helpText = [field.help, `Factory: ${field.factory}.`, bounds].filter(Boolean).join(' '); if (helpText) { const help = document.createElement('small'); help.id = `help-${field.id}`; help.textContent = helpText; control.setAttribute('aria-describedby', help.id); label.append(help); } section.append(label); } sections.push(section); } q('#draft-fields').replaceChildren(...sections); } function syncEditorValues(fields) { for (const field of fields) { const control = q(`[data-field-id="${field.id}"]`); if (!control) continue; if (field.kind === 'bool' && field.options.length === 0) { if (document.activeElement !== control) control.checked = field.value; } else if (document.activeElement !== control && control.value !== String(field.value)) { control.value = String(field.value); } } } function renderValidation(messages) { q('#validation').replaceChildren(...messages.map(message => Object.assign(document.createElement('li'), {textContent: message}))); } function renderConfig(config) { latestConfig = config; setText(q('#schema-id'), config.schema_id || '—'); setText(q('#config-state'), config.state); q('#restart-guidance').hidden = config.state !== 'restart required'; renderValidation(config.validation); const structure = JSON.stringify(config.fields.map(fieldStructure)); if (structure !== editorStructure) { editorStructure = structure; buildEditor(config.fields); } syncEditorValues(config.fields); renderPermissions(); } function renderPermissions() { if (latestState === null) return; const busy = pendingActions.size !== 0; q('#path').disabled = latestState.can_disconnect || busy; q('#connect-serial').disabled = latestState.can_disconnect || busy; q('#disconnect').hidden = !latestState.can_disconnect; q('#disconnect').disabled = busy; q('#save').disabled = !latestState.can_save || busy; for (const control of q('#draft-fields').elements) { control.disabled = !latestState.can_edit || busy; } } function frequency(value) { if (!Number.isFinite(value)) return '—'; const digits = value >= 100 ? 0 : value >= 10 ? 1 : 2; return `${value.toFixed(digits)} Hz`; } function packetValues(values) { return typeof values === 'string' ? values : JSON.stringify(values, null, 2); } function createPacketRows(key) { const row = document.createElement('tr'); const messageCell = document.createElement('td'); const toggle = document.createElement('button'); toggle.type = 'button'; toggle.className = 'packet-toggle'; messageCell.append(toggle); row.append(messageCell); const cells = []; for (let index = 0; index < 4; index += 1) { const cell = document.createElement('td'); cells.push(cell); row.append(cell); } const detail = document.createElement('tr'); detail.className = 'packet-detail'; detail.hidden = true; const detailCell = document.createElement('td'); detailCell.colSpan = 5; const values = document.createElement('pre'); detailCell.append(values); detail.append(detailCell); const entry = {row, toggle, cells, detail, values, latestValues: null}; toggle.addEventListener('click', () => { if (expandedPackets.has(key)) { expandedPackets.delete(key); } else { expandedPackets.add(key); setText(values, packetValues(entry.latestValues)); } const expanded = expandedPackets.has(key); toggle.setAttribute('aria-expanded', String(expanded)); detail.hidden = !expanded; }); return entry; } function renderPackets(snapshot) { const body = q('#packet-rows'); const packets = [...snapshot.packets].sort((left, right) => left.name.localeCompare(right.name) || left.system_id - right.system_id || left.component_id - right.component_id || left.message_id - right.message_id); const liveKeys = new Set(packets.map(packet => `${packet.system_id}:${packet.component_id}:${packet.message_id}`)); for (const key of expandedPackets) { if (!liveKeys.has(key)) expandedPackets.delete(key); } for (const [key, entry] of packetRows) { if (liveKeys.has(key)) continue; entry.row.remove(); entry.detail.remove(); packetRows.delete(key); } if (packets.length === 0) { if (!body.querySelector('.empty-row')) { const row = document.createElement('tr'); row.className = 'empty-row'; const cell = document.createElement('td'); cell.colSpan = 5; cell.textContent = 'No MAVLink packets received on this link.'; row.append(cell); body.replaceChildren(row); } return; } body.querySelector('.empty-row')?.remove(); const orderedRows = []; for (const packet of packets) { const key = `${packet.system_id}:${packet.component_id}:${packet.message_id}`; let entry = packetRows.get(key); if (!entry) { entry = createPacketRows(key); packetRows.set(key, entry); } entry.latestValues = packet.values; setText(entry.toggle, packet.name); entry.toggle.setAttribute('aria-expanded', String(expandedPackets.has(key))); for (const [index, value] of [ `${packet.system_id}:${packet.component_id}`, packet.message_id, frequency(packet.frequency_hz), packet.count, ].entries()) { setText(entry.cells[index], value); } entry.detail.hidden = !expandedPackets.has(key); setText(entry.values, packetValues(packet.values)); orderedRows.push(entry.row, entry.detail); } let cursor = body.firstChild; for (const row of orderedRows) { if (row !== cursor) body.insertBefore(row, cursor); cursor = row.nextSibling; } } function logTime(receivedAtMs) { const received = new Date(receivedAtMs); if (Number.isNaN(received.getTime())) return '—'; return received.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 3, hour12: false, }); } function createLogEntry(entry) { const item = document.createElement('li'); item.dataset.logId = String(entry.id); const levelClass = entry.level.split(/[^A-Z]+/, 1)[0].toLowerCase(); item.className = `log-entry log-${levelClass}`; const time = document.createElement('time'); time.dateTime = new Date(entry.received_at_ms).toISOString(); time.textContent = logTime(entry.received_at_ms); const level = document.createElement('span'); level.className = 'log-level'; level.textContent = entry.level; const text = document.createElement('span'); text.className = 'log-text'; if (entry.truncated) { const truncated = document.createElement('span'); truncated.className = 'log-truncated'; truncated.textContent = 'TRUNCATED'; text.append(truncated, document.createTextNode(entry.text)); } else { text.textContent = entry.text; } item.title = `MAVLink source ${entry.system_id}:${entry.component_id}`; item.append(time, level, text); return item; } function renderLogs(snapshot) { const entries = q('#log-entries'); if (snapshot.session !== logSession) { logSession = snapshot.session; latestLogId = -1; entries.replaceChildren(); } const followsLatest = entries.scrollHeight - entries.scrollTop - entries.clientHeight < 32; const retainedIds = new Set(snapshot.entries.map(entry => String(entry.id))); for (const rendered of entries.querySelectorAll('[data-log-id]')) { if (!retainedIds.has(rendered.dataset.logId)) rendered.remove(); } const newEntries = snapshot.entries.filter(entry => entry.id > latestLogId); if (newEntries.length !== 0) { entries.querySelector('.empty-log')?.remove(); entries.append(...newEntries.map(createLogEntry)); latestLogId = newEntries[newEntries.length - 1].id; if (followsLatest) entries.scrollTop = entries.scrollHeight; } else if (entries.children.length === 0) { const empty = document.createElement('li'); empty.className = 'empty-log'; empty.textContent = 'No Kaptan logs received on this link.'; entries.append(empty); } setText(q('#log-retention'), snapshot.discarded === 0 ? '' : `${snapshot.discarded} older host-side log entries discarded after reaching the display limit.`); } function renderState(state) { latestState = state; setText(q('#connection-status'), state.path ? `${state.status} (${state.path})` : state.status); showError(state.error ?? ''); q('#configuration').hidden = !state.has_config; q('#packet-monitor').hidden = !state.can_disconnect; q('#log-monitor').hidden = !state.can_disconnect; renderPermissions(); } async function refresh() { try { const [state, packets, logs] = await Promise.all([ api('/api/state'), api('/api/packets'), api('/api/logs'), ]); renderState(state); renderPackets(packets); renderLogs(logs); if (state.config_revision !== configRevision) { const config = await api('/api/config'); renderConfig(config); configRevision = state.config_revision; q('#configuration').hidden = latestConfig.schema_id === null; } } catch (error) { showError(error.message); } finally { window.setTimeout(refresh, 500); } } refresh();