930 lines
29 KiB
JavaScript
930 lines
29 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 containers = await api('/containers-dashboard', 'GET');
|
|
const list = Array.isArray(containers) ? containers : (containers?.containers || []);
|
|
|
|
// 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 c of (list || [])) {
|
|
const cname = normalizeContainerName((c?.Names && c.Names[0]) ? c.Names[0] : (c?.Names || c?.Name || c?.name || ''));
|
|
if (!cname) continue;
|
|
|
|
const key = cssSafeId(cname);
|
|
|
|
const cpuRaw = c?._dashboard_cpu;
|
|
const memBytesRaw = c?._dashboard_mem_usage;
|
|
const memPctRaw = c?._dashboard_mem_perc;
|
|
|
|
const cpuPct = Number(cpuRaw);
|
|
const memBytes = Number(memBytesRaw);
|
|
const memPct = Number(memPctRaw);
|
|
|
|
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,
|
|
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'),
|
|
};
|
|
}
|
|
|
|
function _execSetStatus(msg) {
|
|
const { status } = _execEls();
|
|
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;
|
|
|
|
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() {
|
|
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 } = _execEls();
|
|
if (!output) return { rows: 24, cols: 80 };
|
|
const w = output.clientWidth || 640;
|
|
const h = output.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;
|
|
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) {
|
|
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...');
|
|
|
|
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;
|
|
_execSetStatus(`Connected (${sid})`);
|
|
_execAttachStream(0, false);
|
|
|
|
_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 (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() {
|
|
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 (_) {}
|
|
execTerminalState.resizeObserver = null;
|
|
}
|
|
if (execTerminalState.resizeTimer) {
|
|
clearTimeout(execTerminalState.resizeTimer);
|
|
execTerminalState.resizeTimer = null;
|
|
}
|
|
_execResetInputQueue('session-closed');
|
|
|
|
execTerminalState.sessionId = '';
|
|
execTerminalState.lastSeq = 0;
|
|
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();
|
|
}
|