feat (ui): exec fase 2
This commit is contained in:
@@ -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_id’s 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.
|
||||||
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user