Files
kaptan/firmware/tools/configurator/web/app.js
T

428 lines
14 KiB
JavaScript

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.default, 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, `Default: ${field.default}.`, 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 severityClass = entry.severity.split(/[^A-Z]+/, 1)[0].toLowerCase();
item.className = `log-entry log-${severityClass}`;
const time = document.createElement('time');
time.dateTime = new Date(entry.received_at_ms).toISOString();
time.textContent = logTime(entry.received_at_ms);
const severity = document.createElement('span');
severity.className = 'log-severity';
severity.textContent = entry.severity;
const text = document.createElement('span');
text.className = 'log-text';
text.textContent = entry.text;
item.title = `MAVLink source ${entry.system_id}:${entry.component_id}`;
item.append(time, severity, 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);
if (state.error) showError(state.error);
q('#configuration').hidden = latestConfig?.schema_id === null || latestConfig === null;
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) {
configRevision = state.config_revision;
renderConfig(await api('/api/config'));
q('#configuration').hidden = latestConfig.schema_id === null;
}
} catch (error) {
showError(error.message);
} finally {
window.setTimeout(refresh, 500);
}
}
refresh();