Unify Settings Layouts and Add Coverage

This commit is contained in:
2026-09-08 21:22:52 +02:00
parent 989821b7c4
commit 4a4d615c59
7 changed files with 295 additions and 43 deletions
+53 -35
View File
@@ -82,10 +82,15 @@ static const char s_index_html[] =
".terminal-host .xterm-viewport{border-radius:7px}\n"
".settings-page{overflow:auto;padding:8px;min-height:0}.settings-page h2{margin:0 0 8px;font-size:18px}"
".settings-values{display:grid;grid-template-columns:minmax(110px,1fr) minmax(0,2fr);gap:8px 16px;max-width:600px}"
".settings-values dt{color:var(--muted)}.settings-values dd{margin:0;overflow-wrap:anywhere}\n"
".serial-edit{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;max-width:600px}"
".serial-edit label{display:grid;gap:4px;color:var(--muted)}.serial-edit input,.serial-edit select{font:inherit;width:100%;min-width:0;padding:8px;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:6px}"
".serial-actions{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0}#network-summary{white-space:pre-wrap;overflow-wrap:anywhere}\n"
".settings-values dt{color:var(--muted)}.settings-values dd{margin:0;white-space:pre-wrap;overflow-wrap:anywhere}\n"
".serial-edit,.settings-edit{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;max-width:600px}"
".serial-edit label,.settings-edit label{display:grid;gap:4px;color:var(--muted);min-width:0}.serial-edit input,.serial-edit select,.settings-edit input,.settings-edit select,.settings-edit textarea{font:inherit;width:100%;min-width:0;padding:8px;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:6px}"
".serial-actions{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0;max-width:600px}\n"
".settings-page h3{font-size:1.17em;margin:1em 0}.settings-page .connection-detail{max-width:600px;overflow-wrap:anywhere}"
".settings-edit .settings-wide{grid-column:1/-1}.settings-edit textarea{min-height:96px;resize:vertical}"
".settings-edit label.settings-check{display:flex;align-items:center;gap:8px}.settings-edit input[type=checkbox]{width:auto;flex:none}"
".settings-edit input[readonly]{font-family:monospace}.settings-values dt{overflow-wrap:anywhere}"
"@media(max-width:360px){.settings-values{grid-template-columns:minmax(0,1fr) minmax(0,2fr)}.settings-edit{grid-template-columns:minmax(0,1fr)}}\n"
".terminal-toolbar{flex-wrap:wrap}.terminal-toolbar .button{min-height:32px;padding:4px 10px}\n"
"@media(max-width:850px){html,body{overflow:auto}.page{height:auto;min-height:100dvh;grid-template-rows:auto auto minmax(280px,1fr)}"
".terminal-panel{min-height:280px}.dashboard{grid-template-columns:1fr}.controls{align-items:flex-start}"
@@ -182,22 +187,22 @@ static const char s_index_html[] =
"<button id=\"settings-accounts\" class=\"button\" type=\"button\" aria-pressed=\"false\">Accounts</button>"
"<button id=\"settings-network\" class=\"button\" type=\"button\" aria-pressed=\"false\">Network</button></div>"
"<div id=\"network-settings\" hidden><h2>Network</h2>"
"<p>Edits apply to RAM only. Save persists the device working configuration, NOT browser drafts. Refresh discards drafts. "
"<p class=\"connection-detail\">Edits apply to RAM only. Save persists the device working configuration, NOT browser drafts. Refresh discards drafts. "
"Wi-Fi Load uses stored configuration only; missing or invalid storage leaves RAM unchanged. No Wi-Fi defaults/reset. "
"Start/Stop also change RAM enabled-at-boot; Save persists that policy. Reconnect/Next do nothing while stopped. "
"Next selects the next enabled profile in priority order, wrapping.</p>"
"<p>Network changes may disconnect HTTPS, SSH and both browser terminals before acknowledgement. Accepted is NOT connected. "
"<p class=\"connection-detail\">Network changes may disconnect HTTPS, SSH and both browser terminals before acknowledgement. Accepted is NOT connected. "
"Recover through STA/AP, UART0 or network-independent native USB serial. Navigation itself preserves terminals and writer lease.</p>"
"<button id=\"network-refresh\" class=\"button\" type=\"button\">Refresh network</button>"
"<p id=\"network-detail\" role=\"status\"></p><pre id=\"network-summary\"></pre>"
"<div id=\"network-edit\" hidden><h3>Wi-Fi working configuration</h3><div class=\"serial-edit\">"
"<button id=\"network-refresh\" class=\"button\" type=\"button\">Refresh</button>"
"<p id=\"network-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"network-summary\" class=\"settings-values\"></dl>"
"<div id=\"network-edit\" hidden><h3>Wi-Fi working configuration</h3><div class=\"settings-edit\">"
"<label>Target<select id=\"network-target\"><option value=\"ap\">Access point / boot policy</option>"
"<option value=\"0\">STA profile 0</option><option value=\"1\">STA profile 1</option>"
"<option value=\"2\">STA profile 2</option><option value=\"3\">STA profile 3</option></select></label>"
"<label id=\"network-boot-label\">Enabled at boot<input id=\"network-boot\" type=\"checkbox\"></label>"
"<label id=\"network-boot-label\" class=\"settings-check\">Enabled at boot<input id=\"network-boot\" type=\"checkbox\"></label>"
"<label id=\"network-policy-label\">AP policy<select id=\"network-policy\"><option>off</option><option>fallback</option><option>always</option></select></label>"
"<label id=\"network-channel-label\">AP channel<input id=\"network-channel\" type=\"number\" min=\"1\" max=\"11\" step=\"1\"></label>"
"<label id=\"network-enabled-label\">Profile enabled<input id=\"network-enabled\" type=\"checkbox\"></label>"
"<label id=\"network-enabled-label\" class=\"settings-check\">Profile enabled<input id=\"network-enabled\" type=\"checkbox\"></label>"
"<label id=\"network-priority-label\">Priority<input id=\"network-priority\" type=\"number\" min=\"0\" max=\"255\" step=\"1\"></label>"
"<label id=\"network-security-label\">Security<select id=\"network-security\"><option value=\"mixed\">WPA2 or stronger (mixed)</option><option value=\"wpa3\">WPA3</option></select></label>"
"<label>SSID editor<select id=\"network-ssid-mode\"><option value=\"text\">Ordinary text (UTF-8)</option><option value=\"hex\">Exact bytes (hex)</option></select></label>"
@@ -205,27 +210,27 @@ static const char s_index_html[] =
"<label>Password action<select id=\"network-password-mode\"><option value=\"keep\">Keep existing (default)</option>"
"<option value=\"replace\">Replace</option><option id=\"network-password-clear\" value=\"clear\">Clear (disabled STA only)</option></select></label>"
"<label>New password<input id=\"network-password\" type=\"password\" maxlength=\"64\" autocomplete=\"new-password\" disabled></label></div>"
"<p id=\"network-password-status\"></p><p id=\"network-ssid-detail\" role=\"status\"></p>"
"<p>SSID maximum: 32 bytes, not characters. Text entry encodes UTF-8 once; hex preserves arbitrary bytes including zero. "
"<p id=\"network-password-status\" class=\"connection-detail\"></p><p id=\"network-ssid-detail\" class=\"connection-detail\" role=\"status\"></p>"
"<p class=\"connection-detail\">SSID maximum: 32 bytes, not characters. Text entry encodes UTF-8 once; hex preserves arbitrary bytes including zero. "
"Switching modes is lossless or refused. Password replacement requires 863 printable ASCII characters; blank never clears. "
"AP always requires a PSK. STA clear requires disabled state. Transient passwords expire after 60 seconds and clear on submission or context change. "
"NVS is unencrypted; replacement/clear is not secure flash erasure. JavaScript cannot securely zero strings.</p>"
"<button id=\"network-apply\" class=\"button\" type=\"button\">Apply selected Wi-Fi target to RAM</button>"
"<div class=\"serial-actions\"><button id=\"network-wifi-save\" class=\"button\" type=\"button\">Save working Wi-Fi to NVS</button>"
"<div class=\"serial-actions\"><button id=\"network-apply\" class=\"button\" type=\"button\">Apply selected Wi-Fi target to RAM</button>"
"<button id=\"network-wifi-save\" class=\"button\" type=\"button\">Save working Wi-Fi to NVS</button>"
"<button id=\"network-wifi-load\" class=\"button\" type=\"button\">Load stored Wi-Fi</button>"
"<button id=\"network-start\" class=\"button\" type=\"button\">Start</button><button id=\"network-stop\" class=\"button\" type=\"button\">Stop</button>"
"</div><h3>Wi-Fi connection</h3><div class=\"serial-actions\"><button id=\"network-start\" class=\"button\" type=\"button\">Start</button><button id=\"network-stop\" class=\"button\" type=\"button\">Stop</button>"
"<button id=\"network-reconnect\" class=\"button\" type=\"button\">Reconnect</button><button id=\"network-next-profile\" class=\"button\" type=\"button\">Next profile</button></div>"
"<h3>mDNS</h3><p>STA-only responder. Expected announcement is not client-verified DNS. "
"<h3>mDNS</h3><p class=\"connection-detail\">STA-only responder. Expected announcement is not client-verified DNS. "
"Suffix edits, Load and Defaults change RAM and request live reannouncement; queue failure can leave RAM changed. "
"Offline edits are used on the next STA IP. mDNS Load may select deterministic MAC defaults. Save explicitly persists.</p>"
"<div class=\"serial-edit\"><label>Hostname suffix (sak-… .local)<input id=\"network-suffix\" maxlength=\"55\" autocomplete=\"off\" spellcheck=\"false\"></label></div>"
"<p>155 lowercase ASCII letters, digits or hyphens; no leading/trailing hyphen.</p><div class=\"serial-actions\">"
"<div class=\"settings-edit\"><label>Hostname suffix (sak-… .local)<input id=\"network-suffix\" maxlength=\"55\" autocomplete=\"off\" spellcheck=\"false\"></label></div>"
"<p class=\"connection-detail\">155 lowercase ASCII letters, digits or hyphens; no leading/trailing hyphen.</p><div class=\"serial-actions\">"
"<button id=\"network-mdns-set\" class=\"button\" type=\"button\">Apply mDNS suffix to RAM</button>"
"<button id=\"network-mdns-save\" class=\"button\" type=\"button\">Save working mDNS to NVS</button>"
"<button id=\"network-mdns-load\" class=\"button\" type=\"button\">Load mDNS</button>"
"<button id=\"network-mdns-defaults\" class=\"button\" type=\"button\">mDNS defaults in RAM</button></div></div>"
"<button id=\"network-result\" class=\"button\" type=\"button\">Check network operation result</button>"
"<p id=\"network-operation-detail\" role=\"status\">After uncertainty, Check Result and Refresh. Never assume timeout or navigation cancels work; no automatic mutation retry.</p></div>"
"<button id=\"network-result\" class=\"button\" type=\"button\">Check Operation Result</button>"
"<p id=\"network-operation-detail\" class=\"connection-detail\" role=\"status\">After uncertainty, Check Result and Refresh. Never assume timeout or navigation cancels work; no automatic mutation retry.</p></div>"
"<div id=\"serial-settings-content\"><h2>Serial</h2><p class=\"connection-detail\">Working UART1 configuration below is not a saved NVS snapshot. "
"Navigation leaves both terminals connected and preserves the serial writer lease.</p>"
"<button id=\"refresh-settings\" class=\"button\" type=\"button\">Refresh</button>"
@@ -264,9 +269,9 @@ static const char s_index_html[] =
"<div id=\"account-settings\" hidden><h2>Accounts</h2>"
"<p class=\"connection-detail\">Role and deletion changes are saved immediately and revoke only the affected account's network sessions. The final administrator is protected. "
"Self changes can immediately close all this account's web/SSH sessions, including browser serial/admin. A disconnect or 401 is NOT proof of success; re-login and inspect before retrying.</p>"
"<button id=\"refresh-accounts\" class=\"button\" type=\"button\">Refresh accounts</button>"
"<button id=\"refresh-accounts\" class=\"button\" type=\"button\">Refresh</button>"
"<p id=\"accounts-detail\" class=\"connection-detail\" role=\"status\">Select Refresh to read accounts.</p>"
"<pre id=\"accounts-list\"></pre><div class=\"serial-edit\">"
"<dl id=\"accounts-list\" class=\"settings-values\"></dl><h3>Account selection / Edit account</h3><div class=\"settings-edit\">"
"<label>Account<select id=\"account-target\">"
"<option id=\"account-option-0\" value=\"0\"></option><option id=\"account-option-1\" value=\"1\"></option>"
"<option id=\"account-option-2\" value=\"2\"></option><option id=\"account-option-3\" value=\"3\"></option>"
@@ -275,24 +280,24 @@ static const char s_index_html[] =
"<label>New role<select id=\"account-role\"><option value=\"user\">user</option><option value=\"admin\">admin</option></select></label></div>"
"<div class=\"serial-actions\"><button id=\"account-change-role\" class=\"button\" type=\"button\">Change role</button>"
"<button id=\"account-delete\" class=\"button\" type=\"button\">Delete account</button>"
"<button id=\"account-result\" class=\"button\" type=\"button\">Check Result</button></div>"
"<h3>Authorized SSH public keys</h3><p>Paste one OpenSSH Ed25519 or ECDSA P-256 public key (maximum 384 UTF-8 bytes). Public keys only; no private-key upload, export or SSH host management. Up to three keys per account.</p>"
"<button id=\"account-result\" class=\"button\" type=\"button\">Check Operation Result</button></div>"
"<h3>Authorized SSH public keys</h3><p class=\"connection-detail\">Paste one OpenSSH Ed25519 or ECDSA P-256 public key (maximum 384 UTF-8 bytes). Public keys only; no private-key upload, export or SSH host management. Up to three keys per account.</p>"
"<button id=\"account-keys-refresh\" class=\"button\" type=\"button\">Read selected account keys</button>"
"<p id=\"account-keys-detail\" role=\"status\"></p><pre id=\"account-keys-list\"></pre>"
"<p id=\"account-keys-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"account-keys-list\" class=\"settings-values\"></dl><div class=\"settings-edit\">"
"<label>Key to delete<select id=\"account-key-index\"><option id=\"key-option-0\" value=\"0\"></option><option id=\"key-option-1\" value=\"1\"></option><option id=\"key-option-2\" value=\"2\"></option></select></label>"
"<label>OpenSSH public key<textarea id=\"account-public-key\" maxlength=\"384\" autocomplete=\"off\" spellcheck=\"false\"></textarea></label>"
"<label class=\"settings-wide\">OpenSSH public key<textarea id=\"account-public-key\" maxlength=\"384\" autocomplete=\"off\" spellcheck=\"false\"></textarea></label></div>"
"<div class=\"serial-actions\"><button id=\"account-key-add\" class=\"button\" type=\"button\">Import public key</button>"
"<button id=\"account-key-delete\" class=\"button\" type=\"button\">Delete selected key</button><button id=\"account-key-clear\" class=\"button\" type=\"button\">Clear all authorized keys</button></div>"
"<div class=\"serial-edit\"><label>Purpose<select id=\"account-purpose\"><option value=\"create\">Create account</option><option value=\"password\">Change selected account password</option></select></label>"
"<h3>Create account / Change password</h3><div class=\"settings-edit\"><label>Purpose<select id=\"account-purpose\"><option value=\"create\">Create account</option><option value=\"password\">Change selected account password</option></select></label>"
"<label id=\"account-username-label\">New username<input id=\"account-username\" maxlength=\"16\" autocomplete=\"off\"></label>"
"<label id=\"account-create-role-label\">Initial role<select id=\"account-create-role\"><option value=\"user\">user</option><option value=\"admin\">admin</option></select></label>"
"<label>Password<input id=\"account-password\" type=\"password\" maxlength=\"64\" autocomplete=\"new-password\"></label>"
"<label>Confirm password<input id=\"account-password-confirm\" type=\"password\" maxlength=\"64\" autocomplete=\"new-password\"></label></div>"
"<p class=\"connection-detail\">12-64 printable ASCII characters; spaces are significant. Confirm every password. Save it before submitting, especially for your own account.</p>"
"<button id=\"account-generate\" class=\"button\" type=\"button\">Generate password (not applied)</button>"
"<div id=\"account-generated-panel\" hidden><label>Generated password — not applied yet<input id=\"account-generated\" readonly autocomplete=\"off\"></label>"
"<p>One-time display, no retrieval. Copy and save now; this display and password fields expire after 60 seconds. Enter it in Confirm password.</p>"
"<label><input id=\"account-password-saved\" type=\"checkbox\">I saved this exact password for this operation and account.</label></div>"
"<div id=\"account-generated-panel\" class=\"settings-edit\" hidden><label class=\"settings-wide\">Generated password — not applied yet<input id=\"account-generated\" readonly autocomplete=\"off\"></label>"
"<p class=\"connection-detail settings-wide\">One-time display, no retrieval. Copy and save now; this display and password fields expire after 60 seconds. Enter it in Confirm password.</p>"
"<label class=\"settings-check settings-wide\"><input id=\"account-password-saved\" type=\"checkbox\">I saved this exact password for this operation and account.</label></div>"
"<p class=\"connection-detail\">Secrets are cleared on submission and when leaving or changing this form. JavaScript cannot securely zero strings or browser-managed copies.</p>"
"<p id=\"account-secret-detail\" class=\"connection-detail\" role=\"status\"></p>"
"<button id=\"account-submit-password\" class=\"button\" type=\"button\">Submit create / password change</button>"
@@ -307,6 +312,13 @@ static const char s_app_js[] =
"(() => {\n"
"'use strict';\n"
"const element = (id) => document.getElementById(id);\n"
"function settingsRows(target, rows) {\n"
" target.textContent = '';\n"
" for (const [label, value] of rows) {\n"
" const dt = document.createElement('dt'), dd = document.createElement('dd');\n"
" dt.textContent = label; dd.textContent = value; target.appendChild(dt); target.appendChild(dd);\n"
" }\n"
"}\n"
"const connectionStatus = element('connection-status');\n"
"const roleStatus = element('role-status');\n"
"const clientIdField = element('client-id');\n"
@@ -491,7 +503,7 @@ static const char s_app_js[] =
" if (!p || Object.keys(p).length !== 4 || p.username !== t.username || p.user_id !== t.user_id || p.auth_generation !== t.auth_generation) throw new Error('Stale key identity');\n"
" if (!Array.isArray(p.keys) || p.keys.length > 3 || new Set(p.keys.map(k => k?.index)).size !== p.keys.length || !p.keys.every(k => k && Object.keys(k).length === 3 && Number.isInteger(k.index) && k.index >= 0 && k.index <= 2 && ['ssh-ed25519','ecdsa-sha2-nistp256'].includes(k.type) && typeof k.fingerprint === 'string' && /^SHA256:[A-Za-z0-9+/]{43}$/.test(k.fingerprint))) throw new Error('Invalid keys');\n"
" accountKeys = p.keys; keysIdentity = identity;\n"
" element('account-keys-list').textContent = accountKeys.map(k => k.index + ': ' + k.type + ' ' + k.fingerprint).join('\\n');\n"
" settingsRows(element('account-keys-list'), accountKeys.map(k => [k.index + ': ', k.type + ' ' + k.fingerprint]));\n"
" for (let i = 0; i < 3; ++i) { const o = element('key-option-' + i), key = accountKeys.find(k => k.index === i); o.value = String(i); o.textContent = key ? key.index + ': ' + key.fingerprint : ''; o.hidden = o.disabled = !key; }\n"
" element('account-key-index').value = accountKeys.length ? String(accountKeys[0].index) : ''; element('account-keys-detail').textContent = t.username + ': ' + accountKeys.length + ' authorized keys. List refreshed.';\n"
" } catch (error) { if (current()) { clearAccountKeys(); element('account-keys-detail').textContent = error.status === 409 || error.message === 'Stale key identity' ? 'Account identity stale. Refresh accounts and select the target again; no automatic retry.' : 'Keys unavailable or invalid. Refresh accounts or explicitly read keys again; no automatic retry.'; accountButtons(); } }\n"
@@ -568,7 +580,7 @@ static const char s_app_js[] =
" !payload.users.every(u => u && Object.keys(u).length === 4 && typeof u.username === 'string' && /^[a-z][a-z0-9_-]{0,15}$/.test(u.username) && validId(u.user_id) && validId(u.auth_generation) && ['user','admin'].includes(u.role)) ||\n"
" new Set(payload.users.map(u => u.username)).size !== payload.users.length) throw new Error('Invalid account list');\n"
" clearAccountSecret(); accounts = payload.users;\n"
" element('accounts-list').textContent = accounts.map(u => u.username + ' — ' + u.role + (u.username === sessionIdentity.username ? ' (you)' : '')).join('\\n');\n"
" settingsRows(element('accounts-list'), accounts.map(u => [u.username, u.role + (u.username === sessionIdentity.username ? ' (you)' : '')]));\n"
" for (let i = 0; i < 8; ++i) { const option = element('account-option-' + i); option.textContent = accounts[i]?.username || ''; option.hidden = option.disabled = !accounts[i]; }\n"
" const index = Math.max(0, accounts.findIndex(t => t.username === previous?.username && t.user_id === previous?.user_id));\n"
" element('account-target').value = String(index); element('account-role').value = accounts[index]?.role || 'user';\n"
@@ -779,8 +791,14 @@ static const char s_app_js[] =
" if (status !== 200 || !validateNetwork(payload)) throw new Error('Invalid network snapshot');\n"
" networkSnapshot = payload; networkFresh = true;\n"
" const w = payload.wifi, r = payload.runtime, m = payload.mdns;\n"
" net('summary').textContent = 'Wi-Fi generation ' + w.generation + '; boot enabled: ' + w.enabled_at_boot + '\\nAP ' + w.ap.policy + ', channel ' + w.ap.channel + ', ' + networkSSIDSummary(w.ap.ssid) + ', password configured: ' + w.ap.password_configured + '\\n' +\n"
" w.profiles.map(p => 'STA ' + p.index + ': enabled ' + p.enabled + ', priority ' + p.priority + ', ' + p.security + ', ' + networkSSIDSummary(p.ssid) + ', password configured: ' + p.password_configured).join('\\n') + '\\nRuntime: ' + r.state + ', started ' + r.started + ', active profile ' + r.active_profile + ', IP ' + (r.ip || 'none') + ', AP running ' + r.ap_running + ', AP clients ' + r.ap_clients + ', last error ' + r.last_error + '\\nmDNS generation ' + m.generation + ': ' + m.hostname + '.local; expected announcement ' + m.announced + ', last error ' + m.last_error + '. Not client-verified DNS.';\n"
" settingsRows(net('summary'), [\n"
" ['Wi-Fi generation', w.generation], ['Enabled at boot', w.enabled_at_boot],\n"
" ['AP policy / channel', w.ap.policy + ' / ' + w.ap.channel], ['AP SSID', networkSSIDSummary(w.ap.ssid)], ['AP password configured', w.ap.password_configured],\n"
" ...w.profiles.flatMap(p => [['STA ' + p.index, 'enabled ' + p.enabled + ', priority ' + p.priority + ', ' + p.security], ['STA ' + p.index + ' SSID', networkSSIDSummary(p.ssid)], ['STA ' + p.index + ' password configured', p.password_configured]]),\n"
" ['Runtime: ', r.state], ['Started', r.started], ['Active profile', r.active_profile], ['IP', r.ip || 'none'],\n"
" ['AP running', r.ap_running], ['AP clients', r.ap_clients], ['Wi-Fi last error', r.last_error],\n"
" ['mDNS generation', m.generation], ['Hostname', m.hostname + '.local'], ['Expected announcement', m.announced],\n"
" ['mDNS last error', m.last_error], ['DNS verification', 'Not client-verified DNS.']]);\n"
" if (!['ap','0','1','2','3'].includes(net('target').value)) net('target').value = 'ap';\n"
" renderNetworkTarget(); net('suffix').value = m.suffix; net('edit').hidden = false;\n"
" net('detail').textContent = (networkPending ? 'Snapshot may be stale: outcome pending or unknown. ' : 'Working snapshot refreshed (Wi-Fi and mDNS are separate consistent copies). ') + 'Browser drafts are not saved; Save persists device working state.';\n"