feat (ui): exec fase 1

This commit is contained in:
kodi
2026-03-04 16:39:41 +01:00
parent d96fc19f41
commit 92e0e04905
3 changed files with 240 additions and 3 deletions
+34
View File
@@ -456,6 +456,40 @@ pre{
.menuItem.ok{ border-color: rgba(45,212,191,.35); }
.menuItem.warn{ border-color: rgba(251,191,36,.35); }
.menuItem.bad{ border-color: rgba(251,113,133,.35); }
.menuItem.info{ border-color: rgba(96,165,250,.45); }
/* Exec terminal modal */
.modalExec{
width: min(1100px, 100%);
}
.execBody{
display: flex;
flex-direction: column;
gap: 10px;
}
.execOutput{
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--mono);
font-size: 12.5px;
color: var(--pre-text);
background: var(--pre-bg);
border: 1px solid var(--soft-line);
border-radius: 12px;
padding: 12px;
min-height: 48vh;
max-height: 64vh;
overflow: auto;
}
.execInputRow{
display: flex;
align-items: center;
gap: 8px;
}
.execInputRow .input{
flex: 1;
}
/* css voor herziene Netwerken pagina */
/* Toolbar controls */
+181 -3
View File
@@ -29,13 +29,17 @@ function _setCollapsed(pod, v) {
localStorage.setItem('pod_group_collapsed:' + pod, v ? '1' : '0');
}
function renderActionsDropdown(menuId, actionFn, targetEsc) {
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>
@@ -71,7 +75,7 @@ function renderContainerRow(c) {
<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))}
${renderActionsDropdown(menuId, 'containerAction', esc(name), true)}
</div>
</td>
</tr>
@@ -172,7 +176,7 @@ function renderContainersGrouped(list, tbody, podStatus) {
<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))}
${renderActionsDropdown(podMenuId, 'podAction', esc(pod), false)}
</div>
` : '<span class="muted">-</span>'}
</td>
@@ -395,3 +399,177 @@ function formatBytes(b) {
if (n >= 1024) return (n / 1024).toFixed(0) + "KiB";
return n + "B";
}
let execTerminalState = {
container: '',
sessionId: '',
source: null,
resizeTimer: null,
resizeObserver: null,
};
function _execEls() {
return {
back: document.getElementById('execModalBack'),
title: document.getElementById('execModalTitle'),
output: document.getElementById('execTerminalOutput'),
input: document.getElementById('execTerminalInput'),
status: document.getElementById('execTerminalStatus'),
modal: document.getElementById('execTerminalModal'),
};
}
function _execSetStatus(msg) {
const { status } = _execEls();
if (status) status.textContent = msg || '';
}
function _execAppendOutput(txt) {
const { output } = _execEls();
if (!output || !txt) return;
output.textContent += txt;
output.scrollTop = output.scrollHeight;
}
function _execClearOutput() {
const { output } = _execEls();
if (output) output.textContent = '';
}
function _execCloseEventSource() {
if (execTerminalState.source) {
try { execTerminalState.source.close(); } catch (_) {}
execTerminalState.source = null;
}
}
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);
}
async function containerExecOpen(name) {
const els = _execEls();
execTerminalState.container = name;
execTerminalState.sessionId = '';
_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;
_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');
};
_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;
const raw = els.input.value || '';
if (!raw) return;
els.input.value = '';
try {
await api(`/containers/exec/${encodeURIComponent(sid)}/input`, 'POST', { data: raw + '\n' });
} catch (e) {
_execAppendOutput(`\n[ERROR] ${e.message || e}\n`);
}
}
function containerExecHandleInputKey(ev) {
if (ev.key !== 'Enter') return;
ev.preventDefault();
containerExecSendInput();
}
async function containerExecClose() {
const els = _execEls();
const sid = execTerminalState.sessionId;
_execCloseEventSource();
if (execTerminalState.resizeObserver) {
try { execTerminalState.resizeObserver.disconnect(); } catch (_) {}
execTerminalState.resizeObserver = null;
}
if (execTerminalState.resizeTimer) {
clearTimeout(execTerminalState.resizeTimer);
execTerminalState.resizeTimer = null;
}
execTerminalState.sessionId = '';
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();
}