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) {
-
-
-
- | Stack |
- Update |
- Containers |
- Uptime |
- Description |
- Compose |
- Path |
- Autostart |
-
-
-
-
- |
-
-
-
+
+
+
+ | Stack |
+ Update |
+ Containers |
+ Uptime |
+ Description |
+ Path |
+ Autostart |
+
+
+
+
+ |
+
+
+
@@ -4909,8 +5126,13 @@ function doHide() {
function attachDockerObserver() {
var dockerTable = document.getElementById('docker_list');
if (dockerTable) {
- var obs = new MutationObserver(function() { setTimeout(doHide, 300); });
- obs.observe(dockerTable, { childList: true, subtree: true });
+ var obs = new MutationObserver(function() {
+ setTimeout(doHide, 300);
+ });
+ obs.observe(dockerTable, {
+ childList: true,
+ subtree: true
+ });
// Initial run now that docker table exists
setTimeout(doHide, 500);
} else {
diff --git a/source/compose.manager/php/compose_util.php b/source/compose.manager/php/compose_util.php
index 2ae78b2..3fc6ce8 100644
--- a/source/compose.manager/php/compose_util.php
+++ b/source/compose.manager/php/compose_util.php
@@ -75,18 +75,54 @@
$safeName = preg_replace('/[^a-zA-Z0-9_.-]/', '_', $containerName);
$socketName = "compose_ct_" . $safeName;
+ // Kill any existing ttyd on this socket (pkill -f is more robust
+ // than pgrep|awk — ensures stale read-only instances are gone)
+ exec("pkill -f " . escapeshellarg($socketName . ".sock") . " 2>/dev/null");
+ usleep(300000); // 300ms for process to exit
+ @unlink("/var/tmp/$socketName.sock");
+
+ // Start ttyd via ttyd-exec wrapper (same as Unraid native docker console).
+ // ttyd-exec sources /etc/default/ttyd for TTYD_OPTS and adds -d0.
+ // No -R flag = writable interactive terminal.
+ $cmd = "ttyd-exec -s9 -om1 -i " . escapeshellarg("/var/tmp/$socketName.sock")
+ . " docker exec -it " . escapeshellarg($containerName)
+ . " " . escapeshellarg($shell);
+ exec($cmd);
+
+ // Wait for ttyd to create the socket (up to 2s) to avoid 502
+ for ($i = 0; $i < 20; $i++) {
+ if (file_exists("/var/tmp/$socketName.sock")) break;
+ usleep(100000);
+ }
+
+ // /logterminal/ proxies to /var/tmp/.sock with full
+ // bidirectional WebSocket — writable because we omit -R.
+ echo "/logterminal/$socketName/";
+ }
+ break;
+ case 'containerLogs':
+ // Open a ttyd viewer for docker logs -f
+ $containerName = $_POST['container'] ?? '';
+ if ($containerName) {
+ $safeName = preg_replace('/[^a-zA-Z0-9_.-]/', '_', $containerName);
+ $socketName = "compose_log_" . $safeName;
+
// Kill any existing ttyd on this socket
- $pid = exec("pgrep -a ttyd | awk '/" . preg_quote($socketName, '/') . "\\.sock/{print \$1}'");
- if ($pid) exec("kill " . intval($pid));
+ exec("pkill -f " . escapeshellarg($socketName . ".sock") . " 2>/dev/null");
+ usleep(300000);
@unlink("/var/tmp/$socketName.sock");
- // Start ttyd with docker exec
+ // Start ttyd with docker logs -f (read-only)
$cmd = "ttyd -R -o -i " . escapeshellarg("/var/tmp/$socketName.sock")
- . " docker exec -it " . escapeshellarg($containerName)
- . " " . escapeshellarg($shell) . " > /dev/null 2>&1 &";
+ . " docker logs -f " . escapeshellarg($containerName) . " > /dev/null 2>&1 &";
exec($cmd);
- // Return the show_ttyd page URL with the custom socket
+ // Wait for ttyd to create the socket (up to 2s) to avoid 502
+ for ($i = 0; $i < 20; $i++) {
+ if (file_exists("/var/tmp/$socketName.sock")) break;
+ usleep(100000);
+ }
+
echo "/plugins/compose.manager/php/show_ttyd.php?socket=" . urlencode($socketName);
}
break;
diff --git a/source/compose.manager/php/compose_util_functions.php b/source/compose.manager/php/compose_util_functions.php
index a41b37a..7bb140c 100644
--- a/source/compose.manager/php/compose_util_functions.php
+++ b/source/compose.manager/php/compose_util_functions.php
@@ -1,7 +1,8 @@
+
+
-echo '';
-echo '';
-echo "";
-?>
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+