1670 lines
98 KiB
C
1670 lines
98 KiB
C
/* SPDX-License-Identifier: GPL-3.0-only */
|
||
/* Offline browser UI response helpers for public and session-authenticated HTTPS routes. */
|
||
|
||
#include "web_ui.h"
|
||
|
||
#include <stdbool.h>
|
||
#include <stddef.h>
|
||
#include <stdint.h>
|
||
|
||
#include "web_assets_data.h"
|
||
|
||
#define WEB_UI_DOCUMENT_CACHE_CONTROL "no-store"
|
||
#define WEB_UI_AUTHORED_ASSET_CACHE_CONTROL "private, max-age=300"
|
||
#define WEB_UI_ASSET_CACHE_CONTROL "private, max-age=604800"
|
||
|
||
static const char s_index_html[] =
|
||
"<!doctype html>\n"
|
||
"<html lang=\"en\">\n"
|
||
"<head>\n"
|
||
"<meta charset=\"utf-8\">\n"
|
||
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n"
|
||
"<meta name=\"color-scheme\" content=\"dark\">\n"
|
||
"<title>ESP32 Serial Console</title>\n"
|
||
"<style>\n"
|
||
":root{color-scheme:dark;--bg:#090d14;--panel:#111824;--panel-2:#161f2e;"
|
||
"--line:#29364a;--text:#e8eef8;--muted:#91a0b5;--accent:#55c2ff;"
|
||
"--good:#52d68b;--warn:#ffc857;--bad:#ff6b7a;--radius:14px}\n"
|
||
"*{box-sizing:border-box}\n"
|
||
"html,body{height:100%;margin:0;overflow:hidden}\n"
|
||
"body{background:radial-gradient(circle at top left,#142033 0,var(--bg) 42rem);"
|
||
"color:var(--text);font:14px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"
|
||
"\"Segoe UI\",sans-serif}\n"
|
||
"button{font:inherit}\n"
|
||
"[hidden]{display:none!important}\n"
|
||
".page{height:100%;height:100dvh;min-height:0;max-width:1440px;margin:auto;"
|
||
"padding:clamp(14px,2.5vw,32px);display:grid;"
|
||
"grid-template-rows:auto auto minmax(0,1fr);gap:16px}\n"
|
||
".topbar{display:flex;align-items:center;justify-content:space-between;gap:16px}\n"
|
||
".brand{display:flex;align-items:center;gap:12px;min-width:0}\n"
|
||
".topbar-actions{display:flex;align-items:center;justify-content:flex-end;gap:10px;min-width:0}\n"
|
||
".account{display:flex;align-items:center;gap:9px;min-width:0;border:1px solid var(--line);"
|
||
"border-radius:10px;padding:6px 10px;background:#101824cc}\n"
|
||
".account-icon{display:grid;place-items:center;width:28px;height:28px;flex:0 0 auto;border-radius:50%;"
|
||
"background:#17334a;color:var(--accent);font-weight:850}\n"
|
||
".account-copy{display:flex;flex-direction:column;min-width:0;line-height:1.2}\n"
|
||
".account-username{max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:750}\n"
|
||
".account-role{color:var(--muted);font-size:11px;font-weight:750;letter-spacing:.06em;text-transform:uppercase}\n"
|
||
".logo{width:42px;height:42px;border:1px solid #347ba5;border-radius:12px;"
|
||
"display:block;padding:4px;background:#10273a;object-fit:contain;"
|
||
"box-shadow:0 0 30px #1c8fc333}\n"
|
||
"h1{font-size:clamp(18px,3vw,24px);line-height:1.15;margin:0}\n"
|
||
".subtitle{color:var(--muted);margin:3px 0 0;font-size:13px}\n"
|
||
".badge{display:inline-flex;align-items:center;gap:7px;white-space:nowrap;"
|
||
"border:1px solid var(--line);border-radius:999px;padding:6px 10px;"
|
||
"background:#121a26;color:var(--muted);font-weight:700;font-size:12px}\n"
|
||
".badge:before{content:\"\";width:7px;height:7px;border-radius:50%;background:currentColor;"
|
||
"box-shadow:0 0 10px currentColor}\n"
|
||
".badge[data-tone=good]{color:var(--good);border-color:#245a42}\n"
|
||
".badge[data-tone=warn]{color:var(--warn);border-color:#675526}\n"
|
||
".badge[data-tone=bad]{color:var(--bad);border-color:#64313b}\n"
|
||
".dashboard{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(280px,.65fr);gap:16px}\n"
|
||
".panel{background:linear-gradient(145deg,#151e2cdd,#0f1621ee);border:1px solid var(--line);"
|
||
"border-radius:var(--radius);box-shadow:0 16px 45px #0005}\n"
|
||
".status-grid{padding:16px;display:grid;grid-template-columns:repeat(4,minmax(105px,1fr));gap:12px}\n"
|
||
".status-item{min-width:0;padding:10px 12px;background:#0b111b99;border:1px solid #202c3e;"
|
||
"border-radius:10px}\n"
|
||
".status-item.interactive{cursor:pointer;transition:border-color .15s,background .15s,transform .15s}\n"
|
||
".status-item.interactive:hover,.status-item.interactive:focus-visible{outline:none;border-color:#4b789f;"
|
||
"background:#101c2b;transform:translateY(-1px)}\n"
|
||
".status-item.wide{grid-column:span 2}\n"
|
||
".label{display:block;color:var(--muted);font-size:11px;font-weight:700;"
|
||
"letter-spacing:.08em;text-transform:uppercase;margin-bottom:6px}\n"
|
||
".value{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:650}\n"
|
||
".controls{padding:16px;display:flex;flex-direction:column;justify-content:center;gap:12px}\n"
|
||
".button-row{display:flex;flex-wrap:wrap;gap:9px}\n"
|
||
".button{min-height:38px;border:1px solid #36506d;border-radius:9px;padding:8px 13px;"
|
||
"background:#18263a;color:var(--text);font-weight:750;cursor:pointer;"
|
||
"transition:background .15s,border-color .15s,transform .15s}\n"
|
||
".button:hover:not(:disabled){background:#203652;border-color:#4b789f;transform:translateY(-1px)}\n"
|
||
".button.primary{background:#126390;border-color:#278abd}\n"
|
||
".button.danger{background:#512631;border-color:#81404e}\n"
|
||
".button:disabled{cursor:not-allowed;opacity:.42}\n"
|
||
".input-state{margin:0;color:var(--warn);font-size:13px}\n"
|
||
".input-state[data-enabled=true]{color:var(--good)}\n"
|
||
".connection-detail{margin:0;color:var(--muted);font-size:12px;min-height:1.45em}\n"
|
||
".session-notice{display:none;margin:0;padding:8px 10px;border:1px solid #64313b;border-radius:8px;"
|
||
"background:#351b23;color:#ffd9de;font-size:12px}\n"
|
||
".session-notice[data-visible=true]{display:block}\n"
|
||
".terminal-panel{min-width:0;min-height:0;padding:10px;display:flex;flex-direction:column;overflow:hidden}\n"
|
||
".terminal-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;"
|
||
"padding:1px 5px 9px;color:var(--muted);font-size:12px}\n"
|
||
".terminal-heading{display:flex;align-items:center;gap:12px;min-width:0}\n"
|
||
".terminal-title{color:var(--text);font-weight:750;letter-spacing:.02em}\n"
|
||
".terminal-modes{display:inline-flex;padding:2px;border:1px solid var(--line);border-radius:9px;background:#0b111b}\n"
|
||
".mode-button{min-height:28px;border:0;border-radius:6px;padding:4px 9px;background:transparent;color:var(--muted);"
|
||
"font-size:12px;font-weight:750;cursor:pointer}\n"
|
||
".mode-button[aria-selected=true]{background:#1b3851;color:var(--text)}\n"
|
||
".terminal-host{flex:1;min-width:0;min-height:0;border-radius:9px;overflow:hidden;"
|
||
"background:#080c12;padding:8px}\n"
|
||
".terminal-host .xterm{width:100%;height:100%}\n"
|
||
".terminal-host .xterm-viewport{border-radius:7px}\n"
|
||
".popover{position:fixed;z-index:20;width:min(92vw,520px);max-height:min(72vh,620px);overflow:auto;"
|
||
"border:1px solid #3c5877;border-radius:13px;padding:14px;background:#111a28;box-shadow:0 24px 70px #000b}\n"
|
||
".popover-head,.settings-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}\n"
|
||
".popover h2,.settings h2,.settings h3{margin:0}.popover h2,.settings h2{font-size:17px}.settings h3{font-size:14px}\n"
|
||
".icon-button{width:34px;height:34px;border:1px solid var(--line);border-radius:8px;background:#18263a;color:var(--text);cursor:pointer}\n"
|
||
".popover-list{display:grid;gap:8px}.client-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;"
|
||
"padding:10px;border:1px solid #29364a;border-radius:9px;background:#0b111b}\n"
|
||
".client-meta{color:var(--muted);font-size:12px}.quick-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}\n"
|
||
".settings-backdrop{position:fixed;z-index:30;inset:0;display:grid;place-items:center;padding:20px;background:#02050acc}\n"
|
||
".settings{width:min(96vw,1040px);max-height:90vh;overflow:auto;border:1px solid #3c5877;border-radius:16px;"
|
||
"padding:18px;background:#111a28;box-shadow:0 28px 90px #000d}\n"
|
||
".settings-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.settings-section{padding:14px;"
|
||
"border:1px solid var(--line);border-radius:11px;background:#0b111b}\n"
|
||
".settings-section.wide{grid-column:1/-1}.settings-form{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-top:12px}\n"
|
||
".settings-form label{display:grid;gap:5px;color:var(--muted);font-size:11px}.settings-form .span-2{grid-column:span 2}.settings-form .span-4{grid-column:1/-1}\n"
|
||
".settings input,.settings select,.settings textarea{min-width:0;width:100%;min-height:36px;border:1px solid #344760;border-radius:8px;"
|
||
"padding:7px;background:#080c12;color:var(--text);font:inherit}.settings textarea{min-height:72px;resize:vertical}.settings-message{min-height:1.4em;color:var(--muted);font-size:12px}\n"
|
||
".settings-subsection{margin-top:12px;padding-top:12px;border-top:1px solid var(--line)}.settings-subsection h4{margin:0;font-size:13px}.secret-state{color:var(--muted);font-size:11px}\n"
|
||
".key-list{display:grid;gap:7px;margin-top:10px}.key-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;align-items:center;padding:8px;border:1px solid var(--line);border-radius:8px}\n"
|
||
".secret-output{margin-top:10px;padding:10px;border:1px solid #675526;border-radius:8px;background:#2d2613;color:#ffe7a3}.secret-output code{user-select:all;overflow-wrap:anywhere}\n"
|
||
"@media(max-width:700px){.settings-grid{grid-template-columns:1fr}.settings-section.wide{grid-column:auto}.settings-form{grid-template-columns:1fr 1fr}}\n"
|
||
"@media(max-width:850px){.dashboard{grid-template-columns:1fr}.controls{align-items:flex-start}"
|
||
".status-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}\n"
|
||
"@media(max-width:700px){.topbar{flex-wrap:wrap}.topbar-actions{width:100%}}\n"
|
||
"@media(max-width:480px){.page{padding:10px;gap:10px}.topbar{align-items:flex-start}"
|
||
".topbar-actions{justify-content:space-between}.account{min-width:0}.account-username{max-width:120px}"
|
||
".logo{width:36px;height:36px}.status-grid{padding:10px;gap:8px}"
|
||
".status-item{padding:9px}.controls{padding:12px}.terminal-panel{padding:7px}"
|
||
".button-row{display:grid;grid-template-columns:1fr 1fr;width:100%}"
|
||
".button:last-child{grid-column:1/-1}.page{grid-template-rows:auto auto minmax(0,1fr)}}\n"
|
||
"</style>\n"
|
||
"<link rel=\"icon\" href=\"data:,\">\n"
|
||
"<link rel=\"stylesheet\" href=\"/assets/xterm.css\">\n"
|
||
"<script defer src=\"/assets/xterm.js\"></script>\n"
|
||
"<script defer src=\"/assets/addon-fit.js\"></script>\n"
|
||
"<script defer src=\"/assets/app.js\"></script>\n"
|
||
"</head>\n"
|
||
"<body>\n"
|
||
"<main class=\"page\">\n"
|
||
"<header class=\"topbar\">\n"
|
||
"<div class=\"brand\"><img class=\"logo\" src=\"/assets/logo.png\""
|
||
"alt=\"ESP32 Serial Swiss Army Knife logo\"><div>"
|
||
"<h1>ESP32 Serial Console</h1><p class=\"subtitle\">Secure local serial workspace</p></div></div>\n"
|
||
"<div class=\"topbar-actions\">\n"
|
||
"<div class=\"account\" aria-label=\"Signed-in account\"><span class=\"account-icon\" aria-hidden=\"true\">@</span>"
|
||
"<span class=\"account-copy\"><span id=\"account-username\" class=\"account-username\">Loading…</span>"
|
||
"<span id=\"account-role\" class=\"account-role\">Account</span></span></div>\n"
|
||
"<span id=\"connection-status\" class=\"badge\" data-tone=\"warn\">Connecting</span>\n"
|
||
"<button id=\"settings-open\" class=\"button\" type=\"button\" hidden>Settings</button>\n"
|
||
"<button id=\"logout\" class=\"button\" type=\"button\" disabled>Logout</button>\n"
|
||
"</div>\n"
|
||
"</header>\n"
|
||
"<section class=\"dashboard\" aria-label=\"Connection and device status\">\n"
|
||
"<div class=\"panel status-grid\">\n"
|
||
"<div class=\"status-item\"><span class=\"label\">Broker role</span>"
|
||
"<span id=\"role-status\" class=\"badge\" data-tone=\"warn\">Observer</span></div>\n"
|
||
"<div class=\"status-item\"><span class=\"label\">Broker client</span>"
|
||
"<span id=\"client-id\" class=\"value\">—</span></div>\n"
|
||
"<div id=\"writer-card\" class=\"status-item\" tabindex=\"-1\" aria-haspopup=\"dialog\"><span class=\"label\">Active writer</span>"
|
||
"<span id=\"writer-id\" class=\"value\">None</span></div>\n"
|
||
"<div id=\"clients-card\" class=\"status-item\" tabindex=\"-1\" aria-haspopup=\"dialog\"><span class=\"label\">Broker clients</span>"
|
||
"<span id=\"broker-clients\" class=\"value\">—</span></div>\n"
|
||
"<div id=\"wifi-card\" class=\"status-item wide\" tabindex=\"-1\" aria-haspopup=\"dialog\"><span class=\"label\">Wi-Fi</span>"
|
||
"<span id=\"wifi-summary\" class=\"value\">Loading…</span></div>\n"
|
||
"<div id=\"serial-card\" class=\"status-item wide\" tabindex=\"-1\" aria-haspopup=\"dialog\"><span class=\"label\">Serial</span>"
|
||
"<span id=\"serial-summary\" class=\"value\">Loading…</span></div>\n"
|
||
"</div>\n"
|
||
"<div class=\"panel controls\">\n"
|
||
"<div class=\"button-row\">\n"
|
||
"<button id=\"request-control\" class=\"button primary\" type=\"button\" disabled>Request control</button>\n"
|
||
"<button id=\"release-control\" class=\"button danger\" type=\"button\" disabled>Release control</button>\n"
|
||
"<button id=\"connection-toggle\" class=\"button danger\" type=\"button\">Disconnect</button>\n"
|
||
"</div>\n"
|
||
"<p id=\"input-state\" class=\"input-state\" data-enabled=\"false\" aria-live=\"polite\">"
|
||
"Observer mode — terminal input is disabled.</p>\n"
|
||
"<p id=\"connection-detail\" class=\"connection-detail\" aria-live=\"polite\">"
|
||
"Requesting a one-time connection ticket…</p>\n"
|
||
"<p id=\"session-notice\" class=\"session-notice\" data-visible=\"false\" role=\"alert\"></p>\n"
|
||
"</div>\n"
|
||
"</section>\n"
|
||
"<section class=\"panel terminal-panel\" aria-label=\"Terminal workspace\">\n"
|
||
"<div class=\"terminal-toolbar\"><div class=\"terminal-heading\">"
|
||
"<span id=\"terminal-title\" class=\"terminal-title\">Live serial stream</span>"
|
||
"<div id=\"terminal-modes\" class=\"terminal-modes\" role=\"tablist\" aria-label=\"Terminal mode\" hidden>"
|
||
"<button id=\"serial-mode\" class=\"mode-button\" type=\"button\" role=\"tab\" aria-selected=\"true\">Serial terminal</button>"
|
||
"<button id=\"admin-mode\" class=\"mode-button\" type=\"button\" role=\"tab\" aria-selected=\"false\">Admin shell</button>"
|
||
"</div></div>\n"
|
||
"<span id=\"terminal-detail\">Binary, unmodified device output</span></div>\n"
|
||
"<div id=\"serial-terminal\" class=\"terminal-host\" role=\"tabpanel\"></div>\n"
|
||
"<div id=\"admin-terminal\" class=\"terminal-host\" role=\"tabpanel\" hidden></div>\n"
|
||
"</section>\n"
|
||
"<aside id=\"quick-popover\" class=\"popover\" role=\"dialog\" aria-modal=\"false\" hidden>"
|
||
"<div class=\"popover-head\"><h2 id=\"popover-title\">Quick settings</h2>"
|
||
"<button id=\"popover-close\" class=\"icon-button\" type=\"button\" aria-label=\"Close\">×</button></div>"
|
||
"<div id=\"popover-content\"></div></aside>\n"
|
||
"<div id=\"settings-backdrop\" class=\"settings-backdrop\" hidden>"
|
||
"<section class=\"settings\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"settings-title\">"
|
||
"<div class=\"settings-head\"><div><h2 id=\"settings-title\">Device settings</h2>"
|
||
"<span class=\"client-meta\">Administrator-only typed controls</span></div>"
|
||
"<button id=\"settings-close\" class=\"icon-button\" type=\"button\" aria-label=\"Close settings\">×</button></div>"
|
||
"<div class=\"settings-grid\">"
|
||
"<section class=\"settings-section wide\"><h3>Serial</h3>"
|
||
"<form id=\"serial-settings-form\" class=\"settings-form\">"
|
||
"<label class=\"span-2\">Baud<input id=\"serial-baud\" name=\"baud\" type=\"number\" min=\"110\" max=\"1000000\"></label>"
|
||
"<label>Data bits<select id=\"serial-data\"><option>7</option><option>8</option></select></label>"
|
||
"<label>Parity<select id=\"serial-parity\"><option>none</option><option>even</option><option>odd</option></select></label>"
|
||
"<label>Stop bits<select id=\"serial-stop-bits\"><option>1</option><option>2</option></select></label>"
|
||
"<label>Flow<select id=\"serial-flow\"><option>none</option><option>rts-cts</option></select></label>"
|
||
"<label>DTR<select id=\"serial-dtr\"><option>inactive</option><option>active</option><option>on-connect</option></select></label>"
|
||
"<label>RTS threshold<input id=\"serial-rts\" type=\"number\" min=\"1\" max=\"127\"></label>"
|
||
"</form><div class=\"quick-actions\">"
|
||
"<button id=\"serial-apply\" class=\"button primary\" type=\"button\">Apply</button>"
|
||
"<button id=\"serial-save\" class=\"button\" type=\"button\">Save</button>"
|
||
"<button id=\"serial-start\" class=\"button\" type=\"button\">Start</button>"
|
||
"<button id=\"serial-stop\" class=\"button\" type=\"button\">Stop</button>"
|
||
"<button id=\"serial-load\" class=\"button\" type=\"button\">Load saved</button>"
|
||
"<button id=\"serial-defaults\" class=\"button\" type=\"button\">Defaults</button>"
|
||
"<button id=\"serial-reset\" class=\"button danger\" type=\"button\">Reset</button>"
|
||
"</div></section>"
|
||
"<section class=\"settings-section wide\"><h3>Users and SSH access</h3>"
|
||
"<p class=\"client-meta\">Passwords and public keys are write-only. Account changes revoke that account's active browser and SSH sessions.</p>"
|
||
"<div class=\"settings-subsection\"><h4>Create account</h4><form id=\"user-create-form\" class=\"settings-form\">"
|
||
"<label>Username<input id=\"user-create-name\" autocomplete=\"off\" maxlength=\"16\" pattern=\"[a-z][a-z0-9_-]{0,15}\" required></label>"
|
||
"<label>Role<select id=\"user-create-role\"><option value=\"user\">user</option><option value=\"admin\">admin</option></select></label>"
|
||
"<label>Password mode<select id=\"user-create-password-mode\"><option value=\"provided\">Set password</option><option value=\"generated\">Generate password</option></select></label>"
|
||
"<label>Password<input id=\"user-create-password\" type=\"password\" autocomplete=\"new-password\" minlength=\"12\" maxlength=\"64\"></label>"
|
||
"</form><div class=\"quick-actions\"><button id=\"user-create\" class=\"button primary\" type=\"button\">Create account</button></div></div>"
|
||
"<div class=\"settings-subsection\"><h4>Edit account</h4><form id=\"user-edit-form\" class=\"settings-form\">"
|
||
"<label class=\"span-2\">Account<select id=\"user-select\"></select></label>"
|
||
"<label>Role<select id=\"user-role\"><option value=\"user\">user</option><option value=\"admin\">admin</option></select></label>"
|
||
"<label>Identity<span id=\"user-identity\" class=\"value\">—</span></label>"
|
||
"<label class=\"span-2\">New password<input id=\"user-password\" type=\"password\" autocomplete=\"new-password\" minlength=\"12\" maxlength=\"64\"></label>"
|
||
"<label class=\"span-2\">Authorized public key<input id=\"user-key\" autocomplete=\"off\" spellcheck=\"false\" placeholder=\"ssh-ed25519 AAAA… optional-comment\"></label>"
|
||
"</form><div class=\"quick-actions\"><button id=\"user-role-apply\" class=\"button\" type=\"button\">Apply role</button>"
|
||
"<button id=\"user-password-set\" class=\"button\" type=\"button\">Set password</button>"
|
||
"<button id=\"user-password-generate\" class=\"button\" type=\"button\">Generate password</button>"
|
||
"<button id=\"user-key-add\" class=\"button\" type=\"button\">Add key</button>"
|
||
"<button id=\"user-key-clear\" class=\"button danger\" type=\"button\">Clear all keys</button>"
|
||
"<button id=\"user-delete\" class=\"button danger\" type=\"button\">Delete account</button></div>"
|
||
"<div id=\"user-keys\" class=\"key-list\"></div>"
|
||
"<div id=\"generated-password-box\" class=\"secret-output\" hidden><strong>One-time generated password</strong><br>"
|
||
"<code id=\"generated-password\"></code><div class=\"quick-actions\"><button id=\"generated-password-copy\" class=\"button\" type=\"button\">Copy</button>"
|
||
"<button id=\"generated-password-clear\" class=\"button\" type=\"button\">Clear</button></div></div></div></section>"
|
||
"<section class=\"settings-section wide\"><h3>Wi-Fi configuration</h3>"
|
||
"<p class=\"client-meta\">Saved secrets are never displayed. A stale form is rejected rather than overwriting newer changes.</p>"
|
||
"<div class=\"settings-subsection\"><h4>Station profile</h4><form id=\"wifi-profile-form\" class=\"settings-form\">"
|
||
"<label>Slot<select id=\"wifi-profile-slot\"><option value=\"0\">0</option><option value=\"1\">1</option><option value=\"2\">2</option><option value=\"3\">3</option></select></label>"
|
||
"<label class=\"span-2\">SSID<input id=\"wifi-profile-ssid\" maxlength=\"32\" autocomplete=\"off\"></label>"
|
||
"<label>Priority<input id=\"wifi-profile-priority\" type=\"number\" min=\"0\" max=\"255\"></label>"
|
||
"<label>Security<select id=\"wifi-profile-security\"><option value=\"mixed\">WPA2 or stronger</option><option value=\"wpa3\">WPA3 only</option></select></label>"
|
||
"<label>Status<span id=\"wifi-profile-status\" class=\"value\">—</span></label>"
|
||
"<label class=\"span-2\">New secret<input id=\"wifi-profile-secret\" type=\"password\" autocomplete=\"new-password\" minlength=\"8\" maxlength=\"63\"><span id=\"wifi-profile-secret-state\" class=\"secret-state\"></span></label>"
|
||
"</form><div class=\"quick-actions\"><button id=\"wifi-profile-apply\" class=\"button primary\" type=\"button\">Apply profile</button>"
|
||
"<button id=\"wifi-profile-toggle\" class=\"button\" type=\"button\">Enable</button>"
|
||
"<button id=\"wifi-profile-secret-set\" class=\"button\" type=\"button\">Set secret</button>"
|
||
"<button id=\"wifi-profile-delete\" class=\"button danger\" type=\"button\">Delete profile</button></div></div>"
|
||
"<div class=\"settings-subsection\"><h4>Fallback access point</h4><form id=\"wifi-ap-form\" class=\"settings-form\">"
|
||
"<label>Policy<select id=\"wifi-ap-policy\"><option value=\"off\">off</option><option value=\"fallback\">fallback</option><option value=\"always\">always</option></select></label>"
|
||
"<label class=\"span-2\">SSID<input id=\"wifi-ap-ssid\" maxlength=\"32\" autocomplete=\"off\"></label>"
|
||
"<label>Channel<input id=\"wifi-ap-channel\" type=\"number\" min=\"1\" max=\"11\"></label>"
|
||
"<label class=\"span-2\">New secret<input id=\"wifi-ap-secret\" type=\"password\" autocomplete=\"new-password\" minlength=\"8\" maxlength=\"63\"><span id=\"wifi-ap-secret-state\" class=\"secret-state\"></span></label>"
|
||
"</form><div class=\"quick-actions\"><button id=\"wifi-ap-apply\" class=\"button primary\" type=\"button\">Apply AP</button>"
|
||
"<button id=\"wifi-ap-secret-set\" class=\"button\" type=\"button\">Set AP secret</button>"
|
||
"<button id=\"wifi-config-save\" class=\"button\" type=\"button\">Save Wi-Fi configuration</button></div></div></section>"
|
||
"<section class=\"settings-section\"><h3>Display aging</h3><p id=\"display-state\" class=\"client-meta\">Local OLED status unavailable.</p>"
|
||
"<form id=\"display-form\" class=\"settings-form\"><label class=\"span-2\">Dim after seconds<input id=\"display-dim\" type=\"number\" min=\"0\" max=\"86400\" required></label>"
|
||
"<label class=\"span-2\">Off after seconds<input id=\"display-off\" type=\"number\" min=\"0\" max=\"86400\" required></label></form>"
|
||
"<div class=\"quick-actions\"><button id=\"display-apply\" class=\"button primary\" type=\"button\">Apply</button>"
|
||
"<button id=\"display-save\" class=\"button\" type=\"button\">Save</button><button id=\"display-load\" class=\"button\" type=\"button\">Load saved</button>"
|
||
"<button id=\"display-defaults\" class=\"button\" type=\"button\">Defaults</button><button id=\"display-reset\" class=\"button danger\" type=\"button\">Reset</button></div></section>"
|
||
"<section class=\"settings-section\"><h3>Network lifecycle</h3><p class=\"client-meta\">Lifecycle actions never reveal saved Wi-Fi secrets.</p>"
|
||
"<div class=\"quick-actions\"><button class=\"button\" data-wifi-action=\"start\">Start</button>"
|
||
"<button class=\"button\" data-wifi-action=\"stop\">Stop</button>"
|
||
"<button class=\"button primary\" data-wifi-action=\"reconnect\">Reconnect</button>"
|
||
"<button class=\"button\" data-wifi-action=\"next-profile\">Next profile</button></div></section>"
|
||
"<section class=\"settings-section\"><h3>Full administration</h3>"
|
||
"<p class=\"client-meta\">Services, diagnostics, security material, display settings, and uncommon operations remain available through the canonical Admin shell.</p>"
|
||
"<div class=\"quick-actions\"><button id=\"settings-admin-shell\" class=\"button primary\" type=\"button\">Open Admin shell</button></div></section>"
|
||
"</div><p id=\"settings-message\" class=\"settings-message\" aria-live=\"polite\"></p>"
|
||
"</section></div>\n"
|
||
"</main>\n"
|
||
"</body>\n"
|
||
"</html>\n";
|
||
|
||
#define WEB_UI_LOGIN_PREFIX \
|
||
"<!doctype html>\n" \
|
||
"<html lang=\"en\">\n" \
|
||
"<head>\n" \
|
||
"<meta charset=\"utf-8\">\n" \
|
||
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n" \
|
||
"<meta name=\"color-scheme\" content=\"dark\">\n" \
|
||
"<title>Sign in · ESP32 Serial Console</title>\n" \
|
||
"<style>\n" \
|
||
":root{color-scheme:dark;--bg:#090d14;--panel:#111824;--line:#29364a;" \
|
||
"--text:#e8eef8;--muted:#91a0b5;--accent:#55c2ff;--bad:#ff6b7a}\n" \
|
||
"*{box-sizing:border-box}\n" \
|
||
"html,body{min-height:100%;margin:0}\n" \
|
||
"body{min-height:100vh;min-height:100dvh;display:grid;place-items:center;padding:24px;" \
|
||
"background:radial-gradient(circle at 15% 5%,#17304a 0,#101827 24rem,var(--bg) 52rem);" \
|
||
"color:var(--text);font:14px/1.5 system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif}\n" \
|
||
".shell{width:min(100%,430px)}\n" \
|
||
".brand{display:flex;align-items:center;gap:14px;margin:0 0 18px;padding:0 4px}\n" \
|
||
".logo{width:52px;height:52px;display:block;object-fit:contain;padding:5px;border:1px solid #347ba5;" \
|
||
"border-radius:14px;background:#10273a;box-shadow:0 0 34px #1c8fc344}\n" \
|
||
".brand strong{display:block;font-size:17px;line-height:1.2}\n" \
|
||
".brand span{display:block;margin-top:3px;color:var(--muted);font-size:12px}\n" \
|
||
".panel{padding:clamp(24px,7vw,36px);border:1px solid var(--line);border-radius:18px;" \
|
||
"background:linear-gradient(145deg,#151e2cf2,#0e151ff7);box-shadow:0 24px 70px #0008}\n" \
|
||
".eyebrow{margin:0 0 6px;color:var(--accent);font-size:11px;font-weight:800;letter-spacing:.12em;" \
|
||
"text-transform:uppercase}\n" \
|
||
"h1{margin:0;font-size:clamp(25px,7vw,32px);line-height:1.15}\n" \
|
||
".intro{margin:10px 0 24px;color:var(--muted)}\n" \
|
||
".alert{margin:0 0 20px;padding:12px 14px;border:1px solid #713943;border-radius:10px;" \
|
||
"background:#351b23;color:#ffd9de}\n" \
|
||
".alert strong,.alert span{display:block}.alert span{margin-top:2px;font-size:13px}\n" \
|
||
"form{display:grid;gap:16px}\n" \
|
||
"label{display:grid;gap:7px;color:#cbd6e6;font-size:12px;font-weight:750;letter-spacing:.03em}\n" \
|
||
"input{width:100%;min-height:46px;border:1px solid #344760;border-radius:10px;padding:10px 12px;" \
|
||
"outline:none;background:#0a1019;color:var(--text);font:inherit;transition:border-color .15s,box-shadow .15s}\n" \
|
||
"input:focus{border-color:var(--accent);box-shadow:0 0 0 3px #55c2ff22}\n" \
|
||
"button{min-height:46px;margin-top:4px;border:1px solid #278abd;border-radius:10px;padding:10px 16px;" \
|
||
"background:linear-gradient(180deg,#1473a5,#105a83);color:white;font:inherit;font-weight:800;cursor:pointer;" \
|
||
"box-shadow:0 9px 24px #08618b35}\n" \
|
||
"button:hover{background:linear-gradient(180deg,#1982b9,#126590)}button:focus-visible{outline:3px solid #55c2ff55;outline-offset:2px}\n" \
|
||
".privacy{margin:18px 4px 0;color:#718096;text-align:center;font-size:11px}\n" \
|
||
"@media(max-width:480px){body{padding:16px}.panel{border-radius:15px}}\n" \
|
||
"</style>\n" \
|
||
"<link rel=\"icon\" href=\"data:,\">\n" \
|
||
"</head>\n" \
|
||
"<body>\n" \
|
||
"<main class=\"shell\">\n" \
|
||
"<div class=\"brand\"><img class=\"logo\" src=\"/assets/logo.png\" " \
|
||
"alt=\"ESP32 Serial Swiss Army Knife logo\"><div><strong>ESP32 Serial Console</strong>" \
|
||
"<span>Secure local serial workspace</span></div></div>\n" \
|
||
"<section class=\"panel\" aria-labelledby=\"login-title\">\n" \
|
||
"<p class=\"eyebrow\">Device access</p>\n" \
|
||
"<h1 id=\"login-title\">Sign in</h1>\n" \
|
||
"<p class=\"intro\">Use your local device account to continue.</p>\n"
|
||
|
||
#define WEB_UI_LOGIN_FORM \
|
||
"<form method=\"post\" action=\"/login\" accept-charset=\"UTF-8\">\n" \
|
||
"<label for=\"username\">Username" \
|
||
"<input id=\"username\" name=\"username\" type=\"text\" autocomplete=\"username\" " \
|
||
"autocapitalize=\"none\" spellcheck=\"false\" required autofocus></label>\n" \
|
||
"<label for=\"password\">Password" \
|
||
"<input id=\"password\" name=\"password\" type=\"password\" autocomplete=\"current-password\" required></label>\n" \
|
||
"<button type=\"submit\">Sign in</button>\n" \
|
||
"</form>\n" \
|
||
"</section>\n" \
|
||
"<p class=\"privacy\">Credentials are sent only to this device over HTTPS.</p>\n" \
|
||
"</main>\n" \
|
||
"</body>\n" \
|
||
"</html>\n"
|
||
|
||
static const char s_login_html[] =
|
||
WEB_UI_LOGIN_PREFIX
|
||
WEB_UI_LOGIN_FORM;
|
||
|
||
static const char s_login_error_html[] =
|
||
WEB_UI_LOGIN_PREFIX
|
||
"<div class=\"alert\" role=\"alert\"><strong>Sign-in failed.</strong>"
|
||
"<span>The username or password was not accepted. Try again.</span></div>\n"
|
||
WEB_UI_LOGIN_FORM;
|
||
|
||
#undef WEB_UI_LOGIN_FORM
|
||
#undef WEB_UI_LOGIN_PREFIX
|
||
|
||
static const char s_app_js[] =
|
||
"(() => {\n"
|
||
"'use strict';\n"
|
||
"const element = (id) => document.getElementById(id);\n"
|
||
"const connectionStatus = element('connection-status');\n"
|
||
"const accountUsernameField = element('account-username');\n"
|
||
"const accountRoleField = element('account-role');\n"
|
||
"const logoutButton = element('logout');\n"
|
||
"const settingsOpen = element('settings-open');\n"
|
||
"const settingsBackdrop = element('settings-backdrop');\n"
|
||
"const settingsClose = element('settings-close');\n"
|
||
"const settingsMessage = element('settings-message');\n"
|
||
"const settingsAdminShell = element('settings-admin-shell');\n"
|
||
"const serialSettingsForm = element('serial-settings-form');\n"
|
||
"const serialBaud = element('serial-baud');\n"
|
||
"const serialData = element('serial-data');\n"
|
||
"const serialParity = element('serial-parity');\n"
|
||
"const serialStopBits = element('serial-stop-bits');\n"
|
||
"const serialFlow = element('serial-flow');\n"
|
||
"const serialDtr = element('serial-dtr');\n"
|
||
"const serialRts = element('serial-rts');\n"
|
||
"const userCreateForm = element('user-create-form');\n"
|
||
"const userCreateName = element('user-create-name');\n"
|
||
"const userCreateRole = element('user-create-role');\n"
|
||
"const userCreatePasswordMode = element('user-create-password-mode');\n"
|
||
"const userCreatePassword = element('user-create-password');\n"
|
||
"const userEditForm = element('user-edit-form');\n"
|
||
"const userSelect = element('user-select');\n"
|
||
"const userRole = element('user-role');\n"
|
||
"const userIdentity = element('user-identity');\n"
|
||
"const userPassword = element('user-password');\n"
|
||
"const userKey = element('user-key');\n"
|
||
"const userKeys = element('user-keys');\n"
|
||
"const generatedPasswordBox = element('generated-password-box');\n"
|
||
"const generatedPassword = element('generated-password');\n"
|
||
"const wifiProfileForm = element('wifi-profile-form');\n"
|
||
"const wifiProfileSlot = element('wifi-profile-slot');\n"
|
||
"const wifiProfileSsid = element('wifi-profile-ssid');\n"
|
||
"const wifiProfilePriority = element('wifi-profile-priority');\n"
|
||
"const wifiProfileSecurity = element('wifi-profile-security');\n"
|
||
"const wifiProfileStatus = element('wifi-profile-status');\n"
|
||
"const wifiProfileSecret = element('wifi-profile-secret');\n"
|
||
"const wifiProfileSecretState = element('wifi-profile-secret-state');\n"
|
||
"const wifiProfileToggle = element('wifi-profile-toggle');\n"
|
||
"const wifiApForm = element('wifi-ap-form');\n"
|
||
"const wifiApPolicy = element('wifi-ap-policy');\n"
|
||
"const wifiApSsid = element('wifi-ap-ssid');\n"
|
||
"const wifiApChannel = element('wifi-ap-channel');\n"
|
||
"const wifiApSecret = element('wifi-ap-secret');\n"
|
||
"const wifiApSecretState = element('wifi-ap-secret-state');\n"
|
||
"const displayForm = element('display-form');\n"
|
||
"const displayDim = element('display-dim');\n"
|
||
"const displayOff = element('display-off');\n"
|
||
"const displayState = element('display-state');\n"
|
||
"const roleStatus = element('role-status');\n"
|
||
"const clientIdField = element('client-id');\n"
|
||
"const writerIdField = element('writer-id');\n"
|
||
"const brokerClientsField = element('broker-clients');\n"
|
||
"const writerCard = element('writer-card');\n"
|
||
"const clientsCard = element('clients-card');\n"
|
||
"const wifiCard = element('wifi-card');\n"
|
||
"const serialCard = element('serial-card');\n"
|
||
"const quickPopover = element('quick-popover');\n"
|
||
"const popoverTitle = element('popover-title');\n"
|
||
"const popoverContent = element('popover-content');\n"
|
||
"const popoverClose = element('popover-close');\n"
|
||
"const wifiSummary = element('wifi-summary');\n"
|
||
"const serialSummary = element('serial-summary');\n"
|
||
"const inputState = element('input-state');\n"
|
||
"const connectionDetail = element('connection-detail');\n"
|
||
"const sessionNotice = element('session-notice');\n"
|
||
"const requestControl = element('request-control');\n"
|
||
"const releaseControl = element('release-control');\n"
|
||
"const connectionToggle = element('connection-toggle');\n"
|
||
"const terminalTitle = element('terminal-title');\n"
|
||
"const terminalDetail = element('terminal-detail');\n"
|
||
"const terminalModes = element('terminal-modes');\n"
|
||
"const serialModeButton = element('serial-mode');\n"
|
||
"const adminModeButton = element('admin-mode');\n"
|
||
"const serialTerminalHost = element('serial-terminal');\n"
|
||
"const adminTerminalHost = element('admin-terminal');\n"
|
||
"const terminalOptions = {\n"
|
||
" allowProposedApi: false, convertEol: false, cursorBlink: true, disableStdin: true,\n"
|
||
" fontFamily: '\"SFMono-Regular\",Consolas,\"Liberation Mono\",monospace',\n"
|
||
" fontSize: 14, scrollback: 5000, theme: {\n"
|
||
" background: '#080c12', foreground: '#dce7f5', cursor: '#55c2ff',\n"
|
||
" selectionBackground: '#285173', black: '#18202b', red: '#ff6b7a',\n"
|
||
" green: '#52d68b', yellow: '#ffc857', blue: '#55aaff',\n"
|
||
" magenta: '#c792ea', cyan: '#55d6be', white: '#e8eef8'\n"
|
||
" }\n"
|
||
"};\n"
|
||
"const serialTerminal = new Terminal(terminalOptions);\n"
|
||
"const adminTerminal = new Terminal({...terminalOptions, disableStdin: false});\n"
|
||
"const serialFitAddon = new FitAddon.FitAddon();\n"
|
||
"const adminFitAddon = new FitAddon.FitAddon();\n"
|
||
"serialTerminal.loadAddon(serialFitAddon);\n"
|
||
"adminTerminal.loadAddon(adminFitAddon);\n"
|
||
"serialTerminal.open(serialTerminalHost);\n"
|
||
"adminTerminal.open(adminTerminalHost);\n"
|
||
"const encoder = new TextEncoder();\n"
|
||
"let socket = null;\n"
|
||
"let adminSocket = null;\n"
|
||
"let adminTicketAbort = null;\n"
|
||
"let adminConnectionGeneration = 0;\n"
|
||
"let terminalMode = 'serial';\n"
|
||
"let sessionAbort = null;\n"
|
||
"let ticketAbort = null;\n"
|
||
"let reconnectTimer = null;\n"
|
||
"let reconnectDelay = 1000;\n"
|
||
"let reconnectEnabled = true;\n"
|
||
"let connectionGeneration = 0;\n"
|
||
"let brokerRole = 'observer';\n"
|
||
"let accountUsername = null;\n"
|
||
"let accountRole = null;\n"
|
||
"let csrfToken = null;\n"
|
||
"let sessionReady = false;\n"
|
||
"let startupInFlight = false;\n"
|
||
"let authRedirecting = false;\n"
|
||
"let clientId = null;\n"
|
||
"let writerId = 0;\n"
|
||
"let unloading = false;\n"
|
||
"let fitFrame = 0;\n"
|
||
"let serialLastFitWidth = 0;\n"
|
||
"let serialLastFitHeight = 0;\n"
|
||
"let adminLastFitWidth = 0;\n"
|
||
"let adminLastFitHeight = 0;\n"
|
||
"let statusInFlight = false;\n"
|
||
"let statusTimer = null;\n"
|
||
"let popoverTrigger = null;\n"
|
||
"let popoverGeneration = 0;\n"
|
||
"let usersState = null;\n"
|
||
"let wifiConfigState = null;\n"
|
||
"const setBadge = (target, text, tone) => {\n"
|
||
" target.textContent = text;\n"
|
||
" target.dataset.tone = tone;\n"
|
||
"};\n"
|
||
"const validId = (value) => Number.isSafeInteger(value) && value >= 0;\n"
|
||
"const displayId = (value, noneText) => validId(value) && value !== 0 ? String(value) : noneText;\n"
|
||
"const socketOpen = () => socket !== null && socket.readyState === WebSocket.OPEN;\n"
|
||
"const adminSocketOpen = () => adminSocket !== null && adminSocket.readyState === WebSocket.OPEN;\n"
|
||
"const updateControls = () => {\n"
|
||
" const writer = brokerRole === 'writer';\n"
|
||
" serialTerminal.options.disableStdin = !writer;\n"
|
||
" requestControl.disabled = !socketOpen() || writer;\n"
|
||
" releaseControl.disabled = !socketOpen() || !writer;\n"
|
||
" const connectionActive = reconnectEnabled || socket !== null || ticketAbort !== null || reconnectTimer !== null;\n"
|
||
" connectionToggle.disabled = unloading || authRedirecting || startupInFlight;\n"
|
||
" logoutButton.disabled = unloading || authRedirecting || !sessionReady;\n"
|
||
" connectionToggle.textContent = connectionActive ? 'Disconnect serial' : 'Connect serial';\n"
|
||
" connectionToggle.classList.toggle('danger', connectionActive);\n"
|
||
" inputState.dataset.enabled = writer ? 'true' : 'false';\n"
|
||
" inputState.textContent = writer\n"
|
||
" ? 'Writer mode — terminal input is enabled.'\n"
|
||
" : 'Observer mode — terminal input is disabled.';\n"
|
||
" setBadge(roleStatus, writer ? 'Writer' : 'Observer', writer ? 'good' : 'warn');\n"
|
||
"};\n"
|
||
"const setBrokerRole = (nextRole) => {\n"
|
||
" brokerRole = nextRole === 'writer' ? 'writer' : 'observer';\n"
|
||
" updateControls();\n"
|
||
"};\n"
|
||
"const setConnection = (text, tone, detail) => {\n"
|
||
" setBadge(connectionStatus, text, tone);\n"
|
||
" connectionDetail.textContent = detail;\n"
|
||
" updateControls();\n"
|
||
"};\n"
|
||
"const setSessionNotice = (message) => {\n"
|
||
" const visible = typeof message === 'string' && message.length > 0;\n"
|
||
" sessionNotice.textContent = visible ? message : '';\n"
|
||
" sessionNotice.dataset.visible = visible ? 'true' : 'false';\n"
|
||
"};\n"
|
||
"const setTerminalMode = (nextMode) => {\n"
|
||
" const admin = nextMode === 'admin' && accountRole === 'admin';\n"
|
||
" terminalMode = admin ? 'admin' : 'serial';\n"
|
||
" serialTerminalHost.hidden = admin;\n"
|
||
" adminTerminalHost.hidden = !admin;\n"
|
||
" serialModeButton.setAttribute('aria-selected', admin ? 'false' : 'true');\n"
|
||
" adminModeButton.setAttribute('aria-selected', admin ? 'true' : 'false');\n"
|
||
" terminalTitle.textContent = admin ? 'Administrative shell' : 'Live serial stream';\n"
|
||
" terminalDetail.textContent = admin\n"
|
||
" ? 'Canonical device administration · serial connection and writer lease remain active'\n"
|
||
" : 'Binary, unmodified device output';\n"
|
||
" if (admin) {\n"
|
||
" adminLastFitWidth = 0; adminLastFitHeight = 0;\n"
|
||
" if (!adminSocketOpen() && adminTicketAbort === null) connectAdmin();\n"
|
||
" adminTerminal.focus();\n"
|
||
" } else {\n"
|
||
" serialLastFitWidth = 0; serialLastFitHeight = 0;\n"
|
||
" serialTerminal.focus();\n"
|
||
" }\n"
|
||
" scheduleFit();\n"
|
||
"};\n"
|
||
"const clearReconnectTimer = () => {\n"
|
||
" if (reconnectTimer !== null) {\n"
|
||
" window.clearTimeout(reconnectTimer);\n"
|
||
" reconnectTimer = null;\n"
|
||
" }\n"
|
||
"};\n"
|
||
"const stopAuthenticatedActivity = () => {\n"
|
||
" reconnectEnabled = false;\n"
|
||
" sessionReady = false;\n"
|
||
" ++connectionGeneration;\n"
|
||
" ++adminConnectionGeneration;\n"
|
||
" clearReconnectTimer();\n"
|
||
" if (statusTimer !== null) { window.clearInterval(statusTimer); statusTimer = null; }\n"
|
||
" if (sessionAbort !== null) { sessionAbort.abort(); sessionAbort = null; }\n"
|
||
" if (ticketAbort !== null) { ticketAbort.abort(); ticketAbort = null; }\n"
|
||
" if (adminTicketAbort !== null) { adminTicketAbort.abort(); adminTicketAbort = null; }\n"
|
||
" if (socket !== null) { const previous = socket; socket = null; previous.close(); }\n"
|
||
" if (adminSocket !== null) { const previous = adminSocket; adminSocket = null; previous.close(); }\n"
|
||
" clientId = null;\n"
|
||
" clientIdField.textContent = '—';\n"
|
||
" usersState = null;\n"
|
||
" wifiConfigState = null;\n"
|
||
" clearGeneratedPassword();\n"
|
||
" setBrokerRole('observer');\n"
|
||
" updateControls();\n"
|
||
"};\n"
|
||
"const navigateToLogin = () => {\n"
|
||
" if (authRedirecting || unloading) return;\n"
|
||
" authRedirecting = true;\n"
|
||
" stopAuthenticatedActivity();\n"
|
||
" csrfToken = null;\n"
|
||
" accountUsername = null;\n"
|
||
" accountRole = null;\n"
|
||
" window.location.replace('/login');\n"
|
||
"};\n"
|
||
"const scheduleReconnect = () => {\n"
|
||
" if (unloading || authRedirecting || !sessionReady || !reconnectEnabled || reconnectTimer !== null) return;\n"
|
||
" const delay = reconnectDelay;\n"
|
||
" reconnectDelay = Math.min(reconnectDelay * 2, 10000);\n"
|
||
" setConnection('Disconnected', 'bad', `Reconnecting in ${Math.ceil(delay / 1000)} second(s)…`);\n"
|
||
" reconnectTimer = window.setTimeout(() => {\n"
|
||
" reconnectTimer = null;\n"
|
||
" connect();\n"
|
||
" }, delay);\n"
|
||
"};\n"
|
||
"const acceptBrokerMessage = (message) => {\n"
|
||
" if (message === null || typeof message !== 'object') return;\n"
|
||
" if (message.type !== 'hello' && message.type !== 'writer') return;\n"
|
||
" if (message.role !== 'writer' && message.role !== 'observer') return;\n"
|
||
" if (!validId(message.writerId)) return;\n"
|
||
" if (message.type === 'hello') {\n"
|
||
" if (!validId(message.clientId) || message.clientId === 0) return;\n"
|
||
" clientId = message.clientId;\n"
|
||
" clientIdField.textContent = String(clientId);\n"
|
||
" reconnectDelay = 1000;\n"
|
||
" }\n"
|
||
" writerId = message.writerId;\n"
|
||
" writerIdField.textContent = displayId(writerId, 'None');\n"
|
||
" setBrokerRole(message.role);\n"
|
||
"};\n"
|
||
"const handleSocketMessage = (event) => {\n"
|
||
" if (typeof event.data === 'string') {\n"
|
||
" try {\n"
|
||
" acceptBrokerMessage(JSON.parse(event.data));\n"
|
||
" } catch (_) {\n"
|
||
" setConnection('Protocol error', 'bad', 'The device sent an invalid control message.');\n"
|
||
" }\n"
|
||
" return;\n"
|
||
" }\n"
|
||
" if (event.data instanceof ArrayBuffer) {\n"
|
||
" serialTerminal.write(new Uint8Array(event.data));\n"
|
||
" }\n"
|
||
"};\n"
|
||
"async function requestTicket(signal) {\n"
|
||
" if (!sessionReady || typeof csrfToken !== 'string') throw new Error('session unavailable');\n"
|
||
" const response = await fetch('/api/ws-ticket', {\n"
|
||
" method: 'POST', credentials: 'same-origin', cache: 'no-store', signal,\n"
|
||
" headers: {'X-CSRF-Token': csrfToken}\n"
|
||
" });\n"
|
||
" if (response.status === 401) {\n"
|
||
" navigateToLogin();\n"
|
||
" throw new Error('session expired');\n"
|
||
" }\n"
|
||
" if (!response.ok) throw new Error('ticket request failed');\n"
|
||
" const payload = await response.json();\n"
|
||
" if (payload === null || typeof payload !== 'object' ||\n"
|
||
" typeof payload.ticket !== 'string' || !/^[A-Za-z0-9_-]{32}$/.test(payload.ticket)) {\n"
|
||
" throw new Error('invalid ticket response');\n"
|
||
" }\n"
|
||
" const ticket = payload.ticket;\n"
|
||
" payload.ticket = '';\n"
|
||
" return ticket;\n"
|
||
"}\n"
|
||
"async function connect() {\n"
|
||
" if (unloading || authRedirecting || !sessionReady || !reconnectEnabled) return;\n"
|
||
" clearReconnectTimer();\n"
|
||
" const generation = ++connectionGeneration;\n"
|
||
" if (ticketAbort !== null) ticketAbort.abort();\n"
|
||
" ticketAbort = new AbortController();\n"
|
||
" if (socket !== null) {\n"
|
||
" const previous = socket;\n"
|
||
" socket = null;\n"
|
||
" previous.close();\n"
|
||
" }\n"
|
||
" clientId = null;\n"
|
||
" clientIdField.textContent = '—';\n"
|
||
" setBrokerRole('observer');\n"
|
||
" setConnection('Connecting', 'warn', 'Requesting a one-time connection ticket…');\n"
|
||
" try {\n"
|
||
" const ticket = await requestTicket(ticketAbort.signal);\n"
|
||
" if (unloading || generation !== connectionGeneration) return;\n"
|
||
" ticketAbort = null;\n"
|
||
" const url = new URL('/ws/serial', window.location.origin);\n"
|
||
" url.protocol = 'wss:';\n"
|
||
" url.searchParams.set('ticket', ticket);\n"
|
||
" const nextSocket = new WebSocket(url.toString());\n"
|
||
" url.search = '';\n"
|
||
" nextSocket.binaryType = 'arraybuffer';\n"
|
||
" socket = nextSocket;\n"
|
||
" nextSocket.addEventListener('open', () => {\n"
|
||
" if (socket !== nextSocket) return;\n"
|
||
" setConnection('Connected', 'good', 'Connected; waiting for broker role information.');\n"
|
||
" });\n"
|
||
" nextSocket.addEventListener('message', (event) => {\n"
|
||
" if (socket === nextSocket) handleSocketMessage(event);\n"
|
||
" });\n"
|
||
" nextSocket.addEventListener('error', () => {\n"
|
||
" if (socket === nextSocket) {\n"
|
||
" setConnection('Connection error', 'bad', 'The WebSocket connection failed.');\n"
|
||
" }\n"
|
||
" });\n"
|
||
" nextSocket.addEventListener('close', () => {\n"
|
||
" if (socket !== nextSocket) return;\n"
|
||
" socket = null;\n"
|
||
" clientId = null;\n"
|
||
" clientIdField.textContent = '—';\n"
|
||
" setBrokerRole('observer');\n"
|
||
" scheduleReconnect();\n"
|
||
" });\n"
|
||
" } catch (error) {\n"
|
||
" if (generation !== connectionGeneration || unloading || authRedirecting || error.name === 'AbortError') return;\n"
|
||
" ticketAbort = null;\n"
|
||
" scheduleReconnect();\n"
|
||
" }\n"
|
||
"}\n"
|
||
"async function requestAdminTicket(signal) {\n"
|
||
" if (!sessionReady || accountRole !== 'admin' || typeof csrfToken !== 'string') throw new Error('admin session unavailable');\n"
|
||
" const response = await fetch('/api/admin/ws-ticket', {\n"
|
||
" method: 'POST', credentials: 'same-origin', cache: 'no-store', signal,\n"
|
||
" headers: {'X-CSRF-Token': csrfToken}\n"
|
||
" });\n"
|
||
" if (response.status === 401) { navigateToLogin(); throw new Error('session expired'); }\n"
|
||
" if (response.status === 403) throw new Error('administrator access required');\n"
|
||
" if (!response.ok) throw new Error('admin ticket request failed');\n"
|
||
" const payload = await response.json();\n"
|
||
" if (payload === null || typeof payload !== 'object' ||\n"
|
||
" typeof payload.ticket !== 'string' || !/^[A-Za-z0-9_-]{32}$/.test(payload.ticket)) {\n"
|
||
" throw new Error('invalid admin ticket response');\n"
|
||
" }\n"
|
||
" const ticket = payload.ticket;\n"
|
||
" payload.ticket = '';\n"
|
||
" return ticket;\n"
|
||
"}\n"
|
||
"async function connectAdmin() {\n"
|
||
" if (unloading || authRedirecting || !sessionReady || accountRole !== 'admin' ||\n"
|
||
" adminSocket !== null || adminTicketAbort !== null) return;\n"
|
||
" const generation = ++adminConnectionGeneration;\n"
|
||
" const controller = new AbortController();\n"
|
||
" adminTicketAbort = controller;\n"
|
||
" adminTerminal.write('\\r\\n[Opening administrative shell…]\\r\\n');\n"
|
||
" try {\n"
|
||
" const ticket = await requestAdminTicket(controller.signal);\n"
|
||
" if (unloading || authRedirecting || generation !== adminConnectionGeneration) return;\n"
|
||
" adminTicketAbort = null;\n"
|
||
" const url = new URL('/ws/admin', window.location.origin);\n"
|
||
" url.protocol = 'wss:';\n"
|
||
" url.searchParams.set('ticket', ticket);\n"
|
||
" const nextSocket = new WebSocket(url.toString());\n"
|
||
" url.search = '';\n"
|
||
" nextSocket.binaryType = 'arraybuffer';\n"
|
||
" adminSocket = nextSocket;\n"
|
||
" nextSocket.addEventListener('open', () => {\n"
|
||
" if (adminSocket === nextSocket && terminalMode === 'admin') adminTerminal.focus();\n"
|
||
" });\n"
|
||
" nextSocket.addEventListener('message', (event) => {\n"
|
||
" if (adminSocket === nextSocket && event.data instanceof ArrayBuffer) {\n"
|
||
" adminTerminal.write(new Uint8Array(event.data));\n"
|
||
" }\n"
|
||
" });\n"
|
||
" nextSocket.addEventListener('error', () => {\n"
|
||
" if (adminSocket === nextSocket) adminTerminal.write('\\r\\n[Administrative shell connection error.]\\r\\n');\n"
|
||
" });\n"
|
||
" nextSocket.addEventListener('close', () => {\n"
|
||
" if (adminSocket !== nextSocket) return;\n"
|
||
" adminSocket = null;\n"
|
||
" adminTerminal.write('\\r\\n[Administrative shell closed. Select Admin shell to reconnect.]\\r\\n');\n"
|
||
" });\n"
|
||
" } catch (error) {\n"
|
||
" if (generation !== adminConnectionGeneration || unloading || authRedirecting || error.name === 'AbortError') return;\n"
|
||
" adminTicketAbort = null;\n"
|
||
" adminTerminal.write(`\\r\\n[Could not open administrative shell: ${error.message}]\\r\\n`);\n"
|
||
" }\n"
|
||
"}\n"
|
||
"async function adminRequest(path, method = 'GET', fields = null) {\n"
|
||
" if (!sessionReady || accountRole !== 'admin') throw new Error('administrator access required');\n"
|
||
" const options = {method, credentials: 'same-origin', cache: 'no-store', headers: {}};\n"
|
||
" if (method === 'POST') {\n"
|
||
" if (typeof csrfToken !== 'string') throw new Error('session unavailable');\n"
|
||
" options.headers['X-CSRF-Token'] = csrfToken;\n"
|
||
" options.headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';\n"
|
||
" options.body = new URLSearchParams(fields);\n"
|
||
" }\n"
|
||
" const response = await fetch(path, options);\n"
|
||
" if (response.status === 401) { navigateToLogin(); throw new Error('session expired'); }\n"
|
||
" if (response.status === 403) throw new Error('administrator access required');\n"
|
||
" if (!response.ok) {\n"
|
||
" const contentType = response.headers.get('content-type') || '';\n"
|
||
" let message = 'operation failed'; let code = null;\n"
|
||
" if (contentType.includes('application/json')) {\n"
|
||
" try {\n"
|
||
" const payload = await response.json();\n"
|
||
" if (typeof payload?.message === 'string' && payload.message.length > 0) message = payload.message;\n"
|
||
" if (typeof payload?.error === 'string' && /^[a-z_]+$/.test(payload.error)) code = payload.error;\n"
|
||
" } catch (_) {}\n"
|
||
" } else {\n"
|
||
" const text = (await response.text()).trim(); if (text.length > 0) message = text;\n"
|
||
" }\n"
|
||
" const error = new Error(message); error.status = response.status; error.code = code;\n"
|
||
" throw error;\n"
|
||
" }\n"
|
||
" return response.headers.get('content-type')?.includes('application/json') ? response.json() : null;\n"
|
||
"}\n"
|
||
"const clearGeneratedPassword = () => {\n"
|
||
" generatedPassword.textContent = '';\n"
|
||
" generatedPasswordBox.hidden = true;\n"
|
||
"};\n"
|
||
"const showGeneratedPassword = (password) => {\n"
|
||
" clearGeneratedPassword();\n"
|
||
" generatedPassword.textContent = password;\n"
|
||
" generatedPasswordBox.hidden = false;\n"
|
||
"};\n"
|
||
"const selectedUser = () => {\n"
|
||
" if (!usersState || !Array.isArray(usersState.users)) return null;\n"
|
||
" return usersState.users.find((user) => String(user.id) === userSelect.value) || null;\n"
|
||
"};\n"
|
||
"const renderSelectedUser = () => {\n"
|
||
" const user = selectedUser();\n"
|
||
" const available = user !== null;\n"
|
||
" userRole.disabled = !available;\n"
|
||
" userPassword.disabled = !available;\n"
|
||
" userKey.disabled = !available;\n"
|
||
" for (const id of ['user-role-apply','user-password-set','user-password-generate','user-key-add','user-key-clear','user-delete']) {\n"
|
||
" element(id).disabled = !available;\n"
|
||
" }\n"
|
||
" userKeys.replaceChildren();\n"
|
||
" if (!available) {\n"
|
||
" userIdentity.textContent = '—';\n"
|
||
" return;\n"
|
||
" }\n"
|
||
" userRole.value = user.role;\n"
|
||
" userIdentity.textContent = `ID ${user.id} · auth generation ${user.auth_generation}`;\n"
|
||
" element('user-password-generate').disabled = user.username === accountUsername;\n"
|
||
" const hasKeys = Array.isArray(user.keys) && user.keys.length > 0;\n"
|
||
" element('user-key-clear').disabled = !hasKeys;\n"
|
||
" if (!hasKeys) {\n"
|
||
" const empty = document.createElement('p');\n"
|
||
" empty.className = 'client-meta';\n"
|
||
" empty.textContent = 'No authorized SSH public keys.';\n"
|
||
" userKeys.append(empty);\n"
|
||
" return;\n"
|
||
" }\n"
|
||
" for (const key of user.keys) {\n"
|
||
" const row = document.createElement('div'); row.className = 'key-row';\n"
|
||
" const copy = document.createElement('div');\n"
|
||
" const title = document.createElement('strong'); title.textContent = `${key.type} · slot ${key.index}`;\n"
|
||
" const fingerprint = document.createElement('div'); fingerprint.className = 'client-meta'; fingerprint.textContent = key.fingerprint;\n"
|
||
" copy.append(title, fingerprint);\n"
|
||
" const remove = makeButton('Remove', async () => {\n"
|
||
" if (!window.confirm(`Remove SSH key slot ${key.index} from ${user.username}?`)) return;\n"
|
||
" await runSettingsAction(() => userOperation('key-delete', {key_index: String(key.index), confirm: 'true'}, user));\n"
|
||
" });\n"
|
||
" row.append(copy, remove); userKeys.append(row);\n"
|
||
" }\n"
|
||
"};\n"
|
||
"async function loadUsers(preferredUserId = null) {\n"
|
||
" const payload = await adminRequest('/api/admin/users');\n"
|
||
" if (!payload || !Number.isSafeInteger(payload.generation) || payload.generation < 1 || !Array.isArray(payload.users)) {\n"
|
||
" throw new Error('invalid user response');\n"
|
||
" }\n"
|
||
" const previous = preferredUserId !== null ? String(preferredUserId) : userSelect.value;\n"
|
||
" usersState = payload;\n"
|
||
" userSelect.replaceChildren();\n"
|
||
" for (const user of payload.users) {\n"
|
||
" if (!validId(user.id) || typeof user.username !== 'string' || (user.role !== 'user' && user.role !== 'admin')) continue;\n"
|
||
" const option = document.createElement('option'); option.value = String(user.id); option.textContent = `${user.username} (${user.role})`; userSelect.append(option);\n"
|
||
" }\n"
|
||
" if ([...userSelect.options].some((option) => option.value === previous)) userSelect.value = previous;\n"
|
||
" renderSelectedUser();\n"
|
||
" return payload;\n"
|
||
"}\n"
|
||
"const printableAscii = (value, minimum, maximum) => value.length >= minimum && value.length <= maximum &&\n"
|
||
" [...value].every((character) => character.codePointAt(0) >= 0x20 && character.codePointAt(0) <= 0x7e);\n"
|
||
"async function userOperation(operation, extra = {}, target = selectedUser()) {\n"
|
||
" if (!usersState) throw new Error('User list is not loaded.');\n"
|
||
" const fields = {operation, expected_generation: String(usersState.generation), ...extra};\n"
|
||
" if (operation !== 'create') {\n"
|
||
" if (!target) throw new Error('Select an account first.');\n"
|
||
" fields.expected_user_id = String(target.id);\n"
|
||
" fields.username = target.username;\n"
|
||
" }\n"
|
||
" const clearSubmittedValues = () => {\n"
|
||
" if ('password' in fields) fields.password = '';\n"
|
||
" if ('password' in extra) extra.password = '';\n"
|
||
" userPassword.value = '';\n"
|
||
" userCreatePassword.value = '';\n"
|
||
" userKey.value = '';\n"
|
||
" };\n"
|
||
" settingsMessage.textContent = `User ${operation} in progress…`;\n"
|
||
" let result;\n"
|
||
" try {\n"
|
||
" result = await adminRequest('/api/admin/users', 'POST', fields);\n"
|
||
" } catch (error) {\n"
|
||
" clearSubmittedValues();\n"
|
||
" clearGeneratedPassword();\n"
|
||
" if (error.code === 'stale') {\n"
|
||
" try {\n"
|
||
" await loadUsers(target?.id ?? null);\n"
|
||
" } catch (_) {\n"
|
||
" usersState = null;\n"
|
||
" userSelect.replaceChildren();\n"
|
||
" renderSelectedUser();\n"
|
||
" throw new Error('User database changed and could not be reloaded. Close Settings and try again.');\n"
|
||
" }\n"
|
||
" throw new Error('User database changed. The account form was reloaded; re-enter any password and try again.');\n"
|
||
" }\n"
|
||
" throw error;\n"
|
||
" } finally {\n"
|
||
" clearSubmittedValues();\n"
|
||
" }\n"
|
||
" if (typeof result?.generated_password === 'string' && result.generated_password.length > 0) {\n"
|
||
" showGeneratedPassword(result.generated_password);\n"
|
||
" result.generated_password = '';\n"
|
||
" }\n"
|
||
" const preferred = validId(result?.user_id) ? result.user_id : target?.id ?? null;\n"
|
||
" await loadUsers(preferred);\n"
|
||
" settingsMessage.textContent = result?.revocation_complete === false\n"
|
||
" ? `User ${operation} committed; a transport session will close on its next authorization check.`\n"
|
||
" : `User ${operation} completed.`;\n"
|
||
"}\n"
|
||
"const bytesToBase64 = (bytes) => {\n"
|
||
" let binary = '';\n"
|
||
" for (const byte of bytes) binary += String.fromCharCode(byte);\n"
|
||
" return btoa(binary);\n"
|
||
"};\n"
|
||
"const ssidToBase64 = (value) => {\n"
|
||
" const bytes = encoder.encode(value);\n"
|
||
" if (bytes.length < 1 || bytes.length > 32) throw new Error('SSID must encode to 1–32 bytes.');\n"
|
||
" return bytesToBase64(bytes);\n"
|
||
"};\n"
|
||
"const ssidFromBase64 = (value) => {\n"
|
||
" const binary = atob(value);\n"
|
||
" const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));\n"
|
||
" return new TextDecoder().decode(bytes);\n"
|
||
"};\n"
|
||
"const ssidFieldValue = (input) => input.value === input.dataset.originalValue\n"
|
||
" ? input.dataset.originalBase64\n"
|
||
" : ssidToBase64(input.value);\n"
|
||
"const selectedWifiProfile = () => {\n"
|
||
" if (!wifiConfigState || !Array.isArray(wifiConfigState.profiles)) return null;\n"
|
||
" return wifiConfigState.profiles.find((profile) => String(profile.slot) === wifiProfileSlot.value) || null;\n"
|
||
"};\n"
|
||
"const fillWifiProfile = () => {\n"
|
||
" const profile = selectedWifiProfile();\n"
|
||
" if (!profile) return;\n"
|
||
" const ssid = ssidFromBase64(profile.ssid_b64);\n"
|
||
" wifiProfileSsid.value = ssid;\n"
|
||
" wifiProfileSsid.dataset.originalValue = ssid;\n"
|
||
" wifiProfileSsid.dataset.originalBase64 = profile.ssid_b64;\n"
|
||
" wifiProfilePriority.value = String(profile.priority);\n"
|
||
" wifiProfileSecurity.value = profile.security;\n"
|
||
" wifiProfileStatus.textContent = `${profile.configured ? 'configured' : 'empty'} · ${profile.enabled ? 'enabled' : 'disabled'}`;\n"
|
||
" wifiProfileSecretState.textContent = profile.secret_set ? 'A secret is set.' : 'No secret is set.';\n"
|
||
" wifiProfileToggle.textContent = profile.enabled ? 'Disable' : 'Enable';\n"
|
||
" wifiProfileToggle.disabled = !profile.enabled && !(profile.configured && profile.secret_set);\n"
|
||
" element('wifi-profile-secret-set').disabled = !profile.configured;\n"
|
||
" wifiProfileSecret.value = '';\n"
|
||
"};\n"
|
||
"const fillWifiAp = () => {\n"
|
||
" if (!wifiConfigState?.ap) return;\n"
|
||
" const ssid = ssidFromBase64(wifiConfigState.ap.ssid_b64);\n"
|
||
" wifiApSsid.value = ssid;\n"
|
||
" wifiApSsid.dataset.originalValue = ssid;\n"
|
||
" wifiApSsid.dataset.originalBase64 = wifiConfigState.ap.ssid_b64;\n"
|
||
" wifiApPolicy.value = wifiConfigState.ap.policy;\n"
|
||
" wifiApChannel.value = String(wifiConfigState.ap.channel);\n"
|
||
" wifiApSecretState.textContent = wifiConfigState.ap.secret_set ? 'A secret is set.' : 'No secret is set.';\n"
|
||
" wifiApSecret.value = '';\n"
|
||
"};\n"
|
||
"async function loadWifiConfig() {\n"
|
||
" const payload = await adminRequest('/api/admin/wifi-config');\n"
|
||
" if (!payload || !Number.isSafeInteger(payload.generation) || payload.generation < 1 ||\n"
|
||
" !payload.ap || !Array.isArray(payload.profiles) || payload.profiles.length !== 4) {\n"
|
||
" throw new Error('invalid Wi-Fi configuration response');\n"
|
||
" }\n"
|
||
" wifiConfigState = payload;\n"
|
||
" fillWifiProfile();\n"
|
||
" fillWifiAp();\n"
|
||
" return payload;\n"
|
||
"}\n"
|
||
"async function wifiConfigOperation(operation, extra = {}) {\n"
|
||
" if (!wifiConfigState) throw new Error('Wi-Fi configuration is not loaded.');\n"
|
||
" const fields = {operation, expected_generation: String(wifiConfigState.generation), ...extra};\n"
|
||
" settingsMessage.textContent = `Wi-Fi ${operation} in progress…`;\n"
|
||
" try {\n"
|
||
" await adminRequest('/api/admin/wifi-config', 'POST', fields);\n"
|
||
" wifiProfileSecret.value = '';\n"
|
||
" wifiApSecret.value = '';\n"
|
||
" await loadWifiConfig();\n"
|
||
" settingsMessage.textContent = `Wi-Fi ${operation} completed.`;\n"
|
||
" window.setTimeout(pollStatus, 300);\n"
|
||
" } catch (error) {\n"
|
||
" wifiProfileSecret.value = '';\n"
|
||
" wifiApSecret.value = '';\n"
|
||
" if (error.code === 'stale') {\n"
|
||
" try { await loadWifiConfig(); } catch (_) {}\n"
|
||
" throw new Error('Wi-Fi configuration changed. The form was reloaded; re-enter any secret and try again.');\n"
|
||
" }\n"
|
||
" throw error;\n"
|
||
" }\n"
|
||
"}\n"
|
||
"const fillSerialSettings = (serial) => {\n"
|
||
" serialBaud.value = String(serial.baud); serialData.value = serial.data;\n"
|
||
" serialParity.value = serial.parity; serialStopBits.value = serial.stop;\n"
|
||
" serialFlow.value = serial.flow; serialDtr.value = serial.dtr; serialRts.value = String(serial.rts);\n"
|
||
"};\n"
|
||
"async function loadSerialSettings() {\n"
|
||
" const serial = await adminRequest('/api/admin/serial');\n"
|
||
" fillSerialSettings(serial);\n"
|
||
" return serial;\n"
|
||
"}\n"
|
||
"const fillDisplaySettings = (display) => {\n"
|
||
" displayDim.value = String(display.dim_seconds);\n"
|
||
" displayOff.value = String(display.off_seconds);\n"
|
||
" displayState.textContent = display.service_available\n"
|
||
" ? `${display.initialized ? 'OLED initialized' : 'OLED unavailable'} · bus ${display.bus_ready ? 'ready' : 'unavailable'} · address 0x${Number(display.address).toString(16).padStart(2, '0')} · contrast ${display.contrast}`\n"
|
||
" : 'Local OLED status unavailable.';\n"
|
||
"};\n"
|
||
"async function loadDisplaySettings() {\n"
|
||
" const display = await adminRequest('/api/admin/display');\n"
|
||
" fillDisplaySettings(display);\n"
|
||
" return display;\n"
|
||
"}\n"
|
||
"async function displayOperation(operation) {\n"
|
||
" const fields = {operation};\n"
|
||
" if (operation === 'apply') {\n"
|
||
" if (!displayForm.reportValidity()) throw new Error('Enter display timeouts from 0 to 86400 seconds.');\n"
|
||
" const dim = Number(displayDim.value);\n"
|
||
" const off = Number(displayOff.value);\n"
|
||
" if (!Number.isInteger(dim) || !Number.isInteger(off) || dim < 0 || dim > 86400 || off < 0 || off > 86400) {\n"
|
||
" throw new Error('Display timeouts must be whole numbers from 0 to 86400.');\n"
|
||
" }\n"
|
||
" if (dim !== 0 && off !== 0 && off <= dim) throw new Error('Display off time must be later than dim time when both are enabled.');\n"
|
||
" fields.dim_seconds = String(dim);\n"
|
||
" fields.off_seconds = String(off);\n"
|
||
" }\n"
|
||
" if (operation === 'reset') {\n"
|
||
" if (!window.confirm('Reset display aging settings to defaults and persist them?')) return;\n"
|
||
" fields.confirm = 'true';\n"
|
||
" }\n"
|
||
" settingsMessage.textContent = `Display ${operation} in progress…`;\n"
|
||
" await adminRequest('/api/admin/display', 'POST', fields);\n"
|
||
" await loadDisplaySettings();\n"
|
||
" settingsMessage.textContent = `Display ${operation} completed.`;\n"
|
||
"}\n"
|
||
"async function serialOperation(operation) {\n"
|
||
" if (operation === 'reset') {\n"
|
||
" if (!window.confirm('Reset serial settings to defaults and persist them?')) return;\n"
|
||
" }\n"
|
||
" if (['apply','load','defaults','reset','stop'].includes(operation)) {\n"
|
||
" const current = await adminRequest('/api/admin/serial');\n"
|
||
" if ((current.rx_pending > 0 || current.tx_pending > 0) &&\n"
|
||
" !window.confirm(`Serial has ${current.rx_pending} RX and ${current.tx_pending} TX bytes pending. Continue with ${operation}?`)) throw new Error('Serial operation cancelled.');\n"
|
||
" }\n"
|
||
" const fields = {operation};\n"
|
||
" if (operation === 'apply') Object.assign(fields, {\n"
|
||
" baud: serialBaud.value, data: serialData.value, parity: serialParity.value,\n"
|
||
" stop: serialStopBits.value, flow: serialFlow.value, dtr: serialDtr.value, rts: serialRts.value\n"
|
||
" });\n"
|
||
" if (operation === 'reset') fields.confirm = 'true';\n"
|
||
" settingsMessage.textContent = `Serial ${operation} in progress…`;\n"
|
||
" await adminRequest('/api/admin/serial', 'POST', fields);\n"
|
||
" await loadSerialSettings();\n"
|
||
" settingsMessage.textContent = `Serial ${operation} completed.`;\n"
|
||
" pollStatus();\n"
|
||
"}\n"
|
||
"async function wifiOperation(operation) {\n"
|
||
" settingsMessage.textContent = `Wi-Fi ${operation} requested…`;\n"
|
||
" await adminRequest('/api/admin/wifi', 'POST', {operation});\n"
|
||
" settingsMessage.textContent = `Wi-Fi ${operation} queued.`;\n"
|
||
" window.setTimeout(pollStatus, 300);\n"
|
||
"}\n"
|
||
"const makeButton = (label, action, primary = false) => {\n"
|
||
" const button = document.createElement('button');\n"
|
||
" button.type = 'button'; button.className = primary ? 'button primary' : 'button';\n"
|
||
" button.textContent = label; button.addEventListener('click', action); return button;\n"
|
||
"};\n"
|
||
"const positionPopover = (trigger) => {\n"
|
||
" const rect = trigger.getBoundingClientRect();\n"
|
||
" const width = Math.min(520, window.innerWidth * .92);\n"
|
||
" quickPopover.style.left = `${Math.max(12, Math.min(rect.left, window.innerWidth - width - 12))}px`;\n"
|
||
" const below = rect.bottom + 8;\n"
|
||
" quickPopover.style.top = `${below + Math.min(420, window.innerHeight * .72) < window.innerHeight ? below : Math.max(12, rect.top - Math.min(420, window.innerHeight * .72) - 8)}px`;\n"
|
||
"};\n"
|
||
"const closePopover = (restoreFocus = false) => {\n"
|
||
" ++popoverGeneration; quickPopover.hidden = true; popoverContent.replaceChildren();\n"
|
||
" const previous = popoverTrigger; popoverTrigger = null;\n"
|
||
" if (restoreFocus && previous !== null) previous.focus();\n"
|
||
"};\n"
|
||
"const clientRow = (client, action = null) => {\n"
|
||
" const row = document.createElement('div'); row.className = 'client-row';\n"
|
||
" const copy = document.createElement('div');\n"
|
||
" const title = document.createElement('strong'); title.textContent = `${client.name || client.type} · ID ${client.id}`;\n"
|
||
" const meta = document.createElement('div'); meta.className = 'client-meta';\n"
|
||
" meta.textContent = `${client.type} · ${client.writer ? 'writer' : 'observer'} · ${client.output_pending}/4096 queued · ${client.output_dropped} dropped`;\n"
|
||
" copy.append(title, meta); row.append(copy); if (action !== null) row.append(action); return row;\n"
|
||
"};\n"
|
||
"async function renderPopover(kind, generation) {\n"
|
||
" if (kind === 'serial') {\n"
|
||
" const serial = await adminRequest('/api/admin/serial'); if (generation !== popoverGeneration) return;\n"
|
||
" const summary = document.createElement('p'); summary.className = 'client-meta';\n"
|
||
" summary.textContent = `${serial.running ? 'Running' : 'Stopped'} · ${serial.baud} baud · ${serial.data}${serial.parity[0].toUpperCase()}${serial.stop} · ${serial.flow} · DTR ${serial.dtr}`;\n"
|
||
" const actions = document.createElement('div'); actions.className = 'quick-actions';\n"
|
||
" actions.append(makeButton(serial.running ? 'Stop' : 'Start', async () => { try { await serialOperation(serial.running ? 'stop' : 'start'); closePopover(); } catch (error) { settingsMessage.textContent = error.message; } }),\n"
|
||
" makeButton('Save', async () => { try { await serialOperation('save'); closePopover(); } catch (error) { settingsMessage.textContent = error.message; } }),\n"
|
||
" makeButton('Open settings', () => { closePopover(); openSettings(); }, true));\n"
|
||
" popoverContent.replaceChildren(summary, actions); return;\n"
|
||
" }\n"
|
||
" if (kind === 'wifi') {\n"
|
||
" const summary = document.createElement('p'); summary.className = 'client-meta'; summary.textContent = wifiSummary.textContent;\n"
|
||
" const actions = document.createElement('div'); actions.className = 'quick-actions';\n"
|
||
" for (const [label, operation] of [['Reconnect','reconnect'],['Next profile','next-profile'],['Start','start'],['Stop','stop']]) {\n"
|
||
" actions.append(makeButton(label, async () => { try { await wifiOperation(operation); closePopover(); } catch (error) { settingsMessage.textContent = error.message; } }, operation === 'reconnect'));\n"
|
||
" }\n"
|
||
" actions.append(makeButton('Open settings', () => { closePopover(); openSettings(); }));\n"
|
||
" popoverContent.replaceChildren(summary, actions); return;\n"
|
||
" }\n"
|
||
" const payload = await adminRequest('/api/admin/broker-clients'); if (generation !== popoverGeneration) return;\n"
|
||
" const list = document.createElement('div'); list.className = 'popover-list';\n"
|
||
" if (payload.clients.length === 0) { const empty = document.createElement('p'); empty.className = 'client-meta'; empty.textContent = 'No broker clients are connected.'; list.append(empty); }\n"
|
||
" for (const client of payload.clients) {\n"
|
||
" let action = null;\n"
|
||
" if (kind === 'writer' && !client.writer) action = makeButton('Assign', async () => {\n"
|
||
" if (!window.confirm(`Transfer the writer lease to ${client.name || client.type} (ID ${client.id})?`)) return;\n"
|
||
" try { await adminRequest('/api/admin/broker-writer', 'POST', {expected: String(payload.writer), target: String(client.id), confirm: 'true'}); await openPopover('writer', popoverTrigger); pollStatus(); }\n"
|
||
" catch (error) { const message = document.createElement('p'); message.className = 'session-notice'; message.dataset.visible = 'true'; message.textContent = error.message; popoverContent.prepend(message); }\n"
|
||
" }, true);\n"
|
||
" list.append(clientRow(client, action));\n"
|
||
" }\n"
|
||
" popoverContent.replaceChildren(list);\n"
|
||
"}\n"
|
||
"async function openPopover(kind, trigger) {\n"
|
||
" if (accountRole !== 'admin' || trigger === null) return;\n"
|
||
" popoverTrigger = trigger; const generation = ++popoverGeneration;\n"
|
||
" popoverTitle.textContent = kind === 'serial' ? 'Serial quick settings' : kind === 'wifi' ? 'Wi-Fi quick settings' : kind === 'writer' ? 'Transfer writer lease' : 'Broker clients';\n"
|
||
" const loading = document.createElement('p'); loading.className = 'client-meta'; loading.textContent = 'Loading…';\n"
|
||
" popoverContent.replaceChildren(loading); quickPopover.hidden = false; positionPopover(trigger);\n"
|
||
" try { await renderPopover(kind, generation); }\n"
|
||
" catch (error) { if (generation === popoverGeneration) { loading.textContent = error.message; popoverContent.replaceChildren(loading); } }\n"
|
||
"}\n"
|
||
"const closeSettings = (restoreFocus = true) => {\n"
|
||
" settingsBackdrop.hidden = true;\n"
|
||
" userCreatePassword.value = ''; userPassword.value = ''; userKey.value = '';\n"
|
||
" wifiProfileSecret.value = ''; wifiApSecret.value = '';\n"
|
||
" clearGeneratedPassword();\n"
|
||
" if (restoreFocus) settingsOpen.focus();\n"
|
||
"};\n"
|
||
"async function openSettings() {\n"
|
||
" if (accountRole !== 'admin') return;\n"
|
||
" closePopover();\n"
|
||
" settingsMessage.textContent = 'Loading settings…';\n"
|
||
" settingsBackdrop.hidden = false;\n"
|
||
" try {\n"
|
||
" await loadSerialSettings();\n"
|
||
" await loadUsers();\n"
|
||
" await loadWifiConfig();\n"
|
||
" await loadDisplaySettings();\n"
|
||
" settingsMessage.textContent = '';\n"
|
||
" } catch (error) {\n"
|
||
" settingsMessage.textContent = error.message;\n"
|
||
" }\n"
|
||
" settingsClose.focus();\n"
|
||
"}\n"
|
||
"serialTerminal.onData((data) => {\n"
|
||
" if (brokerRole !== 'writer' || !socketOpen()) return;\n"
|
||
" const bytes = encoder.encode(data);\n"
|
||
" for (let offset = 0; offset < bytes.length; offset += 1024) {\n"
|
||
" socket.send(bytes.subarray(offset, Math.min(offset + 1024, bytes.length)));\n"
|
||
" }\n"
|
||
"});\n"
|
||
"adminTerminal.onData((data) => {\n"
|
||
" if (!adminSocketOpen()) return;\n"
|
||
" const bytes = encoder.encode(data);\n"
|
||
" for (let offset = 0; offset < bytes.length; offset += 1024) {\n"
|
||
" adminSocket.send(bytes.subarray(offset, Math.min(offset + 1024, bytes.length)));\n"
|
||
" }\n"
|
||
"});\n"
|
||
"serialModeButton.addEventListener('click', () => setTerminalMode('serial'));\n"
|
||
"adminModeButton.addEventListener('click', () => setTerminalMode('admin'));\n"
|
||
"for (const [card, kind] of [[serialCard,'serial'],[wifiCard,'wifi'],[clientsCard,'clients'],[writerCard,'writer']]) {\n"
|
||
" card.addEventListener('pointerenter', () => openPopover(kind, card));\n"
|
||
" card.addEventListener('focus', () => openPopover(kind, card));\n"
|
||
" card.addEventListener('click', () => openPopover(kind, card));\n"
|
||
" card.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openPopover(kind, card); } });\n"
|
||
"}\n"
|
||
"popoverClose.addEventListener('click', () => closePopover(true));\n"
|
||
"document.addEventListener('pointerdown', (event) => {\n"
|
||
" if (!quickPopover.hidden && !quickPopover.contains(event.target) && event.target !== popoverTrigger && !popoverTrigger?.contains(event.target)) closePopover();\n"
|
||
"});\n"
|
||
"document.addEventListener('keydown', (event) => {\n"
|
||
" if (event.key !== 'Escape') return;\n"
|
||
" if (!quickPopover.hidden) closePopover(true);\n"
|
||
" else if (!settingsBackdrop.hidden) closeSettings();\n"
|
||
"});\n"
|
||
"settingsOpen.addEventListener('click', openSettings);\n"
|
||
"settingsClose.addEventListener('click', () => closeSettings());\n"
|
||
"settingsBackdrop.addEventListener('pointerdown', (event) => { if (event.target === settingsBackdrop) closeSettings(); });\n"
|
||
"settingsAdminShell.addEventListener('click', () => { closeSettings(false); setTerminalMode('admin'); });\n"
|
||
"serialSettingsForm.addEventListener('submit', (event) => event.preventDefault());\n"
|
||
"const runSettingsAction = async (action, secretScope = null) => {\n"
|
||
" try { await action(); }\n"
|
||
" catch (error) { if (secretScope === 'user') clearGeneratedPassword(); settingsMessage.textContent = error.message; }\n"
|
||
" finally {\n"
|
||
" if (secretScope === 'user') { userPassword.value = ''; userCreatePassword.value = ''; userKey.value = ''; }\n"
|
||
" if (secretScope === 'wifi') { wifiProfileSecret.value = ''; wifiApSecret.value = ''; }\n"
|
||
" }\n"
|
||
"};\n"
|
||
"const updateCreatePasswordMode = () => {\n"
|
||
" const generated = userCreatePasswordMode.value === 'generated';\n"
|
||
" userCreatePassword.disabled = generated;\n"
|
||
" userCreatePassword.required = !generated;\n"
|
||
" if (generated) userCreatePassword.value = '';\n"
|
||
"};\n"
|
||
"userCreatePasswordMode.addEventListener('change', updateCreatePasswordMode);\n"
|
||
"updateCreatePasswordMode();\n"
|
||
"userSelect.addEventListener('change', renderSelectedUser);\n"
|
||
"userCreateForm.addEventListener('submit', (event) => event.preventDefault());\n"
|
||
"userEditForm.addEventListener('submit', (event) => event.preventDefault());\n"
|
||
"element('user-create').addEventListener('click', () => runSettingsAction(async () => {\n"
|
||
" updateCreatePasswordMode();\n"
|
||
" if (!userCreateForm.reportValidity()) return;\n"
|
||
" const generated = userCreatePasswordMode.value === 'generated';\n"
|
||
" if (!generated && !printableAscii(userCreatePassword.value, 12, 64)) throw new Error('Password must be 12–64 printable ASCII characters.');\n"
|
||
" await userOperation('create', {\n"
|
||
" username: userCreateName.value,\n"
|
||
" role: userCreateRole.value,\n"
|
||
" password_mode: generated ? 'generated' : 'provided',\n"
|
||
" ...(generated ? {} : {password: userCreatePassword.value})\n"
|
||
" }, null);\n"
|
||
" userCreateName.value = '';\n"
|
||
"}, 'user'));\n"
|
||
"element('user-role-apply').addEventListener('click', () => runSettingsAction(async () => {\n"
|
||
" const user = selectedUser(); if (!user) return;\n"
|
||
" if (!window.confirm(`Change ${user.username} from ${user.role} to ${userRole.value}?`)) return;\n"
|
||
" await userOperation('set-role', {role: userRole.value, confirm: 'true'}, user);\n"
|
||
"}, 'user'));\n"
|
||
"element('user-password-set').addEventListener('click', () => runSettingsAction(async () => {\n"
|
||
" const user = selectedUser(); if (!user) return;\n"
|
||
" if (!userPassword.reportValidity() || !printableAscii(userPassword.value, 12, 64)) throw new Error('Password must be 12–64 printable ASCII characters.');\n"
|
||
" if (!window.confirm(`Replace the password for ${user.username}? Active sessions for that account will be revoked.`)) return;\n"
|
||
" await userOperation('set-password', {password_mode: 'provided', password: userPassword.value}, user);\n"
|
||
"}, 'user'));\n"
|
||
"element('user-password-generate').addEventListener('click', () => runSettingsAction(async () => {\n"
|
||
" const user = selectedUser(); if (!user) return;\n"
|
||
" if (user.username === accountUsername) throw new Error('Generating a replacement password for the current administrator is disabled. Set one explicitly instead.');\n"
|
||
" if (!window.confirm(`Generate and install a new password for ${user.username}? It will be shown only once.`)) return;\n"
|
||
" await userOperation('set-password', {password_mode: 'generated'}, user);\n"
|
||
"}, 'user'));\n"
|
||
"element('user-key-add').addEventListener('click', () => runSettingsAction(async () => {\n"
|
||
" const user = selectedUser(); if (!user) return;\n"
|
||
" const parts = userKey.value.trim().split(/\\s+/);\n"
|
||
" if (parts.length < 2 || !['ssh-ed25519','ecdsa-sha2-nistp256'].includes(parts[0]) || !/^[A-Za-z0-9+/]+={0,2}$/.test(parts[1])) {\n"
|
||
" throw new Error('Enter an OpenSSH Ed25519 or ECDSA P-256 public key.');\n"
|
||
" }\n"
|
||
" await userOperation('key-add', {key_type: parts[0], key_base64: parts[1]}, user);\n"
|
||
"}, 'user'));\n"
|
||
"element('user-key-clear').addEventListener('click', () => runSettingsAction(async () => {\n"
|
||
" const user = selectedUser(); if (!user || !Array.isArray(user.keys) || user.keys.length === 0) return;\n"
|
||
" if (!window.confirm(`Remove all SSH public keys from ${user.username}?`)) return;\n"
|
||
" await userOperation('key-clear', {confirm: 'true'}, user);\n"
|
||
"}, 'user'));\n"
|
||
"element('user-delete').addEventListener('click', () => runSettingsAction(async () => {\n"
|
||
" const user = selectedUser(); if (!user) return;\n"
|
||
" if (!window.confirm(`Permanently delete account ${user.username}?`)) return;\n"
|
||
" await userOperation('delete', {confirm: 'true'}, user);\n"
|
||
"}, 'user'));\n"
|
||
"element('generated-password-clear').addEventListener('click', clearGeneratedPassword);\n"
|
||
"element('generated-password-copy').addEventListener('click', () => runSettingsAction(async () => {\n"
|
||
" if (generatedPassword.textContent.length === 0) return;\n"
|
||
" await navigator.clipboard.writeText(generatedPassword.textContent);\n"
|
||
" settingsMessage.textContent = 'Generated password copied. Clear it after storing it safely.';\n"
|
||
"}));\n"
|
||
"wifiProfileForm.addEventListener('submit', (event) => event.preventDefault());\n"
|
||
"wifiApForm.addEventListener('submit', (event) => event.preventDefault());\n"
|
||
"wifiProfileSlot.addEventListener('change', fillWifiProfile);\n"
|
||
"element('wifi-profile-apply').addEventListener('click', () => runSettingsAction(() => wifiConfigOperation('profile-apply', {\n"
|
||
" slot: wifiProfileSlot.value,\n"
|
||
" ssid_b64: ssidFieldValue(wifiProfileSsid),\n"
|
||
" priority: wifiProfilePriority.value,\n"
|
||
" security: wifiProfileSecurity.value\n"
|
||
"}), 'wifi'));\n"
|
||
"wifiProfileToggle.addEventListener('click', () => runSettingsAction(() => {\n"
|
||
" const profile = selectedWifiProfile(); if (!profile) throw new Error('Select a profile first.');\n"
|
||
" return wifiConfigOperation(profile.enabled ? 'profile-disable' : 'profile-enable', {slot: String(profile.slot)});\n"
|
||
"}, 'wifi'));\n"
|
||
"element('wifi-profile-secret-set').addEventListener('click', () => runSettingsAction(() => {\n"
|
||
" const profile = selectedWifiProfile(); if (!profile) throw new Error('Select a profile first.');\n"
|
||
" if (!wifiProfileSecret.reportValidity() || !printableAscii(wifiProfileSecret.value, 8, 63)) throw new Error('Wi-Fi secret must be 8–63 printable ASCII characters.');\n"
|
||
" return wifiConfigOperation('profile-secret', {slot: String(profile.slot), secret: wifiProfileSecret.value});\n"
|
||
"}, 'wifi'));\n"
|
||
"element('wifi-profile-delete').addEventListener('click', () => runSettingsAction(() => {\n"
|
||
" const profile = selectedWifiProfile(); if (!profile) throw new Error('Select a profile first.');\n"
|
||
" if (!window.confirm(`Delete Wi-Fi profile slot ${profile.slot}, including its stored secret?`)) return Promise.resolve();\n"
|
||
" return wifiConfigOperation('profile-delete', {slot: String(profile.slot), confirm: 'true'});\n"
|
||
"}, 'wifi'));\n"
|
||
"element('wifi-ap-apply').addEventListener('click', () => runSettingsAction(() => wifiConfigOperation('ap-apply', {\n"
|
||
" policy: wifiApPolicy.value,\n"
|
||
" ssid_b64: ssidFieldValue(wifiApSsid),\n"
|
||
" channel: wifiApChannel.value\n"
|
||
"}), 'wifi'));\n"
|
||
"element('wifi-ap-secret-set').addEventListener('click', () => runSettingsAction(() => {\n"
|
||
" if (!wifiApSecret.reportValidity() || !printableAscii(wifiApSecret.value, 8, 63)) throw new Error('AP secret must be 8–63 printable ASCII characters.');\n"
|
||
" return wifiConfigOperation('ap-secret', {secret: wifiApSecret.value});\n"
|
||
"}, 'wifi'));\n"
|
||
"element('wifi-config-save').addEventListener('click', () => runSettingsAction(() => wifiConfigOperation('save'), 'wifi'));\n"
|
||
"displayForm.addEventListener('submit', (event) => event.preventDefault());\n"
|
||
"element('display-apply').addEventListener('click', () => runSettingsAction(() => displayOperation('apply')));\n"
|
||
"for (const operation of ['save','load','defaults','reset']) element(`display-${operation}`).addEventListener('click', () => runSettingsAction(() => displayOperation(operation)));\n"
|
||
"element('serial-apply').addEventListener('click', () => runSettingsAction(() => serialOperation('apply')));\n"
|
||
"for (const operation of ['save','start','stop','load','defaults','reset']) element(`serial-${operation}`).addEventListener('click', () => runSettingsAction(() => serialOperation(operation)));\n"
|
||
"for (const button of document.querySelectorAll('[data-wifi-action]')) button.addEventListener('click', () => runSettingsAction(() => wifiOperation(button.dataset.wifiAction)));\n"
|
||
"requestControl.addEventListener('click', () => {\n"
|
||
" if (brokerRole !== 'writer' && socketOpen()) socket.send('request-writer');\n"
|
||
"});\n"
|
||
"releaseControl.addEventListener('click', () => {\n"
|
||
" if (brokerRole === 'writer' && socketOpen()) socket.send('release-writer');\n"
|
||
"});\n"
|
||
"logoutButton.addEventListener('click', async () => {\n"
|
||
" if (unloading || authRedirecting || !sessionReady || typeof csrfToken !== 'string') return;\n"
|
||
" const token = csrfToken;\n"
|
||
" setSessionNotice('');\n"
|
||
" authRedirecting = true;\n"
|
||
" stopAuthenticatedActivity();\n"
|
||
" try {\n"
|
||
" const response = await fetch('/logout', {\n"
|
||
" method: 'POST', credentials: 'same-origin', cache: 'no-store',\n"
|
||
" headers: {'X-CSRF-Token': token}\n"
|
||
" });\n"
|
||
" if (!response.ok) throw new Error('logout request failed');\n"
|
||
" csrfToken = null;\n"
|
||
" accountUsername = null;\n"
|
||
" accountRole = null;\n"
|
||
" window.location.assign('/login');\n"
|
||
" } catch (_) {\n"
|
||
" authRedirecting = false;\n"
|
||
" csrfToken = null;\n"
|
||
" accountUsername = null;\n"
|
||
" accountRole = null;\n"
|
||
" accountUsernameField.textContent = 'Restoring…';\n"
|
||
" accountRoleField.textContent = 'Session';\n"
|
||
" setSessionNotice('Logout failed; session remains active.');\n"
|
||
" startAuthenticatedUi();\n"
|
||
" }\n"
|
||
"});\n"
|
||
"connectionToggle.addEventListener('click', () => {\n"
|
||
" const connectionActive = reconnectEnabled || socket !== null || ticketAbort !== null || reconnectTimer !== null;\n"
|
||
" if (!connectionActive) {\n"
|
||
" reconnectEnabled = true;\n"
|
||
" reconnectDelay = 1000;\n"
|
||
" if (!sessionReady) { startAuthenticatedUi(); return; }\n"
|
||
" connect();\n"
|
||
" return;\n"
|
||
" }\n"
|
||
" reconnectEnabled = false;\n"
|
||
" ++connectionGeneration;\n"
|
||
" clearReconnectTimer();\n"
|
||
" if (ticketAbort !== null) { ticketAbort.abort(); ticketAbort = null; }\n"
|
||
" if (socket !== null) { const previous = socket; socket = null; previous.close(); }\n"
|
||
" clientId = null;\n"
|
||
" clientIdField.textContent = '—';\n"
|
||
" setBrokerRole('observer');\n"
|
||
" setConnection('Disconnected', 'warn', 'Disconnected by user. Automatic reconnect is paused.');\n"
|
||
"});\n"
|
||
"const fitTerminal = () => {\n"
|
||
" fitFrame = 0;\n"
|
||
" const admin = terminalMode === 'admin';\n"
|
||
" const host = admin ? adminTerminalHost : serialTerminalHost;\n"
|
||
" const instance = admin ? adminTerminal : serialTerminal;\n"
|
||
" const addon = admin ? adminFitAddon : serialFitAddon;\n"
|
||
" const bounds = host.getBoundingClientRect();\n"
|
||
" const width = Math.floor(bounds.width);\n"
|
||
" const height = Math.floor(bounds.height);\n"
|
||
" const unchanged = admin\n"
|
||
" ? width === adminLastFitWidth && height === adminLastFitHeight\n"
|
||
" : width === serialLastFitWidth && height === serialLastFitHeight;\n"
|
||
" if (width < 1 || height < 1 || unchanged) return;\n"
|
||
" if (admin) { adminLastFitWidth = width; adminLastFitHeight = height; }\n"
|
||
" else { serialLastFitWidth = width; serialLastFitHeight = height; }\n"
|
||
" try {\n"
|
||
" const dimensions = addon.proposeDimensions();\n"
|
||
" if (dimensions && dimensions.cols > 0 && dimensions.rows > 0 &&\n"
|
||
" (dimensions.cols !== instance.cols || dimensions.rows !== instance.rows)) {\n"
|
||
" instance.resize(dimensions.cols, dimensions.rows);\n"
|
||
" }\n"
|
||
" } catch (_) {}\n"
|
||
"};\n"
|
||
"const scheduleFit = () => {\n"
|
||
" if (fitFrame === 0) fitFrame = window.requestAnimationFrame(fitTerminal);\n"
|
||
"};\n"
|
||
"const resizeObserver = 'ResizeObserver' in window ? new ResizeObserver(scheduleFit) : null;\n"
|
||
"if (resizeObserver !== null) { resizeObserver.observe(serialTerminalHost); resizeObserver.observe(adminTerminalHost); }\n"
|
||
"window.addEventListener('resize', scheduleFit);\n"
|
||
"const textValue = (value, fallback) => typeof value === 'string' && value.length > 0 ? value : fallback;\n"
|
||
"const updateStatus = (status) => {\n"
|
||
" const wifi = status !== null && typeof status === 'object' ? status.wifi : null;\n"
|
||
" if (wifi && wifi.available) {\n"
|
||
" const parts = [textValue(wifi.state, 'unknown')];\n"
|
||
" if (typeof wifi.sta_ipv4 === 'string' && wifi.sta_ipv4 !== '0.0.0.0') parts.push(wifi.sta_ipv4);\n"
|
||
" if (Number.isFinite(wifi.rssi)) parts.push(`${wifi.rssi} dBm`);\n"
|
||
" if (Number.isFinite(wifi.channel) && wifi.channel > 0) parts.push(`channel ${wifi.channel}`);\n"
|
||
" if (wifi.ap_running && Number.isFinite(wifi.ap_clients)) parts.push(`AP clients ${wifi.ap_clients}`);\n"
|
||
" wifiSummary.textContent = parts.join(' · ');\n"
|
||
" } else {\n"
|
||
" wifiSummary.textContent = 'Unavailable';\n"
|
||
" }\n"
|
||
" const serial = status !== null && typeof status === 'object' ? status.serial : null;\n"
|
||
" if (serial && typeof serial === 'object') {\n"
|
||
" const parts = [serial.running ? 'Running' : 'Stopped'];\n"
|
||
" if (serial.config_available) {\n"
|
||
" if (Number.isFinite(serial.baud)) parts.push(`${serial.baud} baud`);\n"
|
||
" parts.push(`${textValue(serial.data_bits, '?')} data`);\n"
|
||
" parts.push(`${textValue(serial.parity, '?')} parity`);\n"
|
||
" parts.push(`${textValue(serial.stop_bits, '?')} stop`);\n"
|
||
" parts.push(`${textValue(serial.flow, '?')} flow`);\n"
|
||
" }\n"
|
||
" serialSummary.textContent = parts.join(' · ');\n"
|
||
" } else {\n"
|
||
" serialSummary.textContent = 'Unavailable';\n"
|
||
" }\n"
|
||
" const broker = status !== null && typeof status === 'object' ? status.broker : null;\n"
|
||
" if (broker && broker.available) {\n"
|
||
" brokerClientsField.textContent = validId(broker.clients) ? String(broker.clients) : '—';\n"
|
||
" if (validId(broker.writer)) {\n"
|
||
" writerId = broker.writer;\n"
|
||
" writerIdField.textContent = displayId(writerId, 'None');\n"
|
||
" }\n"
|
||
" } else {\n"
|
||
" brokerClientsField.textContent = '—';\n"
|
||
" }\n"
|
||
"};\n"
|
||
"async function pollStatus() {\n"
|
||
" if (unloading || statusInFlight) return;\n"
|
||
" statusInFlight = true;\n"
|
||
" try {\n"
|
||
" const response = await fetch('/api/status', {credentials: 'same-origin', cache: 'no-store'});\n"
|
||
" if (response.status === 401) { navigateToLogin(); return; }\n"
|
||
" if (!response.ok) throw new Error('status request failed');\n"
|
||
" updateStatus(await response.json());\n"
|
||
" } catch (_) {\n"
|
||
" wifiSummary.textContent = 'Unavailable';\n"
|
||
" serialSummary.textContent = 'Unavailable';\n"
|
||
" brokerClientsField.textContent = '—';\n"
|
||
" } finally {\n"
|
||
" statusInFlight = false;\n"
|
||
" }\n"
|
||
"}\n"
|
||
"async function loadSession() {\n"
|
||
" const controller = new AbortController();\n"
|
||
" sessionAbort = controller;\n"
|
||
" try {\n"
|
||
" const response = await fetch('/api/session', {\n"
|
||
" method: 'GET', credentials: 'same-origin', cache: 'no-store', signal: controller.signal\n"
|
||
" });\n"
|
||
" if (response.status === 401) {\n"
|
||
" navigateToLogin();\n"
|
||
" throw new Error('session expired');\n"
|
||
" }\n"
|
||
" if (!response.ok) throw new Error('session request failed');\n"
|
||
" const payload = await response.json();\n"
|
||
" if (payload === null || typeof payload !== 'object' ||\n"
|
||
" typeof payload.username !== 'string' || payload.username.length === 0 ||\n"
|
||
" (payload.role !== 'user' && payload.role !== 'admin') ||\n"
|
||
" typeof payload.csrf !== 'string' || payload.csrf.length === 0) {\n"
|
||
" throw new Error('invalid session response');\n"
|
||
" }\n"
|
||
" accountUsername = payload.username;\n"
|
||
" accountRole = payload.role;\n"
|
||
" csrfToken = payload.csrf;\n"
|
||
" payload.csrf = '';\n"
|
||
" accountUsernameField.textContent = accountUsername;\n"
|
||
" accountRoleField.textContent = accountRole === 'admin' ? 'Administrator' : 'User';\n"
|
||
" const administrator = accountRole === 'admin';\n"
|
||
" terminalModes.hidden = !administrator;\n"
|
||
" settingsOpen.hidden = !administrator;\n"
|
||
" for (const card of [serialCard,wifiCard,clientsCard,writerCard]) {\n"
|
||
" card.classList.toggle('interactive', administrator); card.tabIndex = administrator ? 0 : -1;\n"
|
||
" }\n"
|
||
" sessionReady = true;\n"
|
||
" if (accountRole !== 'admin') setTerminalMode('serial');\n"
|
||
" else if (terminalMode === 'admin') connectAdmin();\n"
|
||
" } finally {\n"
|
||
" if (sessionAbort === controller) sessionAbort = null;\n"
|
||
" }\n"
|
||
"}\n"
|
||
"async function startAuthenticatedUi() {\n"
|
||
" if (unloading || authRedirecting || startupInFlight || sessionReady) return;\n"
|
||
" startupInFlight = true;\n"
|
||
" reconnectEnabled = true;\n"
|
||
" setConnection('Connecting', 'warn', 'Loading account session…');\n"
|
||
" try {\n"
|
||
" await loadSession();\n"
|
||
" if (unloading || authRedirecting || !sessionReady) return;\n"
|
||
" scheduleFit();\n"
|
||
" pollStatus();\n"
|
||
" statusTimer = window.setInterval(pollStatus, 5000);\n"
|
||
" connect();\n"
|
||
" } catch (error) {\n"
|
||
" if (unloading || authRedirecting || error.name === 'AbortError') return;\n"
|
||
" csrfToken = null;\n"
|
||
" accountUsername = null;\n"
|
||
" accountRole = null;\n"
|
||
" sessionReady = false;\n"
|
||
" reconnectEnabled = false;\n"
|
||
" accountUsernameField.textContent = 'Unavailable';\n"
|
||
" accountRoleField.textContent = 'Session';\n"
|
||
" setConnection('Session unavailable', 'bad', 'Unable to load the account session. Select Connect to try again.');\n"
|
||
" } finally {\n"
|
||
" startupInFlight = false;\n"
|
||
" updateControls();\n"
|
||
" }\n"
|
||
"}\n"
|
||
"const shutdown = () => {\n"
|
||
" if (unloading) return;\n"
|
||
" unloading = true;\n"
|
||
" ++connectionGeneration;\n"
|
||
" ++adminConnectionGeneration;\n"
|
||
" clearReconnectTimer();\n"
|
||
" if (statusTimer !== null) window.clearInterval(statusTimer);\n"
|
||
" if (sessionAbort !== null) sessionAbort.abort();\n"
|
||
" if (fitFrame !== 0) window.cancelAnimationFrame(fitFrame);\n"
|
||
" if (resizeObserver !== null) resizeObserver.disconnect();\n"
|
||
" window.removeEventListener('resize', scheduleFit);\n"
|
||
" if (ticketAbort !== null) ticketAbort.abort();\n"
|
||
" if (adminTicketAbort !== null) adminTicketAbort.abort();\n"
|
||
" if (socket !== null) socket.close();\n"
|
||
" if (adminSocket !== null) adminSocket.close();\n"
|
||
" socket = null;\n"
|
||
" adminSocket = null;\n"
|
||
" csrfToken = null;\n"
|
||
" accountUsername = null;\n"
|
||
" accountRole = null;\n"
|
||
"};\n"
|
||
"window.addEventListener('pagehide', shutdown);\n"
|
||
"window.addEventListener('pageshow', (event) => {\n"
|
||
" if (!event.persisted) return;\n"
|
||
" unloading = false; authRedirecting = false; startupInFlight = false; sessionReady = false;\n"
|
||
" statusTimer = null; sessionAbort = null; ticketAbort = null; adminTicketAbort = null;\n"
|
||
" socket = null; adminSocket = null; reconnectEnabled = true;\n"
|
||
" if (resizeObserver !== null) { resizeObserver.observe(serialTerminalHost); resizeObserver.observe(adminTerminalHost); }\n"
|
||
" window.addEventListener('resize', scheduleFit); scheduleFit();\n"
|
||
" startAuthenticatedUi();\n"
|
||
"});\n"
|
||
"updateControls();\n"
|
||
"scheduleFit();\n"
|
||
"startAuthenticatedUi();\n"
|
||
"})();\n";
|
||
|
||
typedef enum {
|
||
WEB_UI_CSP_NONE = 0,
|
||
WEB_UI_CSP_APP,
|
||
WEB_UI_CSP_LOGIN,
|
||
} web_ui_csp_policy_t;
|
||
|
||
typedef struct {
|
||
const char *content_type;
|
||
const char *cache_control;
|
||
const char *content_encoding;
|
||
const uint8_t *data;
|
||
size_t length;
|
||
web_ui_csp_policy_t csp_policy;
|
||
} web_ui_response_t;
|
||
|
||
static esp_err_t describe_resource(web_ui_resource_t resource,
|
||
web_ui_response_t *response)
|
||
{
|
||
response->cache_control = WEB_UI_ASSET_CACHE_CONTROL;
|
||
response->content_encoding = NULL;
|
||
response->csp_policy = WEB_UI_CSP_NONE;
|
||
|
||
switch (resource) {
|
||
case WEB_UI_RESOURCE_INDEX:
|
||
response->content_type = "text/html; charset=utf-8";
|
||
response->cache_control = WEB_UI_DOCUMENT_CACHE_CONTROL;
|
||
response->data = (const uint8_t *)s_index_html;
|
||
response->length = sizeof(s_index_html) - 1U;
|
||
response->csp_policy = WEB_UI_CSP_APP;
|
||
return ESP_OK;
|
||
case WEB_UI_RESOURCE_LOGIN:
|
||
response->content_type = "text/html; charset=utf-8";
|
||
response->cache_control = WEB_UI_DOCUMENT_CACHE_CONTROL;
|
||
response->data = (const uint8_t *)s_login_html;
|
||
response->length = sizeof(s_login_html) - 1U;
|
||
response->csp_policy = WEB_UI_CSP_LOGIN;
|
||
return ESP_OK;
|
||
case WEB_UI_RESOURCE_LOGIN_ERROR:
|
||
response->content_type = "text/html; charset=utf-8";
|
||
response->cache_control = WEB_UI_DOCUMENT_CACHE_CONTROL;
|
||
response->data = (const uint8_t *)s_login_error_html;
|
||
response->length = sizeof(s_login_error_html) - 1U;
|
||
response->csp_policy = WEB_UI_CSP_LOGIN;
|
||
return ESP_OK;
|
||
case WEB_UI_RESOURCE_XTERM_JS:
|
||
response->content_type = "text/javascript; charset=utf-8";
|
||
response->content_encoding = "gzip";
|
||
response->data = web_asset_xterm_js_gz;
|
||
response->length = web_asset_xterm_js_gz_size;
|
||
return ESP_OK;
|
||
case WEB_UI_RESOURCE_XTERM_CSS:
|
||
response->content_type = "text/css; charset=utf-8";
|
||
response->content_encoding = "gzip";
|
||
response->data = web_asset_xterm_css_gz;
|
||
response->length = web_asset_xterm_css_gz_size;
|
||
return ESP_OK;
|
||
case WEB_UI_RESOURCE_ADDON_FIT_JS:
|
||
response->content_type = "text/javascript; charset=utf-8";
|
||
response->content_encoding = "gzip";
|
||
response->data = web_asset_addon_fit_js_gz;
|
||
response->length = web_asset_addon_fit_js_gz_size;
|
||
return ESP_OK;
|
||
case WEB_UI_RESOURCE_APP_JS:
|
||
response->content_type = "text/javascript; charset=utf-8";
|
||
response->cache_control = WEB_UI_AUTHORED_ASSET_CACHE_CONTROL;
|
||
response->data = (const uint8_t *)s_app_js;
|
||
response->length = sizeof(s_app_js) - 1U;
|
||
return ESP_OK;
|
||
case WEB_UI_RESOURCE_LOGO_PNG:
|
||
response->content_type = "image/png";
|
||
response->data = web_asset_logo_png;
|
||
response->length = web_asset_logo_png_size;
|
||
return ESP_OK;
|
||
default:
|
||
return ESP_ERR_INVALID_ARG;
|
||
}
|
||
}
|
||
|
||
static esp_err_t set_response_headers(httpd_req_t *request,
|
||
const web_ui_response_t *response)
|
||
{
|
||
esp_err_t result = httpd_resp_set_type(request, response->content_type);
|
||
if (result == ESP_OK) {
|
||
result = httpd_resp_set_hdr(request, "Cache-Control",
|
||
response->cache_control);
|
||
}
|
||
if (result == ESP_OK) {
|
||
result = httpd_resp_set_hdr(request, "X-Content-Type-Options",
|
||
"nosniff");
|
||
}
|
||
if (result == ESP_OK) {
|
||
result = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
|
||
}
|
||
if (result == ESP_OK && response->content_encoding != NULL) {
|
||
result = httpd_resp_set_hdr(request, "Content-Encoding",
|
||
response->content_encoding);
|
||
}
|
||
if (result == ESP_OK && response->csp_policy == WEB_UI_CSP_APP) {
|
||
result = httpd_resp_set_hdr(
|
||
request, "Content-Security-Policy",
|
||
"default-src 'none'; script-src 'self' 'sha256-5ukY3vEyRwFowsj4k3O4ilN8ezlXoZu1w90PPHg0YVE='; "
|
||
"script-src-elem 'self' 'sha256-5ukY3vEyRwFowsj4k3O4ilN8ezlXoZu1w90PPHg0YVE='; "
|
||
"style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; "
|
||
"base-uri 'none'; form-action 'none'; "
|
||
"frame-ancestors 'none'");
|
||
}
|
||
if (result == ESP_OK && response->csp_policy == WEB_UI_CSP_LOGIN) {
|
||
result = httpd_resp_set_hdr(
|
||
request, "Content-Security-Policy",
|
||
"default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; "
|
||
"img-src 'self' data:; base-uri 'none'; form-action 'self'; "
|
||
"frame-ancestors 'none'");
|
||
}
|
||
return result;
|
||
}
|
||
|
||
esp_err_t web_ui_send_response(httpd_req_t *request,
|
||
web_ui_resource_t resource)
|
||
{
|
||
if (request == NULL) {
|
||
return ESP_ERR_INVALID_ARG;
|
||
}
|
||
|
||
web_ui_response_t response = {0};
|
||
esp_err_t result = describe_resource(resource, &response);
|
||
if (result == ESP_OK) {
|
||
result = set_response_headers(request, &response);
|
||
}
|
||
if (result == ESP_OK) {
|
||
result = httpd_resp_send(request, (const char *)response.data,
|
||
(ssize_t)response.length);
|
||
}
|
||
return result;
|
||
}
|