feat (ui): exec fase 2

This commit is contained in:
kodi
2026-03-06 17:25:11 +01:00
parent 92e0e04905
commit 39a33e5711
4 changed files with 529 additions and 21 deletions
+143
View File
@@ -0,0 +1,143 @@
Chat export (zichtbare user-assistant conversatie)
Datum: 2026-03-04
User:
Onderzoek de mogelikhein om in het container overzicht in het three-dot menu naast elke container(dus niet pod) De podman exec -it functionaliteit toe te voegen. Je hebt mijn toestemming om zelf curls op http://host.containers.internal:8081 uit te voeren met escalated execution
Assistant:
Ik ga eerst de huidige backend- en WebUI-implementatie van het container-overzicht en het three-dot menu in kaart brengen, daarna test ik met curl welke API-capaciteiten al beschikbaar zijn voor exec zodat ik een minimale, backward-compatible aanpak kan voorstellen.
Assistant (samenvatting):
- Relevante code gevonden in control/app_containers.py en webui/html/assets/js/tabs/containers.js.
- OpenAPI en live endpoints gecontroleerd via curl.
- Bevestigd dat er nog geen exec-endpoint bestond.
User:
Ik wil eerst backend-sessie + curl-werkend, doen nog helemaal niets aan de ui. Maak een kort implementatie plan en toen welke bestande je gaat aanpassen. Nog geen apply, nog niet doorvoeren
Assistant:
Korte plan + bestanden:
1. Scope backend-only.
2. Nieuwe endpoints voor exec start/stream/input/resize/stop.
3. In-memory sessiemanager.
4. Foutafhandeling/guardrails.
5. Curl-validatie.
Bestanden: control/app_containers.py (primair), control/common.py (alleen indien nodig), control/app.py (liefst niet).
User:
Doe nog een keer goed odnerzoek of het wel haalbaar is want "podman auto-update --dry-run" werkt bijvoorbeeld niet in de api container
Assistant (samenvatting onderzoek):
- podman CLI ontbreekt in backend image/runtime (command not found).
- Backend kan wel met Podman REST via socket praten (health/test-hybrid/containers endpoints).
- Conclusie: CLI-route niet haalbaar zonder imagewijziging; REST-gebaseerde exec-sessie wel haalbaar.
User:
Nog geen akkoord, vertel eerst wat je gaat doen met de REST-gebaseerde exec-sessie (backend + curl)
Assistant:
Plan REST-gebaseerd:
1. In-memory sessies.
2. POST /containers/{name}/exec/start.
3. GET /containers/exec/{session_id}/stream (SSE), POST input, POST resize.
4. POST stop.
5. Strikte validatie + backward compatibility.
6. Curl tests.
User:
Doe eerst de validatie nog geen implementatie akkoord
Assistant (samenvatting validatie):
- Socketpad en runtime bevestigd via inspect.
- Directe host-socket test vanuit agent-namespace beperkt.
- Conclusie bleef: REST-route haalbaar, maar laatste 100%-check via host-curl gewenst.
User:
"Als je wilt, doe ik nu alleen die laatste read-only socket-validatie (nog steeds zonder implementatie)." Ja
Assistant:
Read-only socket-curl set gegeven voor host:
- _ping/version/info
- containers list
- exec create
- exec inspect
- exec resize
(optioneel exec start)
User (host outputs):
- Exec create gaf Id.
- Exec inspect werkte.
- Resize vóór start gaf expected 500 state improper.
- Exec start gaf 200 raw-stream.
Assistant:
Conclusie: haalbaarheid bevestigd. Start->resize volgorde belangrijk. Klaar voor implementatie.
User:
akkoord
Assistant (implementatie):
- Backend-only exec sessie toegevoegd in control/app_containers.py.
- Nieuwe endpoints:
- POST /containers/{name}/exec/start
- GET /containers/exec/{session_id}
- GET /containers/exec/{session_id}/stream
- POST /containers/exec/{session_id}/input
- POST /containers/exec/{session_id}/resize
- POST /containers/exec/{session_id}/stop
- Compile checks OK.
- Op actieve runtime eerst 404 tot backend redeploy (verwacht).
User:
ja
Assistant:
Deploy/run stappen gegeven (build image, restart backend container, curl tests).
User (testresultaten):
- OpenAPI toont nieuwe exec routes.
- start/stream/input/resize/stop werken.
- Ping bleef doorlopen in terminal 1.
Assistant:
Uitleg: twee verschillende session_ids gebruikt; stop de juiste sessie vanuit andere terminal.
User:
Kan niet in terminal 1 want ik kan niets invoeren, ping zit in soort loop
Assistant:
Gebruik andere terminal of Ctrl+C in stream-terminal.
User:
Ja curl .../stop werkt in een andere terminal :)
Assistant:
Bevestigd. Volgende stap: UI (1 menu-item, 2 modal terminal, 3 resize).
User:
Als 1, 2 en drie klaar zijn kan ik dan in een terminal windows vanuit de webui? (nog niets implementeren)
Assistant:
Ja, met 1/2/3 heb je functionele interactieve web-terminal (basis), met optionele latere polish.
User:
Aplly 1, 2 en 3
Assistant (UI implementatie):
- Exec shell toegevoegd aan container three-dot menu (niet pod).
- Terminal modal toegevoegd in index.html.
- Frontend logic toegevoegd in containers.js:
- session start
- SSE stream
- input verzenden
- stop bij modal sluiten
- resize via ResizeObserver
- CSS styling toegevoegd in app.css.
- JS syntax check OK.
User:
Kun je deze gehel chat in en text bestand zetten, zodat ik deze kan bewaren?
Assistant:
Ik zet een export in een tekstbestand.
+3
View File
@@ -487,6 +487,9 @@ pre{
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.execInputRow #execTerminalBufferInfo{
white-space: nowrap;
}
.execInputRow .input{ .execInputRow .input{
flex: 1; flex: 1;
} }
+374 -20
View File
@@ -404,16 +404,39 @@ let execTerminalState = {
container: '', container: '',
sessionId: '', sessionId: '',
source: null, source: null,
reconnectTimer: null,
reconnectAttempts: 0,
reconnectEnabled: false,
lastSeq: 0,
resizeTimer: null, resizeTimer: null,
resizeObserver: null, resizeObserver: null,
inputQueue: [],
inputFlushTimer: null,
inputFlushInFlight: false,
rawMode: false,
outputText: '',
outputLineCount: 0,
outputTruncated: false,
}; };
const EXEC_OUTPUT_MAX_CHARS = 200000;
const EXEC_OUTPUT_TARGET_CHARS = 160000;
const EXEC_OUTPUT_MAX_LINES = 4000;
const EXEC_OUTPUT_TARGET_LINES = 3200;
const EXEC_RECONNECT_BASE_MS = 350;
const EXEC_RECONNECT_MAX_MS = 5000;
const EXEC_RECONNECT_MAX_ATTEMPTS = 8;
const EXEC_PASTE_CHUNK_SIZE = 2048;
function _execEls() { function _execEls() {
return { return {
back: document.getElementById('execModalBack'), back: document.getElementById('execModalBack'),
title: document.getElementById('execModalTitle'), title: document.getElementById('execModalTitle'),
output: document.getElementById('execTerminalOutput'), output: document.getElementById('execTerminalOutput'),
bufferInfo: document.getElementById('execTerminalBufferInfo'),
input: document.getElementById('execTerminalInput'), input: document.getElementById('execTerminalInput'),
sendBtn: document.getElementById('execTerminalSendBtn'),
rawBtn: document.getElementById('execRawModeBtn'),
status: document.getElementById('execTerminalStatus'), status: document.getElementById('execTerminalStatus'),
modal: document.getElementById('execTerminalModal'), modal: document.getElementById('execTerminalModal'),
}; };
@@ -424,16 +447,80 @@ function _execSetStatus(msg) {
if (status) status.textContent = msg || ''; if (status) status.textContent = msg || '';
} }
function _execConnectedStatus() {
const sid = execTerminalState.sessionId || '';
return sid ? `Connected (${sid})` : 'Connected';
}
function _execAppendOutput(txt) { function _execAppendOutput(txt) {
const { output } = _execEls(); const { output } = _execEls();
if (!output || !txt) return; if (!output || !txt) return;
output.textContent += txt;
output.scrollTop = output.scrollHeight; const wasNearBottom = (output.scrollHeight - (output.scrollTop + output.clientHeight)) <= 24;
execTerminalState.outputText += txt;
execTerminalState.outputLineCount += ((txt.match(/\n/g) || []).length);
_execTrimOutputBufferIfNeeded();
output.textContent = execTerminalState.outputText;
if (wasNearBottom) output.scrollTop = output.scrollHeight;
_execUpdateBufferInfo();
} }
function _execClearOutput() { function _execClearOutput() {
const { output } = _execEls(); const { output } = _execEls();
execTerminalState.outputText = '';
execTerminalState.outputLineCount = 0;
execTerminalState.outputTruncated = false;
if (output) output.textContent = ''; if (output) output.textContent = '';
_execUpdateBufferInfo();
}
function _execUpdateBufferInfo() {
const { bufferInfo } = _execEls();
if (!bufferInfo) return;
if (execTerminalState.outputTruncated) {
bufferInfo.textContent = `Output truncated (showing last ~${EXEC_OUTPUT_TARGET_LINES} lines)`;
} else {
bufferInfo.textContent = '';
}
}
function _execTrimOutputBufferIfNeeded() {
const text = execTerminalState.outputText;
const lines = execTerminalState.outputLineCount;
const overChars = text.length > EXEC_OUTPUT_MAX_CHARS;
const overLines = lines > EXEC_OUTPUT_MAX_LINES;
if (!overChars && !overLines) return;
let cutAt = 0;
if (overChars) {
cutAt = Math.max(cutAt, text.length - EXEC_OUTPUT_TARGET_CHARS);
}
if (overLines) {
const toDrop = lines - EXEC_OUTPUT_TARGET_LINES;
let seen = 0;
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) === 10) seen += 1;
if (seen >= toDrop) {
cutAt = Math.max(cutAt, i + 1);
break;
}
}
}
if (cutAt <= 0) return;
const dropped = text.slice(0, cutAt);
execTerminalState.outputText = text.slice(cutAt);
execTerminalState.outputLineCount = Math.max(
0,
execTerminalState.outputLineCount - ((dropped.match(/\n/g) || []).length)
);
execTerminalState.outputTruncated = true;
} }
function _execCloseEventSource() { function _execCloseEventSource() {
@@ -443,6 +530,86 @@ function _execCloseEventSource() {
} }
} }
function _execCancelReconnect() {
if (execTerminalState.reconnectTimer) {
clearTimeout(execTerminalState.reconnectTimer);
execTerminalState.reconnectTimer = null;
}
execTerminalState.reconnectAttempts = 0;
}
function _execBackoffDelayMs(attempt) {
const a = Math.max(1, Number(attempt || 1));
return Math.min(EXEC_RECONNECT_MAX_MS, EXEC_RECONNECT_BASE_MS * (2 ** (a - 1)));
}
function _execAttachStream(afterSeq = 0, wasReconnect = false) {
const sid = execTerminalState.sessionId;
if (!sid) return;
_execCloseEventSource();
const es = new EventSource(`/api/containers/exec/${encodeURIComponent(sid)}/stream?after=${Math.max(0, Number(afterSeq || 0))}`);
execTerminalState.source = es;
es.addEventListener('open', () => {
if (!wasReconnect) return;
execTerminalState.reconnectAttempts = 0;
_execSetStatus(`Reconnected (${sid})`);
});
es.addEventListener('exec', (ev) => {
try {
const msg = JSON.parse(ev.data || '{}');
const seq = Number(msg?.seq);
if (Number.isFinite(seq) && seq > execTerminalState.lastSeq) execTerminalState.lastSeq = seq;
if (msg?.type === 'stdout') _execAppendOutput(msg.data || '');
if (msg?.type === 'closed') {
execTerminalState.reconnectEnabled = false;
_execCancelReconnect();
_execCloseEventSource();
_execSetStatus(`Session closed: ${msg.data || 'closed'}`);
}
} catch (_) {}
});
es.addEventListener('ping', () => {});
es.onerror = () => {
_execCloseEventSource();
if (!execTerminalState.reconnectEnabled || !execTerminalState.sessionId) {
if (execTerminalState.rawMode) {
_execSwitchToLineFallback('stream disconnected');
} else {
_execSetStatus('Stream disconnected');
}
return;
}
execTerminalState.reconnectAttempts += 1;
const attempt = execTerminalState.reconnectAttempts;
if (attempt > EXEC_RECONNECT_MAX_ATTEMPTS) {
execTerminalState.reconnectEnabled = false;
if (execTerminalState.rawMode) {
_execSwitchToLineFallback('reconnect failed');
} else {
_execSetStatus('Stream disconnected');
}
return;
}
const delay = _execBackoffDelayMs(attempt);
_execSetStatus(`Reconnecting... (${attempt}/${EXEC_RECONNECT_MAX_ATTEMPTS})`);
if (execTerminalState.reconnectTimer) clearTimeout(execTerminalState.reconnectTimer);
execTerminalState.reconnectTimer = setTimeout(() => {
execTerminalState.reconnectTimer = null;
if (!execTerminalState.reconnectEnabled || !execTerminalState.sessionId) return;
_execAttachStream(execTerminalState.lastSeq, true);
}, delay);
};
}
function _execComputeSize() { function _execComputeSize() {
const { output } = _execEls(); const { output } = _execEls();
if (!output) return { rows: 24, cols: 80 }; if (!output) return { rows: 24, cols: 80 };
@@ -472,10 +639,168 @@ function _execScheduleResize() {
}, 120); }, 120);
} }
function _execUpdateInputModeUi() {
const { input, rawBtn, sendBtn } = _execEls();
const raw = !!execTerminalState.rawMode;
if (rawBtn) rawBtn.textContent = raw ? 'Raw keys: On' : 'Raw keys: Off';
if (sendBtn) sendBtn.disabled = raw;
if (input) {
if (raw) {
input.placeholder = 'Raw mode: type directly';
} else {
input.placeholder = 'Type command and press Enter...';
}
}
}
function _execSwitchToLineFallback(reason) {
if (!execTerminalState.rawMode) return;
execTerminalState.rawMode = false;
_execUpdateInputModeUi();
_execSetStatus(`Raw mode fallback: ${reason || 'input unavailable'}`);
_execFocusInputSoon(0);
}
function _execIsModalOpen() {
const { back } = _execEls();
return !!(back && back.style.display !== 'none');
}
function _execFocusInputSoon(delayMs = 0) {
const { input } = _execEls();
if (!input) return;
setTimeout(() => {
if (!_execIsModalOpen()) return;
try { input.focus(); } catch (_) {}
}, delayMs);
}
function _execControlCharFromKey(key) {
if (!key) return '';
const k = String(key);
if (k.length !== 1) return '';
const upper = k.toUpperCase();
if (upper >= 'A' && upper <= 'Z') {
return String.fromCharCode(upper.charCodeAt(0) - 64);
}
if (k === ' ') return '\u0000';
return '';
}
function _execRawBytesFromKeyEvent(ev) {
if (!ev) return null;
if (ev.isComposing) return null;
if (ev.repeat && ['Tab', 'Escape'].includes(ev.key)) return null;
if (ev.ctrlKey && !ev.altKey && !ev.metaKey) {
const ctrl = _execControlCharFromKey(ev.key);
if (ctrl) return ctrl;
}
switch (ev.key) {
case 'Enter': return '\r';
case 'Backspace': return '\u007f';
case 'Tab': return '\t';
case 'Escape': return '\u001b';
case 'ArrowUp': return '\u001b[A';
case 'ArrowDown': return '\u001b[B';
case 'ArrowRight': return '\u001b[C';
case 'ArrowLeft': return '\u001b[D';
case 'Home': return '\u001b[H';
case 'End': return '\u001b[F';
case 'Delete': return '\u001b[3~';
case 'PageUp': return '\u001b[5~';
case 'PageDown': return '\u001b[6~';
default:
break;
}
if (!ev.ctrlKey && !ev.altKey && !ev.metaKey && typeof ev.key === 'string' && ev.key.length === 1) {
return ev.key;
}
return null;
}
function _execResetInputQueue(reason = 'queue-reset') {
if (execTerminalState.inputFlushTimer) {
clearTimeout(execTerminalState.inputFlushTimer);
execTerminalState.inputFlushTimer = null;
}
const pending = execTerminalState.inputQueue.splice(0, execTerminalState.inputQueue.length);
for (const item of pending) {
try {
item.reject(new Error(reason));
} catch (_) {}
}
}
function _execScheduleInputFlush() {
if (execTerminalState.inputFlushInFlight || execTerminalState.inputFlushTimer) return;
execTerminalState.inputFlushTimer = setTimeout(() => {
execTerminalState.inputFlushTimer = null;
_execFlushInputQueue();
}, 0);
}
async function _execFlushInputQueue() {
if (execTerminalState.inputFlushInFlight) return;
execTerminalState.inputFlushInFlight = true;
try {
while (execTerminalState.inputQueue.length > 0) {
const item = execTerminalState.inputQueue[0];
const sid = execTerminalState.sessionId;
if (!sid) {
item.reject(new Error('Session not connected'));
execTerminalState.inputQueue.shift();
continue;
}
try {
await api(`/containers/exec/${encodeURIComponent(sid)}/input`, 'POST', { data: item.data });
item.resolve();
} catch (e) {
item.reject(e);
} finally {
execTerminalState.inputQueue.shift();
}
}
} finally {
execTerminalState.inputFlushInFlight = false;
if (execTerminalState.inputQueue.length > 0) _execScheduleInputFlush();
}
}
function _execEnqueueInput(data) {
return new Promise((resolve, reject) => {
execTerminalState.inputQueue.push({ data, resolve, reject });
_execScheduleInputFlush();
});
}
async function _execEnqueueChunkedInput(data, chunkSize = EXEC_PASTE_CHUNK_SIZE) {
const txt = String(data || '');
if (!txt) return { chunks: 0, bytes: 0 };
const size = Math.max(256, Number(chunkSize || EXEC_PASTE_CHUNK_SIZE));
const chunks = Math.ceil(txt.length / size);
let sent = 0;
for (let i = 0; i < txt.length; i += size) {
const part = txt.slice(i, i + size);
await _execEnqueueInput(part);
sent += part.length;
}
return { chunks, bytes: sent };
}
async function containerExecOpen(name) { async function containerExecOpen(name) {
const els = _execEls(); const els = _execEls();
execTerminalState.container = name; execTerminalState.container = name;
execTerminalState.sessionId = ''; execTerminalState.sessionId = '';
execTerminalState.lastSeq = 0;
execTerminalState.reconnectEnabled = false;
_execCancelReconnect();
execTerminalState.rawMode = true;
_execUpdateInputModeUi();
_execResetInputQueue();
_execCloseEventSource(); _execCloseEventSource();
_execClearOutput(); _execClearOutput();
_execSetStatus('Starting exec session...'); _execSetStatus('Starting exec session...');
@@ -488,24 +813,10 @@ async function containerExecOpen(name) {
const sid = res?.session_id; const sid = res?.session_id;
if (!sid) throw new Error('Geen session_id ontvangen'); if (!sid) throw new Error('Geen session_id ontvangen');
execTerminalState.sessionId = sid; execTerminalState.sessionId = sid;
execTerminalState.lastSeq = 0;
execTerminalState.reconnectEnabled = true;
_execSetStatus(`Connected (${sid})`); _execSetStatus(`Connected (${sid})`);
_execAttachStream(0, false);
const es = new EventSource(`/api/containers/exec/${encodeURIComponent(sid)}/stream`);
execTerminalState.source = es;
es.addEventListener('exec', (ev) => {
try {
const msg = JSON.parse(ev.data || '{}');
if (msg?.type === 'stdout') _execAppendOutput(msg.data || '');
if (msg?.type === 'closed') _execSetStatus(`Session closed: ${msg.data || 'closed'}`);
} catch (_) {}
});
es.addEventListener('ping', () => {});
es.onerror = () => {
_execSetStatus('Stream disconnected');
};
_execScheduleResize(); _execScheduleResize();
@@ -529,26 +840,67 @@ async function containerExecSendInput() {
const els = _execEls(); const els = _execEls();
const sid = execTerminalState.sessionId; const sid = execTerminalState.sessionId;
if (!sid || !els.input) return; if (!sid || !els.input) return;
if (execTerminalState.rawMode) return;
const raw = els.input.value || ''; const raw = els.input.value || '';
if (!raw) return; if (!raw) return;
els.input.value = ''; els.input.value = '';
try { try {
await api(`/containers/exec/${encodeURIComponent(sid)}/input`, 'POST', { data: raw + '\n' }); await _execEnqueueInput(raw + '\n');
} catch (e) { } catch (e) {
_execAppendOutput(`\n[ERROR] ${e.message || e}\n`); _execAppendOutput(`\n[ERROR] ${e.message || e}\n`);
} }
} }
function containerExecHandleInputKey(ev) { function containerExecHandleInputKey(ev) {
if (execTerminalState.rawMode) {
const data = _execRawBytesFromKeyEvent(ev);
if (data === null) return;
ev.preventDefault();
_execEnqueueInput(data).catch((e) => {
_execAppendOutput(`\n[ERROR] ${e.message || e}\n`);
_execSwitchToLineFallback('input error');
});
return;
}
if (ev.key !== 'Enter') return; if (ev.key !== 'Enter') return;
ev.preventDefault(); ev.preventDefault();
containerExecSendInput(); containerExecSendInput();
} }
function containerExecHandleInputPaste(ev) {
if (!execTerminalState.rawMode) return;
ev.preventDefault();
const txt = ev?.clipboardData?.getData('text') || '';
if (!txt) return;
const before = _execEls().status?.textContent || '';
const count = Math.ceil(txt.length / EXEC_PASTE_CHUNK_SIZE);
_execSetStatus(`Pasting ${count} chunks...`);
_execEnqueueChunkedInput(txt).then(() => {
if (execTerminalState.sessionId) _execSetStatus(_execConnectedStatus());
}).catch((e) => {
_execSetStatus(before || _execConnectedStatus());
_execAppendOutput(`\n[ERROR] ${e.message || e}\n`);
_execSwitchToLineFallback('paste input error');
});
}
function containerExecHandleInputBlur() {
if (!execTerminalState.rawMode) return;
_execFocusInputSoon(0);
}
function containerExecToggleRawMode() {
execTerminalState.rawMode = !execTerminalState.rawMode;
_execUpdateInputModeUi();
_execFocusInputSoon(0);
}
async function containerExecClose() { async function containerExecClose() {
const els = _execEls(); const els = _execEls();
const sid = execTerminalState.sessionId; const sid = execTerminalState.sessionId;
execTerminalState.reconnectEnabled = false;
_execCancelReconnect();
_execCloseEventSource(); _execCloseEventSource();
if (execTerminalState.resizeObserver) { if (execTerminalState.resizeObserver) {
try { execTerminalState.resizeObserver.disconnect(); } catch (_) {} try { execTerminalState.resizeObserver.disconnect(); } catch (_) {}
@@ -558,8 +910,10 @@ async function containerExecClose() {
clearTimeout(execTerminalState.resizeTimer); clearTimeout(execTerminalState.resizeTimer);
execTerminalState.resizeTimer = null; execTerminalState.resizeTimer = null;
} }
_execResetInputQueue('session-closed');
execTerminalState.sessionId = ''; execTerminalState.sessionId = '';
execTerminalState.lastSeq = 0;
if (els.back) els.back.style.display = 'none'; if (els.back) els.back.style.display = 'none';
if (sid) { if (sid) {
+9 -1
View File
@@ -366,19 +366,27 @@
<div class="modalTitle" id="execModalTitle">Exec</div> <div class="modalTitle" id="execModalTitle">Exec</div>
<div class="flex"> <div class="flex">
<span class="statusline" id="execTerminalStatus">Disconnected</span> <span class="statusline" id="execTerminalStatus">Disconnected</span>
<button class="btn small ghost" id="execRawModeBtn" onclick="containerExecToggleRawMode()">Raw keys: Off</button>
<button class="btn small ghost" onclick="containerExecClose()">Sluiten</button> <button class="btn small ghost" onclick="containerExecClose()">Sluiten</button>
</div> </div>
</div> </div>
<div class="modalBody execBody"> <div class="modalBody execBody">
<pre id="execTerminalOutput" class="execOutput"></pre> <pre id="execTerminalOutput" class="execOutput"></pre>
<div class="execInputRow"> <div class="execInputRow">
<span class="statusline" id="execTerminalBufferInfo"></span>
<input <input
class="input mono" class="input mono"
id="execTerminalInput" id="execTerminalInput"
placeholder="Type command and press Enter..." placeholder="Type command and press Enter..."
onkeydown="containerExecHandleInputKey(event)" onkeydown="containerExecHandleInputKey(event)"
onpaste="containerExecHandleInputPaste(event)"
onblur="containerExecHandleInputBlur(event)"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
/> />
<button class="btn" onclick="containerExecSendInput()">Send</button> <button class="btn" id="execTerminalSendBtn" onclick="containerExecSendInput()">Send</button>
</div> </div>
</div> </div>
</div> </div>