Files
podman-mvp/webui/html/assets/js/tabs/containers.js
T
kodi f016c2bae0 perf: stats poll via lichtgewicht /api/stats i.p.v. /containers-dashboard
De frontend haalde CPU/mem stats op via het zware /containers-dashboard
endpoint (Podman call + os.walk + systemctl subprocesses per container).
Nu gaat de stats poll via een nieuw /api/stats endpoint dat alleen de
bestaande in-memory cache teruggeeft (<5ms vs ~400ms).

- app_containers.py: /api/stats endpoint toegevoegd (cache direct return)
- app_containers.py: _STATS_SHOWN_NAMES bijgehouden per dashboard call
  (filtert infra/management containers eruit op basis van _dashboard_source)
- containers.js: pollContainersDashboardStatsOnce() gebruikt /api/stats

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 15:09:53 +01:00

1098 lines
34 KiB
JavaScript

// ---- Pods ----
async function podAction(action, name) {
try {
const res = await api(`/pods/actions/${encodeURIComponent(action)}/${encodeURIComponent(name)}`, 'POST');
showModal(`Pod ${action}: ${name}`, JSON.stringify(res, null, 2));
await fetchContainers(); // refresh pods + containers view in één keer
} catch (e) {
showModal(`Pod ${action} fout`, e.stack || e.message);
}
}
// ---- Containers ----
function _podKey(c) {
return (c.PodName && String(c.PodName).trim()) ? String(c.PodName).trim() : '(geen pod)';
}
function _cmpStr(a, b) {
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
}
function _isCollapsed(pod) {
return localStorage.getItem('pod_group_collapsed:' + pod) === '1';
}
function _setCollapsed(pod, v) {
localStorage.setItem('pod_group_collapsed:' + pod, v ? '1' : '0');
}
function renderActionsDropdown(menuId, actionFn, targetEsc, includeExec = false) {
// actionFn is string: "containerAction" of "podAction"
// targetEsc is al esc(...) dus veilig in onclick
const execBtn = includeExec
? `<button class="menuItem info" onclick="containerExecOpen('${targetEsc}'); closeAllMenus();">Exec shell</button>`
: '';
return `
<span class="actions-menu">
<button class="btn icon" title="Acties" onclick="toggleMenu('${menuId}')">⋮</button>
<div class="menuPanel" id="${menuId}">
${execBtn}
<button class="menuItem ok" onclick="${actionFn}('start','${targetEsc}'); closeAllMenus();">Start</button>
<button class="menuItem warn" onclick="${actionFn}('restart','${targetEsc}'); closeAllMenus();">Restart</button>
<button class="menuItem bad" onclick="${actionFn}('stop','${targetEsc}'); closeAllMenus();">Stop</button>
</div>
</span>
`;
}
function renderContainerRow(c) {
const name = (c.Names && c.Names[0]) ? c.Names[0] : (c.Names || c.Name || c.name || '');
const status = c.Status || c.State || c.state || '';
const podName = c.PodName || '-';
const image = c.Image || c.image || '';
const managed = c._dashboard_source || 'podman';
const menuId = `menu-${cssSafeId('c:' + normalizeContainerName(name))}`;
const inPod = !!(c.PodName && String(c.PodName).trim());
const ports = inPod
? '' // verberg bij pod-containers
: ((c._dashboard_published_ports || []).join(", ")
|| (c.Ports || []).map(p => `${p.host_port}:${p.container_port}`).join(", "));
return `
<tr>
<td><strong>${esc(name)}</strong></td>
<td>${badgeFromStatus(status)}</td>
<td>${podName}</td>
<td class="muted">${esc(image)}</td>
<td>${badgeFromStatus(managed)}</td>
<td class="muted num" id="cpu-${cssSafeId(normalizeContainerName(name))}">-</td>
<td class="muted num" id="mem-${cssSafeId(normalizeContainerName(name))}">-</td>
<td>${ports || '-'}</td>
<td>
<div class="flex">
<button class="btn icon" title="Inspect" onclick="containerInspect(decodeURIComponent('${encodeURIComponent(name)}'))">🔍</button>
<button class="btn icon" title="Logs" onclick="containerLogs(decodeURIComponent('${encodeURIComponent(name)}'))">📄</button>
${renderActionsDropdown(menuId, 'containerAction', esc(name), true)}
</div>
</td>
</tr>
`;
}
let containersC2P = new Map();
function renderContainersGrouped(list, tbody, podStatus) {
const groups = new Map();
for (const c of (list || [])) {
const k = _podKey(c);
if (!groups.has(k)) groups.set(k, []);
groups.get(k).push(c);
}
for (const podName of Object.keys(podStatus || {})) {
if (!groups.has(podName)) groups.set(podName, []);
}
const keys = Array.from(groups.keys()).sort((a, b) => {
if (a === '(geen pod)' && b !== '(geen pod)') return 1;
if (b === '(geen pod)' && a !== '(geen pod)') return -1;
return _cmpStr(a, b);
});
containersC2P = new Map();
let html = '';
for (const pod of keys) {
const items = groups.get(pod) || [];
items.sort((a, b) =>
_cmpStr(
(a.Names && a.Names[0]) ? a.Names[0] : (a.Name || ''),
(b.Names && b.Names[0]) ? b.Names[0] : (b.Name || '')
)
);
const collapsed = _isCollapsed(pod);
const isRealPod = (pod !== '(geen pod)');
const total = items.length;
const podMenuId = `menu-${cssSafeId('p:' + pod)}`;
let podPortsText = "-";
if (isRealPod) {
const podPorts = Array.from(new Set(
items.flatMap(c => (c._dashboard_published_ports || []))
)).sort((a, b) => _cmpStr(a, b));
podPortsText = podPorts.length ? podPorts.join(", ") : "-";
}
let cls = 'muted';
let label = '-';
if (total > 0) {
const running = items.filter(x => {
const s = (x.Status || x.State || '').toLowerCase();
return s === 'running';
}).length;
label = `${running}/${total}`;
if (running === total) cls = 'ok';
else if (running === 0) cls = 'bad';
else cls = 'warn';
} else {
const ps = String((podStatus && podStatus[pod]) || '').toLowerCase();
if (ps === 'active') { cls = 'ok'; label = 'active'; }
else if (ps === 'inactive') { cls = 'bad'; label = 'inactive'; }
else { cls = 'muted'; label = ps || 'unknown'; }
}
html += `
<tr class="pod-group-row">
<td>
<span class="pod-toggle" data-pod="${encodeURIComponent(pod)}" style="cursor:pointer; user-select:none;">
${collapsed ? '▶' : '▼'} <b>${esc(pod)}</b>
</span>
</td>
<td>
<span class="btn small ${cls}">
${label}
</span>
</td>
<td class="muted">-</td>
<td class="muted">-</td>
<td>${isRealPod ? '<span class="btn small muted">pod</span>' : '<span class="muted">-</span>'}</td>
<td class="muted num" id="podcpu-${cssSafeId(pod)}" data-podstatus="${esc((podStatus && podStatus[pod]) || '')}">-</td>
<td class="muted num" id="podmem-${cssSafeId(pod)}" data-podstatus="${esc((podStatus && podStatus[pod]) || '')}">-</td>
<td class="mono">${esc(podPortsText)}</td>
<td>
${isRealPod ? `
<div class="flex">
<!-- placeholders voor uitlijning -->
<button class="btn icon" disabled style="visibility:hidden;" aria-hidden="true" tabindex="-1">🔍</button>
<button class="btn icon" disabled style="visibility:hidden;" aria-hidden="true" tabindex="-1">📄</button>
${renderActionsDropdown(podMenuId, 'podAction', esc(pod), false)}
</div>
` : '<span class="muted">-</span>'}
</td>
</tr>
`;
for (const c of items) {
const cname = normalizeContainerName((c.Names && c.Names[0]) ? c.Names[0] : (c.Names || c.Name || c.name || ''));
if (cname) containersC2P.set(cname, pod);
const row = renderContainerRow(c).replace(
'<tr',
`<tr class="pod-item-row" data-pod="${encodeURIComponent(pod)}"${collapsed ? ' style="display:none;"' : ''}`
);
html += row;
}
}
tbody.innerHTML = html;
tbody.onclick = (ev) => {
const t = ev.target.closest('.pod-toggle');
if (!t) return;
const podEnc = t.getAttribute('data-pod') || '';
const pod = decodeURIComponent(podEnc);
const isNowCollapsed = !_isCollapsed(pod);
_setCollapsed(pod, isNowCollapsed);
tbody.querySelectorAll(`.pod-item-row[data-pod="${CSS.escape(podEnc)}"]`).forEach(r => {
r.style.display = isNowCollapsed ? 'none' : '';
});
t.innerHTML =
`${isNowCollapsed ? '▶' : '▼'} <b>${pod}</b> ` +
`<span style="opacity:.7;">(${tbody.querySelectorAll(`.pod-item-row[data-pod="${CSS.escape(podEnc)}"]`).length})</span>`;
};
}
async function fetchContainers() {
const [containers, pods] = await Promise.all([
api('/containers-dashboard', 'GET'),
api('/pods-dashboard', 'GET')
]);
const list = Array.isArray(containers) ? containers : (containers?.containers || []);
document.getElementById('countContainers').textContent = list.length;
if (typeof window.updateNavCount === 'function') {
window.updateNavCount('countNavContainers', list.length);
}
const podsList = Array.isArray(pods) ? pods : [];
const podStatus = {};
for (const p of podsList) {
const n = p?.Name || p?.name;
if (!n) continue;
podStatus[n] = (p?.Status || p?.status || '').toLowerCase(); // "active"/"inactive"/...
}
const tbody = document.getElementById('containersTbody');
if (!tbody) return;
renderContainersGrouped(list, tbody, podStatus);
}
let containersDashboardStatsTimer = null;
let containersDashboardStatsInFlight = false;
function startContainersDashboardStatsPoll() {
if (containersDashboardStatsTimer) return;
// immediate tick, daarna elke 1s
pollContainersDashboardStatsOnce();
containersDashboardStatsTimer = setInterval(pollContainersDashboardStatsOnce, 1000);
}
function stopContainersDashboardStatsPoll() {
if (!containersDashboardStatsTimer) return;
clearInterval(containersDashboardStatsTimer);
containersDashboardStatsTimer = null;
resetPodTotals();
}
async function pollContainersDashboardStatsOnce() {
if (containersDashboardStatsInFlight) return;
containersDashboardStatsInFlight = true;
try {
const stats = await api('/stats', 'GET');
// totals per pod voor deze poll tick
const podCpu = new Map(); // podName -> cpuPct sum
const podMem = new Map(); // podName -> memBytes sum
const podMemPct = new Map(); // podName -> memPct sum
for (const [cname, s] of Object.entries(stats || {})) {
const key = cssSafeId(cname);
const cpuPct = Number(s?.cpu);
const memBytes = Number(s?.mem_usage);
const memPct = Number(s?.mem_perc);
const pod = containersC2P.get(cname);
if (pod) {
if (Number.isFinite(cpuPct)) podCpu.set(pod, (podCpu.get(pod) || 0) + cpuPct);
if (Number.isFinite(memBytes)) podMem.set(pod, (podMem.get(pod) || 0) + memBytes);
if (Number.isFinite(memPct)) podMemPct.set(pod, (podMemPct.get(pod) || 0) + memPct);
}
const cpuEl = document.getElementById(`cpu-${key}`);
if (cpuEl) cpuEl.textContent = Number.isFinite(cpuPct) ? (cpuPct.toFixed(2) + "%") : "-";
const memEl = document.getElementById(`mem-${key}`);
if (memEl) {
if (Number.isFinite(memBytes) && Number.isFinite(memPct)) {
memEl.textContent = `${formatBytes(memBytes)} (${memPct.toFixed(1)}%)`;
} else {
memEl.textContent = "-";
}
}
}
for (const [pod, cpuSum] of podCpu.entries()) {
const el = document.getElementById(`podcpu-${cssSafeId(pod)}`);
if (el) {
el.textContent = cpuSum.toFixed(2) + "%";
el.classList.remove('stale');
}
}
for (const [pod, memSum] of podMem.entries()) {
const el = document.getElementById(`podmem-${cssSafeId(pod)}`);
if (el) {
const mp = podMemPct.get(pod) || 0;
el.textContent = `${formatBytes(memSum)} (${Number(mp).toFixed(1)}%)`;
el.classList.remove('stale');
}
}
} catch (e) {
resetPodTotals();
} finally {
containersDashboardStatsInFlight = false;
}
}
function resetPodTotals() {
document.querySelectorAll('[id^="podcpu-"]').forEach(el => {
const ps = String(el.getAttribute('data-podstatus') || '').toLowerCase();
if (ps === 'inactive') {
el.textContent = '-';
el.classList.remove('stale');
} else {
el.textContent = '—';
el.classList.add('stale');
}
});
document.querySelectorAll('[id^="podmem-"]').forEach(el => {
const ps = String(el.getAttribute('data-podstatus') || '').toLowerCase();
if (ps === 'inactive') {
el.textContent = '-';
el.classList.remove('stale');
} else {
el.textContent = '—';
el.classList.add('stale');
}
});
}
async function containerInspect(name) {
try {
const res = await api(`/containers/inspect/${encodeURIComponent(name)}`, 'GET');
showModal(`Inspect: ${name}`, JSON.stringify(res, null, 2));
} catch (e) {
showModal(`Inspect fout: ${name}`, e.stack || e.message);
}
}
async function containerLogs(name) {
try {
const res = await api(`/containers/logs/${encodeURIComponent(name)}`, 'GET');
const logs = res.logs ?? JSON.stringify(res, null, 2);
showModal(`Logs: ${name}`, logs);
} catch (e) {
showModal(`Logs fout: ${name}`, e.stack || e.message);
}
}
async function containerAction(action, name) {
try {
const res = await api(`/containers/${encodeURIComponent(action)}/${encodeURIComponent(name)}`, 'POST');
showModal(`Container ${action}: ${name}`, JSON.stringify(res, null, 2));
await fetchContainers();
} catch (e) {
showModal(`Container ${action} fout`, e.stack || e.message);
}
}
function cssSafeId(s) {
const bytes = new TextEncoder().encode(String(s));
let bin = '';
bytes.forEach(b => bin += String.fromCharCode(b));
return btoa(bin).replaceAll('=','').replaceAll('+','-').replaceAll('/','_');
}
function normalizeContainerName(n) {
if (!n) return '';
// Podman kan soms "/name" geven, we strippen de leading slash
return ('' + n).replace(/^\//, '');
}
function formatBytes(b) {
const n = Number(b || 0);
if (n >= 1024**3) return (n / 1024**3).toFixed(1) + "GiB";
if (n >= 1024**2) return (n / 1024**2).toFixed(0) + "MiB";
if (n >= 1024) return (n / 1024).toFixed(0) + "KiB";
return n + "B";
}
let execTerminalState = {
container: '',
sessionId: '',
source: null,
term: null,
fitAddon: null,
useXterm: false,
reconnectTimer: null,
reconnectAttempts: 0,
reconnectEnabled: false,
lastSeq: 0,
resizeTimer: null,
resizeObserver: null,
inputQueue: [],
inputFlushTimer: null,
inputFlushInFlight: false,
rawMode: false,
outputText: '',
outputLineCount: 0,
outputTruncated: false,
sessionInfo: null,
infoPollTimer: null,
infoTickTimer: null,
};
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;
const EXEC_SESSION_INFO_POLL_MS = 7000;
const EXEC_IDLE_TTL_SECONDS = 60 * 60;
function _execEls() {
return {
back: document.getElementById('execModalBack'),
title: document.getElementById('execModalTitle'),
output: document.getElementById('execTerminalOutput'),
termHost: document.getElementById('execTerminalHost'),
inputRow: document.getElementById('execInputRow'),
bufferInfo: document.getElementById('execTerminalBufferInfo'),
input: document.getElementById('execTerminalInput'),
sendBtn: document.getElementById('execTerminalSendBtn'),
rawBtn: document.getElementById('execRawModeBtn'),
status: document.getElementById('execTerminalStatus'),
modal: document.getElementById('execTerminalModal'),
};
}
function _execSetStatus(msg) {
const { status } = _execEls();
if (status) status.textContent = msg || '';
}
function _execCanUseXterm() {
return !!(window.Terminal && window.FitAddon && window.FitAddon.FitAddon);
}
function _execSetTerminalUiMode(useXterm) {
const { termHost, output, inputRow, rawBtn } = _execEls();
if (termHost) termHost.style.display = useXterm ? 'block' : 'none';
if (output) output.style.display = useXterm ? 'none' : 'block';
if (inputRow) inputRow.style.display = useXterm ? 'none' : 'flex';
if (rawBtn) rawBtn.style.display = useXterm ? 'none' : 'inline-flex';
}
function _execDestroyTerminal() {
if (execTerminalState.term) {
try { execTerminalState.term.dispose(); } catch (_) {}
}
execTerminalState.term = null;
execTerminalState.fitAddon = null;
execTerminalState.useXterm = false;
}
function _execInitTerminal() {
const { termHost } = _execEls();
if (!_execCanUseXterm() || !termHost) {
_execDestroyTerminal();
_execSetTerminalUiMode(false);
return false;
}
_execDestroyTerminal();
try {
const fitAddon = new window.FitAddon.FitAddon();
const term = new window.Terminal({
cursorBlink: true,
convertEol: false,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 13,
scrollback: 5000,
theme: {
background: '#0a0f1d',
foreground: '#d9e6ff',
},
});
term.loadAddon(fitAddon);
term.open(termHost);
fitAddon.fit();
term.onData((data) => {
const payload = String(data || '');
if (!payload) return;
const p = payload.length > EXEC_PASTE_CHUNK_SIZE
? _execEnqueueChunkedInput(payload)
: _execEnqueueInput(payload);
p.catch((e) => {
_execAppendOutput(`\n[ERROR] ${e.message || e}\n`);
_execSwitchToLineFallback('xterm input error');
});
});
execTerminalState.term = term;
execTerminalState.fitAddon = fitAddon;
execTerminalState.useXterm = true;
_execSetTerminalUiMode(true);
term.focus();
return true;
} catch (_) {
_execDestroyTerminal();
_execSetTerminalUiMode(false);
return false;
}
}
function _execConnectedStatus() {
const sid = execTerminalState.sessionId || '';
if (!sid) return 'Connected';
const info = execTerminalState.sessionInfo;
if (!info) return `Connected (${sid})`;
if (info.closed) return `Session closed: ${info.close_reason || 'closed'}`;
const last = Number(info.last_activity || 0);
if (!Number.isFinite(last) || last <= 0) return `Connected (${sid})`;
const now = Math.floor(Date.now() / 1000);
const idleAge = Math.max(0, now - last);
const idleLeft = Math.max(0, EXEC_IDLE_TTL_SECONDS - idleAge);
const mm = String(Math.floor(idleLeft / 60)).padStart(2, '0');
const ss = String(idleLeft % 60).padStart(2, '0');
return `Connected (${sid}) - idle in ${mm}:${ss}`;
}
function _execStopSessionInfoTimers() {
if (execTerminalState.infoPollTimer) {
clearInterval(execTerminalState.infoPollTimer);
execTerminalState.infoPollTimer = null;
}
if (execTerminalState.infoTickTimer) {
clearInterval(execTerminalState.infoTickTimer);
execTerminalState.infoTickTimer = null;
}
}
function _execCanUpdateConnectedStatus() {
if (!execTerminalState.sessionId) return false;
if (
execTerminalState.reconnectEnabled &&
execTerminalState.reconnectAttempts > 0 &&
!execTerminalState.source
) {
return false;
}
return true;
}
async function _execRefreshSessionInfo() {
const sid = execTerminalState.sessionId;
if (!sid) return;
try {
const info = await api(`/containers/exec/${encodeURIComponent(sid)}`, 'GET');
if (sid !== execTerminalState.sessionId) return;
execTerminalState.sessionInfo = info || null;
if (execTerminalState.sessionInfo?.closed) {
execTerminalState.reconnectEnabled = false;
_execCancelReconnect();
_execCloseEventSource();
_execSetStatus(`Session closed: ${execTerminalState.sessionInfo.close_reason || 'closed'}`);
_execStopSessionInfoTimers();
return;
}
if (_execCanUpdateConnectedStatus()) _execSetStatus(_execConnectedStatus());
} catch (_) {
// stream reconnect status has priority
}
}
function _execStartSessionInfoTimers() {
_execStopSessionInfoTimers();
_execRefreshSessionInfo();
execTerminalState.infoPollTimer = setInterval(_execRefreshSessionInfo, EXEC_SESSION_INFO_POLL_MS);
execTerminalState.infoTickTimer = setInterval(() => {
if (!_execCanUpdateConnectedStatus()) return;
_execSetStatus(_execConnectedStatus());
}, 1000);
}
function _execAppendOutput(txt) {
if (execTerminalState.useXterm && execTerminalState.term) {
try { execTerminalState.term.write(String(txt || '')); } catch (_) {}
return;
}
const { output } = _execEls();
if (!output || !txt) return;
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() {
if (execTerminalState.useXterm && execTerminalState.term) {
try { execTerminalState.term.reset(); } catch (_) {}
}
const { output } = _execEls();
execTerminalState.outputText = '';
execTerminalState.outputLineCount = 0;
execTerminalState.outputTruncated = false;
if (output) output.textContent = '';
_execUpdateBufferInfo();
}
function _execUpdateBufferInfo() {
if (execTerminalState.useXterm) return;
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() {
if (execTerminalState.source) {
try { execTerminalState.source.close(); } catch (_) {}
execTerminalState.source = null;
}
}
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, termHost } = _execEls();
const target = (execTerminalState.useXterm && termHost) ? termHost : output;
if (!target) return { rows: 24, cols: 80 };
const w = target.clientWidth || 640;
const h = target.clientHeight || 360;
const cols = Math.max(20, Math.floor(w / 8));
const rows = Math.max(8, Math.floor(h / 18));
return { rows, cols };
}
async function _execResizeNow() {
const sid = execTerminalState.sessionId;
if (!sid) return;
if (execTerminalState.useXterm && execTerminalState.fitAddon) {
try { execTerminalState.fitAddon.fit(); } catch (_) {}
}
const { rows, cols } = _execComputeSize();
try {
await api(`/containers/exec/${encodeURIComponent(sid)}/resize`, 'POST', { rows, cols });
} catch (_) {
// ignore while session is not fully running yet
}
}
function _execScheduleResize() {
if (execTerminalState.resizeTimer) clearTimeout(execTerminalState.resizeTimer);
execTerminalState.resizeTimer = setTimeout(() => {
execTerminalState.resizeTimer = null;
_execResizeNow();
}, 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) {
_execDestroyTerminal();
_execSetTerminalUiMode(false);
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;
execTerminalState.sessionInfo = null;
_execCancelReconnect();
_execStopSessionInfoTimers();
execTerminalState.rawMode = true;
_execInitTerminal();
_execUpdateInputModeUi();
_execResetInputQueue();
_execCloseEventSource();
_execClearOutput();
_execSetStatus('Starting exec session...');
if (els.title) els.title.textContent = `Exec: ${name}`;
if (els.back) els.back.style.display = 'flex';
try {
const res = await api(`/containers/${encodeURIComponent(name)}/exec/start`, 'POST', { cmd: ['/bin/sh'], tty: true });
const sid = res?.session_id;
if (!sid) throw new Error('Geen session_id ontvangen');
execTerminalState.sessionId = sid;
execTerminalState.lastSeq = 0;
execTerminalState.reconnectEnabled = true;
execTerminalState.sessionInfo = null;
_execSetStatus(`Connected (${sid})`);
_execAttachStream(0, false);
_execStartSessionInfoTimers();
_execScheduleResize();
if (execTerminalState.resizeObserver) {
try { execTerminalState.resizeObserver.disconnect(); } catch (_) {}
execTerminalState.resizeObserver = null;
}
if (typeof ResizeObserver !== 'undefined' && els.modal) {
execTerminalState.resizeObserver = new ResizeObserver(() => _execScheduleResize());
execTerminalState.resizeObserver.observe(els.modal);
}
if (execTerminalState.useXterm && execTerminalState.term) {
execTerminalState.term.focus();
} else if (els.input) {
els.input.focus();
}
} catch (e) {
_execSetStatus('Start failed');
_execAppendOutput(`\n[ERROR] ${e.message || e}\n`);
}
}
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 _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() {
if (execTerminalState.useXterm) return;
execTerminalState.rawMode = !execTerminalState.rawMode;
_execUpdateInputModeUi();
_execFocusInputSoon(0);
}
async function containerExecClose() {
const els = _execEls();
const sid = execTerminalState.sessionId;
execTerminalState.reconnectEnabled = false;
_execCancelReconnect();
_execStopSessionInfoTimers();
_execCloseEventSource();
_execDestroyTerminal();
_execSetTerminalUiMode(false);
if (execTerminalState.resizeObserver) {
try { execTerminalState.resizeObserver.disconnect(); } catch (_) {}
execTerminalState.resizeObserver = null;
}
if (execTerminalState.resizeTimer) {
clearTimeout(execTerminalState.resizeTimer);
execTerminalState.resizeTimer = null;
}
_execResetInputQueue('session-closed');
execTerminalState.sessionId = '';
execTerminalState.lastSeq = 0;
execTerminalState.sessionInfo = null;
if (els.back) els.back.style.display = 'none';
if (sid) {
try {
await api(`/containers/exec/${encodeURIComponent(sid)}/stop`, 'POST');
} catch (_) {}
}
}
function closeExecModal(ev) {
if (ev.target.id !== 'execModalBack') return;
containerExecClose();
}