Add browser Serial/Admin terminal switching
Keep the serial connection and lease intact while providing a separate, bounded admin terminal with explicit open and close controls. Fence retained terminal state across sessions and add fit-readiness retries with regression coverage.
This commit is contained in:
+181
-38
@@ -25,6 +25,7 @@ static const char s_index_html[] =
|
||||
"--line:#29364a;--text:#e8eef8;--muted:#91a0b5;--accent:#55c2ff;"
|
||||
"--good:#52d68b;--warn:#ffc857;--bad:#ff6b7a;--radius:14px}\n"
|
||||
"*{box-sizing:border-box}\n"
|
||||
"[hidden]{display:none!important}\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,"
|
||||
@@ -65,6 +66,7 @@ static const char s_index_html[] =
|
||||
"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[aria-pressed=true]{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"
|
||||
@@ -74,11 +76,13 @@ static const char s_index_html[] =
|
||||
".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-title{color:var(--text);font-weight:750;letter-spacing:.02em}\n"
|
||||
"#terminal{flex:1;min-width:0;min-height:0;border-radius:9px;overflow:hidden;"
|
||||
".terminal-host{flex:1;min-width:0;min-height:0;border-radius:9px;overflow:hidden;"
|
||||
"background:#080c12;padding:8px}\n"
|
||||
"#terminal .xterm{width:100%;height:100%}\n"
|
||||
"#terminal .xterm-viewport{border-radius:7px}\n"
|
||||
"@media(max-width:850px){.dashboard{grid-template-columns:1fr}.controls{align-items:flex-start}"
|
||||
".terminal-host .xterm{width:100%;height:100%}\n"
|
||||
".terminal-host .xterm-viewport{border-radius:7px}\n"
|
||||
".terminal-toolbar{flex-wrap:wrap}.terminal-toolbar .button{min-height:32px;padding:4px 10px}\n"
|
||||
"@media(max-width:850px){html,body{overflow:auto}.page{height:auto;min-height:100dvh;grid-template-rows:auto auto minmax(280px,1fr)}"
|
||||
".terminal-panel{min-height:280px}.dashboard{grid-template-columns:1fr}.controls{align-items:flex-start}"
|
||||
".status-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}\n"
|
||||
"@media(max-width:480px){.page{padding:10px;gap:10px}.topbar{align-items:flex-start}"
|
||||
".logo{width:36px;height:36px}.status-grid{padding:10px;gap:8px}"
|
||||
@@ -156,10 +160,16 @@ static const char s_index_html[] =
|
||||
"<p id=\"connection-detail\" class=\"connection-detail\" aria-live=\"polite\">Loading application… If loading fails, <a href=\"/login\">open login</a> or reload this page.</p>\n"
|
||||
"</div>\n"
|
||||
"</section>\n"
|
||||
"<section class=\"panel terminal-panel\" aria-label=\"Serial terminal\">\n"
|
||||
"<div class=\"terminal-toolbar\"><span class=\"terminal-title\">Live serial stream</span>"
|
||||
"<span>Binary, unmodified device output</span></div>\n"
|
||||
"<div id=\"terminal\"></div>\n"
|
||||
"<section class=\"panel terminal-panel\" aria-label=\"Terminal workspace\">\n"
|
||||
"<div class=\"terminal-toolbar\"><span id=\"terminal-title\" class=\"terminal-title\">Live serial stream</span>"
|
||||
"<div id=\"terminal-selector\" hidden role=\"group\" aria-label=\"Selected terminal\">"
|
||||
"<button id=\"select-serial\" class=\"button\" type=\"button\" aria-pressed=\"true\">Serial</button> "
|
||||
"<button id=\"select-admin\" class=\"button\" type=\"button\" aria-pressed=\"false\">Admin</button></div>"
|
||||
"<button id=\"admin-toggle\" class=\"button\" type=\"button\" hidden>Open admin</button></div>\n"
|
||||
"<p id=\"admin-detail\" class=\"connection-detail\" aria-live=\"polite\" hidden>Admin closed. Serial stays connected.</p>\n"
|
||||
"<p id=\"output-detail\" class=\"connection-detail\" aria-live=\"polite\">Scrollback: 5000 lines per terminal; oldest lines expire.</p>\n"
|
||||
"<div id=\"terminal\" class=\"terminal-host\"></div>\n"
|
||||
"<div id=\"admin-terminal\" class=\"terminal-host\" hidden></div>\n"
|
||||
"</section>\n"
|
||||
"</main>\n"
|
||||
"</body>\n"
|
||||
@@ -184,6 +194,12 @@ static const char s_app_js[] =
|
||||
"const signOut = element('sign-out');\n"
|
||||
"const sessionInfo = element('session-info');\n"
|
||||
"const terminalHost = element('terminal');\n"
|
||||
"const adminHost = element('admin-terminal');\n"
|
||||
"const adminToggle = element('admin-toggle');\n"
|
||||
"const adminDetail = element('admin-detail');\n"
|
||||
"let accountRole = 'user', selected = 'serial';\n"
|
||||
"let adminTerminal = null, adminFit = null, adminSocket = null, adminAbort = null;\n"
|
||||
"let adminGeneration = 0, adminTimer = null;\n"
|
||||
"const terminal = new Terminal({\n"
|
||||
" allowProposedApi: false, convertEol: false, cursorBlink: true, disableStdin: true,\n"
|
||||
" fontFamily: '\"SFMono-Regular\",Consolas,\"Liberation Mono\",monospace',\n"
|
||||
@@ -198,7 +214,93 @@ static const char s_app_js[] =
|
||||
"terminal.loadAddon(fitAddon);\n"
|
||||
"terminal.open(terminalHost);\n"
|
||||
"const encoder = new TextEncoder();\n"
|
||||
"// Bound xterm's asynchronous write backlog separately from its scrollback.\n"
|
||||
"const output = {serial: {pending: 0, dropped: 0}, admin: {pending: 0, dropped: 0}};\n"
|
||||
"function writeOutput(name, target, data) {\n"
|
||||
" const state = output[name];\n"
|
||||
" if (data.byteLength > 65536 - state.pending) {\n"
|
||||
" state.dropped = Math.min(Number.MAX_SAFE_INTEGER, state.dropped + data.byteLength);\n"
|
||||
" element('output-detail').textContent = `Browser output dropped: serial ${output.serial.dropped} B, admin ${output.admin.dropped} B. Scrollback: 5000 lines each; oldest lines expire.`;\n"
|
||||
" return;\n"
|
||||
" }\n"
|
||||
" state.pending += data.byteLength;\n"
|
||||
" target.write(new Uint8Array(data), () => { state.pending -= data.byteLength; });\n"
|
||||
"}\n"
|
||||
"function closeAdmin(detail = 'Admin closed. Serial connection and lease are unchanged.') {\n"
|
||||
" ++adminGeneration;\n"
|
||||
" if (adminAbort) adminAbort.abort();\n"
|
||||
" adminAbort = null;\n"
|
||||
" window.clearTimeout(adminTimer); adminTimer = null;\n"
|
||||
" if (adminSocket) {\n"
|
||||
" const previous = adminSocket; adminSocket = null;\n"
|
||||
" previous.onopen = previous.onmessage = previous.onerror = previous.onclose = null;\n"
|
||||
" previous.close();\n"
|
||||
" }\n"
|
||||
" adminDetail.textContent = detail;\n"
|
||||
" updateControls();\n"
|
||||
"}\n"
|
||||
"function selectTerminal(mode) {\n"
|
||||
" if (unloading || navigating || loggingOut || !sessionVerified || (mode === 'admin' && accountRole !== 'admin')) return;\n"
|
||||
" selected = mode;\n"
|
||||
" if (mode === 'admin' && !adminTerminal) {\n"
|
||||
" adminTerminal = new Terminal({...terminal.options, disableStdin: true, scrollback: 5000});\n"
|
||||
" adminFit = new FitAddon.FitAddon(); adminTerminal.loadAddon(adminFit); adminTerminal.open(adminHost);\n"
|
||||
" adminTerminal.onData((data) => {\n"
|
||||
" if (selected !== 'admin' || !adminSocket || adminSocket.readyState !== WebSocket.OPEN || unloading || navigating || suspended) return;\n"
|
||||
" const bytes = encoder.encode(data);\n"
|
||||
" if (bytes.length > 4096 || adminSocket.bufferedAmount + bytes.length > 4096) {\n"
|
||||
" closeAdmin('Admin input exceeded the browser buffer limit; shell closed to discard partial input. Open admin to retry.'); return;\n"
|
||||
" }\n"
|
||||
" for (let offset = 0; offset < bytes.length; offset += 512) adminSocket.send(bytes.subarray(offset, offset + 512));\n"
|
||||
" });\n"
|
||||
" }\n"
|
||||
" terminalHost.hidden = mode !== 'serial'; adminHost.hidden = mode !== 'admin';\n"
|
||||
" adminToggle.hidden = adminDetail.hidden = mode !== 'admin';\n"
|
||||
" element('select-serial').setAttribute('aria-pressed', String(mode === 'serial'));\n"
|
||||
" element('select-admin').setAttribute('aria-pressed', String(mode === 'admin'));\n"
|
||||
" element('terminal-title').textContent = mode === 'serial' ? 'Live serial stream' : 'Administration shell';\n"
|
||||
" lastFitWidth = lastFitHeight = 0; updateControls(); scheduleFit();\n"
|
||||
" (mode === 'serial' ? terminal : adminTerminal).focus();\n"
|
||||
"}\n"
|
||||
"async function openAdmin() {\n"
|
||||
" if (accountRole !== 'admin' || selected !== 'admin' || unloading || navigating || loggingOut || suspended || !csrf || adminSocket || adminAbort) return;\n"
|
||||
" const generation = ++adminGeneration, work = workGeneration;\n"
|
||||
" const controller = new AbortController(); adminAbort = controller;\n"
|
||||
" const current = () => generation === adminGeneration;\n"
|
||||
" adminDetail.textContent = 'Opening admin shell...'; updateControls();\n"
|
||||
" try {\n"
|
||||
" const {payload} = await api('/api/admin/ws-ticket', work, {method: 'POST', signal: controller.signal, current});\n"
|
||||
" if (!payload || !/^[A-Za-z0-9_-]{32}$/.test(payload.ticket)) throw new Error('Invalid ticket');\n"
|
||||
" if (!live(work) || !current()) return;\n"
|
||||
" const url = new URL('/ws/admin', window.location.origin); url.protocol = 'wss:';\n"
|
||||
" url.searchParams.set('ticket', payload.ticket); payload.ticket = '';\n"
|
||||
" const next = new WebSocket(url.toString()); url.search = '';\n"
|
||||
" adminSocket = next; adminAbort = null; next.binaryType = 'arraybuffer';\n"
|
||||
" const active = () => live(work) && current() && adminSocket === next;\n"
|
||||
" adminTimer = window.setTimeout(() => { if (active()) closeAdmin('Admin connection timed out. Open admin to retry.'); }, 15000);\n"
|
||||
" next.onopen = () => {\n"
|
||||
" if (!active()) return;\n"
|
||||
" window.clearTimeout(adminTimer); adminTimer = null;\n"
|
||||
" adminDetail.textContent = 'Admin connected. Input goes only to the selected terminal. Existing web-shell command restrictions apply.'; updateControls();\n"
|
||||
" };\n"
|
||||
" next.onmessage = (event) => {\n"
|
||||
" if (!active()) return;\n"
|
||||
" if (!(event.data instanceof ArrayBuffer)) { closeAdmin('Invalid admin output. Open admin to retry.'); return; }\n"
|
||||
" writeOutput('admin', adminTerminal, event.data);\n"
|
||||
" };\n"
|
||||
" next.onerror = () => { if (active()) closeAdmin('Admin connection failed or capacity unavailable. Open admin to retry.'); };\n"
|
||||
" next.onclose = () => { if (active()) closeAdmin('Admin shell ended. Serial is unchanged. Open admin to reconnect.'); };\n"
|
||||
" } catch (error) {\n"
|
||||
" if (live(work) && current()) closeAdmin(error.status ? error.message + ' Open admin to retry explicitly.' : 'Admin connection failed. Open admin to retry.');\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
"let socket = null;\n"
|
||||
"function closeSerial() {\n"
|
||||
" if (!socket) return;\n"
|
||||
" const previous = socket; socket = null;\n"
|
||||
" previous.onopen = previous.onmessage = previous.onerror = previous.onclose = null;\n"
|
||||
" previous.close();\n"
|
||||
"}\n"
|
||||
"let ticketAbort = null;\n"
|
||||
"let reconnectTimer = null;\n"
|
||||
"let reconnectDelay = 1000;\n"
|
||||
@@ -209,11 +311,13 @@ static const char s_app_js[] =
|
||||
"let writerId = 0;\n"
|
||||
"let unloading = false;\n"
|
||||
"let fitFrame = 0;\n"
|
||||
"let fitRetries = 0;\n"
|
||||
"let lastFitWidth = 0;\n"
|
||||
"let lastFitHeight = 0;\n"
|
||||
"let statusInFlight = false;\n"
|
||||
"let statusTimer = null;\n"
|
||||
"let csrf = '';\n"
|
||||
"let sessionIdentity = null, sessionVerified = false;\n"
|
||||
"let sessionDeadline = 0;\n"
|
||||
"let expiryTimer = null;\n"
|
||||
"let navigating = false;\n"
|
||||
@@ -226,6 +330,9 @@ static const char s_app_js[] =
|
||||
"const cancelWork = () => {\n"
|
||||
" ++workGeneration;\n"
|
||||
" ++connectionGeneration;\n"
|
||||
" if (fitFrame) window.cancelAnimationFrame(fitFrame);\n"
|
||||
" fitFrame = 0; fitRetries = 0;\n"
|
||||
" closeAdmin();\n"
|
||||
" clearReconnectTimer();\n"
|
||||
" for (const controller of requests) controller.abort();\n"
|
||||
" requests.clear();\n"
|
||||
@@ -236,20 +343,22 @@ static const char s_app_js[] =
|
||||
" statusInFlight = false;\n"
|
||||
" window.clearTimeout(expiryTimer);\n"
|
||||
" expiryTimer = null;\n"
|
||||
" if (socket) { const previous = socket; socket = null; previous.close(); }\n"
|
||||
" closeSerial();\n"
|
||||
" clientId = null;\n"
|
||||
" clientIdField.textContent = '—';\n"
|
||||
" setRole('observer');\n"
|
||||
"};\n"
|
||||
"const login = () => {\n"
|
||||
"const login = (path = '/login') => {\n"
|
||||
" if (navigating) return;\n"
|
||||
" navigating = true;\n"
|
||||
" csrf = '';\n"
|
||||
" cancelWork();\n"
|
||||
" reconnectEnabled = false;\n"
|
||||
" if (fitFrame) window.cancelAnimationFrame(fitFrame);\n"
|
||||
" if (resizeObserver !== null) resizeObserver.disconnect();\n"
|
||||
" window.removeEventListener('resize', scheduleFit);\n"
|
||||
" updateControls();\n"
|
||||
" if (!window.sakLoginNavigating) { window.sakLoginNavigating = true; window.location.replace('/login'); }\n"
|
||||
" if (!window.sakLoginNavigating) { window.sakLoginNavigating = true; window.location.replace(path); }\n"
|
||||
"};\n"
|
||||
"window.sakSessionExpired = login;\n"
|
||||
"if (window.sakLoginNavigating) { navigating = true; reconnectEnabled = false; }\n"
|
||||
@@ -308,7 +417,21 @@ static const char s_app_js[] =
|
||||
" !Number.isInteger(payload.expires_in) || payload.expires_in < 0 || payload.expires_in > 3600) {\n"
|
||||
" throw new Error('Invalid session response. Reload to retry.');\n"
|
||||
" }\n"
|
||||
" // CSRF is stable for a session; never attach a new cookie identity to old scrollback.\n"
|
||||
" if (sessionIdentity && (sessionIdentity.username !== payload.username ||\n"
|
||||
" sessionIdentity.role !== payload.role || sessionIdentity.csrf !== payload.csrf)) {\n"
|
||||
" payload.csrf = ''; sessionIdentity = null; sessionVerified = false;\n"
|
||||
" terminalHost.hidden = adminHost.hidden = true;\n"
|
||||
" login('/'); return false;\n"
|
||||
" }\n"
|
||||
" sessionIdentity = {username: payload.username, role: payload.role, csrf: payload.csrf};\n"
|
||||
" sessionVerified = true;\n"
|
||||
" csrf = payload.csrf; payload.csrf = '';\n"
|
||||
" accountRole = payload.role;\n"
|
||||
" element('terminal-selector').hidden = accountRole !== 'admin';\n"
|
||||
" if (accountRole !== 'admin') { closeAdmin(); selectTerminal('serial'); }\n"
|
||||
" terminalHost.hidden = selected !== 'serial'; adminHost.hidden = selected !== 'admin';\n"
|
||||
" scheduleFit();\n"
|
||||
" const deadline = Date.now() + payload.expires_in * 1000;\n"
|
||||
" sessionDeadline = sessionDeadline ? Math.min(sessionDeadline, deadline) : deadline;\n"
|
||||
" sessionInfo.textContent = `${payload.username} · Session expires at ${new Date(sessionDeadline).toLocaleTimeString()} (one hour absolute; traffic does not extend it).`;\n"
|
||||
@@ -354,7 +477,11 @@ static const char s_app_js[] =
|
||||
"const socketOpen = () => socket !== null && socket.readyState === WebSocket.OPEN;\n"
|
||||
"const updateControls = () => {\n"
|
||||
" const writer = role === 'writer';\n"
|
||||
" terminal.options.disableStdin = !writer;\n"
|
||||
" terminal.options.disableStdin = !writer || selected !== 'serial' || !socketOpen();\n"
|
||||
" const adminOpen = adminSocket !== null && adminSocket.readyState === WebSocket.OPEN;\n"
|
||||
" if (adminTerminal) adminTerminal.options.disableStdin = selected !== 'admin' || !adminOpen || suspended || unloading || navigating;\n"
|
||||
" adminToggle.textContent = adminSocket || adminAbort ? 'Close admin' : 'Open admin';\n"
|
||||
" adminToggle.disabled = accountRole !== 'admin' || unloading || navigating || loggingOut || suspended || !csrf;\n"
|
||||
" requestControl.disabled = !socketOpen() || writer;\n"
|
||||
" releaseControl.disabled = !socketOpen() || !writer;\n"
|
||||
" const connectionActive = reconnectEnabled || socket !== null || ticketAbort !== null || reconnectTimer !== null;\n"
|
||||
@@ -366,6 +493,7 @@ static const char s_app_js[] =
|
||||
" inputState.textContent = writer\n"
|
||||
" ? 'Writer mode — terminal input is enabled.'\n"
|
||||
" : 'Observer mode — terminal input is disabled.';\n"
|
||||
" if (selected === 'admin') inputState.textContent = `Serial ${writer ? 'writer lease retained' : 'observer'}; serial input disabled while hidden. Admin input ${adminOpen ? 'enabled' : 'disabled'}.`;\n"
|
||||
" setBadge(roleStatus, writer ? 'Writer' : 'Observer', writer ? 'good' : 'warn');\n"
|
||||
"};\n"
|
||||
"const setRole = (nextRole) => {\n"
|
||||
@@ -418,7 +546,7 @@ static const char s_app_js[] =
|
||||
" return;\n"
|
||||
" }\n"
|
||||
" if (event.data instanceof ArrayBuffer) {\n"
|
||||
" terminal.write(new Uint8Array(event.data));\n"
|
||||
" writeOutput('serial', terminal, event.data);\n"
|
||||
" }\n"
|
||||
"};\n"
|
||||
"async function requestTicket(signal, generation) {\n"
|
||||
@@ -435,11 +563,7 @@ static const char s_app_js[] =
|
||||
" const work = workGeneration;\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"
|
||||
" closeSerial();\n"
|
||||
" clientId = null;\n"
|
||||
" clientIdField.textContent = '—';\n"
|
||||
" setRole('observer');\n"
|
||||
@@ -458,26 +582,26 @@ static const char s_app_js[] =
|
||||
" url.search = '';\n"
|
||||
" nextSocket.binaryType = 'arraybuffer';\n"
|
||||
" socket = nextSocket;\n"
|
||||
" nextSocket.addEventListener('open', () => {\n"
|
||||
" nextSocket.onopen = () => {\n"
|
||||
" if (!live(work) || generation !== connectionGeneration || socket !== nextSocket) return;\n"
|
||||
" setConnection('Connected', 'good', 'Connected; waiting for broker role information.');\n"
|
||||
" });\n"
|
||||
" nextSocket.addEventListener('message', (event) => {\n"
|
||||
" };\n"
|
||||
" nextSocket.onmessage = (event) => {\n"
|
||||
" if (live(work) && generation === connectionGeneration && socket === nextSocket) handleSocketMessage(event);\n"
|
||||
" });\n"
|
||||
" nextSocket.addEventListener('error', () => {\n"
|
||||
" };\n"
|
||||
" nextSocket.onerror = () => {\n"
|
||||
" if (live(work) && generation === connectionGeneration && socket === nextSocket) {\n"
|
||||
" setConnection('Connection error', 'bad', 'The WebSocket connection failed.');\n"
|
||||
" }\n"
|
||||
" });\n"
|
||||
" nextSocket.addEventListener('close', () => {\n"
|
||||
" };\n"
|
||||
" nextSocket.onclose = () => {\n"
|
||||
" if (!live(work) || generation !== connectionGeneration || socket !== nextSocket) return;\n"
|
||||
" socket = null;\n"
|
||||
" closeSerial();\n"
|
||||
" clientId = null;\n"
|
||||
" clientIdField.textContent = '—';\n"
|
||||
" setRole('observer');\n"
|
||||
" scheduleReconnect();\n"
|
||||
" });\n"
|
||||
" };\n"
|
||||
" } catch (error) {\n"
|
||||
" if (generation !== connectionGeneration || unloading || error.name === 'AbortError') return;\n"
|
||||
" ticketAbort = null;\n"
|
||||
@@ -493,7 +617,7 @@ static const char s_app_js[] =
|
||||
" }\n"
|
||||
"}\n"
|
||||
"terminal.onData((data) => {\n"
|
||||
" if (role !== 'writer' || !socketOpen()) return;\n"
|
||||
" if (selected !== 'serial' || role !== 'writer' || !socketOpen() || unloading || navigating || suspended) 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"
|
||||
@@ -519,33 +643,47 @@ static const char s_app_js[] =
|
||||
" ++connectionGeneration;\n"
|
||||
" clearReconnectTimer();\n"
|
||||
" if (ticketAbort !== null) { ticketAbort.abort(); ticketAbort = null; }\n"
|
||||
" if (socket !== null) { const previous = socket; socket = null; previous.close(); }\n"
|
||||
" closeSerial();\n"
|
||||
" clientId = null;\n"
|
||||
" clientIdField.textContent = '—';\n"
|
||||
" setRole('observer');\n"
|
||||
" setConnection('Disconnected', 'warn', 'Disconnected by user. Automatic reconnect is paused.');\n"
|
||||
"});\n"
|
||||
"element('select-serial').addEventListener('click', () => selectTerminal('serial'));\n"
|
||||
"element('select-admin').addEventListener('click', () => selectTerminal('admin'));\n"
|
||||
"adminToggle.addEventListener('click', () => { if (adminSocket || adminAbort) closeAdmin(); else openAdmin(); });\n"
|
||||
"const fitTerminal = () => {\n"
|
||||
" fitFrame = 0;\n"
|
||||
" const bounds = terminalHost.getBoundingClientRect();\n"
|
||||
" if (unloading || navigating || loggingOut || !sessionVerified) return;\n"
|
||||
" const target = selected === 'admin' ? adminTerminal : terminal;\n"
|
||||
" const addon = selected === 'admin' ? adminFit : fitAddon;\n"
|
||||
" const bounds = (selected === 'admin' ? adminHost : terminalHost).getBoundingClientRect();\n"
|
||||
" const width = Math.floor(bounds.width);\n"
|
||||
" const height = Math.floor(bounds.height);\n"
|
||||
" if (width < 1 || height < 1 || (width === lastFitWidth && height === lastFitHeight)) return;\n"
|
||||
" lastFitWidth = width;\n"
|
||||
" lastFitHeight = height;\n"
|
||||
" if (width > 0 && height > 0 && width === lastFitWidth && height === lastFitHeight) return;\n"
|
||||
" try {\n"
|
||||
" const dimensions = fitAddon.proposeDimensions();\n"
|
||||
" if (dimensions && dimensions.cols > 0 && dimensions.rows > 0 &&\n"
|
||||
" (dimensions.cols !== terminal.cols || dimensions.rows !== terminal.rows)) {\n"
|
||||
" terminal.resize(dimensions.cols, dimensions.rows);\n"
|
||||
" const dimensions = width > 0 && height > 0 ? addon.proposeDimensions() : null;\n"
|
||||
" if (dimensions && Number.isInteger(dimensions.cols) && dimensions.cols > 0 &&\n"
|
||||
" Number.isInteger(dimensions.rows) && dimensions.rows > 0) {\n"
|
||||
" if (dimensions.cols !== target.cols || dimensions.rows !== target.rows) target.resize(dimensions.cols, dimensions.rows);\n"
|
||||
" lastFitWidth = width; lastFitHeight = height; return;\n"
|
||||
" }\n"
|
||||
" } catch (_) {}\n"
|
||||
" if (fitRetries > 0) {\n"
|
||||
" --fitRetries;\n"
|
||||
" const generation = workGeneration;\n"
|
||||
" fitFrame = window.requestAnimationFrame(() => { if (live(generation)) fitTerminal(); });\n"
|
||||
" }\n"
|
||||
"};\n"
|
||||
"const scheduleFit = () => {\n"
|
||||
" if (!unloading && !navigating && fitFrame === 0) fitFrame = window.requestAnimationFrame(fitTerminal);\n"
|
||||
" if (unloading || navigating || loggingOut) return;\n"
|
||||
" fitRetries = 3;\n"
|
||||
" const generation = workGeneration;\n"
|
||||
" if (fitFrame === 0) fitFrame = window.requestAnimationFrame(() => { if (live(generation)) fitTerminal(); });\n"
|
||||
"};\n"
|
||||
"const resizeObserver = 'ResizeObserver' in window ? new ResizeObserver(scheduleFit) : null;\n"
|
||||
"if (resizeObserver !== null) resizeObserver.observe(terminalHost);\n"
|
||||
"if (resizeObserver !== null) resizeObserver.observe(adminHost);\n"
|
||||
"window.addEventListener('resize', scheduleFit);\n"
|
||||
"const textValue = (value, fallback) => typeof value === 'string' && value.length > 0 ? value : fallback;\n"
|
||||
"const updateStatus = (status) => {\n"
|
||||
@@ -606,9 +744,11 @@ static const char s_app_js[] =
|
||||
" if (unloading) return;\n"
|
||||
" cancelWork();\n"
|
||||
" unloading = true; csrf = '';\n"
|
||||
" sessionVerified = false; terminalHost.hidden = adminHost.hidden = true;\n"
|
||||
" if (fitFrame !== 0) window.cancelAnimationFrame(fitFrame);\n"
|
||||
" fitFrame = 0;\n"
|
||||
" if (resizeObserver !== null) resizeObserver.disconnect();\n"
|
||||
" window.removeEventListener('resize', scheduleFit);\n"
|
||||
" updateControls();\n"
|
||||
"};\n"
|
||||
"window.addEventListener('pagehide', shutdown);\n"
|
||||
@@ -616,6 +756,9 @@ static const char s_app_js[] =
|
||||
" if (!event.persisted || navigating) return;\n"
|
||||
" unloading = false; loggingOut = false;\n"
|
||||
" if (resizeObserver !== null) resizeObserver.observe(terminalHost);\n"
|
||||
" if (resizeObserver !== null) resizeObserver.observe(adminHost);\n"
|
||||
" window.addEventListener('resize', scheduleFit);\n"
|
||||
" lastFitWidth = lastFitHeight = 0;\n"
|
||||
" scheduleFit(); updateControls();\n"
|
||||
" if (reconnectEnabled && !suspended) connect();\n"
|
||||
" else {\n"
|
||||
|
||||
Reference in New Issue
Block a user