Fix Terminal State!
Release rolling / release (push) Has been cancelled

This commit is contained in:
Raven Scott
2026-07-16 14:06:57 -04:00
parent 2663464fcb
commit 55421cdecd
3 changed files with 412 additions and 227 deletions
+405 -227
View File
@@ -1015,9 +1015,11 @@ function navigateToView(viewName, opts = {}) {
// Expose for auto-refresh poller
if (typeof window !== 'undefined') window.currentView = viewName;
// Drop live log stream when leaving container details
if (leavingDetails && typeof stopDetailsLogs === 'function') {
stopDetailsLogs();
// Drop live streams / PTYs when leaving container details
if (leavingDetails) {
if (typeof stopDetailsLogs === 'function') stopDetailsLogs();
if (typeof cleanupDetailsTerminal === 'function') cleanupDetailsTerminal();
if (typeof destroyDetailsStatsCharts === 'function') destroyDetailsStatsCharts();
}
if (viewName === 'dashboard') {
@@ -4141,6 +4143,10 @@ let currentContainerDetails = null;
* Force exactly one container-details tab visible.
* Bootstrap Tab.show() fails to hide the previous pane when its trigger lost
* `active` (e.g. after a global nav-link reset), so panes stack.
*
* Programmatic class toggles do NOT fire Bootstrap shown/hidden events every
* live tab (logs / terminal / stats / processes) must start/stop here as well
* as via the BS event listeners.
*/
function activateContainerDetailsTab(tabButtonId = 'overview-tab') {
const tabList = document.getElementById('container-details-tabs');
@@ -4155,16 +4161,28 @@ function activateContainerDetailsTab(tabButtonId = 'overview-tab') {
? tabContent.querySelector(targetSelector)
: null;
const wasLogs =
document.getElementById('logs-tab')?.classList.contains('active') ||
document.getElementById('logs-pane')?.classList.contains('active');
const willBeLogs = tabButtonId === 'logs-tab';
const isActive = (tabId, paneId) =>
document.getElementById(tabId)?.classList.contains('active') ||
document.getElementById(paneId)?.classList.contains('active');
// Programmatic class toggles do not fire Bootstrap shown/hidden events —
// stop log stream when leaving Logs so the next open always restarts cleanly.
const wasLogs = isActive('logs-tab', 'logs-pane');
const wasTerminal = isActive('terminal-tab', 'terminal-pane');
const wasStats = isActive('stats-tab', 'stats-pane');
const willBeLogs = tabButtonId === 'logs-tab';
const willBeTerminal = tabButtonId === 'terminal-tab';
const willBeStats = tabButtonId === 'stats-tab';
const willBeProcesses = tabButtonId === 'processes-tab';
// Leave handlers (before pane class flip)
if (wasLogs && !willBeLogs && typeof stopDetailsLogs === 'function') {
stopDetailsLogs();
}
if (wasTerminal && !willBeTerminal && typeof cleanupDetailsTerminal === 'function') {
cleanupDetailsTerminal();
}
if (wasStats && !willBeStats && typeof destroyDetailsStatsCharts === 'function') {
destroyDetailsStatsCharts();
}
tabList.querySelectorAll('.nav-link').forEach((link) => {
const active = link === targetBtn;
@@ -4179,12 +4197,28 @@ function activateContainerDetailsTab(tabButtonId = 'overview-tab') {
pane.classList.toggle('active', active);
});
if (willBeLogs && currentContainerDetails?.Id && typeof startDetailsLogs === 'function') {
startDetailsLogs(currentContainerDetails.Id);
// Enter handlers (after pane is visible so FitAddon / charts measure correctly)
const cid = currentContainerDetails?.Id;
if (willBeLogs && cid && typeof startDetailsLogs === 'function') {
startDetailsLogs(cid);
}
if (willBeTerminal && cid && typeof initDetailsTerminal === 'function') {
initDetailsTerminal(cid);
}
if (willBeStats && currentContainerDetails && typeof updateContainerDetailsStats === 'function') {
updateContainerDetailsStats(currentContainerDetails);
}
if (willBeProcesses && cid && typeof loadContainerTop === 'function') {
loadContainerTop(cid);
}
}
function showContainerDetails(container) {
// Stop streams/PTY from a previous container before switching
if (typeof stopDetailsLogs === 'function') stopDetailsLogs();
if (typeof cleanupDetailsTerminal === 'function') cleanupDetailsTerminal();
if (typeof destroyDetailsStatsCharts === 'function') destroyDetailsStatsCharts();
currentContainerDetails = container;
if (typeof window !== 'undefined') window.currentContainerDetails = container;
navigateToView('container-details');
@@ -6483,6 +6517,350 @@ async function startDetailsLogs(containerId) {
window.startDetailsLogs = startDetailsLogs;
window.stopDetailsLogs = stopDetailsLogs;
// ─── Container-details Terminal tab (serialized start/stop) ───────────────
// Alternating timeout bug: hidden.bs.tab fired killTerminal without awaiting,
// then shown.bs.tab started a new PTY — the late kill destroyed the new session.
// All start/stop ops run on a single promise chain so kill always finishes first.
/** @type {null | {
* xterm: import('@xterm/xterm').Terminal,
* fitAddon: object,
* fitController: object,
* inputCoalescer: object,
* onDataDisposable: { dispose?: () => void },
* containerId: string,
* gen: number,
* }} */
let detailsTerminalSession = null;
/** Bumped on every stop/start to abandon stale async work */
let detailsTerminalGen = 0;
/** Serializes terminal start/stop so kill never races start */
let detailsTerminalChain = Promise.resolve();
let detailsTerminalFontSize = 14;
let detailsTerminalTheme = 'dark';
const DETAILS_TERMINAL_THEMES = {
dark: {
background: '#000000',
foreground: '#ffffff',
cursor: '#ffffff',
selectionBackground: '#4d4d4d',
},
light: {
background: '#ffffff',
foreground: '#000000',
cursor: '#000000',
selectionBackground: '#b3d4fc',
},
'solarized-dark': {
background: '#002b36',
foreground: '#839496',
cursor: '#93a1a1',
selectionBackground: '#073642',
},
'solarized-light': {
background: '#fdf6e3',
foreground: '#657b83',
cursor: '#586e75',
selectionBackground: '#eee8d5',
},
monokai: {
background: '#272822',
foreground: '#f8f8f2',
cursor: '#f8f8f0',
selectionBackground: '#49483e',
},
};
/**
* @param {() => Promise<void> | void} fn
* @returns {Promise<void>}
*/
function enqueueDetailsTerminalOp(fn) {
const run = detailsTerminalChain.then(
() => fn(),
() => fn()
);
detailsTerminalChain = run.catch((err) => {
console.warn('[terminal] op failed', err?.message || err);
});
return run;
}
function disposeDetailsTerminalLocal(session) {
if (!session) return;
try {
session.inputCoalescer?.flush?.();
session.inputCoalescer?.destroy?.();
session.onDataDisposable?.dispose?.();
session.fitController?.disconnect?.();
session.xterm?.dispose?.();
} catch {
// ignore
}
}
/**
* Tear down local xterm + remote PTY. Safe when already idle.
* Bumps gen so any concurrent start is abandoned after it returns.
* @returns {Promise<void>}
*/
async function cleanupDetailsTerminalNow() {
detailsTerminalGen += 1;
const session = detailsTerminalSession;
detailsTerminalSession = null;
disposeDetailsTerminalLocal(session);
const el = document.getElementById('container-terminal-xterm');
if (el) el.innerHTML = '';
const cid = session?.containerId;
if (cid && manager.active?.connected) {
try {
await manager.request(Methods.killTerminal, { containerId: cid });
} catch {
// already gone
}
}
}
/**
* Public: enqueue terminal cleanup (tab leave / leave details view).
* @returns {Promise<void>}
*/
function cleanupDetailsTerminal() {
return enqueueDetailsTerminalOp(() => cleanupDetailsTerminalNow());
}
/**
* Open a fresh details-tab PTY for containerId. Always awaits any prior kill.
* @param {string} containerId
* @returns {Promise<void>}
*/
function initDetailsTerminal(containerId) {
if (!containerId) return Promise.resolve();
return enqueueDetailsTerminalOp(() => initDetailsTerminalNow(containerId));
}
/**
* @param {string} containerId
* @returns {Promise<void>}
*/
async function initDetailsTerminalNow(containerId) {
// Fully stop any existing session first (await kill — fixes re-entry race)
if (detailsTerminalSession) {
await cleanupDetailsTerminalNow();
} else {
detailsTerminalGen += 1;
}
const myGen = detailsTerminalGen;
let TerminalCtor;
let FitAddonCtor;
try {
TerminalCtor = getTerminalCtor();
FitAddonCtor = getFitAddonCtor();
} catch (err) {
console.error('[ERROR] Terminal libraries not loaded', err);
return;
}
const terminalContainer = document.getElementById('container-terminal-xterm');
const wrap = document.getElementById('container-terminal-content');
if (!terminalContainer) return;
// Abandoned while waiting for constructors / DOM
if (myGen !== detailsTerminalGen) return;
if (wrap) {
wrap.style.minHeight = wrap.style.minHeight || '360px';
wrap.style.height = wrap.style.height || '100%';
}
terminalContainer.style.width = '100%';
terminalContainer.style.height = '100%';
terminalContainer.style.minHeight = '320px';
terminalContainer.innerHTML = '';
const theme =
DETAILS_TERMINAL_THEMES[detailsTerminalTheme] || defaultXtermOptions().theme;
const xterm = new TerminalCtor(
defaultXtermOptions({
fontSize: detailsTerminalFontSize,
theme,
})
);
const fitAddon = new FitAddonCtor();
xterm.loadAddon(fitAddon);
xterm.open(terminalContainer);
xterm.writeln('\x1b[90mConnecting to container shell…\x1b[0m');
const sendResize = (cols, rows) => {
if (!manager.active?.connected || !cols || !rows) return;
if (myGen !== detailsTerminalGen) return;
manager.event(Methods.terminalResize, { containerId, cols, rows });
};
const fitController = createFitController(fitAddon, xterm, sendResize);
fitController.observe(terminalContainer);
if (wrap) fitController.observe(wrap);
const inputCoalescer = createInputCoalescer(({ data, encoding }) => {
if (!manager.active?.connected) return;
if (myGen !== detailsTerminalGen) return;
manager.event(Methods.terminalInput, {
containerId,
data,
encoding: encoding || 'utf8',
});
});
const onDataDisposable = xterm.onData((data) => {
if (!manager.active?.connected) return;
if (myGen !== detailsTerminalGen) return;
inputCoalescer.push(data);
});
// Wait a frame so the tab pane is visible (FitAddon needs real dimensions)
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
if (myGen !== detailsTerminalGen) {
disposeDetailsTerminalLocal({
xterm,
fitController,
inputCoalescer,
onDataDisposable,
});
return;
}
const dims = safeFit(fitAddon, xterm) || { cols: xterm.cols, rows: xterm.rows };
if (!manager.active?.connected) {
xterm.writeln('\r\n\x1b[31m[ERROR] Not connected\x1b[0m');
disposeDetailsTerminalLocal({
xterm,
fitController,
inputCoalescer,
onDataDisposable,
});
return;
}
try {
await manager.request(Methods.startTerminal, {
containerId,
cols: dims.cols,
rows: dims.rows,
tty: true,
});
} catch (err) {
if (myGen === detailsTerminalGen) {
xterm.writeln(`\r\n\x1b[31m[ERROR] ${err?.message || err}\x1b[0m`);
}
// Leave xterm up with the error if still current; otherwise dispose
if (myGen !== detailsTerminalGen) {
disposeDetailsTerminalLocal({
xterm,
fitController,
inputCoalescer,
onDataDisposable,
});
try {
await manager.request(Methods.killTerminal, { containerId });
} catch {
// ignore
}
return;
}
// Keep a minimal session so user sees the error (no remote PTY)
detailsTerminalSession = {
xterm,
fitAddon,
fitController,
inputCoalescer,
onDataDisposable,
containerId,
gen: myGen,
failed: true,
};
return;
}
// User left the tab while startTerminal was in flight — kill the PTY we just opened
if (myGen !== detailsTerminalGen) {
disposeDetailsTerminalLocal({
xterm,
fitController,
inputCoalescer,
onDataDisposable,
});
try {
await manager.request(Methods.killTerminal, { containerId });
} catch {
// ignore
}
return;
}
detailsTerminalSession = {
xterm,
fitAddon,
fitController,
inputCoalescer,
onDataDisposable,
containerId,
gen: myGen,
};
sendResize(dims.cols, dims.rows);
xterm.focus();
updateDetailsTerminalFontSizeDisplay();
}
function appendDetailsTerminalOutput(data, encoding = 'base64') {
if (!detailsTerminalSession?.xterm || detailsTerminalSession.failed) return;
const text = decodePayload(data, encoding);
if (text) detailsTerminalSession.xterm.write(text);
}
function updateDetailsTerminalFontSizeDisplay() {
const display = document.getElementById('terminal-font-size-display');
if (display) display.textContent = String(detailsTerminalFontSize);
}
function applyDetailsTerminalTheme(theme) {
detailsTerminalTheme = theme;
if (detailsTerminalSession?.xterm) {
detailsTerminalSession.xterm.options.theme =
DETAILS_TERMINAL_THEMES[theme] || DETAILS_TERMINAL_THEMES.dark;
}
}
function applyDetailsTerminalFont(size) {
detailsTerminalFontSize = size;
if (detailsTerminalSession?.xterm) {
detailsTerminalSession.xterm.options.fontSize = size;
detailsTerminalSession.fitController?.fitNow?.();
}
updateDetailsTerminalFontSizeDisplay();
}
window.initDetailsTerminal = initDetailsTerminal;
window.cleanupDetailsTerminal = cleanupDetailsTerminal;
window.handleDetailsTerminalOutput = (data, containerId, encoding) => {
if (
detailsTerminalSession &&
detailsTerminalSession.containerId === containerId &&
detailsTerminalSession.gen === detailsTerminalGen
) {
appendDetailsTerminalOutput(data, encoding);
}
};
/**
* Re-apply container list / dashboard after settings change (e.g. hide-label filters).
*/
@@ -6836,240 +7214,48 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
// Terminal state for details tab
let detailsTerminalSession = null;
let terminalFontSize = 14;
let terminalTheme = 'dark';
// Terminal theme configurations
const terminalThemes = {
dark: {
background: '#000000',
foreground: '#ffffff',
cursor: '#ffffff',
selectionBackground: '#4d4d4d'
},
light: {
background: '#ffffff',
foreground: '#000000',
cursor: '#000000',
selectionBackground: '#b3d4fc'
},
'solarized-dark': {
background: '#002b36',
foreground: '#839496',
cursor: '#93a1a1',
selectionBackground: '#073642'
},
'solarized-light': {
background: '#fdf6e3',
foreground: '#657b83',
cursor: '#586e75',
selectionBackground: '#eee8d5'
},
monokai: {
background: '#272822',
foreground: '#f8f8f2',
cursor: '#f8f8f0',
selectionBackground: '#49483e'
}
};
// Initialize terminal for details tab
function initDetailsTerminal(containerId) {
let TerminalCtor;
let FitAddonCtor;
try {
TerminalCtor = getTerminalCtor();
FitAddonCtor = getFitAddonCtor();
} catch (err) {
console.error('[ERROR] Terminal libraries not loaded', err);
return;
}
const terminalContainer = document.getElementById('container-terminal-xterm');
const wrap = document.getElementById('container-terminal-content');
if (!terminalContainer) return;
if (detailsTerminalSession) {
cleanupDetailsTerminal();
}
// Ensure host has size for FitAddon
if (wrap) {
wrap.style.minHeight = wrap.style.minHeight || '360px';
wrap.style.height = wrap.style.height || '100%';
}
terminalContainer.style.width = '100%';
terminalContainer.style.height = '100%';
terminalContainer.style.minHeight = '320px';
const theme =
terminalThemes[terminalTheme] ||
defaultXtermOptions().theme;
const xterm = new TerminalCtor(
defaultXtermOptions({
fontSize: terminalFontSize,
theme,
})
);
const fitAddon = new FitAddonCtor();
xterm.loadAddon(fitAddon);
terminalContainer.innerHTML = '';
xterm.open(terminalContainer);
const sendResize = (cols, rows) => {
if (!manager.active?.connected || !cols || !rows) return;
manager.event(Methods.terminalResize, { containerId, cols, rows });
};
const fitController = createFitController(fitAddon, xterm, sendResize);
fitController.observe(terminalContainer);
if (wrap) fitController.observe(wrap);
const inputCoalescer = createInputCoalescer(({ data, encoding }) => {
if (!manager.active?.connected) return;
manager.event(Methods.terminalInput, {
containerId,
data,
encoding: encoding || 'utf8',
});
});
const onDataDisposable = xterm.onData((data) => {
if (!manager.active?.connected) return;
inputCoalescer.push(data);
});
detailsTerminalSession = {
xterm,
fitAddon,
fitController,
inputCoalescer,
onDataDisposable,
containerId,
};
// Fit when tab is visible, then start PTY with size.
// Server probes bash → sh → ash → … until a working shell is found.
requestAnimationFrame(() => {
const dims = safeFit(fitAddon, xterm) || { cols: xterm.cols, rows: xterm.rows };
if (manager.active?.connected) {
manager
.request(Methods.startTerminal, {
containerId,
cols: dims.cols,
rows: dims.rows,
tty: true,
})
.catch((err) => {
xterm.writeln(`\r\n\x1b[31m[ERROR] ${err.message}\x1b[0m`);
});
sendResize(dims.cols, dims.rows);
}
xterm.focus();
});
updateTerminalFontSizeDisplay();
}
// Cleanup terminal for details tab
function cleanupDetailsTerminal() {
if (!detailsTerminalSession) return;
const sid = detailsTerminalSession.containerId;
try {
detailsTerminalSession.inputCoalescer?.flush();
detailsTerminalSession.inputCoalescer?.destroy();
if (sid && manager.active?.connected) {
manager.request(Methods.killTerminal, { containerId: sid }).catch(() => {});
}
detailsTerminalSession.onDataDisposable?.dispose();
detailsTerminalSession.fitController?.disconnect();
detailsTerminalSession.xterm?.dispose();
} catch {
// ignore
}
detailsTerminalSession = null;
const el = document.getElementById('container-terminal-xterm');
if (el) el.innerHTML = '';
}
// Append terminal output
function appendDetailsTerminalOutput(data, encoding = 'base64') {
if (!detailsTerminalSession?.xterm) return;
const text = decodePayload(data, encoding);
if (text) detailsTerminalSession.xterm.write(text);
}
// Update font size display
function updateTerminalFontSizeDisplay() {
const display = document.getElementById('terminal-font-size-display');
if (display) {
display.textContent = terminalFontSize;
}
}
// Apply terminal theme
function applyTerminalTheme(theme) {
terminalTheme = theme;
if (detailsTerminalSession && detailsTerminalSession.xterm) {
detailsTerminalSession.xterm.options.theme = terminalThemes[theme];
}
}
// Set up terminal tab
// Terminal tab — lifecycle is module-level (serialized start/stop).
// Bootstrap events fire on user clicks; activateContainerDetailsTab covers
// programmatic switches that do not emit BS events.
const terminalTab = document.getElementById('terminal-tab');
if (terminalTab) {
terminalTab.addEventListener('shown.bs.tab', () => {
if (currentContainerDetails) {
if (currentContainerDetails?.Id) {
initDetailsTerminal(currentContainerDetails.Id);
}
});
terminalTab.addEventListener('hidden.bs.tab', () => {
cleanupDetailsTerminal();
});
}
// Terminal controls
const terminalFontDecreaseBtn = document.getElementById('terminal-font-decrease');
const terminalFontIncreaseBtn = document.getElementById('terminal-font-increase');
const terminalFontResetBtn = document.getElementById('terminal-font-reset');
const terminalCopyBtn = document.getElementById('terminal-copy-btn');
const terminalClearBtn = document.getElementById('terminal-clear-btn');
const terminalThemeSelect = document.getElementById('terminal-theme-select');
const applyDetailsFont = (size) => {
terminalFontSize = size;
if (detailsTerminalSession?.xterm) {
detailsTerminalSession.xterm.options.fontSize = size;
detailsTerminalSession.fitController?.fitNow();
}
updateTerminalFontSizeDisplay();
};
if (terminalFontDecreaseBtn) {
terminalFontDecreaseBtn.addEventListener('click', () => {
if (terminalFontSize > 8) applyDetailsFont(terminalFontSize - 1);
if (detailsTerminalFontSize > 8) {
applyDetailsTerminalFont(detailsTerminalFontSize - 1);
}
});
}
if (terminalFontIncreaseBtn) {
terminalFontIncreaseBtn.addEventListener('click', () => {
if (terminalFontSize < 28) applyDetailsFont(terminalFontSize + 1);
if (detailsTerminalFontSize < 28) {
applyDetailsTerminalFont(detailsTerminalFontSize + 1);
}
});
}
if (terminalFontResetBtn) {
terminalFontResetBtn.addEventListener('click', () => applyDetailsFont(14));
terminalFontResetBtn.addEventListener('click', () => applyDetailsTerminalFont(14));
}
if (terminalCopyBtn) {
terminalCopyBtn.addEventListener('click', () => {
if (detailsTerminalSession && detailsTerminalSession.xterm) {
if (detailsTerminalSession?.xterm) {
const selection = detailsTerminalSession.xterm.getSelection();
if (selection) {
copyToClipboard(selection, terminalCopyBtn);
@@ -7079,7 +7265,6 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
}
if (terminalClearBtn) {
terminalClearBtn.addEventListener('click', async () => {
if (!detailsTerminalSession?.xterm) return;
@@ -7093,20 +7278,13 @@ document.addEventListener('DOMContentLoaded', () => {
if (ok) detailsTerminalSession.xterm.clear();
});
}
if (terminalThemeSelect) {
terminalThemeSelect.value = terminalTheme;
terminalThemeSelect.value = detailsTerminalTheme;
terminalThemeSelect.addEventListener('change', (e) => {
applyTerminalTheme(e.target.value);
applyDetailsTerminalTheme(e.target.value);
});
}
// Handle terminal output for details tab
window.handleDetailsTerminalOutput = (data, containerId, encoding) => {
if (detailsTerminalSession && detailsTerminalSession.containerId === containerId) {
appendDetailsTerminalOutput(data, encoding);
}
};
updateDetailsTerminalFontSizeDisplay();
});
/**