feat(configurator): add web configuration console
This commit is contained in:
1 parent
ceb2134392
commit
b7631c0bc7
14 files changed
+4513
No files matched your search
@@ -0,0 +1,427 @@
|
||||
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();
|
||||
@@ -0,0 +1,74 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Kaptan configurator</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main-content">Skip to configurator</a>
|
||||
<main id="main-content">
|
||||
<section id="connection" aria-labelledby="connection-heading">
|
||||
<h2 id="connection-heading">Connection</h2>
|
||||
<form id="connect-form">
|
||||
<label for="path">Serial device path</label>
|
||||
<div class="control-line">
|
||||
<input id="path" required autocomplete="off" placeholder="/dev/ttyACM0">
|
||||
<button id="connect-serial" type="submit">Connect serial</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="connection-status" role="status" aria-live="polite">Disconnected.</p>
|
||||
<p id="connect-error" class="error" role="alert" aria-live="assertive"></p>
|
||||
<button id="disconnect" type="button" hidden>Disconnect</button>
|
||||
</section>
|
||||
|
||||
<section id="configuration" aria-labelledby="configuration-heading" hidden>
|
||||
<h2 id="configuration-heading">Board configuration</h2>
|
||||
<details open>
|
||||
<summary>Configuration status</summary>
|
||||
<dl>
|
||||
<dt>Schema</dt><dd><code id="schema-id">—</code></dd>
|
||||
<dt>State</dt><dd id="config-state">—</dd>
|
||||
</dl>
|
||||
</details>
|
||||
|
||||
<aside id="restart-guidance" class="important" role="status" hidden>
|
||||
Saved configuration becomes active after the controller restarts.
|
||||
</aside>
|
||||
<ul id="validation"></ul>
|
||||
<form id="draft-fields" class="draft-fields"></form>
|
||||
<div class="actions" aria-label="Configuration actions">
|
||||
<button id="save" type="button">Save</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="log-monitor" aria-labelledby="log-monitor-heading" hidden>
|
||||
<h2 id="log-monitor-heading">Runtime logs</h2>
|
||||
<p id="log-retention" class="hint"></p>
|
||||
<ol id="log-entries" class="log-entries">
|
||||
<li class="empty-log">No Kaptan logs received on this link.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section id="packet-monitor" aria-labelledby="packet-monitor-heading" hidden>
|
||||
<h2 id="packet-monitor-heading">Incoming MAVLink</h2>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Message</th>
|
||||
<th scope="col">Source</th>
|
||||
<th scope="col">Message ID</th>
|
||||
<th scope="col">Frequency</th>
|
||||
<th scope="col">Received</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="packet-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,254 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--background: #fff;
|
||||
--foreground: #161616;
|
||||
--muted: #595959;
|
||||
--rule: #8a8a8a;
|
||||
--subtle: #f2f2f2;
|
||||
--link: #0000c0;
|
||||
--visited: #6b287c;
|
||||
--error: #9f1111;
|
||||
--warning: #6b4b00;
|
||||
--focus: #005fcc;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #111;
|
||||
--foreground: #eee;
|
||||
--muted: #bbb;
|
||||
--rule: #777;
|
||||
--subtle: #202020;
|
||||
--link: #8db8ff;
|
||||
--visited: #d4a1e4;
|
||||
--error: #ff9d9d;
|
||||
--warning: #f0c76a;
|
||||
--focus: #82b7ff;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
html {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font: 15px/1.45 ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
width: min(100% - 2rem, 76rem);
|
||||
margin: 0 auto 4rem;
|
||||
}
|
||||
|
||||
header,
|
||||
section {
|
||||
border-bottom: 1px solid var(--rule);
|
||||
padding: 1.25rem 0;
|
||||
}
|
||||
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1 { margin-bottom: .35rem; font-size: 1.35rem; }
|
||||
h2 { font-size: 1.15rem; }
|
||||
h3 { font-size: 1rem; }
|
||||
|
||||
a {
|
||||
color: var(--link);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: .15em;
|
||||
}
|
||||
|
||||
a:visited { color: var(--visited); }
|
||||
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: .5rem;
|
||||
top: -5rem;
|
||||
padding: .5rem;
|
||||
background: var(--background);
|
||||
border: 2px solid var(--focus);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.skip-link:focus { top: .5rem; }
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
max-width: 100%;
|
||||
padding: .35rem .5rem;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 0;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button { background: var(--subtle); cursor: pointer; }
|
||||
|
||||
button:disabled,
|
||||
input:disabled,
|
||||
select:disabled { cursor: not-allowed; opacity: .58; }
|
||||
|
||||
:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
|
||||
.control-line,
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
|
||||
.control-line input:first-of-type { flex: 1 1 18rem; }
|
||||
.hint, small { color: var(--muted); }
|
||||
|
||||
.error {
|
||||
min-height: 1.45em;
|
||||
color: var(--error);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status,
|
||||
.important {
|
||||
padding: .7rem;
|
||||
border-left: .3rem solid var(--warning);
|
||||
background: var(--subtle);
|
||||
}
|
||||
|
||||
.important { margin: 1rem 0; }
|
||||
.important h3 { margin-bottom: .35rem; }
|
||||
.important p { margin-bottom: 0; }
|
||||
|
||||
details { margin: .75rem 0; }
|
||||
summary { cursor: pointer; font-weight: 700; }
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
gap: .25rem 1rem;
|
||||
}
|
||||
|
||||
dt { color: var(--muted); }
|
||||
dd { margin: 0; overflow-wrap: anywhere; }
|
||||
dl > div { display: contents; }
|
||||
|
||||
.draft-fields {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.draft-fields fieldset {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
|
||||
gap: .9rem;
|
||||
min-width: 0;
|
||||
padding: .75rem;
|
||||
border: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
.draft-fields label {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: .25rem;
|
||||
}
|
||||
|
||||
.field-key { color: var(--muted); font-size: .85rem; }
|
||||
|
||||
.table-scroll { overflow-x: auto; }
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: .45rem .7rem .45rem 0;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th { color: var(--muted); }
|
||||
td:first-child { width: 100%; }
|
||||
td:nth-child(n + 3),
|
||||
th:nth-child(n + 3) { text-align: right; }
|
||||
|
||||
.empty-row { color: var(--muted); }
|
||||
.empty-row td { text-align: left; }
|
||||
|
||||
.packet-toggle {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--link);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: .15em;
|
||||
}
|
||||
|
||||
.packet-detail td {
|
||||
padding: .5rem .75rem;
|
||||
background: var(--subtle);
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.packet-detail pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.log-entries {
|
||||
max-height: 26rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--rule);
|
||||
list-style: none;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
display: grid;
|
||||
grid-template-columns: 7.5rem 5.5rem minmax(0, 1fr);
|
||||
gap: .75rem;
|
||||
padding: .4rem .55rem;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
.log-entry:last-child { border-bottom: 0; }
|
||||
.log-entry time { color: var(--muted); }
|
||||
.log-severity { font-weight: 700; }
|
||||
.log-text { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.log-emergency,
|
||||
.log-alert,
|
||||
.log-critical,
|
||||
.log-error { color: var(--error); }
|
||||
.log-warning { color: var(--warning); }
|
||||
|
||||
.empty-log {
|
||||
padding: .55rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#validation:empty,
|
||||
#validation-summary:empty,
|
||||
#log-retention:empty,
|
||||
#connect-error:empty { display: none; }
|
||||
|
||||
#validation { color: var(--error); }
|
||||
|
||||
@media (max-width: 38rem) {
|
||||
body { width: min(100% - 1rem, 76rem); }
|
||||
dl { grid-template-columns: 1fr; }
|
||||
dd { margin-bottom: .5rem; }
|
||||
.control-line { align-items: stretch; flex-direction: column; }
|
||||
.control-line > * { width: 100%; }
|
||||
.log-entry { grid-template-columns: 1fr auto; gap: .2rem .75rem; }
|
||||
.log-text { grid-column: 1 / -1; }
|
||||
}
|
||||
Reference in new issue
Block a user