feat (ui): exec fase 2
This commit is contained in:
@@ -487,6 +487,9 @@ pre{
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.execInputRow #execTerminalBufferInfo{
|
||||
white-space: nowrap;
|
||||
}
|
||||
.execInputRow .input{
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -404,16 +404,39 @@ let execTerminalState = {
|
||||
container: '',
|
||||
sessionId: '',
|
||||
source: null,
|
||||
reconnectTimer: null,
|
||||
reconnectAttempts: 0,
|
||||
reconnectEnabled: false,
|
||||
lastSeq: 0,
|
||||
resizeTimer: 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() {
|
||||
return {
|
||||
back: document.getElementById('execModalBack'),
|
||||
title: document.getElementById('execModalTitle'),
|
||||
output: document.getElementById('execTerminalOutput'),
|
||||
bufferInfo: document.getElementById('execTerminalBufferInfo'),
|
||||
input: document.getElementById('execTerminalInput'),
|
||||
sendBtn: document.getElementById('execTerminalSendBtn'),
|
||||
rawBtn: document.getElementById('execRawModeBtn'),
|
||||
status: document.getElementById('execTerminalStatus'),
|
||||
modal: document.getElementById('execTerminalModal'),
|
||||
};
|
||||
@@ -424,16 +447,80 @@ function _execSetStatus(msg) {
|
||||
if (status) status.textContent = msg || '';
|
||||
}
|
||||
|
||||
function _execConnectedStatus() {
|
||||
const sid = execTerminalState.sessionId || '';
|
||||
return sid ? `Connected (${sid})` : 'Connected';
|
||||
}
|
||||
|
||||
function _execAppendOutput(txt) {
|
||||
const { output } = _execEls();
|
||||
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() {
|
||||
const { output } = _execEls();
|
||||
execTerminalState.outputText = '';
|
||||
execTerminalState.outputLineCount = 0;
|
||||
execTerminalState.outputTruncated = false;
|
||||
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() {
|
||||
@@ -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() {
|
||||
const { output } = _execEls();
|
||||
if (!output) return { rows: 24, cols: 80 };
|
||||
@@ -472,10 +639,168 @@ function _execScheduleResize() {
|
||||
}, 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) {
|
||||
const els = _execEls();
|
||||
execTerminalState.container = name;
|
||||
execTerminalState.sessionId = '';
|
||||
execTerminalState.lastSeq = 0;
|
||||
execTerminalState.reconnectEnabled = false;
|
||||
_execCancelReconnect();
|
||||
execTerminalState.rawMode = true;
|
||||
_execUpdateInputModeUi();
|
||||
_execResetInputQueue();
|
||||
_execCloseEventSource();
|
||||
_execClearOutput();
|
||||
_execSetStatus('Starting exec session...');
|
||||
@@ -488,24 +813,10 @@ async function containerExecOpen(name) {
|
||||
const sid = res?.session_id;
|
||||
if (!sid) throw new Error('Geen session_id ontvangen');
|
||||
execTerminalState.sessionId = sid;
|
||||
execTerminalState.lastSeq = 0;
|
||||
execTerminalState.reconnectEnabled = true;
|
||||
_execSetStatus(`Connected (${sid})`);
|
||||
|
||||
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');
|
||||
};
|
||||
_execAttachStream(0, false);
|
||||
|
||||
_execScheduleResize();
|
||||
|
||||
@@ -529,26 +840,67 @@ async function containerExecSendInput() {
|
||||
const els = _execEls();
|
||||
const sid = execTerminalState.sessionId;
|
||||
if (!sid || !els.input) return;
|
||||
if (execTerminalState.rawMode) return;
|
||||
const raw = els.input.value || '';
|
||||
if (!raw) return;
|
||||
els.input.value = '';
|
||||
try {
|
||||
await api(`/containers/exec/${encodeURIComponent(sid)}/input`, 'POST', { data: raw + '\n' });
|
||||
await _execEnqueueInput(raw + '\n');
|
||||
} catch (e) {
|
||||
_execAppendOutput(`\n[ERROR] ${e.message || e}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
ev.preventDefault();
|
||||
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() {
|
||||
const els = _execEls();
|
||||
const sid = execTerminalState.sessionId;
|
||||
|
||||
execTerminalState.reconnectEnabled = false;
|
||||
_execCancelReconnect();
|
||||
_execCloseEventSource();
|
||||
if (execTerminalState.resizeObserver) {
|
||||
try { execTerminalState.resizeObserver.disconnect(); } catch (_) {}
|
||||
@@ -558,8 +910,10 @@ async function containerExecClose() {
|
||||
clearTimeout(execTerminalState.resizeTimer);
|
||||
execTerminalState.resizeTimer = null;
|
||||
}
|
||||
_execResetInputQueue('session-closed');
|
||||
|
||||
execTerminalState.sessionId = '';
|
||||
execTerminalState.lastSeq = 0;
|
||||
if (els.back) els.back.style.display = 'none';
|
||||
|
||||
if (sid) {
|
||||
|
||||
@@ -366,19 +366,27 @@
|
||||
<div class="modalTitle" id="execModalTitle">Exec</div>
|
||||
<div class="flex">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modalBody execBody">
|
||||
<pre id="execTerminalOutput" class="execOutput"></pre>
|
||||
<div class="execInputRow">
|
||||
<span class="statusline" id="execTerminalBufferInfo"></span>
|
||||
<input
|
||||
class="input mono"
|
||||
id="execTerminalInput"
|
||||
placeholder="Type command and press Enter..."
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user