diff --git a/source/compose.manager/compose.manager.dashboard.page b/source/compose.manager/compose.manager.dashboard.page index 96cd1e9..40790af 100644 --- a/source/compose.manager/compose.manager.dashboard.page +++ b/source/compose.manager/compose.manager.dashboard.page @@ -264,29 +264,45 @@ $script = <<<'EOT' } function stackAction(stackName, action) { + // Map dashboard action names to compose_util.php action names + var actionMap = { + 'start': 'composeUp', + 'stop': 'composeStop', + 'restart': 'composeUpRecreate' + }; + var apiAction = actionMap[action] || ('compose' + action.charAt(0).toUpperCase() + action.slice(1)); + var actionTitle = action.charAt(0).toUpperCase() + action.slice(1); var $status = $('#compose_stacks_status'); - $status.text(action.charAt(0).toUpperCase() + action.slice(1) + 'ing ' + stackName + '...'); + $status.text(actionTitle + 'ing ' + stackName + '...'); $.post('/plugins/compose.manager/php/compose_util.php', { - action: 'compose' + action.charAt(0).toUpperCase() + action.slice(1), + action: apiAction, path: '/boot/config/plugins/compose.manager/projects/' + stackName - }, function() { - setTimeout(loadComposeStacks, 1000); + }, function(data) { + if (data) { + if (typeof openBox === 'function') { + openBox(data, actionTitle + ' Stack ' + stackName, 800, 1200, true); + } else { + Shadowbox.open({content: data, player: 'iframe', title: actionTitle + ' Stack ' + stackName, height: 800, width: 1200}); + } + } + // Refresh stacks after a delay to allow command to take effect + setTimeout(function() { stackContainerCache = {}; loadComposeStacks(); }, 3000); }); } function openStackLogs(stackName) { - // Open combined logs for the stack using the same approach as the Compose page + // Open combined logs for the stack in a new window var projectPath = '/boot/config/plugins/compose.manager/projects/' + stackName; $.post('/plugins/compose.manager/php/compose_util.php', { action: 'composeLogs', path: projectPath }, function(data) { - if (data && typeof openBox === 'function') { - openBox(data, 'Stack ' + stackName + ' Logs', 800, 1200, true); - } else if (data) { - // Fallback: open in shadowbox or new window - Shadowbox.open({content: data, player: 'iframe', title: 'Stack ' + stackName + ' Logs', height: 800, width: 1200}); + if (data) { + var h = Math.min(screen.availHeight, 800); + var w = Math.min(screen.availWidth, 1200); + window.open(data, 'Logs_' + stackName.replace(/[^a-zA-Z0-9]/g, '_'), + 'height=' + h + ',width=' + w + ',resizable=yes,scrollbars=yes'); } }); } @@ -325,16 +341,16 @@ $script = <<<'EOT' opts.push({divider:true}); opts.push({text:'Stop', icon:'fa-stop', action:function(e){ e.preventDefault(); - dockerAction(ctId, 'stop'); + dockerAction(ctName, 'stop'); }}); opts.push({text:'Restart', icon:'fa-refresh', action:function(e){ e.preventDefault(); - dockerAction(ctId, 'restart'); + dockerAction(ctName, 'restart'); }}); } else { opts.push({text:'Start', icon:'fa-play', action:function(e){ e.preventDefault(); - dockerAction(ctId, 'start'); + dockerAction(ctName, 'start'); }}); } @@ -344,29 +360,53 @@ $script = <<<'EOT' } window.addContainerContext = addContainerContext; - function dockerAction(ctId, action) { - debugLog('dockerAction:', ctId, action); - $.post('/plugins/dynamix.docker.manager/include/Events.php', { - action: 'docker_' + action, - container: ctId + function dockerAction(ctName, action) { + debugLog('dockerAction:', ctName, action); + $.post(caURL, { + action: 'containerAction', + container: ctName, + containerAction: action }, function() { - // Refresh after a short delay - setTimeout(loadComposeStacks, 1500); + // exec.php runs docker commands synchronously, so this fires after completion + if (!window.hideDockerComposeContainers && typeof window.loadlist === 'function') { + // Compose containers are visible in Docker tile — loadlist() refreshes it, + // and our loadlist hook (below) will also trigger loadComposeStacks() + window.loadlist(); + } else { + // Compose containers hidden from Docker tile — only refresh our stacks (faster) + loadComposeStacks(); + } }); } - // Open terminal - call global openTerminal (from Unraid's HeadInlineJS.php) - // For logs: openTerminal('docker', containerName, '.log') - // For console: openTerminal('docker', containerName, shell) function openDockerTerminal(name, isLogs, shell) { - if (typeof window.openTerminal === 'function') { - window.openTerminal('docker', name, isLogs ? '.log' : (shell || '/bin/bash')); + if (isLogs) { + // Logs — start ttyd via plugin, open in new window (same as stack logs) + $.post('/plugins/compose.manager/php/compose_util.php', { + action: 'containerLogs', + container: name + }, function(data) { + if (data) { + var h = Math.min(screen.availHeight, 800); + var w = Math.min(screen.availWidth, 1200); + window.open(data, 'Logs_' + name.replace(/[^a-zA-Z0-9]/g, '_'), + 'height=' + h + ',width=' + w + ',resizable=yes,scrollbars=yes'); + } + }); } else { - // Fallback if global function not available - var url = isLogs - ? '/logterminal/' + name.replace(/[ #]/g, '_') + '.log/' - : '/webterminal/docker/'; - window.open(url, '_blank'); + // Console — start writable ttyd via plugin, open directly + $.post('/plugins/compose.manager/php/compose_util.php', { + action: 'containerConsole', + container: name, + shell: shell || '/bin/bash' + }, function(data) { + if (data) { + var h = Math.min(screen.availHeight, 800); + var w = Math.min(screen.availWidth, 1200); + window.open(data, 'Console_' + name.replace(/[^a-zA-Z0-9]/g, '_'), + 'height=' + h + ',width=' + w + ',resizable=yes,scrollbars=yes'); + } + }); } } @@ -464,12 +504,16 @@ $script = <<<'EOT' if (data.partial > 0) statusText += ', Partial: ' + data.partial; $('#compose_stacks_status').text(statusText); + // Clear container cache since DOM will be rebuilt + stackContainerCache = {}; + var html = ''; if (data.stacks.length === 0) { html = '
No compose stacks defined
'; } else { data.stacks.forEach(function(stack, idx) { - var stackId = 'stack-' + idx; + // Use folder name for stable IDs (survives DOM rebuilds) + var stackId = 'stack-' + stack.folder.replace(/[^a-zA-Z0-9_-]/g, '_'); var state = stack.state; var stateIcon = state === 'started' ? 'fa-play' : (state === 'partial' ? 'fa-exclamation-circle' : 'fa-square'); @@ -520,6 +564,27 @@ $script = <<<'EOT' $('#compose_dash_content').html(html); + // Restore expanded stacks after DOM rebuild + Object.keys(expandedStacks).forEach(function(stackId) { + if (expandedStacks[stackId]) { + var $containers = $('#compose-dash-ct-' + stackId); + var $icon = $('#compose-dash-exp-' + stackId); + if ($containers.length) { + $containers.addClass('expanded'); + $icon.addClass('expanded'); + // Re-fetch container data since DOM was rebuilt + // containers div is a sibling after the stack div, not a child + var folder = $containers.prev('.compose-dash-stack').data('folder'); + if (folder) { + loadStackContainers(stackId, folder); + } + } else { + // Stack no longer exists, clean up + delete expandedStacks[stackId]; + } + } + }); + // Attach row click handlers using event delegation (allows context menu clicks to bubble) $('#compose_dash_content').off('click', '.compose-dash-stack').on('click', '.compose-dash-stack', function(e) { // Don't toggle if clicking on icon (context menu trigger) @@ -546,12 +611,12 @@ $script = <<<'EOT' $('.compose-dash-stack.stopped').hide(); } noStacks(); - + // Hide compose-managed containers from the Docker Containers dashboard tile if (window.hideDockerComposeContainers && data.composeContainerNames && data.composeContainerNames.length > 0) { setupComposeContainerHiding(data.composeContainerNames); } - + debugLog('Loaded', data.stacks.length, 'stacks, context menus attached via onclick'); }, 'json').fail(function() { $('#compose_stacks_status').text('Stacks -- Error loading'); @@ -575,29 +640,29 @@ $script = <<<'EOT' } } window.noStacks = noStacks; - + // Hide compose-managed containers from Unraid's Docker Containers dashboard tile. // Docker containers load asynchronously via loadlist() -> $.post('DashboardApps.php') // We use $(document).ajaxComplete() to detect when Docker containers are loaded, // and a CSS class with !important to prevent jQuery animations from re-showing elements. var composeContainerNameSet = null; var hideContainersSetup = false; - + function setupComposeContainerHiding(containerNames) { if (!containerNames || containerNames.length === 0) return; - + debugLog('Setting up compose container hiding for:', containerNames); console.log('[ComposeManager] Container names to hide:', containerNames); - + // Build a lookup object for fast case-insensitive matching composeContainerNameSet = {}; containerNames.forEach(function(n) { composeContainerNameSet[n.toLowerCase()] = true; }); - + // Inject CSS class for hiding (uses !important to override jQuery animations like hideMe) if (!$('#compose-hide-style').length) { $('head').append(''); } - + // Only set up the ajaxComplete handler once if (!hideContainersSetup) { hideContainersSetup = true; @@ -612,7 +677,7 @@ $script = <<<'EOT' } }); } - + // Fallback: run several times in case Docker loaded before us doHideContainers(); setTimeout(doHideContainers, 500); @@ -620,20 +685,20 @@ $script = <<<'EOT' setTimeout(doHideContainers, 3000); setTimeout(doHideContainers, 5000); } - + function doHideContainers() { if (!composeContainerNameSet) return; - + var $dockerView = $('#docker_view'); if (!$dockerView.length) return; - + var hiddenCount = 0; // DashboardApps.php renders: $dockerView.find('span.outer.apps').each(function() { var $el = $(this); // Skip if we already hid this element if ($el.hasClass('compose-hidden-container')) return; - + var name = extractContainerName($el); if (name && composeContainerNameSet[name.toLowerCase()]) { debugLog('Hiding Docker tile container:', name); @@ -642,13 +707,13 @@ $script = <<<'EOT' hiddenCount++; } }); - + if (hiddenCount > 0) { debugLog('Hidden', hiddenCount, 'compose containers from Docker tile'); updateDockerTileCounts(); } } - + function extractContainerName($el) { // Strategy 1: Parse onclick="addDockerContainerContext('ContainerName','ImageId',...)" // from child element @@ -662,7 +727,7 @@ $script = <<<'EOT' var match = onclick.match(/addDockerContainerContext\(\s*'([^']+)'/); if (match) return match[1]; } - + // Strategy 2: Inner span text // DashboardApps.php renders: ContainerName... var $inner = $el.find('span.inner > span').first(); @@ -670,10 +735,10 @@ $script = <<<'EOT' var t = $inner.text().trim(); if (t) return t; } - + return null; } - + function updateDockerTileCounts() { // Recalculate visible Docker container counts excluding hidden compose containers var started = $('#docker_view').find('span.outer.apps.started').not('.compose-hidden-container').length; @@ -681,7 +746,7 @@ $script = <<<'EOT' var paused = $('#docker_view').find('span.outer.apps.paused').not('.compose-hidden-container').length; $('.apps.switch').html("Containers -- Started: " + started + ", Stopped: " + stopped + ", Paused: " + paused); } - + $(function() { // Initialize toggle switch (like Docker's apps toggle) var cookie = (typeof $.cookie === 'function' && $.cookie('unraid_settings')) ? JSON.parse($.cookie('unraid_settings')) : {}; diff --git a/source/compose.manager/php/compose_list.php b/source/compose.manager/php/compose_list.php index 34db419..a5a3f47 100644 --- a/source/compose.manager/php/compose_list.php +++ b/source/compose.manager/php/compose_list.php @@ -314,16 +314,7 @@ $o .= "$uptimeDisplay"; // Description column (advanced only) - $o .= "$descriptionHtml"; - - // Version/Image info column (advanced only) - shows compose file info - $composeVersion = ''; - if (isset($containersByProject[$sanitizedProjectName][0]['Labels'])) { - if (preg_match('/com\.docker\.compose\.version=([^,]+)/', $containersByProject[$sanitizedProjectName][0]['Labels'], $vMatch)) { - $composeVersion = 'Compose v' . $vMatch[1]; - } - } - $o .= "$composeVersion"; + $o .= "$descriptionHtml"; // Path column (advanced only) $o .= "$pathHtml"; @@ -337,7 +328,7 @@ // Expandable details row (hidden by default) $o .= ""; - $o .= ""; + $o .= ""; $o .= "
"; $o .= " Loading containers..."; $o .= "
"; @@ -347,7 +338,7 @@ // If no stacks found, show a message if ($stackCount === 0) { - $o = "No Docker Compose stacks found. Click 'Add New Stack' to create one."; + $o = "No Docker Compose stacks found. Click 'Add New Stack' to create one."; } // Output the HTML diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index 91ffcec..ef49e31 100644 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -15,6 +15,9 @@ $showComposeOnTop = ($cfg['SHOW_COMPOSE_ON_TOP'] ?? 'false') === 'true'; $hideComposeFromDocker = ($cfg['HIDE_COMPOSE_FROM_DOCKER'] ?? 'false') === 'true'; +// Get Docker Compose CLI version +$composeVersion = trim(shell_exec('docker compose version --short 2>/dev/null') ?? ''); + // Note: Stack list is now loaded asynchronously via compose_list.php // This improves page load time by deferring expensive docker commands ?> @@ -24,44 +27,116 @@ are applied synchronously BEFORE any HTML renders — prevents FOUC. Non-critical styles remain in comboButton.css loaded via . */ ?> @@ -80,6 +155,7 @@ var autoCheckDays = ; var showComposeOnTop = ; var hideComposeFromDocker = ; + var composeCliVersion = ; // Timers for async operations (plugin-specific to avoid collision with Unraid's global timers) var composeTimers = {}; @@ -214,7 +290,7 @@ function composeLoadlist() { } catch (e) {} clearTimeout(composeTimers.load); $('#compose-local-spinner').fadeOut('fast'); - $('#compose_list').html('Failed to load stack list. Please refresh the page.'); + $('#compose_list').html('Failed to load stack list. Please refresh the page.'); }); } @@ -868,8 +944,17 @@ function updateStackUpdateUI(stackName, stackInfo) { isRunning = stateText.indexOf('started') !== -1 || stateText.indexOf('partial') !== -1; } - if (!isRunning) { - // Stack is not running - show stopped status + // If the stack is stopped and we have no previously-checked update + // data, show "stopped". But if a prior update check produced valid + // container info (images are still on disk), display it — the SHA + // comparison is still accurate even when the stack isn't running. + var hasCheckedData = stackInfo.containers && stackInfo.containers.length > 0 && + stackInfo.containers.some(function(ct) { + return ct.hasUpdate !== undefined || ct.localSha || ct.updateStatus; + }); + + if (!isRunning && !hasCheckedData) { + // Stack is not running and no prior update info - show stopped $updateCell.html(' stopped'); return; } @@ -1073,24 +1158,44 @@ function applyListView(animate) { $table.addClass('cm-advanced-view'); var endHeight = $table.outerHeight(); - $table.css({ height: startHeight, overflow: 'hidden' }) - .animate({ height: endHeight }, 400); - $changing.animate({ opacity: 1 }, 400).promise().done(function() { - $table.css({ height: '', overflow: '' }); + $table.css({ + height: startHeight, + overflow: 'hidden' + }) + .animate({ + height: endHeight + }, 400); + $changing.animate({ + opacity: 1 + }, 400).promise().done(function() { + $table.css({ + height: '', + overflow: '' + }); $changing.css('opacity', ''); }); } else { // Hiding advanced columns: fade out, then remove class var startHeight = $table.outerHeight(); - $changing.animate({ opacity: 0 }, 300).promise().done(function() { + $changing.animate({ + opacity: 0 + }, 300).promise().done(function() { $table.removeClass('cm-advanced-view'); var endHeight = $table.outerHeight(); - $table.css({ height: startHeight, overflow: 'hidden' }) - .animate({ height: endHeight }, 400, function() { - $table.css({ height: '', overflow: '' }); - $changing.css('opacity', ''); - }); + $table.css({ + height: startHeight, + overflow: 'hidden' + }) + .animate({ + height: endHeight + }, 400, function() { + $table.css({ + height: '', + overflow: '' + }); + $changing.css('opacity', ''); + }); }); } } else { @@ -1162,6 +1267,16 @@ functionBefore: function(instance, helper) { $('#compose_stacks').before(standaloneToggle); } } + // Inject Compose CLI version next to the page title + if (composeCliVersion) { + $('div.content').children('div.title').each(function() { + var txt = $(this).text().trim(); + if (/Compose/i.test(txt) && !/Docker\s*Containers/i.test(txt)) { + $(this).append(' v' + escapeHtml(composeCliVersion) + ''); + } + }); + } + // Initialize the Advanced/Basic view toggle. // labels_placement:'left' puts both labels to the left of the slider. // The plugin shows only the active label: "Basic View" (white) when @@ -1214,6 +1329,18 @@ functionBefore: function(instance, helper) { // If there are stacks queued for compose list reload (start/stop), trigger a reload if (pendingComposeReloadStacks.length > 0) { + // Immediately replace stale update-column text with a + // loading spinner so the user never sees outdated "stopped" + pendingComposeReloadStacks.forEach(function(project) { + var $row = $('#compose_stacks tr.compose-sortable[data-project="' + project + '"]'); + if ($row.length) { + $row.find('.compose-updatecolumn').html( + ' loading…' + ); + } + }); + // Tell the loadlist hook to skip composeLoadlist once + skipNextComposeLoadlist = true; // Schedule a debounced processor to handle pending compose reloads try { composeClientDebug('eboxObserver:pending-compose-reloads', { @@ -1276,6 +1403,13 @@ function wrapLoadlist() { window.loadlist = function() { originalLoadlist.apply(this, arguments); + // Skip compose reload if refreshStackRow is already handling it + if (pendingComposeRefreshCount > 0 || skipNextComposeLoadlist) { + composeClientDebug('hookLoadlist: suppressed composeLoadlist (pending=' + pendingComposeRefreshCount + ', skip=' + skipNextComposeLoadlist + ')'); + skipNextComposeLoadlist = false; + return; + } + if (isComposePanelVisible()) { // Visible — refresh with debounce clearTimeout(composeRefreshTimer); @@ -1300,7 +1434,9 @@ function wrapLoadlist() { if (wrapLoadlist()) clearInterval(hookInterval); }, 500); // Give up after 30s - setTimeout(function() { clearInterval(hookInterval); }, 30000); + setTimeout(function() { + clearInterval(hookInterval); + }, 30000); } // Tabbed mode: watch for tab switches to flush stale data @@ -1313,7 +1449,10 @@ function wrapLoadlist() { } }); }); - panelObserver.observe(tabPanel, { attributes: true, attributeFilter: ['style'] }); + panelObserver.observe(tabPanel, { + attributes: true, + attributeFilter: ['style'] + }); } })(); }); @@ -1372,7 +1511,9 @@ function addStack() { var modal = document.getElementById('compose-stack-modal-overlay'); if (modal) { var btns = modal.querySelectorAll('button'); - btns.forEach(function(btn) { btn.disabled = true; }); + btns.forEach(function(btn) { + btn.disabled = true; + }); } $.post( caURL, { @@ -1873,6 +2014,13 @@ function promptRecreateContainers() { // Track stacks that need update check after operation completes // Using array to support Update All Stacks operation var pendingUpdateCheckStacks = []; + // >0 while refreshStackRow AJAX calls are in-flight; the loadlist + // hook skips composeLoadlist until all pending refreshes complete. + var pendingComposeRefreshCount = 0; + // One-shot flag: consumed by the loadlist hook so the very next + // loadlist call skips composeLoadlist even if refreshStackRow + // already completed before loadlist fires. + var skipNextComposeLoadlist = false; // Track stacks that need a full compose list reload after start/stop operations var pendingComposeReloadStacks = []; // Timer for batching compose reloads to avoid duplicate refreshes @@ -1917,11 +2065,12 @@ function processPendingComposeReloads() { return; } - // Otherwise update each parent row from cached container details + // Fetch fresh container data from server, then update each parent row. + // The cache is stale after compose up/down so we must re-fetch. reloadStacks.forEach(function(project) { try { var stackId = $('#compose_stacks tr.compose-sortable[data-project="' + project + '"]').attr('id').replace('stack-row-', ''); - updateParentStackFromContainers(stackId, project); + refreshStackRow(stackId, project); } catch (e) { try { composeClientDebug('processPendingComposeReloads:update-failed', { @@ -1933,6 +2082,67 @@ function processPendingComposeReloads() { }); } + // Fetch fresh container data from server and update the parent stack row. + // Unlike updateParentStackFromContainers() which uses stale cache, this + // always makes an AJAX call to get current container states. + function refreshStackRow(stackId, project) { + pendingComposeRefreshCount++; + $.post(caURL, { + action: 'getStackContainers', + script: project + }, function(data) { + if (data) { + try { + var response = JSON.parse(data); + if (response.result === 'success') { + var containers = response.containers || []; + // Normalize PascalCase keys + containers.forEach(function(c) { + if (c.UpdateStatus !== undefined && c.updateStatus === undefined) c.updateStatus = c.UpdateStatus; + if (c.LocalSha !== undefined && c.localSha === undefined) c.localSha = c.LocalSha; + if (c.RemoteSha !== undefined && c.remoteSha === undefined) c.remoteSha = c.RemoteSha; + if (c.hasUpdate === undefined && c.updateStatus) { + c.hasUpdate = (c.updateStatus === 'update-available'); + } + }); + // Merge saved update status so we don't lose checked info + if (stackUpdateStatus[project] && stackUpdateStatus[project].containers) { + containers.forEach(function(container) { + var cName = container.Name || container.Service; + stackUpdateStatus[project].containers.forEach(function(update) { + if (cName === (update.container || update.name || update.service)) { + if (update.hasUpdate !== undefined) container.hasUpdate = update.hasUpdate; + if (update.updateStatus || update.status) container.updateStatus = update.status || update.updateStatus; + if (update.localSha) container.localSha = update.localSha; + if (update.remoteSha) container.remoteSha = update.remoteSha; + if (update.isPinned !== undefined) container.isPinned = update.isPinned; + } + }); + }); + } + // Update cache with fresh data + stackContainersCache[stackId] = containers; + // Now update the row using the fresh cache + updateParentStackFromContainers(stackId, project); + // If details are expanded, refresh them too + if (expandedStacks[stackId]) { + renderContainerDetails(stackId, containers, project); + } + } + } catch (e) { + composeClientDebug('refreshStackRow:parse-error', { project: project, err: e.toString() }); + // Fallback: update from whatever cache we have + updateParentStackFromContainers(stackId, project); + } + } + pendingComposeRefreshCount = Math.max(0, pendingComposeRefreshCount - 1); + }).fail(function() { + // On network failure, fall back to cache-based update + updateParentStackFromContainers(stackId, project); + pendingComposeRefreshCount = Math.max(0, pendingComposeRefreshCount - 1); + }); + } + // Toggle per-stack action-in-progress UI (replace status icon with spinner) function setStackActionInProgress(stackName, inProgress) { try { @@ -2247,35 +2457,35 @@ function renderStackActionDialog(action, stackName, path, profile, containers) { // Action-specific configuration var config = { 'up': { - title: 'Start Stack?', + title: 'Start ' + escapeHtml(stackName) + '?', description: 'This will start all containers in ' + escapeHtml(stackName) + '.', listTitle: 'CONTAINERS TO START', warning: 'Images will be pulled if not present locally.', warningIcon: 'info-circle', warningColor: '#08f', - confirmText: 'Yes, start stack', + confirmText: 'Start Stack', showVersionArrow: false, confirmedFn: ComposeUpConfirmed }, 'down': { - title: 'Stop Stack?', - description: 'This will stop and remove all containers in ' + escapeHtml(stackName) + '.', + title: 'Stop ' + escapeHtml(stackName) + '?', + description: 'This will shut down all containers in ' + escapeHtml(stackName) + '.', listTitle: 'CONTAINERS TO STOP', - warning: 'Containers will be stopped and removed. Data in volumes will be preserved.', + warning: 'Containers will be removed but data in volumes is preserved.', warningIcon: 'exclamation-triangle', warningColor: '#f80', - confirmText: 'Yes, stop stack', + confirmText: 'Stop Stack', showVersionArrow: false, confirmedFn: ComposeDownConfirmed }, 'update': { - title: 'Update Stack?', + title: 'Update ' + escapeHtml(stackName) + '?', description: 'This will pull the latest images and recreate containers in ' + escapeHtml(stackName) + '.', listTitle: 'CONTAINERS TO UPDATE', - warning: 'Running containers will be briefly interrupted.', + warning: 'Running containers will be recreated with the latest images.', warningIcon: 'exclamation-triangle', warningColor: '#f80', - confirmText: 'Yes, update stack', + confirmText: 'Update Stack', showVersionArrow: true, confirmedFn: UpdateStackConfirmed } @@ -2389,8 +2599,8 @@ function ComposePull(path, profile = "") { } function ComposeLogs(pathOrProject, profile = "") { - var height = 800; - var width = 1200; + var height = Math.min(screen.availHeight, 800); + var width = Math.min(screen.availWidth, 1200); // Support both project name (legacy) and path var path = pathOrProject.includes('/') ? pathOrProject : compose_root + "/" + pathOrProject; $.post(compURL, { @@ -2399,7 +2609,8 @@ function ComposeLogs(pathOrProject, profile = "") { profile: profile }, function(data) { if (data) { - openBox(data, "Stack " + basename(path) + " Logs", height, width, true); + window.open(data, 'Logs_' + basename(path), + 'height=' + height + ',width=' + width + ',resizable=yes,scrollbars=yes'); } }) } @@ -3747,10 +3958,10 @@ function renderContainerDetails(stackId, containers, project) { html += '' + ipAddresses.map(escapeHtml).join('
') + '
'; // Container Port - html += '' + containerPorts.slice(0, 3).map(escapeHtml).join('
') + (containerPorts.length > 3 ? '
...' : '') + '
'; + html += '' + containerPorts.map(escapeHtml).join('
') + '
'; // LAN IP:Port - html += '' + lanPorts.slice(0, 3).map(escapeHtml).join('
') + (lanPorts.length > 3 ? '
...' : '') + '
'; + html += '' + lanPorts.map(escapeHtml).join('
') + '
'; html += ''; }); @@ -3888,7 +4099,9 @@ function updateParentStackFromContainers(stackId, project) { }); }); // Recompute hasUpdate from merged containers - stackInfo.hasUpdate = stackInfo.containers.some(function(c) { return c.hasUpdate; }); + stackInfo.hasUpdate = stackInfo.containers.some(function(c) { + return c.hasUpdate; + }); } // Cache the merged update status and apply UI update @@ -4031,22 +4244,23 @@ function addComposeContainerContext(elementId) { }); } - // Console (if running) — ttyd + Shadowbox (same proven mechanism used for stack operations) + // Console (if running) — start writable ttyd, open in new window if (running) { opts.push({ text: 'Console', icon: 'fa-terminal', action: function(e) { e.preventDefault(); - $.post(compURL, {action: 'containerConsole', container: containerName, shell: shell}, function(data) { + $.post(compURL, { + action: 'containerConsole', + container: containerName, + shell: shell + }, function(data) { if (data) { var height = Math.min(screen.availHeight, 800); - var width = Math.min(screen.availWidth, 1200); - if (typeof openBox === 'function') { - openBox(data, 'Console: ' + containerName, height, width, true); - } else { - Shadowbox.open({content: data, player: 'iframe', title: 'Console: ' + containerName, height: height, width: width}); - } + var width = Math.min(screen.availWidth, 1200); + window.open(data, 'Console_' + containerName.replace(/[^a-zA-Z0-9]/g, '_'), + 'height=' + height + ',width=' + width + ',resizable=yes,scrollbars=yes'); } }); } @@ -4106,21 +4320,23 @@ function addComposeContainerContext(elementId) { divider: true }); - // Logs - uses Unraid's openTerminal + // Logs — start ttyd via plugin, open in new window (same as stack logs) opts.push({ text: 'Logs', icon: 'fa-navicon', action: function(e) { e.preventDefault(); - if (typeof openTerminal === 'function') { - openTerminal('docker', containerName, '.log'); - } else { - swal({ - title: 'Logs', - text: 'Terminal not available', - type: 'info' - }); - } + $.post(compURL, { + action: 'containerLogs', + container: containerName + }, function(data) { + if (data) { + var height = Math.min(screen.availHeight, 800); + var width = Math.min(screen.availWidth, 1200); + window.open(data, 'Logs_' + containerName.replace(/[^a-zA-Z0-9]/g, '_'), + 'height=' + height + ',width=' + width + ',resizable=yes,scrollbars=yes'); + } + }); } }); @@ -4232,7 +4448,9 @@ function containerAction(containerName, action, stackId) { } catch (e) {} // Also refresh Unraid's Docker containers widget if (typeof window.loadlist === 'function') { - setTimeout(function() { window.loadlist(); }, 1500); + setTimeout(function() { + window.loadlist(); + }, 1500); } } else { // Restore status icon or remove overlay spinner @@ -4544,25 +4762,24 @@ function addComposeStackContext(elementId) {
- - - - - - - - - - - - - - - - - - -
StackUpdateContainersUptimeDescriptionComposePathAutostart
+ + + + + + + + + + + + + + + + + +
StackUpdateContainersUptimeDescriptionPathAutostart