From ba0b451583939e3128ddaa44127e4fa8684077e1 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 31 May 2026 11:38:29 -0400 Subject: [PATCH 01/67] fix: update plugin URL to point to the dev branch --- compose.manager.plg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose.manager.plg b/compose.manager.plg index 670363e4..205b191a 100644 --- a/compose.manager.plg +++ b/compose.manager.plg @@ -10,7 +10,7 @@ - + From 34009ee10d90d816e06edea1c48bb82fd8f25a53 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 31 May 2026 12:00:01 -0400 Subject: [PATCH 02/67] fix: correct version and package details in plugin metadata --- compose.manager.plg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compose.manager.plg b/compose.manager.plg index 205b191a..9cec6fd3 100644 --- a/compose.manager.plg +++ b/compose.manager.plg @@ -2,11 +2,11 @@ - + - - + + From 5644e4b8f8521591b84322ceba806c3fd83aac75 Mon Sep 17 00:00:00 2001 From: Chad Condon Date: Sun, 31 May 2026 19:18:00 -0700 Subject: [PATCH 03/67] feat: show docker.versions changelogs in the update dialog When docker.versions is installed, a Changelog link appears next to the SHA comparison for each container with a pending update. Clicking it opens the docker.versions changelog in a sized iframe modal via Nchan, matching the existing Docker tab experience. docker.versions is an optional dependency: the PHP endpoint detects it via is_dir(), the JS flag defaults false, and the NchanSubscriber guard prevents any breakage if the plugin is absent or removed while the page is open. A 10-second timeout shows a fallback message if no data arrives. Dialog sizing is applied via inline styles rather than borrowing docker.versions' CSS classes. --- source/compose.manager/include/Exec.php | 7 ++- .../javascript/composeManagerMain.js | 63 +++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/source/compose.manager/include/Exec.php b/source/compose.manager/include/Exec.php index 63bcfa9b..f93aee8a 100644 --- a/source/compose.manager/include/Exec.php +++ b/source/compose.manager/include/Exec.php @@ -1504,15 +1504,16 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool case 'getSavedUpdateStatus': // Load saved update status from file $composeUpdateStatusFile = COMPOSE_UPDATE_STATUS_FILE; + $dockerVersionsInstalled = is_dir('/usr/local/emhttp/plugins/docker.versions'); if (is_file($composeUpdateStatusFile)) { $savedStatus = json_decode(file_get_contents($composeUpdateStatusFile), true); if ($savedStatus) { - echo json_encode(['result' => 'success', 'stacks' => $savedStatus]); + echo json_encode(['result' => 'success', 'stacks' => $savedStatus, 'dockerVersionsInstalled' => $dockerVersionsInstalled]); } else { - echo json_encode(['result' => 'success', 'stacks' => []]); + echo json_encode(['result' => 'success', 'stacks' => [], 'dockerVersionsInstalled' => $dockerVersionsInstalled]); } } else { - echo json_encode(['result' => 'success', 'stacks' => []]); + echo json_encode(['result' => 'success', 'stacks' => [], 'dockerVersionsInstalled' => $dockerVersionsInstalled]); } break; case 'getLogs': diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js index 707e23ca..bcd12931 100644 --- a/source/compose.manager/javascript/composeManagerMain.js +++ b/source/compose.manager/javascript/composeManagerMain.js @@ -966,6 +966,9 @@ function updateTabModifiedState() { // Update status cache per stack var stackUpdateStatus = {}; +// Set to true when docker.versions plugin is detected (populated from getSavedUpdateStatus response) +var dockerVersionsInstalled = false; + // Load saved update status from server (called on page load) // If auto-check is enabled and interval has elapsed, trigger a fresh check // Also checks for pending rechecks from recent update operations @@ -978,6 +981,7 @@ function loadSavedUpdateStatus() { var response = JSON.parse(data); if (response.result === 'success' && response.stacks) { stackUpdateStatus = response.stacks; + if (response.dockerVersionsInstalled) dockerVersionsInstalled = true; // Update the UI for each stack with saved status for (var stackName in response.stacks) { @@ -3501,6 +3505,62 @@ function mergeUpdateStatus(containers, project) { return containers; } +// Show docker.versions changelog for a single container in a modal. +// Only called when dockerVersionsInstalled is true; guards against NchanSubscriber +// being unavailable (e.g. docker.versions removed without page reload). +// +// Coupling points with docker.versions (update here if that plugin changes them): +// - Nchan topic: /sub/changelog +// - PHP endpoint: /plugins/docker.versions/server/GetChangelog.php +function showComposeChangelog(containerName) { + if (typeof NchanSubscriber === 'undefined') return; + + var nchan = new NchanSubscriber('/sub/changelog'); + var timeoutId = null; + + // Append all Nchan messages directly to the iframe body. Avoids depending on + // docker.versions' internal HTML class names to route content to sub-elements. + nchan.on('message', function(data) { + var iframeDoc = $('#myIframe')[0] && $('#myIframe')[0].contentDocument; + if (!iframeDoc) return; + $(iframeDoc).find('body').css('background-color', 'white').append(data); + }); + nchan.start(); + + swal({ + title: 'Changelog: ' + containerName, + text: '', + html: true, + closeOnConfirm: true, + showCancelButton: false, + allowOutsideClick: true, + }, function() { + clearTimeout(timeoutId); + nchan.stop(); + swal.close(); + }); + + // Size the dialog to match docker.versions' changelog modal without borrowing + // its CSS classes (avoids depending on its stylesheet being loaded). + // Equivalent to: .sweet-alert.change-log-summary + .change-log-iframe-container + #myIframe + $('.sweet-alert').css({ width: '75%', maxWidth: '75%' }); + $('#myIframe').parent().css('height', '80%'); + $('#myIframe').css('height', '75vh'); + + // Show a fallback if docker.versions sends nothing (endpoint moved, Nchan topic + // changed, or container not found by docker.versions). + timeoutId = setTimeout(function() { + var iframeDoc = $('#myIframe')[0] && $('#myIframe')[0].contentDocument; + if (iframeDoc && !$(iframeDoc).find('body').children().length) { + $(iframeDoc).find('body').html( + '

Changelog unavailable — no data received from docker.versions.

' + ); + } + }, 10000); + + $.get('/plugins/docker.versions/server/GetChangelog.php', { 'cts[]': containerName }); +} + // Unified stack action dialog - handles up, down, and update actions function showStackActionDialog(action, path, profile) { var stackName = basename(path); @@ -3753,6 +3813,9 @@ function renderStackActionDialog(action, displayName, path, profile, containers, html += ' '; html += '' + composeEscapeHtml(remoteSha.substring(0, 8)) + ''; html += ''; + if (dockerVersionsInstalled) { + html += '
Changelog
'; + } } else if (localSha) { // No update - just show current SHA (greyed) html += '
' + composeEscapeHtml(localSha.substring(0, 8)) + '
'; From 61289f8f3d9a1ceb0285226f18a92064a2640f01 Mon Sep 17 00:00:00 2001 From: Chad Condon Date: Sun, 31 May 2026 19:36:10 -0700 Subject: [PATCH 04/67] test: add unit tests for getSavedUpdateStatus docker.versions detection Tests cover all three response paths (no file, valid file, invalid file) against both the docker.versions-installed and not-installed states. Uses UnraidStreamWrapper to map the plugin directory to a controlled temp path without touching the real filesystem. --- tests/unit/ExecActionsTest.php | 83 ++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/unit/ExecActionsTest.php b/tests/unit/ExecActionsTest.php index 24cdf870..0b164fae 100644 --- a/tests/unit/ExecActionsTest.php +++ b/tests/unit/ExecActionsTest.php @@ -16,6 +16,7 @@ use PluginTests\TestCase; use PluginTests\Mocks\FunctionMocks; +use PluginTests\StreamWrapper\UnraidStreamWrapper; require_once '/usr/local/emhttp/plugins/compose.manager/include/Util.php'; @@ -1057,6 +1058,88 @@ public function testSetStackSettingsRejectsUnsupportedIconUrlTypes(): void } } + // =========================================== + // getSavedUpdateStatus Action Tests + // =========================================== + + /** + * Returns dockerVersionsInstalled=false when the plugin directory is absent. + */ + public function testGetSavedUpdateStatusDockerVersionsNotInstalled(): void + { + @unlink(COMPOSE_UPDATE_STATUS_FILE); + + $output = $this->executeAction('getSavedUpdateStatus'); + $result = json_decode($output, true); + + $this->assertIsArray($result); + $this->assertEquals('success', $result['result']); + $this->assertSame([], $result['stacks']); + $this->assertFalse($result['dockerVersionsInstalled']); + } + + /** + * Returns dockerVersionsInstalled=true when the plugin directory exists. + */ + public function testGetSavedUpdateStatusDockerVersionsInstalled(): void + { + @unlink(COMPOSE_UPDATE_STATUS_FILE); + + $fakeDir = sys_get_temp_dir() . '/fake_docker_versions_' . getmypid(); + mkdir($fakeDir, 0755, true); + $this->externalCleanupPaths[] = $fakeDir; + UnraidStreamWrapper::addMapping('/usr/local/emhttp/plugins/docker.versions', $fakeDir); + + $output = $this->executeAction('getSavedUpdateStatus'); + $result = json_decode($output, true); + + $this->assertIsArray($result); + $this->assertEquals('success', $result['result']); + $this->assertSame([], $result['stacks']); + $this->assertTrue($result['dockerVersionsInstalled']); + } + + /** + * Returns saved stacks alongside dockerVersionsInstalled when a status file exists. + */ + public function testGetSavedUpdateStatusIncludesSavedStacks(): void + { + $savedStatus = ['my-stack' => ['hasUpdate' => true, 'containers' => []]]; + file_put_contents(COMPOSE_UPDATE_STATUS_FILE, json_encode($savedStatus)); + + $fakeDir = sys_get_temp_dir() . '/fake_docker_versions_' . getmypid(); + mkdir($fakeDir, 0755, true); + $this->externalCleanupPaths[] = $fakeDir; + UnraidStreamWrapper::addMapping('/usr/local/emhttp/plugins/docker.versions', $fakeDir); + + $output = $this->executeAction('getSavedUpdateStatus'); + $result = json_decode($output, true); + + $this->assertIsArray($result); + $this->assertEquals('success', $result['result']); + $this->assertEquals($savedStatus, $result['stacks']); + $this->assertTrue($result['dockerVersionsInstalled']); + + @unlink(COMPOSE_UPDATE_STATUS_FILE); + } + + /** + * Falls back to empty stacks when the status file contains invalid JSON. + */ + public function testGetSavedUpdateStatusHandlesInvalidStatusFile(): void + { + file_put_contents(COMPOSE_UPDATE_STATUS_FILE, 'not-valid-json'); + + $output = $this->executeAction('getSavedUpdateStatus'); + $result = json_decode($output, true); + + $this->assertIsArray($result); + $this->assertEquals('success', $result['result']); + $this->assertSame([], $result['stacks']); + + @unlink(COMPOSE_UPDATE_STATUS_FILE); + } + // =========================================== // checkStackLock Action Tests // =========================================== From 3736859973d34d143a52635538ce452a9d5a0dc6 Mon Sep 17 00:00:00 2001 From: Chad Condon Date: Sun, 31 May 2026 19:43:38 -0700 Subject: [PATCH 05/67] docs: document docker.versions changelog integration in user guide --- docs/user-guide.md | 10 +++ .../javascript/composeManagerMain.js | 66 ++++++++++++++++++- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 7c67cd5e..a670c1b8 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -41,6 +41,16 @@ Each stack supports the following actions: | **Edit Stack** | Open the stack editor | | **Remove Stack** | Delete the stack configuration | +### Checking for Updates + +Use **Check Updates** on a stack to query the registry for newer image versions. Results are cached until the next manual or scheduled check (see [Update Checking settings](configuration.md#update-checking)). + +When updates are available, clicking **Update Stack** opens a confirmation dialog listing each container alongside its current and incoming image digest. Containers that are already up to date are dimmed. + +#### Changelogs + +If the [docker.versions](https://github.com/phyzical/docker.versions) Unraid plugin is installed, a **Changelog** link appears below the digest for each container with a pending update. Clicking it opens the release notes for that image in a modal. No configuration is required — Compose Manager detects docker.versions automatically. + ## Autostart Enable autostart to have stacks start automatically when the Unraid array starts. diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js index bcd12931..6926024a 100644 --- a/source/compose.manager/javascript/composeManagerMain.js +++ b/source/compose.manager/javascript/composeManagerMain.js @@ -3505,6 +3505,59 @@ function mergeUpdateStatus(containers, project) { return containers; } +// Minimal markdown renderer for docker.versions release bodies. +// docker.versions publishes the raw GitHub API `body` field (Markdown) inside +// plain
elements. This converts the common patterns found in release +// notes to HTML; no external library required. +function composeRenderMarkdown(md) { + if (!md || !md.trim()) return ''; + var blocks = []; + md = md.replace(/```(?:[^\n]*)?\n([\s\S]*?)```/g, function(_, code) { + blocks.push('
' +
+            code.replace(/&/g,'&').replace(//g,'>') + '
'); + return '\x00' + (blocks.length - 1) + '\x00'; + }); + md = md.replace(/`([^`\n]+)`/g, function(_, c) { + return '' + + c.replace(/&/g,'&').replace(//g,'>') + ''; + }); + var out = ''; + var inUL = false; + md.split('\n').forEach(function(line) { + var h = line.match(/^(#{1,6})\s+(.*)/); + if (h) { + if (inUL) { out += ''; inUL = false; } + var n = h[1].length; + out += '' + composeInlineMd(h[2]) + ''; + return; + } + var li = line.match(/^\s*[-*+]\s+(.*)/); + if (li) { + if (!inUL) { out += '
    '; inUL = true; } + out += '
  • ' + composeInlineMd(li[1]) + '
  • '; + return; + } + if (!line.trim()) { + if (inUL) { out += '
'; inUL = false; } + out += '
'; + return; + } + if (inUL) { out += ''; inUL = false; } + out += '

' + composeInlineMd(line) + '

'; + }); + if (inUL) out += ''; + return out.replace(/\x00(\d+)\x00/g, function(_, i) { return blocks[i]; }); +} + +function composeInlineMd(t) { + t = t.replace(/\*\*\*(.+?)\*\*\*/g, '$1'); + t = t.replace(/\*\*(.+?)\*\*/g, '$1'); + t = t.replace(/\*(.+?)\*/g, '$1'); + t = t.replace(/~~(.+?)~~/g, '$1'); + t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + return t; +} + // Show docker.versions changelog for a single container in a modal. // Only called when dockerVersionsInstalled is true; guards against NchanSubscriber // being unavailable (e.g. docker.versions removed without page reload). @@ -3518,12 +3571,19 @@ function showComposeChangelog(containerName) { var nchan = new NchanSubscriber('/sub/changelog'); var timeoutId = null; - // Append all Nchan messages directly to the iframe body. Avoids depending on - // docker.versions' internal HTML class names to route content to sub-elements. + // Append Nchan messages to the iframe body. docker.versions publishes raw + // Markdown inside bare
elements; render those before inserting. nchan.on('message', function(data) { var iframeDoc = $('#myIframe')[0] && $('#myIframe')[0].contentDocument; if (!iframeDoc) return; - $(iframeDoc).find('body').css('background-color', 'white').append(data); + var tmp = document.createElement('div'); + tmp.innerHTML = data; + tmp.querySelectorAll('div').forEach(function(div) { + if (div.children.length === 0 && div.textContent.trim()) { + div.innerHTML = composeRenderMarkdown(div.textContent); + } + }); + $(iframeDoc).find('body').css('background-color', 'white').append(tmp.innerHTML); }); nchan.start(); From 1e26d684f5266989e24810435a06901f90906cc2 Mon Sep 17 00:00:00 2001 From: Chad Condon Date: Sun, 31 May 2026 20:29:59 -0700 Subject: [PATCH 06/67] feat: return to update dialog when changelog is dismissed When the user closes the changelog modal (OK button or outside click), re-open the Update Stack dialog for the same stack so they can continue reviewing other containers or proceed with the update. Pass path and profile through to showComposeChangelog via data attributes so the callback has enough context to reopen the dialog. --- source/compose.manager/javascript/composeManagerMain.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js index 6926024a..bf869b21 100644 --- a/source/compose.manager/javascript/composeManagerMain.js +++ b/source/compose.manager/javascript/composeManagerMain.js @@ -3565,7 +3565,7 @@ function composeInlineMd(t) { // Coupling points with docker.versions (update here if that plugin changes them): // - Nchan topic: /sub/changelog // - PHP endpoint: /plugins/docker.versions/server/GetChangelog.php -function showComposeChangelog(containerName) { +function showComposeChangelog(containerName, path, profile) { if (typeof NchanSubscriber === 'undefined') return; var nchan = new NchanSubscriber('/sub/changelog'); @@ -3598,6 +3598,7 @@ function showComposeChangelog(containerName) { clearTimeout(timeoutId); nchan.stop(); swal.close(); + if (path) showStackActionDialog('update', path, profile || ''); }); // Size the dialog to match docker.versions' changelog modal without borrowing @@ -3874,7 +3875,7 @@ function renderStackActionDialog(action, displayName, path, profile, containers, html += '' + composeEscapeHtml(remoteSha.substring(0, 8)) + ''; html += '
'; if (dockerVersionsInstalled) { - html += ''; + html += ''; } } else if (localSha) { // No update - just show current SHA (greyed) From 6aba01d84160c2e25258deef8e2113564cfa5c70 Mon Sep 17 00:00:00 2001 From: Chad Condon Date: Sun, 31 May 2026 20:55:36 -0700 Subject: [PATCH 07/67] fix: discard stale Nchan buffer and concurrent changelog messages /sub/changelog is a shared channel: Nchan delivers the last buffered message to every new subscriber, and docker.versions' own subscriber may be actively publishing output for other containers concurrently. Both cases caused wrong containers' changelogs to appear in the dialog. GetChangelog.php always publishes

as its first message. Use that as a start-of-stream marker: discard everything received before it, clear the iframe on arrival, and suppress loadingInfo progress messages that are noise in this context. --- .../javascript/composeManagerMain.js | 43 ++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js index bf869b21..1a598ed0 100644 --- a/source/compose.manager/javascript/composeManagerMain.js +++ b/source/compose.manager/javascript/composeManagerMain.js @@ -3571,9 +3571,39 @@ function showComposeChangelog(containerName, path, profile) { var nchan = new NchanSubscriber('/sub/changelog'); var timeoutId = null; - // Append Nchan messages to the iframe body. docker.versions publishes raw - // Markdown inside bare
elements; render those before inserting. + // /sub/changelog is a shared Nchan channel used by docker.versions for all + // changelog requests. Two problems arise if we naively append all messages: + // + // 1. Stale buffer: Nchan delivers the last buffered message to every new + // subscriber, so we may immediately receive output from a prior request. + // 2. Concurrent contamination: docker.versions' own subscriber may be actively + // receiving changelog output for a different container at the same time. + // + // GetChangelog.php always publishes

as its very first + // message. We use that as a start-of-stream marker: discard everything that + // arrives before it, clear the iframe when it arrives, then display from there. + // loadingInfo progress messages are also suppressed — they're informational + // noise that clutters the changelog view. + var started = false; nchan.on('message', function(data) { + if (data.includes("class='loading'") && !data.includes("class='loadingInfo'")) { + started = true; + clearTimeout(timeoutId); + var iframeDoc = $('#myIframe')[0] && $('#myIframe')[0].contentDocument; + if (iframeDoc) $(iframeDoc).find('body').empty().css('background-color', 'white'); + timeoutId = setTimeout(function() { + var iframeDoc = $('#myIframe')[0] && $('#myIframe')[0].contentDocument; + if (iframeDoc && !$(iframeDoc).find('body').children().length) { + $(iframeDoc).find('body').html( + '

Changelog unavailable — no data received from docker.versions.

' + ); + } + }, 10000); + return; + } + if (!started) return; + if (data.includes("class='loadingInfo'")) return; + var iframeDoc = $('#myIframe')[0] && $('#myIframe')[0].contentDocument; if (!iframeDoc) return; var tmp = document.createElement('div'); @@ -3583,7 +3613,7 @@ function showComposeChangelog(containerName, path, profile) { div.innerHTML = composeRenderMarkdown(div.textContent); } }); - $(iframeDoc).find('body').css('background-color', 'white').append(tmp.innerHTML); + $(iframeDoc).find('body').append(tmp.innerHTML); }); nchan.start(); @@ -3608,11 +3638,12 @@ function showComposeChangelog(containerName, path, profile) { $('#myIframe').parent().css('height', '80%'); $('#myIframe').css('height', '75vh'); - // Show a fallback if docker.versions sends nothing (endpoint moved, Nchan topic - // changed, or container not found by docker.versions). + // Fallback shown only if the start marker never arrives (docker.versions + // endpoint moved, Nchan topic changed, or container not found). timeoutId = setTimeout(function() { + if (started) return; var iframeDoc = $('#myIframe')[0] && $('#myIframe')[0].contentDocument; - if (iframeDoc && !$(iframeDoc).find('body').children().length) { + if (iframeDoc) { $(iframeDoc).find('body').html( '

Changelog unavailable — no data received from docker.versions.

' ); From e5ce5246e8bbb09e6b2b9cf49d57a403a26f3679 Mon Sep 17 00:00:00 2001 From: Chad Condon Date: Sun, 31 May 2026 20:58:51 -0700 Subject: [PATCH 08/67] fix: whitelist changelog message types, filter scaffolding noise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker.versions publishes many message types — Container: headers, URL links, warnings, empty
 containers, loadingInfo progress — that are
structural scaffolding for its own full-page view and are noise in our
modal.  Replace the loadingInfo-only blacklist with a whitelist: accept
only class='releasesInfo' release entries and the version-summary 

containing '---->', and discard everything else. --- .../javascript/composeManagerMain.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js index 1a598ed0..60cf6a40 100644 --- a/source/compose.manager/javascript/composeManagerMain.js +++ b/source/compose.manager/javascript/composeManagerMain.js @@ -3582,8 +3582,9 @@ function showComposeChangelog(containerName, path, profile) { // GetChangelog.php always publishes

as its very first // message. We use that as a start-of-stream marker: discard everything that // arrives before it, clear the iframe when it arrives, then display from there. - // loadingInfo progress messages are also suppressed — they're informational - // noise that clutters the changelog view. + // After the start marker, only release entries (class='releasesInfo') and + // the version-summary h3 with '---->' are displayed; everything else + // (warnings, Container: header, URL links, progress) is filtered out. var started = false; nchan.on('message', function(data) { if (data.includes("class='loading'") && !data.includes("class='loadingInfo'")) { @@ -3602,7 +3603,16 @@ function showComposeChangelog(containerName, path, profile) { return; } if (!started) return; - if (data.includes("class='loadingInfo'")) return; + + // Whitelist: only the two message types that are useful in this context. + // class='releasesInfo' — the
block for each release entry + //

with '---->' — the "current tag → latest tag" version summary + // Everything else (Container: header, URL links, warnings, empty
+        // container, loadingInfo progress) is informational scaffolding for
+        // docker.versions' own full-page view and is noise here.
+        var isReleaseEntry = data.includes("class='releasesInfo'");
+        var isVersionSummary = /^]/.test(data.trim()) && data.includes('---->');
+        if (!isReleaseEntry && !isVersionSummary) return;
 
         var iframeDoc = $('#myIframe')[0] && $('#myIframe')[0].contentDocument;
         if (!iframeDoc) return;

From ec6d67e8bb69996050aaa873300c1db9473ff993 Mon Sep 17 00:00:00 2001
From: Chad Condon 
Date: Wed, 3 Jun 2026 18:01:55 -0700
Subject: [PATCH 09/67] refactor: delegate changelog display to
 docker.versions' showChangeLog()

Replace the custom NchanSubscriber, message filtering, and markdown
renderer with a direct call to docker.versions' own showChangeLog()
function, which already handles all of that correctly.

Hide the OK/confirm button (it would call updateContainer, bypassing
compose_plugin's update mechanism) and poll for dialog close to reopen
the Update Stack dialog afterward.
---
 .../javascript/composeManagerMain.js          | 174 ++----------------
 1 file changed, 20 insertions(+), 154 deletions(-)

diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js
index 60cf6a40..a5cdbe43 100644
--- a/source/compose.manager/javascript/composeManagerMain.js
+++ b/source/compose.manager/javascript/composeManagerMain.js
@@ -3505,162 +3505,28 @@ function mergeUpdateStatus(containers, project) {
     return containers;
 }
 
-// Minimal markdown renderer for docker.versions release bodies.
-// docker.versions publishes the raw GitHub API `body` field (Markdown) inside
-// plain 
elements. This converts the common patterns found in release -// notes to HTML; no external library required. -function composeRenderMarkdown(md) { - if (!md || !md.trim()) return ''; - var blocks = []; - md = md.replace(/```(?:[^\n]*)?\n([\s\S]*?)```/g, function(_, code) { - blocks.push('
' +
-            code.replace(/&/g,'&').replace(//g,'>') + '
'); - return '\x00' + (blocks.length - 1) + '\x00'; - }); - md = md.replace(/`([^`\n]+)`/g, function(_, c) { - return '' + - c.replace(/&/g,'&').replace(//g,'>') + ''; - }); - var out = ''; - var inUL = false; - md.split('\n').forEach(function(line) { - var h = line.match(/^(#{1,6})\s+(.*)/); - if (h) { - if (inUL) { out += ''; inUL = false; } - var n = h[1].length; - out += '' + composeInlineMd(h[2]) + ''; - return; - } - var li = line.match(/^\s*[-*+]\s+(.*)/); - if (li) { - if (!inUL) { out += '
    '; inUL = true; } - out += '
  • ' + composeInlineMd(li[1]) + '
  • '; - return; - } - if (!line.trim()) { - if (inUL) { out += '
'; inUL = false; } - out += '
'; - return; - } - if (inUL) { out += ''; inUL = false; } - out += '

' + composeInlineMd(line) + '

'; - }); - if (inUL) out += ''; - return out.replace(/\x00(\d+)\x00/g, function(_, i) { return blocks[i]; }); -} - -function composeInlineMd(t) { - t = t.replace(/\*\*\*(.+?)\*\*\*/g, '$1'); - t = t.replace(/\*\*(.+?)\*\*/g, '$1'); - t = t.replace(/\*(.+?)\*/g, '$1'); - t = t.replace(/~~(.+?)~~/g, '$1'); - t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); - return t; -} - -// Show docker.versions changelog for a single container in a modal. -// Only called when dockerVersionsInstalled is true; guards against NchanSubscriber -// being unavailable (e.g. docker.versions removed without page reload). +// Show docker.versions changelog for a single container. +// Delegates entirely to docker.versions' own showChangeLog() so that display +// logic, Nchan subscription management, and message routing stay in one place. // -// Coupling points with docker.versions (update here if that plugin changes them): -// - Nchan topic: /sub/changelog -// - PHP endpoint: /plugins/docker.versions/server/GetChangelog.php +// Coupling point: showChangeLog(containerName) — global function from +// docker.versions/scripts/changelog.js. If that function is renamed or +// removed, this feature silently does nothing. function showComposeChangelog(containerName, path, profile) { - if (typeof NchanSubscriber === 'undefined') return; - - var nchan = new NchanSubscriber('/sub/changelog'); - var timeoutId = null; - - // /sub/changelog is a shared Nchan channel used by docker.versions for all - // changelog requests. Two problems arise if we naively append all messages: - // - // 1. Stale buffer: Nchan delivers the last buffered message to every new - // subscriber, so we may immediately receive output from a prior request. - // 2. Concurrent contamination: docker.versions' own subscriber may be actively - // receiving changelog output for a different container at the same time. - // - // GetChangelog.php always publishes

as its very first - // message. We use that as a start-of-stream marker: discard everything that - // arrives before it, clear the iframe when it arrives, then display from there. - // After the start marker, only release entries (class='releasesInfo') and - // the version-summary h3 with '---->' are displayed; everything else - // (warnings, Container: header, URL links, progress) is filtered out. - var started = false; - nchan.on('message', function(data) { - if (data.includes("class='loading'") && !data.includes("class='loadingInfo'")) { - started = true; - clearTimeout(timeoutId); - var iframeDoc = $('#myIframe')[0] && $('#myIframe')[0].contentDocument; - if (iframeDoc) $(iframeDoc).find('body').empty().css('background-color', 'white'); - timeoutId = setTimeout(function() { - var iframeDoc = $('#myIframe')[0] && $('#myIframe')[0].contentDocument; - if (iframeDoc && !$(iframeDoc).find('body').children().length) { - $(iframeDoc).find('body').html( - '

Changelog unavailable — no data received from docker.versions.

' - ); - } - }, 10000); - return; - } - if (!started) return; - - // Whitelist: only the two message types that are useful in this context. - // class='releasesInfo' — the
block for each release entry - //

with '---->' — the "current tag → latest tag" version summary - // Everything else (Container: header, URL links, warnings, empty
-        // container, loadingInfo progress) is informational scaffolding for
-        // docker.versions' own full-page view and is noise here.
-        var isReleaseEntry = data.includes("class='releasesInfo'");
-        var isVersionSummary = /^]/.test(data.trim()) && data.includes('---->');
-        if (!isReleaseEntry && !isVersionSummary) return;
-
-        var iframeDoc = $('#myIframe')[0] && $('#myIframe')[0].contentDocument;
-        if (!iframeDoc) return;
-        var tmp = document.createElement('div');
-        tmp.innerHTML = data;
-        tmp.querySelectorAll('div').forEach(function(div) {
-            if (div.children.length === 0 && div.textContent.trim()) {
-                div.innerHTML = composeRenderMarkdown(div.textContent);
-            }
-        });
-        $(iframeDoc).find('body').append(tmp.innerHTML);
-    });
-    nchan.start();
-
-    swal({
-        title: 'Changelog: ' + containerName,
-        text: '',
-        html: true,
-        closeOnConfirm: true,
-        showCancelButton: false,
-        allowOutsideClick: true,
-    }, function() {
-        clearTimeout(timeoutId);
-        nchan.stop();
-        swal.close();
-        if (path) showStackActionDialog('update', path, profile || '');
-    });
-
-    // Size the dialog to match docker.versions' changelog modal without borrowing
-    // its CSS classes (avoids depending on its stylesheet being loaded).
-    // Equivalent to: .sweet-alert.change-log-summary + .change-log-iframe-container + #myIframe
-    $('.sweet-alert').css({ width: '75%', maxWidth: '75%' });
-    $('#myIframe').parent().css('height', '80%');
-    $('#myIframe').css('height', '75vh');
-
-    // Fallback shown only if the start marker never arrives (docker.versions
-    // endpoint moved, Nchan topic changed, or container not found).
-    timeoutId = setTimeout(function() {
-        if (started) return;
-        var iframeDoc = $('#myIframe')[0] && $('#myIframe')[0].contentDocument;
-        if (iframeDoc) {
-            $(iframeDoc).find('body').html(
-                '

Changelog unavailable — no data received from docker.versions.

' - ); - } - }, 10000); - - $.get('/plugins/docker.versions/server/GetChangelog.php', { 'cts[]': containerName }); + if (typeof showChangeLog !== 'function') return; + showChangeLog(containerName); + // docker.versions' OK button calls updateContainer(), which bypasses + // compose_plugin's update mechanism. Hide it so the only exit is Cancel. + setTimeout(function() { $('.sweet-alert .confirm').hide(); }, 0); + if (!path) return; + // Reopen the Update Stack dialog when the changelog dialog closes. + // SweetAlert 1.x has no close event; poll the showSweetAlert class instead. + var appeared = false; + var poll = setInterval(function() { + var open = $('.sweet-alert').hasClass('showSweetAlert'); + if (!appeared) { if (open) appeared = true; return; } + if (!open) { clearInterval(poll); showStackActionDialog('update', path, profile || ''); } + }, 100); } // Unified stack action dialog - handles up, down, and update actions From 83f66247f7cec2e54b0b4293ab1c9063131f358a Mon Sep 17 00:00:00 2001 From: Chad Condon Date: Wed, 3 Jun 2026 18:28:54 -0700 Subject: [PATCH 10/67] fix: plug poll interval leak and skip dialog reopen when warnings disabled Two bugs found in code review: 1. The setInterval poll had no escape hatch when showChangeLog() opened no SweetAlert dialog (e.g. container unknown to docker.versions), so the 'appeared' flag never flipped true and clearInterval was never reached. Cap at 300 ticks (30 s) so it always self-cleans. 2. When DISABLE_ACTION_WARNINGS is true, renderStackActionDialog() has a fast-path that calls UpdateStackConfirmed() directly without showing a dialog. Reopening the update dialog after changelog dismiss would trigger an immediate update rather than a confirmation prompt. Skip the reopen in that mode by checking the setting via getConfig(). --- .../javascript/composeManagerMain.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js index a5cdbe43..2cb3a534 100644 --- a/source/compose.manager/javascript/composeManagerMain.js +++ b/source/compose.manager/javascript/composeManagerMain.js @@ -3521,11 +3521,23 @@ function showComposeChangelog(containerName, path, profile) { if (!path) return; // Reopen the Update Stack dialog when the changelog dialog closes. // SweetAlert 1.x has no close event; poll the showSweetAlert class instead. + // Cap at 300 ticks (30 s) so the interval self-cleans if the dialog never opens. var appeared = false; + var ticks = 0; var poll = setInterval(function() { + if (++ticks > 300) { clearInterval(poll); return; } var open = $('.sweet-alert').hasClass('showSweetAlert'); if (!appeared) { if (open) appeared = true; return; } - if (!open) { clearInterval(poll); showStackActionDialog('update', path, profile || ''); } + if (!open) { + clearInterval(poll); + // Skip reopening when DISABLE_ACTION_WARNINGS is true: renderStackActionDialog + // has a fast-path that calls UpdateStackConfirmed directly in that mode, + // so reopening would trigger an immediate update rather than a dialog. + getConfig().then(function(cfg) { + if (cfg && cfg.DISABLE_ACTION_WARNINGS === 'true') return; + showStackActionDialog('update', path, profile || ''); + }); + } }, 100); } From e246ff86038f47cd7c6e39a57ee9884e2846b671 Mon Sep 17 00:00:00 2001 From: Chad Condon Date: Wed, 3 Jun 2026 19:00:37 -0700 Subject: [PATCH 11/67] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/unit/ExecActionsTest.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/unit/ExecActionsTest.php b/tests/unit/ExecActionsTest.php index 0b164fae..5c1e8c89 100644 --- a/tests/unit/ExecActionsTest.php +++ b/tests/unit/ExecActionsTest.php @@ -1065,12 +1065,16 @@ public function testSetStackSettingsRejectsUnsupportedIconUrlTypes(): void /** * Returns dockerVersionsInstalled=false when the plugin directory is absent. */ - public function testGetSavedUpdateStatusDockerVersionsNotInstalled(): void - { - @unlink(COMPOSE_UPDATE_STATUS_FILE); +public function testGetSavedUpdateStatusDockerVersionsNotInstalled(): void +{ + @unlink(COMPOSE_UPDATE_STATUS_FILE); - $output = $this->executeAction('getSavedUpdateStatus'); - $result = json_decode($output, true); + // Make the test deterministic even when running on a host that has docker.versions installed. + $fakeDir = sys_get_temp_dir() . '/fake_docker_versions_absent_' . getmypid(); + UnraidStreamWrapper::addMapping('/usr/local/emhttp/plugins/docker.versions', $fakeDir); + + $output = $this->executeAction('getSavedUpdateStatus'); + $result = json_decode($output, true); $this->assertIsArray($result); $this->assertEquals('success', $result['result']); From e2fbe938ff2f98a543b2b39180c1f8429901ff39 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 7 Jun 2026 18:20:04 -0400 Subject: [PATCH 12/67] fix(profiles): unify action profile picker and enforce profile precedence --- .../compose.manager/include/ComposeList.php | 12 ++- .../javascript/composeManagerMain.js | 95 +++++++++++++++---- 2 files changed, 90 insertions(+), 17 deletions(-) diff --git a/source/compose.manager/include/ComposeList.php b/source/compose.manager/include/ComposeList.php index 2f7c3503..88c625cb 100755 --- a/source/compose.manager/include/ComposeList.php +++ b/source/compose.manager/include/ComposeList.php @@ -74,6 +74,16 @@ $profiles = $stackInfo->getProfiles(); $profilesJson = htmlspecialchars(json_encode($profiles ?: []), ENT_QUOTES, 'UTF-8'); + // Get default profiles for actions like force update + $defaultProfiles = $stackInfo->getDefaultProfiles(); + $defaultProfilesStr = implode(',', $defaultProfiles); + $defaultProfilesHtml = htmlspecialchars($defaultProfilesStr, ENT_QUOTES, 'UTF-8'); + + // Get running profiles so UI can prioritize current runtime selection + $runningProfiles = $stackInfo->getRunningProfiles(); + $runningProfilesStr = implode(',', $runningProfiles); + $runningProfilesHtml = htmlspecialchars($runningProfilesStr, ENT_QUOTES, 'UTF-8'); + // Determine status text and class for badge $statusText = "Stopped"; $statusClass = "status-stopped"; @@ -160,7 +170,7 @@ $hasBuild = $stackInfo->hasBuildConfig() ? '1' : '0'; // Main row - Docker tab structure with expand arrow on left - $o .= ""; + $o .= ""; // Arrow column $o .= ""; diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js index 2cb3a534..c61d5a26 100644 --- a/source/compose.manager/javascript/composeManagerMain.js +++ b/source/compose.manager/javascript/composeManagerMain.js @@ -2584,8 +2584,23 @@ function editStackSettings(myID) { // Unified update warning dialog - called from stack row and container table function showUpdateWarning(project, stackId) { var path = compose_root + '/' + project; - // Use the existing UpdateStack function which already has the warning dialog - UpdateStack(path, ""); + // Use row metadata so force-update from status column behaves like context menu. + var $stackRow = $('#compose_stacks tr.compose-sortable[data-project="' + project + '"]'); + var profiles = []; + var runningProfile = ''; + var defaultProfile = ''; + + if ($stackRow.length > 0) { + profiles = $stackRow.data('profiles') || []; + runningProfile = $stackRow.attr('data-running-profile') || ''; + defaultProfile = $stackRow.attr('data-default-profile') || ''; + } + + if (profiles.length > 0) { + showProfileSelector('forceUpdate', path, profiles, runningProfile, defaultProfile); + } else { + ForceUpdateStack(path); + } } // Show a brief swal when a background command is dispatched @@ -3999,6 +4014,8 @@ function executeStackAction(action) { var projectName = $row.data('projectname'); var path = $row.data('path'); var profiles = $row.data('profiles') || []; + var runningProfile = $row.data('running-profile') || ''; + var defaultProfile = $row.data('default-profile') || ''; var isUp = $row.data('isup') == "1"; closeStackActionsMenu(); @@ -4006,7 +4023,7 @@ function executeStackAction(action) { // Handle profile selection if profiles exist and action supports it var profileSupportedActions = ['up', 'down', 'update', 'pull', 'logs']; if (profiles.length > 0 && profileSupportedActions.includes(action)) { - showProfileSelector(action, path, profiles); + showProfileSelector(action, path, profiles, runningProfile, defaultProfile); return; } @@ -4040,7 +4057,13 @@ function executeStackAction(action) { } } -function showProfileSelector(action, path, profiles) { +function showProfileSelector(action, path, profiles, runningProfile, defaultProfile) { + if (typeof runningProfile === 'undefined') { + runningProfile = ''; + } + if (typeof defaultProfile === 'undefined') { + defaultProfile = ''; + } var actionNames = { 'up': 'Compose Up', 'down': 'Compose Down', @@ -4060,12 +4083,42 @@ function showProfileSelector(action, path, profiles) { profileHtml += '
'; profileHtml += ''; profileHtml += '
'; + + function parseProfileList(rawValue) { + if (!rawValue) return []; + return rawValue.split(',').map(function(p) { + return p.trim(); + }).filter(function(p) { + return p !== ''; + }); + } + + // Selection priority: running profiles -> default profiles -> all (*) + var runningProfileSet = parseProfileList(runningProfile); + var defaultProfileSet = parseProfileList(defaultProfile); + var preselectedProfiles = []; + var showAllProfilesChecked = false; + + if (runningProfileSet.indexOf('*') !== -1) { + showAllProfilesChecked = true; + } else if (runningProfileSet.length > 0) { + preselectedProfiles = runningProfileSet; + } else if (defaultProfileSet.indexOf('*') !== -1) { + showAllProfilesChecked = true; + } else if (defaultProfileSet.length > 0) { + preselectedProfiles = defaultProfileSet; + } else { + showAllProfilesChecked = true; + } + var profileCheckboxDisabled = showAllProfilesChecked ? 'disabled' : ''; + profileHtml += '
'; - profileHtml += ''; + profileHtml += ''; profileHtml += '
'; profileHtml += '
'; profiles.forEach(function(profile) { - profileHtml += ''; + var isChecked = !showAllProfilesChecked && preselectedProfiles.indexOf(profile) >= 0; + profileHtml += ''; }); profileHtml += '
'; profileHtml += '
Default services are always included. Select multiple profiles to include profile-based services.
'; @@ -4125,6 +4178,14 @@ function toggleAllProfiles(checkbox) { $('.profile_checkbox').prop('disabled', disabled).prop('checked', false); } +function updateProfileCheckboxes() { + // If any individual profile is checked, uncheck "all profiles" + var anyIndividualChecked = $('.profile_checkbox:checked').length > 0; + if (anyIndividualChecked) { + $('#profile_all_profiles').prop('checked', false); + } +} + function openEditorModalByProject(project, projectName, initialTab) { if (typeof ace === 'undefined') { swal({ @@ -6575,6 +6636,8 @@ function addComposeStackContext(elementId) { var $row = $('#stack-row-' + stackId); var path = $row.data('path'); var profiles = $row.data('profiles') || []; + var runningProfile = $row.data('running-profile') || ''; + var defaultProfile = $row.data('default-profile') || ''; var webuiUrl = $row.data('webui') || ''; var hasBuild = $row.data('hasbuild') == "1"; var hasExistingContainers = false; @@ -6646,7 +6709,7 @@ function addComposeStackContext(elementId) { action: function(e) { e.preventDefault(); if (profiles.length > 0) { - showProfileSelector('up', path, profiles); + showProfileSelector('up', path, profiles, runningProfile, defaultProfile); } else { ComposeUp(path); } @@ -6660,7 +6723,7 @@ function addComposeStackContext(elementId) { action: function(e) { e.preventDefault(); if (profiles.length > 0) { - showProfileSelector('down', path, profiles); + showProfileSelector('down', path, profiles, runningProfile, defaultProfile); } else { ComposeDown(path); } @@ -6674,7 +6737,7 @@ function addComposeStackContext(elementId) { action: function(e) { e.preventDefault(); if (profiles.length > 0) { - showProfileSelector('stop', path, profiles); + showProfileSelector('stop', path, profiles, runningProfile, defaultProfile); } else { ComposeStop(path); } @@ -6688,7 +6751,7 @@ function addComposeStackContext(elementId) { action: function(e) { e.preventDefault(); if (profiles.length > 0) { - showProfileSelector('restart', path, profiles); + showProfileSelector('restart', path, profiles, runningProfile, defaultProfile); } else { ComposeRestart(path); } @@ -6709,7 +6772,7 @@ function addComposeStackContext(elementId) { action: function(e) { e.preventDefault(); if (profiles.length > 0) { - showProfileSelector('update', path, profiles); + showProfileSelector('update', path, profiles, runningProfile, defaultProfile); } else { UpdateStack(path); } @@ -6724,7 +6787,7 @@ function addComposeStackContext(elementId) { action: function(e) { e.preventDefault(); if (profiles.length > 0) { - showProfileSelector('forceUpdate', path, profiles); + showProfileSelector('forceUpdate', path, profiles, runningProfile, defaultProfile); } else { ForceUpdateStack(path); } @@ -6741,7 +6804,7 @@ function addComposeStackContext(elementId) { action: function(e) { e.preventDefault(); if (profiles.length > 0) { - showProfileSelector('up', path, profiles); + showProfileSelector('up', path, profiles, runningProfile, defaultProfile); } else { ComposeUp(path); } @@ -6756,7 +6819,7 @@ function addComposeStackContext(elementId) { action: function(e) { e.preventDefault(); if (profiles.length > 0) { - showProfileSelector('down', path, profiles); + showProfileSelector('down', path, profiles, runningProfile, defaultProfile); } else { ComposeDown(path); } @@ -6776,7 +6839,7 @@ function addComposeStackContext(elementId) { action: function(e) { e.preventDefault(); if (profiles.length > 0) { - showProfileSelector('pull', path, profiles); + showProfileSelector('pull', path, profiles, runningProfile, defaultProfile); } else { ComposePull(path); } @@ -6791,7 +6854,7 @@ function addComposeStackContext(elementId) { action: function(e) { e.preventDefault(); if (profiles.length > 0) { - showProfileSelector('update', path, profiles); + showProfileSelector('update', path, profiles, runningProfile, defaultProfile); } else { UpdateStack(path); } From 17d5fa08e6c0143fe60cf6764817fc416e408329 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 7 Jun 2026 19:00:14 -0400 Subject: [PATCH 13/67] fix(composeSortable): improve drag-and-drop behavior by detaching non-sortable rows --- .../javascript/composeSortable.js | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/source/compose.manager/javascript/composeSortable.js b/source/compose.manager/javascript/composeSortable.js index a0d2ce0d..04de705a 100644 --- a/source/compose.manager/javascript/composeSortable.js +++ b/source/compose.manager/javascript/composeSortable.js @@ -112,24 +112,43 @@ function initComposeSortable() { items: '> tr.compose-sortable', cursor: 'grab', axis: 'y', - containment: 'parent', + // No containment — 'parent' clamps the helper's Y to the container edge, + // which makes jQuery UI compare positions using the clipped offset instead + // of the true mouse position. That means dragging above the first row + // never triggers a swap (can't reach position 0). axis:'y' already + // prevents horizontal drift, so containment is not needed. cancel: '[data-stackid], .compose-updatecolumn a, .compose-updatecolumn .exec, .auto_start, .switchButton, a, button, input', delay: 100, opacity: 0.5, zIndex: 9999, forcePlaceholderSize: true, start: function (event, ui) { - var $detailsRow = getComposeDetailsRowForItem(ui.item); - if ($detailsRow.length) { - ui.item.data('compose-details-row', $detailsRow.detach()); - } + // Detach ALL non-sortable rows (details/expand rows) for the duration + // of the drag. jQuery UI measures each item's offset().top to decide + // where the placeholder goes; interspersed non-sortable rows shift those + // offsets and cause erratic placeholder jumps. Store each detached row + // on its sibling stack-row so we can reattach it cleanly in stop(). + $tbody.children('tr:not(.compose-sortable)').each(function () { + var $dr = $(this); + var $stackRow = $dr.prev('tr.compose-sortable'); + if ($stackRow.length) { + $stackRow.data('_detached-details', $dr.detach()); + } + }); }, update: function () { saveComposeSortOrder(); }, stop: function (event, ui) { - reattachComposeDetailsRow(ui.item); - normalizeComposeDetailsRowOrder($tbody); + // Reattach all details rows in the new (post-drop) order. + $tbody.children('tr.compose-sortable').each(function () { + var $stackRow = $(this); + var $dr = $stackRow.data('_detached-details'); + if ($dr && $dr.length) { + $dr.insertAfter($stackRow); + $stackRow.removeData('_detached-details'); + } + }); } }); } From a42c4902236d315118258b5cac0cc49f2c938a69 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 7 Jun 2026 20:27:40 -0400 Subject: [PATCH 14/67] fix(composeSortable): enhance details row detachment logic during drag-and-drop --- .../javascript/composeSortable.js | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/source/compose.manager/javascript/composeSortable.js b/source/compose.manager/javascript/composeSortable.js index 04de705a..af53fe54 100644 --- a/source/compose.manager/javascript/composeSortable.js +++ b/source/compose.manager/javascript/composeSortable.js @@ -123,15 +123,19 @@ function initComposeSortable() { zIndex: 9999, forcePlaceholderSize: true, start: function (event, ui) { - // Detach ALL non-sortable rows (details/expand rows) for the duration - // of the drag. jQuery UI measures each item's offset().top to decide - // where the placeholder goes; interspersed non-sortable rows shift those - // offsets and cause erratic placeholder jumps. Store each detached row - // on its sibling stack-row so we can reattach it cleanly in stop(). - $tbody.children('tr:not(.compose-sortable)').each(function () { - var $dr = $(this); - var $stackRow = $dr.prev('tr.compose-sortable'); - if ($stackRow.length) { + // Detach each stack row's details row by explicit ID lookup rather than + // DOM adjacency. When jQuery UI fires 'start' it has already inserted + // the ui-sortable-placeholder into the tbody, and it lands between + // ui.item and that row's details row. A prev('tr.compose-sortable') + // traversal would then find the placeholder instead of the stack row, + // returning an empty set — leaving the expanded details row un-stored + // and therefore orphaned in the DOM after the drop. + $tbody.children('tr.compose-sortable').each(function () { + var $stackRow = $(this); + var rowId = $stackRow.attr('id') || ''; + if (rowId.indexOf('stack-row-') !== 0) { return; } + var $dr = $('#details-row-' + rowId.replace('stack-row-', '')); + if ($dr.length) { $stackRow.data('_detached-details', $dr.detach()); } }); From b95c55747bfe0482e3a3246ee72e76b18fb0c67d Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Mon, 8 Jun 2026 21:47:01 -0400 Subject: [PATCH 15/67] fix(update): update button now triggers update action instead of always triggering force update --- .../javascript/composeManagerMain.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js index c61d5a26..cbd0240e 100644 --- a/source/compose.manager/javascript/composeManagerMain.js +++ b/source/compose.manager/javascript/composeManagerMain.js @@ -1331,7 +1331,7 @@ function updateStackUpdateUI(stackName, stackInfo) { // Update the stack row's update column (match Docker tab style) if (updateCount > 0) { // Updates available - orange "update ready" style with clickable link and SHA info - var updateHtml = ''; + var updateHtml = ''; updateHtml += ' ' + updateCount + ' update' + (updateCount > 1 ? 's' : '') + ''; updateHtml += ''; @@ -1369,14 +1369,14 @@ function updateStackUpdateUI(stackName, stackInfo) { // Some containers pinned, rest up-to-date var html = ' up-to-date'; html += '
' + pinnedCount + ' pinned
'; - html += ''; + html += ''; $updateCell.html(html); } else { // No updates, no pinned - green "up-to-date" style (like Docker tab) // Basic view: just shows up-to-date // Advanced view: shows force update link var html = ' up-to-date'; - html += ''; + html += ''; $updateCell.html(html); } } else { @@ -2582,13 +2582,14 @@ function editStackSettings(myID) { } // Unified update warning dialog - called from stack row and container table -function showUpdateWarning(project, stackId) { +function showUpdateWarning(project, stackId, updateAction) { var path = compose_root + '/' + project; - // Use row metadata so force-update from status column behaves like context menu. + // Use row metadata so profile selection behavior matches context menu actions. var $stackRow = $('#compose_stacks tr.compose-sortable[data-project="' + project + '"]'); var profiles = []; var runningProfile = ''; var defaultProfile = ''; + updateAction = updateAction || 'update'; if ($stackRow.length > 0) { profiles = $stackRow.data('profiles') || []; @@ -2597,9 +2598,11 @@ function showUpdateWarning(project, stackId) { } if (profiles.length > 0) { - showProfileSelector('forceUpdate', path, profiles, runningProfile, defaultProfile); - } else { + showProfileSelector(updateAction, path, profiles, runningProfile, defaultProfile); + } else if (updateAction === 'forceUpdate') { ForceUpdateStack(path); + } else { + UpdateStack(path); } } @@ -6045,7 +6048,7 @@ function renderContainerDetails(stackId, containers, project) { } } else if (ctHasUpdate) { // Update available - orange "update ready" style with SHA diff - html += ''; + html += ''; html += ' update ready'; html += ''; if (ctLocalSha && ctRemoteSha) { From 492e6cf81782587274a16a5e3c0fa6b7aceb29b5 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Mon, 8 Jun 2026 22:06:05 -0400 Subject: [PATCH 16/67] feat(container): add persistent container cache and icon resolution logic --- source/compose.manager/include/Exec.php | 164 +++++++++++++++++- source/compose.manager/include/Util.php | 3 + .../javascript/composeManagerMain.js | 7 +- 3 files changed, 171 insertions(+), 3 deletions(-) diff --git a/source/compose.manager/include/Exec.php b/source/compose.manager/include/Exec.php index f93aee8a..49c3a21e 100644 --- a/source/compose.manager/include/Exec.php +++ b/source/compose.manager/include/Exec.php @@ -63,6 +63,66 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool } } +if (!function_exists('composeLoadPersistentContainerCache')) { + function composeLoadPersistentContainerCache(): array + { + $cacheFile = '/boot/config/plugins/compose.manager/containers.cache.json'; + if (!is_file($cacheFile)) { + return []; + } + $decoded = json_decode(file_get_contents($cacheFile), true); + return is_array($decoded) ? $decoded : []; + } +} + +if (!function_exists('composeSavePersistentContainerCache')) { + function composeSavePersistentContainerCache(array $cache): void + { + $cacheFile = '/boot/config/plugins/compose.manager/containers.cache.json'; + file_put_contents($cacheFile, json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + } +} + +if (!function_exists('composeResolveContainerIcon')) { + /** + * Resolve container icon using stack cache first, then docker inspect fallback. + * + * @param string $containerName + * @param string $service + * @param array $iconByService + * @param array $iconByName + * @param array $inspectIconCache Per-request inspect memoization by container name + */ + function composeResolveContainerIcon(string $containerName, string $service, array $iconByService, array $iconByName, array &$inspectIconCache): string + { + if ($service !== '' && isset($iconByService[$service]) && $iconByService[$service] !== '') { + return $iconByService[$service]; + } + + if ($containerName !== '' && isset($iconByName[$containerName]) && $iconByName[$containerName] !== '') { + return $iconByName[$containerName]; + } + + if ($containerName === '') { + return ''; + } + + if (isset($inspectIconCache[$containerName])) { + return $inspectIconCache[$containerName]; + } + + global $docker_label_icon; + $labelTemplate = '{{ index .Config.Labels "' . $docker_label_icon . '" }}'; + $cmd = 'docker inspect ' . escapeshellarg($containerName) . ' --format ' . escapeshellarg($labelTemplate) . ' 2>/dev/null'; + $icon = trim((string) shell_exec($cmd)); + if ($icon === '') { + $icon = ''; + } + $inspectIconCache[$containerName] = $icon; + return $icon; + } +} + switch ($_POST['action']) { case 'composeLogger': $message = $_POST['msg'] ?? ''; @@ -1290,6 +1350,25 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool $updateResults = []; $DockerUpdate = new DockerUpdate(); + $persistentContainerCache = composeLoadPersistentContainerCache(); + $stackContainerCache = $persistentContainerCache[$script] ?? []; + $iconByService = []; + $iconByName = []; + foreach ($stackContainerCache as $cachedService => $cachedContainer) { + if (!is_array($cachedContainer)) { + continue; + } + $cachedIcon = trim((string) ($cachedContainer['icon'] ?? '')); + if ($cachedIcon !== '') { + $iconByService[(string) $cachedService] = $cachedIcon; + } + $cachedName = trim((string) ($cachedContainer['name'] ?? '')); + if ($cachedName !== '' && $cachedIcon !== '') { + $iconByName[$cachedName] = $cachedIcon; + } + } + $inspectIconCache = []; + $stackCacheDirty = false; // Load the update status file to get SHA values $dockerManPaths = [ @@ -1320,10 +1399,32 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool // Second pass: check updates and collect results foreach ($rows as $container) { - $containerName = $container['Name'] ?? ''; - $image = $container['Image'] ?? ''; + $containerLower = array_change_key_case($container, CASE_LOWER); + $containerName = trim((string) ($containerLower['name'] ?? $containerLower['names'] ?? '')); + $service = trim((string) ($containerLower['service'] ?? '')); + $image = trim((string) ($containerLower['image'] ?? '')); if ($containerName && $image) { + $icon = composeResolveContainerIcon($containerName, $service, $iconByService, $iconByName, $inspectIconCache); + + if ($service !== '' && $icon !== '') { + if (!isset($stackContainerCache[$service]) || !is_array($stackContainerCache[$service])) { + $stackContainerCache[$service] = []; + } + if (($stackContainerCache[$service]['icon'] ?? '') !== $icon) { + $stackContainerCache[$service]['icon'] = $icon; + $stackCacheDirty = true; + } + if (($stackContainerCache[$service]['name'] ?? '') === '') { + $stackContainerCache[$service]['name'] = $containerName; + $stackCacheDirty = true; + } + if (($stackContainerCache[$service]['service'] ?? '') === '') { + $stackContainerCache[$service]['service'] = $service; + $stackCacheDirty = true; + } + } + // Normalize image name (strip docker.io/ prefix, @sha256: digest, add library/ for official images) $image = ContainerInfo::normalizeImageForUpdateCheck($image); @@ -1354,7 +1455,9 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool $updateResults[] = ContainerInfo::fromUpdateResponse([ 'container' => $containerName, + 'service' => $service, 'image' => $image, + 'icon' => $icon, 'hasUpdate' => $hasUpdate, 'status' => $statusText, 'localSha' => $localSha, @@ -1362,6 +1465,11 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool ])->toUpdateArray(); } } + + if ($stackCacheDirty) { + $persistentContainerCache[$script] = $stackContainerCache; + composeSavePersistentContainerCache($persistentContainerCache); + } } echo json_encode(['result' => 'success', 'updates' => $updateResults, 'projectName' => $projectName]); @@ -1390,6 +1498,9 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool $allUpdates = []; $DockerUpdate = new DockerUpdate(); + $persistentContainerCache = composeLoadPersistentContainerCache(); + $persistentCacheDirty = false; + $inspectIconCache = []; // Path to update status file $dockerManPaths = [ @@ -1399,6 +1510,23 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool foreach (StackInfo::allFromRoot($compose_root) as $stackInfoItem) { $stackName = $stackInfoItem->projectFolder; $projectName = $stackInfoItem->projectFolder; + $stackContainerCache = $persistentContainerCache[$stackName] ?? []; + $iconByService = []; + $iconByName = []; + foreach ($stackContainerCache as $cachedService => $cachedContainer) { + if (!is_array($cachedContainer)) { + continue; + } + $cachedIcon = trim((string) ($cachedContainer['icon'] ?? '')); + if ($cachedIcon !== '') { + $iconByService[(string) $cachedService] = $cachedIcon; + } + $cachedName = trim((string) ($cachedContainer['name'] ?? '')); + if ($cachedName !== '' && $cachedIcon !== '') { + $iconByName[$cachedName] = $cachedIcon; + } + } + $stackCacheDirty = false; $rows = $stackInfoItem->getContainerList(); @@ -1434,11 +1562,32 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool foreach ($rows as $container) { $containerLower = array_change_key_case($container, CASE_LOWER); $containerName = trim($containerLower['name'] ?? $containerLower['names'] ?? ''); + $service = trim((string) ($containerLower['service'] ?? '')); $image = trim($containerLower['image'] ?? ''); $state = strtolower(trim($containerLower['state'] ?? '')); // Only check updates for running containers if ($containerName && $image && $state === 'running') { + $icon = composeResolveContainerIcon($containerName, $service, $iconByService, $iconByName, $inspectIconCache); + + if ($service !== '' && $icon !== '') { + if (!isset($stackContainerCache[$service]) || !is_array($stackContainerCache[$service])) { + $stackContainerCache[$service] = []; + } + if (($stackContainerCache[$service]['icon'] ?? '') !== $icon) { + $stackContainerCache[$service]['icon'] = $icon; + $stackCacheDirty = true; + } + if (($stackContainerCache[$service]['name'] ?? '') === '') { + $stackContainerCache[$service]['name'] = $containerName; + $stackCacheDirty = true; + } + if (($stackContainerCache[$service]['service'] ?? '') === '') { + $stackContainerCache[$service]['service'] = $service; + $stackCacheDirty = true; + } + } + $image = ContainerInfo::normalizeImageForUpdateCheck($image); $DockerUpdate->reloadUpdateStatus($image); @@ -1467,7 +1616,9 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool $stackUpdates[] = ContainerInfo::fromUpdateResponse([ 'container' => $containerName, + 'service' => $service, 'image' => $image, + 'icon' => $icon, 'hasUpdate' => $hasUpdate, 'status' => ($updateStatus === null) ? 'unknown' : ($updateStatus ? 'up-to-date' : 'update-available'), 'localSha' => $localSha, @@ -1475,6 +1626,11 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool ])->toUpdateArray(); } } + + if ($stackCacheDirty) { + $persistentContainerCache[$stackName] = $stackContainerCache; + $persistentCacheDirty = true; + } } $allUpdates[$stackName] = [ @@ -1484,6 +1640,10 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool ]; } + if ($persistentCacheDirty) { + composeSavePersistentContainerCache($persistentContainerCache); + } + // Save the update status for all stacks $savedStatus = $allUpdates; foreach ($savedStatus as $stackKey => &$stackData) { diff --git a/source/compose.manager/include/Util.php b/source/compose.manager/include/Util.php index 170adcda..1209f676 100644 --- a/source/compose.manager/include/Util.php +++ b/source/compose.manager/include/Util.php @@ -1163,6 +1163,7 @@ public static function fromUpdateResponse(array $raw): self $info->id = $raw['ID'] ?? $raw['Id'] ?? $raw['id'] ?? ''; $info->service = $raw['service'] ?? $raw['Service'] ?? ''; $info->image = $raw['image'] ?? $raw['Image'] ?? ''; + $info->icon = $raw['icon'] ?? $raw['Icon'] ?? ''; $info->hasUpdate = $raw['hasUpdate'] ?? false; $info->updateStatus = $raw['status'] ?? $raw['updateStatus'] ?? 'unknown'; $info->localSha = $raw['localSha'] ?? ''; @@ -1239,7 +1240,9 @@ public function toUpdateArray(): array { return [ 'name' => $this->name, + 'service' => $this->service, 'image' => $this->image, + 'icon' => $this->icon, 'hasUpdate' => $this->hasUpdate, 'updateStatus' => $this->updateStatus, 'localSha' => $this->localSha, diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js index cbd0240e..5a78e1d1 100644 --- a/source/compose.manager/javascript/composeManagerMain.js +++ b/source/compose.manager/javascript/composeManagerMain.js @@ -3512,11 +3512,16 @@ function mergeUpdateStatus(containers, project) { var cInfo = createContainerInfo(container); stackUpdateStatus[project].containers.forEach(function(update) { var uInfo = createContainerInfo(update); - if (cInfo.name === uInfo.name) { + var sameName = cInfo.name && uInfo.name && cInfo.name === uInfo.name; + var sameService = cInfo.service && uInfo.service && cInfo.service === uInfo.service; + if (sameName || sameService) { container.hasUpdate = uInfo.hasUpdate; container.updateStatus = uInfo.updateStatus; container.localSha = uInfo.localSha; container.remoteSha = uInfo.remoteSha; + if ((!container.icon || !isValidIconSrc(container.icon)) && uInfo.icon) { + container.icon = uInfo.icon; + } } }); }); From 0ebfc01eab524e0f21927d5fcfd479d92392d595 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Mon, 8 Jun 2026 22:37:45 -0400 Subject: [PATCH 17/67] feat: add progressive stack list loading --- .../compose.manager/include/ComposeList.php | 40 +- .../javascript/composeManagerMain.js | 655 +++++++++++++----- tests/unit/ContainerInfoTest.php | 4 +- 3 files changed, 526 insertions(+), 173 deletions(-) diff --git a/source/compose.manager/include/ComposeList.php b/source/compose.manager/include/ComposeList.php index 88c625cb..03aabfff 100755 --- a/source/compose.manager/include/ComposeList.php +++ b/source/compose.manager/include/ComposeList.php @@ -10,10 +10,37 @@ $cfg = parse_plugin_cfg($sName); +$mode = isset($_GET['mode']) ? trim((string)$_GET['mode']) : 'html'; +if ($mode === 'list') { + $projects = StackInfo::listProjectFolders($compose_root); + echo json_encode([ + 'result' => 'success', + 'projects' => array_values($projects), + ]); + exit; +} + $o = ""; $stackCount = 0; -foreach (StackInfo::allFromRoot($compose_root) as $stackInfo) { +$stackInfos = []; +if ($mode === 'row') { + $project = isset($_GET['project']) ? basename(trim((string)$_GET['project'])) : ''; + if ($project === '') { + echo json_encode(['result' => 'error', 'message' => 'Project not specified.']); + exit; + } + try { + $stackInfos = [StackInfo::fromProject($compose_root, $project)]; + } catch (\Throwable $e) { + echo json_encode(['result' => 'error', 'message' => 'Project not found.']); + exit; + } +} else { + $stackInfos = StackInfo::allFromRoot($compose_root); +} + +foreach ($stackInfos as $stackInfo) { $stackCount++; $projectName = $stackInfo->getName(); @@ -260,9 +287,16 @@ } // If no stacks found, show a message -if ($stackCount === 0) { +if ($mode !== 'row' && $stackCount === 0) { $o = "No Docker Compose stacks found. Click 'Add New Stack' to create one."; } // Output the HTML -echo $o; +if ($mode === 'row') { + echo json_encode([ + 'result' => 'success', + 'html' => $o, + ]); +} else { + echo $o; +} diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js index 5a78e1d1..ebe507ee 100644 --- a/source/compose.manager/javascript/composeManagerMain.js +++ b/source/compose.manager/javascript/composeManagerMain.js @@ -283,116 +283,213 @@ function composeLoadlist() { showComposeSpinner('Loading stack list...'); }, 500); - $.get('/plugins/compose.manager/include/ComposeList.php') - .done(function(data) { - clearTimeout(composeTimers.load); + function finalizeComposeLoadlist(resolvePayload) { + clearTimeout(composeTimers.load); - // Insert the loaded content - $('#compose_list').html(data); + // Signal load subscribers (e.g. dockerload cache) that the list changed + $(document).trigger('composeListRefreshed'); - // Signal load subscribers (e.g. dockerload cache) that the list changed - $(document).trigger('composeListRefreshed'); + // Initialize UI components for the newly loaded content + initStackListUI(); - // Initialize UI components for the newly loaded content - initStackListUI(); + // Debug: log initial per-stack rendered status icons (data-status attribute) + try { + var initialStatuses = []; + $('#compose_stacks tr.compose-sortable').each(function() { + var project = $(this).data('project'); + var icon = $(this).find('.compose-status-icon').first(); + var status = icon.attr('data-status') || icon.attr('class') || ''; + initialStatuses.push({ + project: project, + status: status + }); + }); + composeLogger('initial-stack-statuses', { + stacks: initialStatuses + }, 'user', 'debug', 'composeLoadlist'); + } catch (e) {} - // Debug: log initial per-stack rendered status icons (data-status attribute) - try { - var initialStatuses = []; - $('#compose_stacks tr.compose-sortable').each(function() { - var project = $(this).data('project'); - var icon = $(this).find('.compose-status-icon').first(); - var status = icon.attr('data-status') || icon.attr('class') || ''; - initialStatuses.push({ - project: project, - status: status - }); + // Normalize icons based on state text to ensure server-side render and + // client-side update logic agree (workaround for caching or older server HTML) + try { + $('#compose_stacks tr.compose-sortable').each(function() { + var $row = $(this); + var stateText = $row.find('.state').text().toLowerCase(); + var $icon = $row.find('.compose-status-icon').first(); + if (!$icon.length) return; + var desiredShape = null; + var desiredColor = 'grey-text'; + if (stateText.indexOf('partial') !== -1) { + desiredShape = 'exclamation-circle'; + desiredColor = 'orange-text'; + } else if (stateText.indexOf('started') !== -1) { + desiredShape = 'play'; + desiredColor = 'green-text'; + } else if (stateText.indexOf('paused') !== -1) { + desiredShape = 'pause'; + desiredColor = 'orange-text'; + } else { + desiredShape = 'square'; + desiredColor = 'grey-text'; + } + + // If icon already matches, skip + if (($icon.hasClass('fa-' + desiredShape) && $icon.hasClass(desiredColor))) return; + + composeLogger('normalize-icon', { + project: $row.data('project'), + stateText: stateText, + desiredShape: desiredShape, + desiredColor: desiredColor, + before: $icon.attr('class') + }, 'user', 'debug', 'composeLoadlist'); + // Remove old fa-* classes, color classes and apply desired ones + $icon.removeClass(function(i, cls) { + return (cls.match(/fa-[^\s]+/g) || []).join(' '); }); - composeLogger('initial-stack-statuses', { - stacks: initialStatuses + $icon.removeClass('green-text orange-text grey-text cyan-text'); + $icon.addClass('fa fa-' + desiredShape + ' ' + desiredColor + ' compose-status-icon'); + composeLogger('normalize-icon-done', { + project: $row.data('project'), + after: $icon.attr('class') }, 'user', 'debug', 'composeLoadlist'); - } catch (e) {} + }); + } catch (e) {} - // Normalize icons based on state text to ensure server-side render and - // client-side update logic agree (workaround for caching or older server HTML) - try { - $('#compose_stacks tr.compose-sortable').each(function() { - var $row = $(this); - var stateText = $row.find('.state').text().toLowerCase(); - var $icon = $row.find('.compose-status-icon').first(); - if (!$icon.length) return; - var desiredShape = null; - var desiredColor = 'grey-text'; - if (stateText.indexOf('partial') !== -1) { - desiredShape = 'exclamation-circle'; - desiredColor = 'orange-text'; - } else if (stateText.indexOf('started') !== -1) { - desiredShape = 'play'; - desiredColor = 'green-text'; - } else if (stateText.indexOf('paused') !== -1) { - desiredShape = 'pause'; - desiredColor = 'orange-text'; - } else { - desiredShape = 'square'; - desiredColor = 'grey-text'; - } + // Cleanup any temporary per-container spinners or leftover in-progress state + try { + $('#compose_list').find('.compose-container-spinner').each(function() { + var $sp = $(this); + var $wrap = $sp.closest('.hand'); + $sp.remove(); + $wrap.find('img').css('opacity', 1); + }); + // Restore any state text preserved by setStackActionInProgress + $('#compose_stacks .state').each(function() { + var $s = $(this); + if ($s.data('orig-text')) { + $s.text($s.data('orig-text')); + $s.removeData('orig-text'); + } + }); + } catch (e) {} - // If icon already matches, skip - if (($icon.hasClass('fa-' + desiredShape) && $icon.hasClass(desiredColor))) return; - - composeLogger('normalize-icon', { - project: $row.data('project'), - stateText: stateText, - desiredShape: desiredShape, - desiredColor: desiredColor, - before: $icon.attr('class') - }, 'user', 'debug', 'composeLoadlist'); - // Remove old fa-* classes, color classes and apply desired ones - $icon.removeClass(function(i, cls) { - return (cls.match(/fa-[^\s]+/g) || []).join(' '); - }); - $icon.removeClass('green-text orange-text grey-text cyan-text'); - $icon.addClass('fa fa-' + desiredShape + ' ' + desiredColor + ' compose-status-icon'); - composeLogger('normalize-icon-done', { - project: $row.data('project'), - after: $icon.attr('class') - }, 'user', 'debug', 'composeLoadlist'); - }); - } catch (e) {} + // Hide compose spinner overlay + hideComposeSpinner(); - // Cleanup any temporary per-container spinners or leftover in-progress state - try { - $('#compose_list').find('.compose-container-spinner').each(function() { - var $sp = $(this); - var $wrap = $sp.closest('.hand'); - $sp.remove(); - $wrap.find('img').css('opacity', 1); - }); - // Restore any state text preserved by setStackActionInProgress - $('#compose_stacks .state').each(function() { - var $s = $(this); - if ($s.data('orig-text')) { - $s.text($s.data('orig-text')); - $s.removeData('orig-text'); - } - }); - } catch (e) {} + // Show buttons now that content is loaded + $('input[type=button]').show(); + + // Notify other features (e.g. hide-from-docker) that compose list is ready + $(document).trigger('compose-list-loaded'); + + try { + resolve(resolvePayload); + } catch (e) { + resolve(); + } + } - // Hide compose spinner overlay + $.get('/plugins/compose.manager/include/ComposeList.php', { + mode: 'list' + }) + .done(function(metaRaw) { + var meta = tryParseJson(metaRaw); + if (!meta || meta.result !== 'success' || !Array.isArray(meta.projects)) { + composeLogger('Invalid stack meta response', { + response: metaRaw + }, 'user', 'error', 'composeLoadlist'); + $('#compose_list').html('Failed to load stack list. Please refresh the page.'); + clearTimeout(composeTimers.load); + hideComposeSpinner(); + reject(new Error('Invalid stack list response')); + return; + } + + var projects = meta.projects; + if (projects.length === 0) { + $('#compose_list').html('No Docker Compose stacks found. Click \"Add New Stack\" to create one.'); + finalizeComposeLoadlist(''); + return; + } + + // Warm expansion settings once so progressive row insertions can expand immediately. + ensureComposeDefaultExpandSettings(); + + // Start loading stack container details in the background immediately. + // UI rendering stays top-down; this just gets data ahead of the visual pipeline. + prefetchStackDetailsInBackground(projects); + + var progressHtml = '' + + '' + + '' + + '' + + 'Loading stacks... 0/' + projects.length + '' + + '' + + '' + + ''; + $('#compose_list').html(progressHtml); + + // Prime cached update status early so rows can render status as soon as they land. + if (!savedUpdateStatusLoaded) { + loadSavedUpdateStatus(); + } + + // Progressive mode shows in-table placeholders, so remove global overlay spinner. + clearTimeout(composeTimers.load); hideComposeSpinner(); - // Show buttons now that content is loaded - $('input[type=button]').show(); + var completed = 0; + var queueIndex = 0; - // Notify other features (e.g. hide-from-docker) that compose list is ready - $(document).trigger('compose-list-loaded'); + function updateProgress() { + $('#compose-load-progress-count').text(completed + '/' + projects.length); + if (completed >= projects.length) { + $('#compose-load-progress-row').remove(); + finalizeComposeLoadlist('progressive'); + } + } - // Resolve the promise so callers know the list has been loaded - try { - resolve(data); - } catch (e) { - resolve(); + function loadNextProjectSequentially() { + if (queueIndex >= projects.length) { + return; + } + + var project = projects[queueIndex]; + queueIndex++; + + $.get('/plugins/compose.manager/include/ComposeList.php', { + mode: 'row', + project: project + }) + .done(function(rowRaw) { + var waitForExpansion = Promise.resolve(); + var rowResp = tryParseJson(rowRaw); + if (rowResp && rowResp.result === 'success' && rowResp.html) { + var $rowChunk = $(rowResp.html); + $('#compose-load-progress-row').before($rowChunk); + initializeProgressiveLoadedRows($rowChunk); + // Enforce top-down UX: wait for this stack's expansion/details work before next stack. + waitForExpansion = composeDefaultExpandQueue; + } else { + $('#compose-load-progress-row').before('Failed to load ' + composeEscapeHtml(project) + '.'); + } + + waitForExpansion.finally(function() { + completed++; + updateProgress(); + loadNextProjectSequentially(); + }); + }) + .fail(function() { + $('#compose-load-progress-row').before('Failed to load ' + composeEscapeHtml(project) + '.'); + completed++; + updateProgress(); + loadNextProjectSequentially(); + }); } + + loadNextProjectSequentially(); }) .fail(function(xhr, status, error) { composeLogger('failed', { @@ -468,12 +565,74 @@ function initStackListUI() { expandedStacks[stackId] = true; }); - // Load saved update status after list is loaded - loadSavedUpdateStatus(); + // Load saved update status after list is loaded (skip if already primed) + if (!savedUpdateStatusLoaded) { + loadSavedUpdateStatus(); + } syncComposeSortModeUI(); } +function initializeProgressiveLoadedRows($rowChunk) { + if (!$rowChunk || !$rowChunk.length) return; + + var $rows = $rowChunk.filter('tr.compose-sortable'); + if (!$rows.length) { + $rows = $rowChunk.find('tr.compose-sortable'); + } + if (!$rows.length) return; + + // Initialize autostart toggles only for newly appended rows. + $rows.find('.auto_start').each(function() { + var $el = $(this); + if ($el.data('switchbutton-initialized')) return; + $el.switchButton({ + labels_placement: 'right', + on_label: 'On', + off_label: 'Off', + clear: false + }); + $el.data('switchbutton-initialized', true); + }); + + // Initialize context menus for only newly added stack icons. + $rows.find('[id^="stack-"][data-stackid]').each(function() { + addComposeStackContext(this.id); + }); + + // Keep description truncation behavior consistent for new rows. + $rows.find('.docker_readmore').not('.stack-details-container .docker_readmore').each(function() { + var $el = $(this); + $el.readmore('destroy'); + $el.readmore({ + maxHeight: 32, + moreLink: "", + lessLink: "" + }); + }); + + // Apply the currently selected basic/advanced mode to new rows. + applyListView(false); + + // Apply cached update status immediately for new rows when available. + $rows.each(function() { + var project = $(this).data('project'); + if (project && stackUpdateStatus[project]) { + updateStackUpdateUI(project, stackUpdateStatus[project]); + } + }); + updateUpdateAllButton(); + + // Apply default expansion policy per row as soon as it is inserted. + applyDefaultExpansionToRows($rows); + + // Notify dockerload/hide-from-docker listeners so new rows can receive live updates now. + $(document).trigger('composeListRefreshed'); + if (typeof window.composeDockerLoadToggle === 'function' && isComposeAdvancedMode()) { + window.composeDockerLoadToggle(true); + } +} + // Load external stylesheets (non-critical styles — critical ones are inline above) (function() { var base = composeBootstrap.comboButtonCss || ''; @@ -965,6 +1124,95 @@ function updateTabModifiedState() { // Update status cache per stack var stackUpdateStatus = {}; +var savedUpdateStatusLoaded = false; + +var composeDefaultExpandSettings = null; +var composeDefaultExpandSettingsPromise = null; +var composeDefaultExpandQueue = Promise.resolve(); + +function ensureComposeDefaultExpandSettings() { + if (composeDefaultExpandSettings !== null) { + return Promise.resolve(composeDefaultExpandSettings); + } + if (composeDefaultExpandSettingsPromise) { + return composeDefaultExpandSettingsPromise; + } + + composeDefaultExpandSettingsPromise = getConfig().then(function(config) { + composeDefaultExpandSettings = { + enabled: config && config['STACKS_DEFAULT_EXPANDED'] == 'true', + onlyRunning: config && config['ONLY_EXPAND_RUNNING_STACKS'] == 'true' + }; + return composeDefaultExpandSettings; + }).catch(function() { + composeDefaultExpandSettings = { + enabled: false, + onlyRunning: false + }; + return composeDefaultExpandSettings; + }); + + return composeDefaultExpandSettingsPromise; +} + +function applyDefaultExpansionToRows($rows) { + if (!$rows || !$rows.length) return; + + ensureComposeDefaultExpandSettings().then(function(settings) { + if (!settings || !settings.enabled) return; + + $rows.each(function() { + var $row = $(this); + var stackId = ($row.attr('id') || '').replace('stack-row-', ''); + if (!stackId) return; + + if (settings.onlyRunning && $row.data('isup') != '1') { + return; + } + + if (expandedStacks[stackId] || $('#details-row-' + stackId).is(':visible')) { + return; + } + + composeDefaultExpandQueue = composeDefaultExpandQueue.then(function() { + return expandStackDetailsSequential(stackId); + }).catch(function() { + return null; + }); + }); + }); +} + +function expandStackDetailsSequential(stackId) { + var $row = $('#stack-row-' + stackId); + var $detailsRow = $('#details-row-' + stackId); + var $expandIcon = $('#expand-icon-' + stackId); + var project = $row.data('project'); + + if (!$row.length || !$detailsRow.length) { + return Promise.resolve(); + } + + // Already expanded/visible: if a load is in progress, wait for it; otherwise skip. + if (expandedStacks[stackId] || $detailsRow.is(':visible')) { + if (stackDetailsLoading[stackId]) { + return loadStackContainerDetails(stackId, project); + } + return Promise.resolve(); + } + + $expandIcon.addClass('expanded'); + expandedStacks[stackId] = true; + + if (stackContainersCache[stackId]) { + renderContainerDetails(stackId, stackContainersCache[stackId], project); + return new Promise(function(resolve) { + $detailsRow.stop(true, true).slideDown(200, resolve); + }); + } + + return loadStackContainerDetails(stackId, project); +} // Set to true when docker.versions plugin is detected (populated from getSavedUpdateStatus response) var dockerVersionsInstalled = false; @@ -973,6 +1221,7 @@ var dockerVersionsInstalled = false; // If auto-check is enabled and interval has elapsed, trigger a fresh check // Also checks for pending rechecks from recent update operations function loadSavedUpdateStatus() { + savedUpdateStatusLoaded = true; $.post(caURL, { action: 'getSavedUpdateStatus' }, function(data) { @@ -1720,18 +1969,6 @@ $(function() { composeLogger('composeDockerLoadToggle not available yet at composeLoadlist completion', null, 'user', 'debug', 'dockerload'); } - getConfig().then(function(config) { - if (config['STACKS_DEFAULT_EXPANDED'] == 'true') { - // Expand all stacks if the default is set to expanded - $('#compose_stacks tr.compose-sortable').each(function() { - if ($(this).data('isup') != "1" && config['ONLY_EXPAND_RUNNING_STACKS'] == 'true') { - return; // Skip stopped stacks if ONLY_EXPAND_RUNNING_STACKS is true - } - var stackId = $(this).attr('id').replace('stack-row-', ''); - toggleStackDetails(stackId); - }); - } - }); }); // ── Cross-widget sync ────────────────────────────────────────── // On the Docker page the Compose stacks list is rendered below @@ -3966,9 +4203,55 @@ var persistentContainerCache = {}; // Persistent cache loaded from disk var stackStartedAtCache = {}; // Cache for stack-level started_at timestamps // Track stacks currently loading details to prevent concurrent reloads var stackDetailsLoading = {}; +var stackDetailsLoadPromises = {}; +var stackDetailsPrefetchPromises = {}; +var stackDetailsPrefetchCache = {}; // Suppress immediate refresh after a render to avoid loops var stackDetailsJustRendered = {}; +function prefetchStackDetailsInBackground(projects) { + if (!Array.isArray(projects) || projects.length === 0) return; + + var maxParallel = 4; + var next = 0; + var active = 0; + + function schedule() { + while (active < maxParallel && next < projects.length) { + (function(project) { + next++; + + if (!project || stackDetailsPrefetchPromises[project] || stackDetailsPrefetchCache[project]) { + schedule(); + return; + } + + active++; + stackDetailsPrefetchPromises[project] = new Promise(function(resolve) { + $.post(caURL, { + action: 'getStackContainers', + script: project + }, function(data) { + var parsed = tryParseJson(data); + if (parsed && parsed.result === 'success') { + stackDetailsPrefetchCache[project] = parsed; + } + resolve(); + }).fail(function() { + resolve(); + }).always(function() { + active--; + delete stackDetailsPrefetchPromises[project]; + schedule(); + }); + }); + })(projects[next]); + } + } + + schedule(); +} + function openStackActionsMenu(event, stackId) { event.stopPropagation(); currentStackId = stackId; @@ -5838,15 +6121,56 @@ function toggleStackDetails(stackId) { function loadStackContainerDetails(stackId, project) { var $container = $('#details-container-' + stackId); + function renderFromResponse(response, finishLoad) { + if (response && response.result === 'success') { + var containers = response.containers || []; + + // Normalize all containers via factory function (PascalCase→camelCase) + containers = containers.map(createContainerInfo).filter(Boolean); + + // Merge update status from stackUpdateStatus if available + mergeUpdateStatus(containers, project); + + stackContainersCache[stackId] = containers; + if (response.startedAt) stackStartedAtCache[stackId] = response.startedAt; + composeLogger('success', { + stackId: stackId, + project: project, + containers: containers.length + }, 'user', 'info', 'container-details'); + renderContainerDetails(stackId, containers, project); + $('#details-row-' + stackId).stop(true, true).slideDown(200, finishLoad); + return true; + } + return false; + } + + function renderError(message, finishLoad, level) { + $container.html('
' + composeEscapeHtml(message) + '
'); + $('#details-row-' + stackId).stop(true, true).slideDown(200, finishLoad); + composeLogger(level || 'error', { + stackId: stackId, + project: project, + message: message + }, 'user', level || 'error', 'container-details'); + } + // Prevent parallel loads for same stack if (stackDetailsLoading[stackId]) { composeLogger('already-loading', { stackId: stackId, project: project }, 'user', 'warning', 'container-details'); - return; + return stackDetailsLoadPromises[stackId] || Promise.resolve(); } stackDetailsLoading[stackId] = true; + stackDetailsLoadPromises[stackId] = new Promise(function(resolve) { + function finishLoad() { + stackDetailsLoading[stackId] = false; + delete stackDetailsLoadPromises[stackId]; + resolve(); + } + composeLogger('start', { stackId: stackId, project: project @@ -5855,70 +6179,65 @@ function loadStackContainerDetails(stackId, project) { // Show loading state $container.html('
Loading container details...
'); - $.post(caURL, { - action: 'getStackContainers', - script: project - }, function(data) { - if (data) { - try { - var response = JSON.parse(data); - if (response.result === 'success') { - var containers = response.containers; + function fetchAndRender(finishLoad) { + $.post(caURL, { + action: 'getStackContainers', + script: project + }, function(data) { + var parsed = tryParseJson(data); + if (parsed && parsed.result === 'success') { + // Keep prefetch cache warm with latest direct fetch too. + stackDetailsPrefetchCache[project] = parsed; + } - // Normalize all containers via factory function (PascalCase→camelCase) - containers = containers.map(createContainerInfo).filter(Boolean); + if (renderFromResponse(parsed, finishLoad)) { + return; + } - // Merge update status from stackUpdateStatus if available - mergeUpdateStatus(containers, project); + if (parsed && parsed.message) { + renderError(parsed.message, finishLoad, 'error'); + } else { + renderError('Failed to load container details', finishLoad, 'warning'); + } + }).fail(function() { + renderError('Failed to load container details', finishLoad, 'error'); + }); + } - stackContainersCache[stackId] = containers; - if (response.startedAt) stackStartedAtCache[stackId] = response.startedAt; - composeLogger('success', { - stackId: stackId, - project: project, - containers: containers.length - }, 'user', 'info', 'container-details'); - renderContainerDetails(stackId, containers, project); - // Slide down details row now that content is rendered - $('#details-row-' + stackId).slideDown(200); - } else { - // Escape error message to prevent XSS - var errorMsg = composeEscapeHtml(response.message || 'Failed to load container details'); - $container.html('
' + errorMsg + '
'); - $('#details-row-' + stackId).slideDown(200); - composeLogger('error', { - stackId: stackId, - project: project, - message: errorMsg - }, 'user', 'error', 'container-details'); - } - } catch (e) { - $container.html('
Failed to parse container details response
'); - $('#details-row-' + stackId).slideDown(200); - composeLogger('parse-error', { - stackId: stackId, - project: project, - err: e.toString() - }, 'user', 'error', 'container-details'); + function consumePrefetchOrFetch(finishLoad) { + // Fast path: prefetch already finished. + if (stackDetailsPrefetchCache[project]) { + var cached = stackDetailsPrefetchCache[project]; + delete stackDetailsPrefetchCache[project]; + if (!renderFromResponse(cached, finishLoad)) { + fetchAndRender(finishLoad); } - } else { - $container.html('
Failed to load container details
'); - $('#details-row-' + stackId).slideDown(200); - composeLogger('empty-response', { - stackId: stackId, - project: project - }, 'user', 'warning', 'container-details'); + return; } - stackDetailsLoading[stackId] = false; - }).fail(function() { - $container.html('
Failed to load container details
'); - $('#details-row-' + stackId).slideDown(200); - stackDetailsLoading[stackId] = false; - composeLogger('failed', { - stackId: stackId, - project: project - }, 'user', 'error', 'container-details'); + + // If a prefetch is currently in-flight for this project, wait for it once. + if (stackDetailsPrefetchPromises[project]) { + stackDetailsPrefetchPromises[project].then(function() { + if (stackDetailsPrefetchCache[project]) { + var prefetched = stackDetailsPrefetchCache[project]; + delete stackDetailsPrefetchCache[project]; + if (renderFromResponse(prefetched, finishLoad)) { + return; + } + } + fetchAndRender(finishLoad); + }); + return; + } + + // No prefetch available: normal path. + fetchAndRender(finishLoad); + } + + consumePrefetchOrFetch(finishLoad); }); + + return stackDetailsLoadPromises[stackId]; } function renderContainerDetails(stackId, containers, project) { diff --git a/tests/unit/ContainerInfoTest.php b/tests/unit/ContainerInfoTest.php index 50538e40..6ce40db1 100644 --- a/tests/unit/ContainerInfoTest.php +++ b/tests/unit/ContainerInfoTest.php @@ -311,13 +311,13 @@ public function testToUpdateArrayContainsOnlyUpdateFields(): void $arr = $info->toUpdateArray(); - $expected = ['name', 'image', 'hasUpdate', 'updateStatus', 'localSha', 'remoteSha', 'isPinned', 'pinnedDigest']; + $expected = ['name', 'service', 'image', 'icon', 'hasUpdate', 'updateStatus', 'localSha', 'remoteSha', 'isPinned', 'pinnedDigest']; $this->assertSame($expected, array_keys($arr)); $this->assertSame('abcdef1234567890abcdef1234567890', $arr['pinnedDigest']); + $this->assertSame('icon.png', $arr['icon']); // Should NOT contain non-update fields $this->assertArrayNotHasKey('state', $arr); - $this->assertArrayNotHasKey('icon', $arr); $this->assertArrayNotHasKey('ports', $arr); } From ed8019c85d15653e4d49b82087144061310cd340 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Mon, 8 Jun 2026 22:51:21 -0400 Subject: [PATCH 18/67] fix(changelog): conditionally load changelog script if available so it works when compose is on its own tab --- source/compose.manager/include/ComposeManager.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/source/compose.manager/include/ComposeManager.php b/source/compose.manager/include/ComposeManager.php index 2e0258d2..5b433cd5 100755 --- a/source/compose.manager/include/ComposeManager.php +++ b/source/compose.manager/include/ComposeManager.php @@ -289,6 +289,9 @@ function compose_manager_cpu_spec_count($cpuSpec) + + + + + + From e6faf7d0d4df2939480ecc4a494c9c7a78836802 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Thu, 11 Jun 2026 14:38:10 -0400 Subject: [PATCH 21/67] chore: remove test compose setup --- test-build-stack/Dockerfile | 2 -- test-build-stack/compose.yaml | 9 --------- test-build-stack/index.html | 10 ---------- 3 files changed, 21 deletions(-) delete mode 100644 test-build-stack/Dockerfile delete mode 100644 test-build-stack/compose.yaml delete mode 100644 test-build-stack/index.html diff --git a/test-build-stack/Dockerfile b/test-build-stack/Dockerfile deleted file mode 100644 index 5ef646a2..00000000 --- a/test-build-stack/Dockerfile +++ /dev/null @@ -1,2 +0,0 @@ -FROM nginx:alpine -COPY index.html /usr/share/nginx/html/index.html diff --git a/test-build-stack/compose.yaml b/test-build-stack/compose.yaml deleted file mode 100644 index 912def19..00000000 --- a/test-build-stack/compose.yaml +++ /dev/null @@ -1,9 +0,0 @@ -services: - test-app: - build: - context: . - dockerfile: Dockerfile - container_name: test-build-app - ports: - - "8888:80" - restart: unless-stopped diff --git a/test-build-stack/index.html b/test-build-stack/index.html deleted file mode 100644 index e7edcdb9..00000000 --- a/test-build-stack/index.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - Build Test - - -

Build Test Stack

-

This stack was built from a Dockerfile.

- - From a0a573f29e1f4a254141a1086ab4c83a3732c48b Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Thu, 11 Jun 2026 14:54:52 -0400 Subject: [PATCH 22/67] fix(sync-plugin-url): enhance branch detection and pluginURL synchronization logic --- .github/workflows/sync-plugin-url.yml | 58 ++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/.github/workflows/sync-plugin-url.yml b/.github/workflows/sync-plugin-url.yml index 2b6075a3..d2b38d9b 100644 --- a/.github/workflows/sync-plugin-url.yml +++ b/.github/workflows/sync-plugin-url.yml @@ -4,15 +4,17 @@ # This prevents issues when merging between dev and main where the # pluginURL would reference the wrong branch for update checks. # -# Triggers only on PR merges to main or dev (the only time the -# pluginURL branch reference can be wrong is after a cross-branch merge) +# Triggers on push and merged PRs to main/dev to self-heal branch links. name: Sync Plugin URL on: + push: + branches: [main, dev] pull_request: types: [closed] branches: [main, dev] + workflow_dispatch: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' @@ -26,32 +28,66 @@ jobs: permissions: contents: write runs-on: ubuntu-latest - # Only run if the PR was actually merged (not just closed) - if: github.event.pull_request.merged == true + if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.merged == true) steps: + - name: Resolve target branch + id: target + run: | + if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then + TARGET_BRANCH="${GITHUB_REF_NAME}" + elif [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then + TARGET_BRANCH="${{ github.event.pull_request.base.ref }}" + else + TARGET_BRANCH="${GITHUB_REF_NAME}" + fi + + case "$TARGET_BRANCH" in + main|dev) + echo "target_branch=$TARGET_BRANCH" >> "$GITHUB_OUTPUT" + ;; + *) + echo "Unsupported branch '$TARGET_BRANCH'; skipping." + echo "target_branch=" >> "$GITHUB_OUTPUT" + ;; + esac + - name: Checkout repository + if: steps.target.outputs.target_branch != '' uses: actions/checkout@v6 with: + ref: ${{ steps.target.outputs.target_branch }} token: ${{ secrets.GITHUB_TOKEN }} - name: Check and fix pluginURL branch + README badge + if: steps.target.outputs.target_branch != '' id: check run: | - BRANCH="${GITHUB_REF_NAME}" + BRANCH="${{ steps.target.outputs.target_branch }}" PLG_FILE="compose.manager.plg" README_FILE="source/compose.manager/README.md" NEEDS_COMMIT=false # Ensure pluginURL uses the current branch - CURRENT_URL_BRANCH=$(grep -oP '$#\1#p' "$PLG_FILE") + if [[ -z "$CURRENT_URL_BRANCH" ]]; then + echo "Failed to parse pluginURL entity in $PLG_FILE" + exit 1 + fi + echo "Current branch: $BRANCH" echo "PLG pluginURL branch: $CURRENT_URL_BRANCH" if [[ "$CURRENT_URL_BRANCH" != "$BRANCH" ]]; then echo "Branch mismatch - fixing pluginURL..." - # Scope to pluginURL line only — avoids corrupting packageURL - sed -i "/pluginURL/s|/&github;/[^/]*/|/\&github;/${BRANCH}/|" "$PLG_FILE" + sed -i -E "s#^##" "$PLG_FILE" + + EXPECTED_LINE="" + if ! grep -Fxq "$EXPECTED_LINE" "$PLG_FILE"; then + echo "pluginURL rewrite verification failed" + exit 1 + fi + NEEDS_COMMIT=true else echo "pluginURL already correct" @@ -81,12 +117,12 @@ jobs: echo "needs_commit=$NEEDS_COMMIT" >> $GITHUB_OUTPUT - name: Commit fix - if: steps.check.outputs.needs_commit == 'true' + if: steps.target.outputs.target_branch != '' && steps.check.outputs.needs_commit == 'true' run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add compose.manager.plg source/compose.manager/README.md - git commit -m "chore: sync pluginURL+README for ${GITHUB_REF_NAME} branch [skip ci]" - git pull --rebase origin "${GITHUB_REF_NAME}" + git commit -m "chore: sync pluginURL+README for ${{ steps.target.outputs.target_branch }} branch [skip ci]" + git pull --rebase origin "${{ steps.target.outputs.target_branch }}" git push From 4acb364492fbf447a07c6135d76d6c8ad9a583a5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 11 Jun 2026 18:55:24 +0000 Subject: [PATCH 23/67] chore: sync pluginURL+README for dev branch [skip ci] --- source/compose.manager/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/compose.manager/README.md b/source/compose.manager/README.md index 42be301a..8baaf817 100644 --- a/source/compose.manager/README.md +++ b/source/compose.manager/README.md @@ -1,3 +1,3 @@ -**Compose Manager Plus** +**Compose Manager Plus (Beta)** A plugin for unRAID that installs Docker Compose and adds a management interface to the web UI. From 339100d93dd070f0c7b8af5e23ea60139ec132c7 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Thu, 11 Jun 2026 15:15:04 -0400 Subject: [PATCH 24/67] fix(Exec.php): improve dockerVersionsInstalled check to verify actual changelog assets --- source/compose.manager/include/Exec.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/source/compose.manager/include/Exec.php b/source/compose.manager/include/Exec.php index 49c3a21e..7fcdd5b1 100644 --- a/source/compose.manager/include/Exec.php +++ b/source/compose.manager/include/Exec.php @@ -1664,7 +1664,9 @@ function composeResolveContainerIcon(string $containerName, string $service, arr case 'getSavedUpdateStatus': // Load saved update status from file $composeUpdateStatusFile = COMPOSE_UPDATE_STATUS_FILE; - $dockerVersionsInstalled = is_dir('/usr/local/emhttp/plugins/docker.versions'); + // Check for actual changelog assets rather than just directory presence + $dockerVersionsInstalled = file_exists('/usr/local/emhttp/plugins/docker.versions/scripts/changelog.js') + && file_exists('/usr/local/emhttp/plugins/docker.versions/styles/styles.css'); if (is_file($composeUpdateStatusFile)) { $savedStatus = json_decode(file_get_contents($composeUpdateStatusFile), true); if ($savedStatus) { From 363c97a7152ac8847b54b8576c53330fe7bfe988 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Thu, 11 Jun 2026 15:15:17 -0400 Subject: [PATCH 25/67] fix(composeLoadlist): bind autostart change handler early for dynamic rows and update loading status on success --- .../javascript/composeManagerMain.js | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js index 00d30586..75f151ee 100644 --- a/source/compose.manager/javascript/composeManagerMain.js +++ b/source/compose.manager/javascript/composeManagerMain.js @@ -279,6 +279,18 @@ function hideComposeSpinner() { function composeLoadlist() { // Return a Promise so callers can reliably .then() / .catch() on completion return new Promise(function(resolve, reject) { + // Bind autostart change handler early, before any rows are rendered or become interactive + // during progressive loading. Use delegated event handler so it works for dynamically added rows. + $('#compose_list').off('change', '.auto_start').on('change', '.auto_start', function() { + var script = $(this).attr("data-scriptname"); + var auto = $(this).prop('checked'); + $.post(caURL, { + action: 'updateAutostart', + script: script, + autostart: auto + }); + }); + composeTimers.load = setTimeout(function() { showComposeSpinner('Loading stack list...'); }, 500); @@ -563,16 +575,8 @@ function initStackListUI() { }); $el.data('switchbutton-initialized', true); }); - // Ensure change handler is bound only once - $('#compose_list').off('change', '.auto_start').on('change', '.auto_start', function() { - var script = $(this).attr("data-scriptname"); - var auto = $(this).prop('checked'); - $.post(caURL, { - action: 'updateAutostart', - script: script, - autostart: auto - }); - }); + // Note: change handler for .auto_start is now bound early in composeLoadlist() + // before any rows are added, so it's ready for both progressive and final renders. // Initialize context menus for stack icons $('[id^="stack-"][data-stackid]').each(function() { @@ -1253,7 +1257,6 @@ var dockerVersionsInstalled = false; // If auto-check is enabled and interval has elapsed, trigger a fresh check // Also checks for pending rechecks from recent update operations function loadSavedUpdateStatus() { - savedUpdateStatusLoaded = true; $.post(caURL, { action: 'getSavedUpdateStatus' }, function(data) { @@ -1264,6 +1267,9 @@ function loadSavedUpdateStatus() { stackUpdateStatus = response.stacks; if (response.dockerVersionsInstalled) dockerVersionsInstalled = true; + // Mark as loaded only after successful callback + savedUpdateStatusLoaded = true; + // Update the UI for each stack with saved status for (var stackName in response.stacks) { var stackInfo = response.stacks[stackName]; @@ -1307,6 +1313,10 @@ function loadSavedUpdateStatus() { } }); } + }).fail(function(xhr, status, error) { + // Reset flag on transport failure so retries can work + savedUpdateStatusLoaded = false; + composeLogger('Failed to load saved update status', { status: status, error: error }, 'user', 'error', 'update-check'); }); } From 292111a9094c85f6a70bfa559958ee4f41946649 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Thu, 11 Jun 2026 15:15:28 -0400 Subject: [PATCH 26/67] fix(ExecActionsTest): improve testGetSavedUpdateStatusDockerVersionsNotInstalled for determinism --- tests/unit/ExecActionsTest.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/unit/ExecActionsTest.php b/tests/unit/ExecActionsTest.php index 5c1e8c89..b1c32d21 100644 --- a/tests/unit/ExecActionsTest.php +++ b/tests/unit/ExecActionsTest.php @@ -1065,16 +1065,16 @@ public function testSetStackSettingsRejectsUnsupportedIconUrlTypes(): void /** * Returns dockerVersionsInstalled=false when the plugin directory is absent. */ -public function testGetSavedUpdateStatusDockerVersionsNotInstalled(): void -{ - @unlink(COMPOSE_UPDATE_STATUS_FILE); + public function testGetSavedUpdateStatusDockerVersionsNotInstalled(): void + { + @unlink(COMPOSE_UPDATE_STATUS_FILE); - // Make the test deterministic even when running on a host that has docker.versions installed. - $fakeDir = sys_get_temp_dir() . '/fake_docker_versions_absent_' . getmypid(); - UnraidStreamWrapper::addMapping('/usr/local/emhttp/plugins/docker.versions', $fakeDir); + // Make the test deterministic even when running on a host that has docker.versions installed. + $fakeDir = sys_get_temp_dir() . '/fake_docker_versions_absent_' . getmypid(); + UnraidStreamWrapper::addMapping('/usr/local/emhttp/plugins/docker.versions', $fakeDir); - $output = $this->executeAction('getSavedUpdateStatus'); - $result = json_decode($output, true); + $output = $this->executeAction('getSavedUpdateStatus'); + $result = json_decode($output, true); $this->assertIsArray($result); $this->assertEquals('success', $result['result']); From 0522b1b1b9dbde5a49a95248e2a98d52e4516741 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 11 Jun 2026 19:17:37 +0000 Subject: [PATCH 27/67] chore: update changelog for v2026.06.11.1517 [skip ci] --- compose.manager.plg | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/compose.manager.plg b/compose.manager.plg index 9cec6fd3..a61c3296 100644 --- a/compose.manager.plg +++ b/compose.manager.plg @@ -36,9 +36,34 @@ > -###2026.05.31 -- Bug Fixes: remove unused stack priority assignment in docker_started event handler -- [View all changes](https://github.com/mstrhakr/compose_plugin/compare/v2026.05.28...v2026.05.31) +###2026.06.11.1517 +- Features: add progressive stack list loading +- Features (container): add persistent container cache and icon resolution logic +- Features: return to update dialog when changelog is dismissed +- Features: show docker.versions changelogs in the update dialog +- Bug Fixes (ExecActionsTest): improve testGetSavedUpdateStatusDockerVersionsNotInstalled for determinism +- Bug Fixes (composeLoadlist): bind autostart change handler early for dynamic rows and update loading status on success +- Bug Fixes (Exec.php): improve dockerVersionsInstalled check to verify actual changelog assets +- Bug Fixes (sync-plugin-url): enhance branch detection and pluginURL synchronization logic +- Bug Fixes (docker.versions): conditionally load external styles for Docker versions if available +- Bug Fixes (compose): optimize cache invalidation logic based on stack/container structure changes +- Bug Fixes (changelog): conditionally load changelog script if available so it works when compose is on its own tab +- Bug Fixes (update): update button now triggers update action instead of always triggering force update +- Bug Fixes (composeSortable): enhance details row detachment logic during drag-and-drop +- Bug Fixes (composeSortable): improve drag-and-drop behavior by detaching non-sortable rows +- Bug Fixes (profiles): unify action profile picker and enforce profile precedence +- Bug Fixes: plug poll interval leak and skip dialog reopen when warnings disabled +- Bug Fixes: whitelist changelog message types, filter scaffolding noise +- Bug Fixes: discard stale Nchan buffer and concurrent changelog messages +- Bug Fixes: correct version and package details in plugin metadata +- Refactoring: delegate changelog display to docker.versions' showChangeLog() +- Documentation: document docker.versions changelog integration in user guide +- Tests: add unit tests for getSavedUpdateStatus docker.versions detection +- Chores: sync pluginURL+README for dev branch [skip ci] +- Chores: remove test compose setup +- Potential fix for pull request finding +- [PR #120](https://github.com/mstrhakr/compose_plugin/pull/120) +- [beta release diff](https://github.com/mstrhakr/compose_plugin/compare/v2026.06.06...v2026.06.11.1517) From 36f6cb9068e3234c5eef67e988764613a650db0b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 11 Jun 2026 19:18:22 +0000 Subject: [PATCH 28/67] Release v2026.06.11.1517 [skip ci] --- compose.manager.plg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compose.manager.plg b/compose.manager.plg index a61c3296..bc2f9150 100644 --- a/compose.manager.plg +++ b/compose.manager.plg @@ -2,11 +2,11 @@ - + - - + + From 7551d4b5196f57b7fefdd562aaf8ca7726ce785b Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Thu, 11 Jun 2026 15:24:52 -0400 Subject: [PATCH 29/67] fix(Exec.php): update dockerVersionsInstalled check to verify plugin directory existence and cleanup tests --- source/compose.manager/include/Exec.php | 5 ++- tests/unit/ExecActionsTest.php | 43 ------------------------- 2 files changed, 2 insertions(+), 46 deletions(-) diff --git a/source/compose.manager/include/Exec.php b/source/compose.manager/include/Exec.php index 7fcdd5b1..1172b65a 100644 --- a/source/compose.manager/include/Exec.php +++ b/source/compose.manager/include/Exec.php @@ -1664,9 +1664,8 @@ function composeResolveContainerIcon(string $containerName, string $service, arr case 'getSavedUpdateStatus': // Load saved update status from file $composeUpdateStatusFile = COMPOSE_UPDATE_STATUS_FILE; - // Check for actual changelog assets rather than just directory presence - $dockerVersionsInstalled = file_exists('/usr/local/emhttp/plugins/docker.versions/scripts/changelog.js') - && file_exists('/usr/local/emhttp/plugins/docker.versions/styles/styles.css'); + // Check if docker.versions plugin directory exists (where changelog assets are expected) + $dockerVersionsInstalled = is_dir('/usr/local/emhttp/plugins/docker.versions'); if (is_file($composeUpdateStatusFile)) { $savedStatus = json_decode(file_get_contents($composeUpdateStatusFile), true); if ($savedStatus) { diff --git a/tests/unit/ExecActionsTest.php b/tests/unit/ExecActionsTest.php index b1c32d21..c492d745 100644 --- a/tests/unit/ExecActionsTest.php +++ b/tests/unit/ExecActionsTest.php @@ -1082,50 +1082,7 @@ public function testGetSavedUpdateStatusDockerVersionsNotInstalled(): void $this->assertFalse($result['dockerVersionsInstalled']); } - /** - * Returns dockerVersionsInstalled=true when the plugin directory exists. - */ - public function testGetSavedUpdateStatusDockerVersionsInstalled(): void - { - @unlink(COMPOSE_UPDATE_STATUS_FILE); - - $fakeDir = sys_get_temp_dir() . '/fake_docker_versions_' . getmypid(); - mkdir($fakeDir, 0755, true); - $this->externalCleanupPaths[] = $fakeDir; - UnraidStreamWrapper::addMapping('/usr/local/emhttp/plugins/docker.versions', $fakeDir); - - $output = $this->executeAction('getSavedUpdateStatus'); - $result = json_decode($output, true); - - $this->assertIsArray($result); - $this->assertEquals('success', $result['result']); - $this->assertSame([], $result['stacks']); - $this->assertTrue($result['dockerVersionsInstalled']); - } - /** - * Returns saved stacks alongside dockerVersionsInstalled when a status file exists. - */ - public function testGetSavedUpdateStatusIncludesSavedStacks(): void - { - $savedStatus = ['my-stack' => ['hasUpdate' => true, 'containers' => []]]; - file_put_contents(COMPOSE_UPDATE_STATUS_FILE, json_encode($savedStatus)); - - $fakeDir = sys_get_temp_dir() . '/fake_docker_versions_' . getmypid(); - mkdir($fakeDir, 0755, true); - $this->externalCleanupPaths[] = $fakeDir; - UnraidStreamWrapper::addMapping('/usr/local/emhttp/plugins/docker.versions', $fakeDir); - - $output = $this->executeAction('getSavedUpdateStatus'); - $result = json_decode($output, true); - - $this->assertIsArray($result); - $this->assertEquals('success', $result['result']); - $this->assertEquals($savedStatus, $result['stacks']); - $this->assertTrue($result['dockerVersionsInstalled']); - - @unlink(COMPOSE_UPDATE_STATUS_FILE); - } /** * Falls back to empty stacks when the status file contains invalid JSON. From d2cdb7f2fb1a8f6ab958a84ec1a9493c8bbad9dc Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Thu, 11 Jun 2026 15:25:22 -0400 Subject: [PATCH 30/67] fix(deploy.sh, test.sh): update file permissions to make scripts executable --- deploy.sh | 0 test.sh | 0 2 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 deploy.sh mode change 100644 => 100755 test.sh diff --git a/deploy.sh b/deploy.sh old mode 100644 new mode 100755 diff --git a/test.sh b/test.sh old mode 100644 new mode 100755 From f14bc73225de9bcdb6c32a42f4b8770d35fcd2fd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 11 Jun 2026 19:26:12 +0000 Subject: [PATCH 31/67] chore: update changelog for v2026.06.11.1526 [skip ci] --- compose.manager.plg | 31 ++++--------------------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/compose.manager.plg b/compose.manager.plg index bc2f9150..fc7834fb 100644 --- a/compose.manager.plg +++ b/compose.manager.plg @@ -36,34 +36,11 @@ > -###2026.06.11.1517 -- Features: add progressive stack list loading -- Features (container): add persistent container cache and icon resolution logic -- Features: return to update dialog when changelog is dismissed -- Features: show docker.versions changelogs in the update dialog -- Bug Fixes (ExecActionsTest): improve testGetSavedUpdateStatusDockerVersionsNotInstalled for determinism -- Bug Fixes (composeLoadlist): bind autostart change handler early for dynamic rows and update loading status on success -- Bug Fixes (Exec.php): improve dockerVersionsInstalled check to verify actual changelog assets -- Bug Fixes (sync-plugin-url): enhance branch detection and pluginURL synchronization logic -- Bug Fixes (docker.versions): conditionally load external styles for Docker versions if available -- Bug Fixes (compose): optimize cache invalidation logic based on stack/container structure changes -- Bug Fixes (changelog): conditionally load changelog script if available so it works when compose is on its own tab -- Bug Fixes (update): update button now triggers update action instead of always triggering force update -- Bug Fixes (composeSortable): enhance details row detachment logic during drag-and-drop -- Bug Fixes (composeSortable): improve drag-and-drop behavior by detaching non-sortable rows -- Bug Fixes (profiles): unify action profile picker and enforce profile precedence -- Bug Fixes: plug poll interval leak and skip dialog reopen when warnings disabled -- Bug Fixes: whitelist changelog message types, filter scaffolding noise -- Bug Fixes: discard stale Nchan buffer and concurrent changelog messages -- Bug Fixes: correct version and package details in plugin metadata -- Refactoring: delegate changelog display to docker.versions' showChangeLog() -- Documentation: document docker.versions changelog integration in user guide -- Tests: add unit tests for getSavedUpdateStatus docker.versions detection -- Chores: sync pluginURL+README for dev branch [skip ci] -- Chores: remove test compose setup -- Potential fix for pull request finding +###2026.06.11.1526 +- Bug Fixes (deploy.sh, test.sh): update file permissions to make scripts executable +- Bug Fixes (Exec.php): update dockerVersionsInstalled check to verify plugin directory existence and cleanup tests - [PR #120](https://github.com/mstrhakr/compose_plugin/pull/120) -- [beta release diff](https://github.com/mstrhakr/compose_plugin/compare/v2026.06.06...v2026.06.11.1517) +- [beta release diff](https://github.com/mstrhakr/compose_plugin/compare/v2026.06.06...v2026.06.11.1526) From 864ad1969dad900279bb4c9c34d12b4ed18fe76c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 11 Jun 2026 19:27:02 +0000 Subject: [PATCH 32/67] Release v2026.06.11.1526 [skip ci] --- compose.manager.plg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compose.manager.plg b/compose.manager.plg index fc7834fb..d8c632e0 100644 --- a/compose.manager.plg +++ b/compose.manager.plg @@ -2,11 +2,11 @@ - + - - + + From 6c392f9660bac87f002d05f034300daa770573fa Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Thu, 11 Jun 2026 20:58:39 -0400 Subject: [PATCH 33/67] fix(cache): add endpoint to retrieve persistent container cache and update loading method --- source/compose.manager/include/Exec.php | 5 +++++ .../javascript/composeManagerMain.js | 20 +++++++++---------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/source/compose.manager/include/Exec.php b/source/compose.manager/include/Exec.php index 1172b65a..23d38904 100644 --- a/source/compose.manager/include/Exec.php +++ b/source/compose.manager/include/Exec.php @@ -136,6 +136,11 @@ function composeResolveContainerIcon(string $containerName, string $service, arr $cfg = @parse_ini_file("/boot/config/plugins/compose.manager/compose.manager.cfg", true, INI_SCANNER_NORMAL); echo json_encode(['result' => 'success', 'config' => $cfg]); break; + case 'getPersistentContainerCache': + $cacheFile = '/boot/config/plugins/compose.manager/containers.cache.json'; + $cache = is_file($cacheFile) ? json_decode(file_get_contents($cacheFile), true) : []; + echo json_encode(['result' => 'success', 'cache' => is_array($cache) ? $cache : []]); + break; case 'addStack': // Validate optional indirect inputs (folder or specific compose file) $indirectDir = isset($_POST['stackPath']) ? trim($_POST['stackPath']) : ''; diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js index 75f151ee..809e3949 100644 --- a/source/compose.manager/javascript/composeManagerMain.js +++ b/source/compose.manager/javascript/composeManagerMain.js @@ -1783,16 +1783,16 @@ function isValidIconSrc(src) { function loadPersistentContainerCache() { return new Promise(function(resolve) { - $.get('/plugins/compose.manager/containers.cache.json') - .done(function(data) { - try { - persistentContainerCache = JSON.parse(data) || {}; - } catch (e) { - persistentContainerCache = {}; - composeLogger('Failed to parse persistent container cache', { - error: e && e.toString() - }, 'user', 'warn', 'loadPersistentContainerCache'); - } + $.ajax({ + url: caURL, + method: 'POST', + dataType: 'json', + data: { + action: 'getPersistentContainerCache' + } + }) + .done(function(response) { + persistentContainerCache = (response && response.cache) ? response.cache : {}; resolve(persistentContainerCache); }) .fail(function() { From 4dbbccf6e18c8117eb63fb29ae83c6b67cb38684 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Thu, 11 Jun 2026 21:25:10 -0400 Subject: [PATCH 34/67] feat: add 'Remove Orphans by Default' option and integrate into compose actions --- source/compose.manager/default.cfg | 1 + .../include/ComposeManager.php | 9 + .../compose.manager/include/ComposeUtil.php | 13 +- source/compose.manager/include/Helpers.php | 16 +- .../javascript/composeManagerMain.js | 299 ++++++++++++------ tests/unit/ComposeManagerMainSourceTest.php | 68 ++++ 6 files changed, 305 insertions(+), 101 deletions(-) diff --git a/source/compose.manager/default.cfg b/source/compose.manager/default.cfg index 3ad39f8c..2af0a493 100755 --- a/source/compose.manager/default.cfg +++ b/source/compose.manager/default.cfg @@ -14,6 +14,7 @@ AUTOSTART_TIMEOUT="300" AUTOSTART_WAIT_FOR_DOCKER="false" AUTOSTART_DOCKER_WAIT_TIMEOUT="120" RUN_IN_BACKGROUND_DEFAULT="false" +REMOVE_ORPHANS_DEFAULT="false" DISABLE_ACTION_WARNINGS="false" NEW_STACK_USE_DEFAULT_COMPOSE_FILES="false" NEW_STACK_OVERRIDE_MANAGEMENT_AUTOMATIC="true" diff --git a/source/compose.manager/include/ComposeManager.php b/source/compose.manager/include/ComposeManager.php index 70ebfd59..f56a8fd7 100755 --- a/source/compose.manager/include/ComposeManager.php +++ b/source/compose.manager/include/ComposeManager.php @@ -635,6 +635,15 @@ function compose_manager_cpu_spec_count($cpuSpec)

+
+ + +
When enabled, the Remove orphans option is pre-checked in Compose Up/Down dialogs and bulk start/stop dialogs. Useful when a stack is edited while containers still exist.
+
+
'; + var removeOrphansHtml = buildRemoveOrphansCheckboxHtml('swal-remove-orphans-startall'); getConfig().then(function(pluginCfg) { var bgDefault = pluginCfg && pluginCfg.RUN_IN_BACKGROUND_DEFAULT === 'true'; + var removeOrphansDefault = pluginCfg && pluginCfg.REMOVE_ORPHANS_DEFAULT === 'true'; var disableWarnings = pluginCfg && pluginCfg.DISABLE_ACTION_WARNINGS === 'true'; if (disableWarnings) { - executeStartAllStacks(stacks, bgDefault, bgDefault); + executeStartAllStacks(stacks, bgDefault, bgDefault, removeOrphansDefault); return; } swal({ title: title, html: true, - text: '

The following stacks will be started:

' + stackNames + '
' + bgCheckboxHtml + '
', + text: '

The following stacks will be started:

' + stackNames + '
' + removeOrphansHtml + bgCheckboxHtml + '
', type: 'warning', showCancelButton: true, confirmButtonText: confirmText, @@ -3681,18 +3736,21 @@ function startAllStacks() { }, function(confirmed) { if (confirmed) { var runInBackground = $('#swal-run-bg-startall').is(':checked'); - executeStartAllStacks(stacks, runInBackground); + var removeOrphans = $('#swal-remove-orphans-startall').is(':checked'); + executeStartAllStacks(stacks, runInBackground, false, removeOrphans); } }); setTimeout(function() { var $cb = $('#swal-run-bg-startall'); if ($cb.length) $cb.prop('checked', bgDefault); + var $removeCb = $('#swal-remove-orphans-startall'); + if ($removeCb.length) $removeCb.prop('checked', removeOrphansDefault); }, 50); }); } -function executeStartAllStacks(stacks, background, suppressBackgroundNotification = false) { +function executeStartAllStacks(stacks, background, suppressBackgroundNotification = false, removeOrphans = false) { var height = 800; var width = 1200; @@ -3715,7 +3773,8 @@ function executeStartAllStacks(stacks, background, suppressBackgroundNotificatio $.post(compURL, { action: 'composeUpMultiple', paths: JSON.stringify(paths), - background: background ? 1 : 0 + background: background ? 1 : 0, + removeOrphans: removeOrphans ? 1 : 0 }, function(data) { var parsed = tryParseJson(data); if (parsed && parsed.background) { @@ -3777,20 +3836,22 @@ function stopAllStacks() { '' + '' + '

'; + var removeOrphansHtml = buildRemoveOrphansCheckboxHtml('swal-remove-orphans-stopall'); getConfig().then(function(pluginCfg) { var bgDefault = pluginCfg && pluginCfg.RUN_IN_BACKGROUND_DEFAULT === 'true'; + var removeOrphansDefault = pluginCfg && pluginCfg.REMOVE_ORPHANS_DEFAULT === 'true'; var disableWarnings = pluginCfg && pluginCfg.DISABLE_ACTION_WARNINGS === 'true'; if (disableWarnings) { - executeStopAllStacks(stacks, bgDefault, bgDefault); + executeStopAllStacks(stacks, bgDefault, bgDefault, removeOrphansDefault); return; } swal({ title: title, html: true, - text: '

The following stacks will be stopped:

' + stackNames + '

Containers will be stopped and removed. Data in volumes will be preserved.

' + bgCheckboxHtml + '
', + text: '

The following stacks will be stopped:

' + stackNames + '

Containers will be stopped and removed. Data in volumes will be preserved.

' + removeOrphansHtml + bgCheckboxHtml + '
', type: 'warning', showCancelButton: true, confirmButtonText: confirmText, @@ -3798,18 +3859,21 @@ function stopAllStacks() { }, function(confirmed) { if (confirmed) { var runInBackground = $('#swal-run-bg-stopall').is(':checked'); - executeStopAllStacks(stacks, runInBackground); + var removeOrphans = $('#swal-remove-orphans-stopall').is(':checked'); + executeStopAllStacks(stacks, runInBackground, false, removeOrphans); } }); setTimeout(function() { var $cb = $('#swal-run-bg-stopall'); if ($cb.length) $cb.prop('checked', bgDefault); + var $removeCb = $('#swal-remove-orphans-stopall'); + if ($removeCb.length) $removeCb.prop('checked', removeOrphansDefault); }, 50); }); } -function executeStopAllStacks(stacks, background, suppressBackgroundNotification = false) { +function executeStopAllStacks(stacks, background, suppressBackgroundNotification = false, removeOrphans = false) { var height = 800; var width = 1200; @@ -3832,7 +3896,8 @@ function executeStopAllStacks(stacks, background, suppressBackgroundNotification $.post(compURL, { action: 'composeDownMultiple', paths: JSON.stringify(paths), - background: background ? 1 : 0 + background: background ? 1 : 0, + removeOrphans: removeOrphans ? 1 : 0 }, function(data) { var parsed = tryParseJson(data); if (parsed && parsed.background) { @@ -3986,13 +4051,16 @@ function showStackActionDialog(action, path, profile) { containers.push(container); }); + var detectedMismatch = cachedContainers.length !== profileServices.length; + composeLogger('profile/unified AJAX done', { profileServices, containers, - cachedContainers + cachedContainers, + detectedMismatch }, 'user', 'debug', 'showStackActionDialog'); containers = mergeUpdateStatus(containers, project); - renderStackActionDialog(action, displayName, path, profile, containers, hasBuild); + renderStackActionDialog(action, displayName, path, profile, containers, hasBuild, detectedMismatch); }).fail(function(xhr, status, error) { // Fallback: if profile resolution fails, show cached container metadata composeLogger('getProfileServices AJAX fail', { @@ -4010,12 +4078,12 @@ function showStackActionDialog(action, path, profile) { }); containers = mergeUpdateStatus(containers, project); } - renderStackActionDialog(action, displayName, path, profile, containers, hasBuild); + renderStackActionDialog(action, displayName, path, profile, containers, hasBuild, cachedContainers.length > 0); }); return; } -function renderStackActionDialog(action, displayName, path, profile, containers, hasBuild) { +function renderStackActionDialog(action, displayName, path, profile, containers, hasBuild, showRemoveOrphans) { hasBuild = hasBuild || false; // Action-specific configuration var pullLabel = hasBuild ? 'Build' : 'Pull'; @@ -4029,6 +4097,7 @@ function renderStackActionDialog(action, displayName, path, profile, containers, warningColor: window.getComputedStyle(document.documentElement).getPropertyValue('--dynamix-ui-dropdownchecklist-color'), confirmText: 'Compose Up', showVersionArrow: false, + showRemoveOrphans: true, confirmedFn: ComposeUpConfirmed }, 'down': { @@ -4040,6 +4109,7 @@ function renderStackActionDialog(action, displayName, path, profile, containers, warningColor: window.getComputedStyle(document.documentElement).getPropertyValue('--dynamix-sb-message-link-color'), confirmText: 'Compose Down', showVersionArrow: false, + showRemoveOrphans: true, confirmedFn: ComposeDownConfirmed }, 'stop': { @@ -4177,6 +4247,15 @@ function renderStackActionDialog(action, displayName, path, profile, containers, // Warning/info text html += '
' + cfg.warning + '
'; + var removeOrphansDefault = false; + + if (cfg.showRemoveOrphans) { + html += ''; + } + // Run-in-background checkbox (appended after config is fetched below) var bgCheckboxHtml = '
' + '' + @@ -4188,14 +4267,18 @@ function renderStackActionDialog(action, displayName, path, profile, containers, // Fetch config to determine default checkbox state, then show swal (or skip warnings) getConfig().then(function(pluginCfg) { var bgDefault = pluginCfg && pluginCfg.RUN_IN_BACKGROUND_DEFAULT === 'true'; + removeOrphansDefault = pluginCfg && pluginCfg.REMOVE_ORPHANS_DEFAULT === 'true'; var disableWarnings = pluginCfg && pluginCfg.DISABLE_ACTION_WARNINGS === 'true'; if (disableWarnings) { // In default background mode (warnings disabled and background enabled), don't show toast if background is used - cfg.confirmedFn(path, profile, bgDefault, bgDefault); + cfg.confirmedFn(path, profile, bgDefault, bgDefault, removeOrphansDefault); return; } + var removeOrphansChecked = removeOrphansDefault || showRemoveOrphans; + var showRemoveOrphansOption = cfg.showRemoveOrphans && (removeOrphansChecked || showRemoveOrphans); + // Use native swal (SweetAlert 1.x) with callback style swal({ title: cfg.title, @@ -4209,8 +4292,9 @@ function renderStackActionDialog(action, displayName, path, profile, containers, if (confirmed) { // Capture checkbox state before swal destroys the DOM var runInBackground = $('#swal-run-bg-checkbox').is(':checked'); + var removeOrphans = $('#swal-remove-orphans-checkbox').is(':checked'); // when running in background, suppress the extra notifyBackgroundStarted popup - cfg.confirmedFn(path, profile, runInBackground, runInBackground); + cfg.confirmedFn(path, profile, runInBackground, runInBackground, removeOrphans); } }); @@ -4220,6 +4304,11 @@ function renderStackActionDialog(action, displayName, path, profile, containers, if ($cb.length) { $cb.prop('checked', bgDefault); } + var $orphanCb = $('#swal-remove-orphans-checkbox'); + if ($orphanCb.length) { + $orphanCb.prop('checked', removeOrphansChecked); + $('#swal-remove-orphans-wrap').toggle(showRemoveOrphansOption); + } }, 50); }); } @@ -5670,11 +5759,95 @@ function saveAllChanges(closeAfterSave) { var totalChanges = editorModal.modifiedTabs.size + editorModal.modifiedSettings.size + editorModal.modifiedLabels.size; var pathSensitiveSettingsChanged = hasPathSensitiveSettingsChanges(); var pathDependentEdits = hasPathDependentEdits(); + var project = editorModal.currentProject; + var removedServices = []; if (totalChanges === 0) { return; } + if (project && editorModal.modifiedTabs.has('compose') && isStackRunning(project)) { + removedServices = getRemovedComposeServices(); + } + + function continueSave() { + // Track if labels are being modified in Automatic mode (need to offer recreate) + var labelsWereModified = editorModal.labelsViewMode === 'basic' && editorModal.modifiedLabels.size > 0; + + // Save modified file tabs (compose, env, and override editor) + editorModal.modifiedTabs.forEach(function(tabName) { + savePromises.push(saveTab(tabName, saveErrors)); + }); + + // Save settings if modified + if (editorModal.modifiedSettings.size > 0) { + savePromises.push(saveSettings(saveErrors)); + } + + // Save labels only in Automatic mode. + if (editorModal.modifiedLabels.size > 0) { + if (editorModal.labelsViewMode === 'basic') { + savePromises.push(saveLabels(saveErrors)); + } else { + skippedManualLabels = true; + } + } + + $.when.apply($, savePromises).then(function() { + var results = Array.prototype.slice.call(arguments); + var allSucceeded = results.every(function(result) { + return result === true; + }); + + if (allSucceeded) { + if (skippedManualLabels) { + swal({ + title: "Partially Saved", + text: "Non-label changes were saved. WebUI label form edits were not saved because Override File Management is set to Manual. Switch to Automatic to save Labels form changes.", + type: "warning" + }); + updateTabModifiedState(); + updateSaveButtonState(); + return; + } + + // Check if we should offer to recreate containers + if (labelsWereModified) { + promptRecreateContainers(closeAfterSave); + } else { + var saveProject = editorModal.currentProject; + if (closeAfterSave) { + doCloseEditorModal(); + } + swal({ + title: "Saved!", + text: "All changes have been saved.", + type: "success", + timer: 1500, + showConfirmButton: false + }); + setTimeout(function() { + if (saveProject) { + refreshStackByProject(saveProject); + } + }, 1600); + } + } else { + var filteredErrors = saveErrors.filter(function(message) { + return message !== '__STALE_PATH__'; + }); + if (filteredErrors.length === 0 && saveErrors.indexOf('__STALE_PATH__') !== -1) { + return; + } + swal({ + title: 'Save Failed', + text: filteredErrors.join('\n') || 'An unknown error occurred while saving.', + type: 'error' + }); + } + }); + } + if (pathSensitiveSettingsChanged && pathDependentEdits) { swal({ title: 'Reload Required', @@ -5711,82 +5884,24 @@ function saveAllChanges(closeAfterSave) { // Save labels only in Automatic mode. if (editorModal.modifiedLabels.size > 0) { - if (editorModal.labelsViewMode === 'basic') { - savePromises.push(saveLabels(saveErrors)); - } else { - skippedManualLabels = true; - } - } - - $.when.apply($, savePromises).then(function() { - var results = Array.prototype.slice.call(arguments); - var allSucceeded = results.every(function(result) { - return result === true; - }); - - if (allSucceeded) { - if (skippedManualLabels) { - swal({ - title: "Partially Saved", - text: "Non-label changes were saved. WebUI label form edits were not saved because Override File Management is set to Manual. Switch to Automatic to save Labels form changes.", - type: "warning" - }); - updateTabModifiedState(); - updateSaveButtonState(); - return; - } - // Check if we should offer to recreate containers - if (labelsWereModified) { - promptRecreateContainers(closeAfterSave); - } else { - var project = editorModal.currentProject; - if (closeAfterSave) { - doCloseEditorModal(); - } - swal({ - title: "Saved!", - text: "All changes have been saved.", - type: "success", - timer: 1500, - showConfirmButton: false - }); - setTimeout(function() { - if (project) { - refreshStackByProject(project); - } - }, 1600); - } - } else { - var filteredErrors = saveErrors.filter(function(message) { - return message !== '__STALE_PATH__'; - }); - if (filteredErrors.length === 0 && saveErrors.indexOf('__STALE_PATH__') !== -1) { - return; - } - var errorText = filteredErrors.length > 0 ? - filteredErrors.join('\n') : - 'Some items could not be saved. Please try again.'; - swal({ - title: "Save Failed", - text: errorText, - type: "error" - }); - } - }).fail(function() { + if (removedServices.length > 0) { swal({ - title: "Save Failed", - text: "An error occurred while saving. Please try again.", - type: "error" + title: 'Running Stack Changed', + text: 'This stack is running and the compose file no longer defines: ' + removedServices.join(', ') + '. Saving now can leave orphaned containers behind. Stop the stack first, or use Remove orphans from the Compose Up/Down dialog to clean them up.', + type: 'warning', + showCancelButton: true, + confirmButtonText: 'Save Anyway', + cancelButtonText: 'Cancel' + }, function(confirmed) { + if (confirmed) { + continueSave(); + } }); - }); -} - -// Save settings -function saveSettings(saveErrors) { - var project = editorModal.currentProject; - var savePromises = []; + return; + } + continueSave(); // Save name if modified if (editorModal.modifiedSettings.has('name')) { var newName = $('#settings-name').val(); diff --git a/tests/unit/ComposeManagerMainSourceTest.php b/tests/unit/ComposeManagerMainSourceTest.php index c2830758..b638d578 100644 --- a/tests/unit/ComposeManagerMainSourceTest.php +++ b/tests/unit/ComposeManagerMainSourceTest.php @@ -16,6 +16,8 @@ class ComposeManagerMainSourceTest extends TestCase { private string $mainPagePath; private string $mainScriptPath; + private string $helpersPath; + private string $composeUtilPath; private string $dockerStartedEventPath; protected function setUp(): void @@ -23,9 +25,13 @@ protected function setUp(): void parent::setUp(); $this->mainPagePath = __DIR__ . '/../../source/compose.manager/include/ComposeManager.php'; $this->mainScriptPath = __DIR__ . '/../../source/compose.manager/javascript/composeManagerMain.js'; + $this->helpersPath = __DIR__ . '/../../source/compose.manager/include/Helpers.php'; + $this->composeUtilPath = __DIR__ . '/../../source/compose.manager/include/ComposeUtil.php'; $this->dockerStartedEventPath = __DIR__ . '/../../source/compose.manager/event/docker_started'; $this->assertFileExists($this->mainPagePath, 'ComposeManager.php must exist'); $this->assertFileExists($this->mainScriptPath, 'composeManagerMain.js must exist'); + $this->assertFileExists($this->helpersPath, 'Helpers.php must exist'); + $this->assertFileExists($this->composeUtilPath, 'ComposeUtil.php must exist'); $this->assertFileExists($this->dockerStartedEventPath, 'event/docker_started must exist'); } @@ -39,6 +45,16 @@ private function getJsSource(): string return file_get_contents($this->mainScriptPath); } + private function getHelpersSource(): string + { + return file_get_contents($this->helpersPath); + } + + private function getComposeUtilSource(): string + { + return file_get_contents($this->composeUtilPath); + } + public function testCpuSpecCountHelperExists(): void { $source = $this->getPhpSource(); @@ -145,6 +161,58 @@ public function testEditorHasOkayApplyCloseButtonsAndChangeCounter(): void $this->assertStringContainsString("promptRecreateContainers(closeAfterSave);", $jsSource); } + public function testRunningComposeSaveWarnsOnRemovedServices(): void + { + $source = $this->getJsSource(); + $this->assertStringContainsString('function getRemovedComposeServices()', $source); + $this->assertStringContainsString('function isStackRunning(project)', $source); + $this->assertStringContainsString('Running Stack Changed', $source); + $this->assertStringContainsString('Remove orphans from the Compose Up/Down dialog', $source); + } + + public function testComposeActionDialogOffersRemoveOrphansToggle(): void + { + $source = $this->getJsSource(); + $this->assertStringContainsString('showRemoveOrphans: true', $source); + $this->assertStringContainsString('swal-remove-orphans-checkbox', $source); + $this->assertStringContainsString('removeOrphans = $(\'#swal-remove-orphans-checkbox\').is(\':checked\')', $source); + $this->assertStringContainsString('removeOrphans: removeOrphans', $source); + } + + public function testBulkActionsOfferRemoveOrphansToggle(): void + { + $source = $this->getJsSource(); + $this->assertStringContainsString('function executeStartAllStacks(stacks, background, suppressBackgroundNotification = false, removeOrphans = false)', $source); + $this->assertStringContainsString('function executeStopAllStacks(stacks, background, suppressBackgroundNotification = false, removeOrphans = false)', $source); + $this->assertStringContainsString('swal-remove-orphans-startall', $source); + $this->assertStringContainsString('swal-remove-orphans-stopall', $source); + $this->assertStringContainsString("removeOrphans: removeOrphans ? 1 : 0", $source); + } + + public function testBackendPassesRemoveOrphansThroughComposeCommand(): void + { + $source = $this->getHelpersSource(); + $this->assertStringContainsString('$removeOrphans = !empty($_POST[\'removeOrphans\'])', $source); + $this->assertStringContainsString('removeOrphans', $source); + $this->assertStringContainsString('--remove-orphans', $source); + } + + public function testComposeUtilRoutesRemoveOrphansFlag(): void + { + $source = $this->getComposeUtilSource(); + $this->assertStringContainsString("\$removeOrphans = isset(\$_POST['removeOrphans']) && \$_POST['removeOrphans'] == '1';", $source); + $this->assertStringContainsString("echoComposeCommand('up', false, \$background, \$removeOrphans);", $source); + $this->assertStringContainsString("echoComposeCommandMultiple('up', \$paths, \$background, \$removeOrphans);", $source); + } + + public function testSettingsIncludeRemoveOrphansDefaultToggle(): void + { + $phpSource = $this->getPhpSource(); + $this->assertStringContainsString('id="REMOVE_ORPHANS_DEFAULT"', $phpSource); + $this->assertStringContainsString('Remove Orphans by Default', $phpSource); + $this->assertStringContainsString('Enable --remove-orphans by default for Compose Up/Down actions', $phpSource); + } + public function testSaveAllChangesDefaultsToApplyMode(): void { $source = $this->getJsSource(); From fb0a9c8ac5448e789808d6b420dd3951cbddbe72 Mon Sep 17 00:00:00 2001 From: Joly0 <13993216+Joly0@users.noreply.github.com> Date: Fri, 12 Jun 2026 10:53:53 +0200 Subject: [PATCH 35/67] Add support for additional compose files per stack Until now a stack could only use its main compose file plus the override file the plugin manages. Many projects ship optional override files (for example docker-compose.gpu.yml for GPU support), but there was no way to include them, because the plugin builds the docker compose command itself and there was nowhere to add another -f file. What this adds: - A new "Additional Compose Files" section in the stack settings. It lists the compose files found in the stack's compose folder as checkboxes, so you can simply tick the ones you want. An advanced field is available for files that live somewhere else (absolute paths, one per line). - The selection is stored in a small extra_compose_files file in the stack folder (one path per line) and the files are appended as extra -f flags after the main compose file and the override file. This automatically applies to every action (up, down, update, pull, logs, autostart) because the command arguments are built in one central place. - The editor's Compose tab got a file dropdown that lists every file the stack is made of (main file, override file, additional files), so all of them can be viewed and edited directly. Safety details: - The editor can only open files that actually belong to the stack; the request is checked against the stack's own file list, so the endpoint cannot be used to read or write unrelated files. - Saved entries must be .yml/.yaml files and absolute paths must be under /mnt/ or /boot/config/. - A settings save coming from an outdated cached browser session leaves the new setting untouched instead of silently clearing it. - Entries pointing to files that no longer exist are skipped with a logged warning instead of breaking the stack. - Selecting additional files switches the stack to explicit file mode, so the selection cannot be ignored by Docker Compose default file discovery. --- .../include/ComposeManager.php | 15 ++ source/compose.manager/include/Exec.php | 125 +++++++++++- source/compose.manager/include/Util.php | 67 +++++- .../javascript/composeManagerMain.js | 190 +++++++++++++++++- tests/unit/ExecActionsTest.php | 46 +++++ tests/unit/ExtraComposeFilesTest.php | 153 ++++++++++++++ 6 files changed, 583 insertions(+), 13 deletions(-) create mode 100644 tests/unit/ExtraComposeFilesTest.php diff --git a/source/compose.manager/include/ComposeManager.php b/source/compose.manager/include/ComposeManager.php index 2e0258d2..64324474 100755 --- a/source/compose.manager/include/ComposeManager.php +++ b/source/compose.manager/include/ComposeManager.php @@ -471,6 +471,10 @@ function compose_manager_cpu_spec_count($cpuSpec)
+
@@ -610,6 +614,17 @@ function compose_manager_cpu_spec_count($cpuSpec)
Path to a specific external compose file (e.g., /mnt/user/appdata/myapp/custom.compose.yml). Leave empty to use folder mode or local project files.
+
+ + + +
+ External files (advanced) + +
+
Selected files are appended as additional -f flags after the main compose and override files (e.g., GPU or environment overrides). Setting this disables default file discovery.
+
+
diff --git a/source/compose.manager/include/Exec.php b/source/compose.manager/include/Exec.php index 63bcfa9b..dd2c315d 100644 --- a/source/compose.manager/include/Exec.php +++ b/source/compose.manager/include/Exec.php @@ -63,6 +63,29 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool } } +if (!function_exists('resolveRequestedComposeFile')) { + /** + * Resolve an explicitly requested compose file (`file` POST param) + * against the stack's editable compose files. + * + * @return string|false|null Matched path, false when the request is + * invalid, or null when no file was requested. + */ + function resolveRequestedComposeFile(StackInfo $stackInfo) + { + $requested = isset($_POST['file']) ? trim((string) $_POST['file']) : ''; + if ($requested === '') { + return null; + } + foreach ($stackInfo->getEditableComposeFiles() as $candidate) { + if (Path::refersToSamePath($candidate, $requested)) { + return $candidate; + } + } + return false; + } +} + switch ($_POST['action']) { case 'composeLogger': $message = $_POST['msg'] ?? ''; @@ -279,6 +302,16 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool $stackInfo = StackInfo::fromProject($compose_root, $script); $composeFilePath = $stackInfo->composeFilePath ?? ($stackInfo->composeSource . '/compose.yaml'); + // Optional explicit file selection (e.g. additional compose files) + $requestedFile = resolveRequestedComposeFile($stackInfo); + if ($requestedFile === false) { + echo json_encode(['result' => 'error', 'message' => 'Requested file is not part of this stack.']); + break; + } + if ($requestedFile !== null) { + $composeFilePath = $requestedFile; + } + // Check file existence consistently regardless of how path was resolved if (is_file($composeFilePath)) { $scriptContents = file_get_contents($composeFilePath); @@ -431,12 +464,24 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool $stackInfo = StackInfo::fromProject($compose_root, $script); $composeFilePath = $stackInfo->composeFilePath ?? ($stackInfo->composeSource . '/' . COMPOSE_FILE_NAMES[0]); + // Optional explicit file selection (e.g. additional compose files) + $requestedFile = resolveRequestedComposeFile($stackInfo); + if ($requestedFile === false) { + echo json_encode(['result' => 'error', 'message' => 'Requested file is not part of this stack.']); + break; + } + $isMainComposeFile = $requestedFile === null || Path::refersToSamePath($requestedFile, $composeFilePath); + if ($requestedFile !== null) { + $composeFilePath = $requestedFile; + } + if (rejectStaleClientPath($composeFilePath, 'compose file')) { break; } - // Before saving, detect service renames and migrate override entries in the project override only - if (is_file($composeFilePath)) { + // Before saving, detect service renames and migrate override entries in the project override only. + // Rename migration only applies to the main compose file. + if ($isMainComposeFile && is_file($composeFilePath)) { $oldContent = file_get_contents($composeFilePath); $stackInfo->overrideInfo->migrateOnRename($oldContent, $scriptContents); } @@ -701,6 +746,31 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool $defaultProfileFile = "$compose_root/$script/default_profile"; $defaultProfile = is_file($defaultProfileFile) ? trim(file_get_contents($defaultProfileFile)) : ""; + // Get additional compose files (one path per line) + $extraComposeFilesFile = "$compose_root/$script/extra_compose_files"; + $extraComposeFiles = is_file($extraComposeFilesFile) ? trim(file_get_contents($extraComposeFilesFile)) : ""; + + // Candidate compose files in the compose source folder for the + // Additional Compose Files selector (*compose*.y(a)ml, excluding the + // main compose file and override files) + $composeFileCandidates = []; + $mainComposeBase = $stackInfo->composeFilePath !== null ? basename($stackInfo->composeFilePath) : ''; + $candidateGlob = glob($stackInfo->composeSource . '/*.{yml,yaml}', GLOB_BRACE) ?: []; + foreach ($candidateGlob as $candidatePath) { + $candidateBase = basename($candidatePath); + if (stripos($candidateBase, 'compose') === false) { + continue; + } + if ($candidateBase === $mainComposeBase) { + continue; + } + if (stripos($candidateBase, '.override.') !== false) { + continue; + } + $composeFileCandidates[] = $candidateBase; + } + sort($composeFileCandidates); + // Get labels tab view mode (per-stack) $labelsViewModeFile = "$compose_root/$script/labels_view_mode"; $labelsViewMode = is_file($labelsViewModeFile) ? strtolower(trim(file_get_contents($labelsViewModeFile))) : 'basic'; @@ -763,6 +833,9 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool 'iconUrl' => $iconUrl, 'webuiUrl' => $webuiUrl, 'defaultProfile' => $defaultProfile, + 'extraComposeFiles' => $extraComposeFiles, + 'composeFileCandidates' => $composeFileCandidates, + 'editableComposeFiles' => $stackInfo->getEditableComposeFiles(), 'labelsViewMode' => $labelsViewMode, 'useDefaultComposeFiles' => $useDefaultComposeFiles, 'indirectMode' => $stackInfo->indirectMode, @@ -896,6 +969,43 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool } } + // Additional compose files (one path per line, absolute or relative to compose source). + // Only processed when the client sends the field: a stale UI session + // that predates this setting must not silently clear it. + $extraComposeFilesProvided = isset($_POST['extraComposeFiles']); + $extraComposeFiles = $extraComposeFilesProvided ? trim($_POST['extraComposeFiles']) : ""; + $extraComposeFilesNormalized = []; + $extraComposeFilesError = ''; + foreach (preg_split('/\R/', $extraComposeFiles) as $extraLine) { + $extraLine = trim($extraLine); + if ($extraLine === '' || strpos($extraLine, '#') === 0) { + continue; + } + if (preg_match('/\.ya?ml$/i', $extraLine) !== 1) { + $extraComposeFilesError = 'Additional compose file must be a .yml or .yaml file: ' . $extraLine; + break; + } + if (Path::isAbsolutePath($extraLine)) { + $realExtra = realpath($extraLine); + if ($realExtra === false || !is_file($realExtra)) { + $extraComposeFilesError = 'Additional compose file does not exist: ' . $extraLine; + break; + } + if (!Path::isAllowedPath($realExtra, ['/mnt', '/boot/config'])) { + $extraComposeFilesError = 'Additional compose files must be under /mnt/ or /boot/config/.'; + break; + } + } elseif (strpos($extraLine, '..') !== false) { + $extraComposeFilesError = 'Relative additional compose file paths must not contain "..": ' . $extraLine; + break; + } + $extraComposeFilesNormalized[] = $extraLine; + } + if ($extraComposeFilesError !== '') { + echo json_encode(['result' => 'error', 'message' => $extraComposeFilesError]); + break; + } + // --- All validation passed, now write everything --- // Set env path @@ -934,6 +1044,17 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool file_put_contents($defaultProfileFile, $defaultProfile); } + // Set additional compose files (skipped entirely when the field was not sent) + if ($extraComposeFilesProvided) { + $extraComposeFilesFile = "$compose_root/$script/extra_compose_files"; + if (empty($extraComposeFilesNormalized)) { + if (is_file($extraComposeFilesFile)) + @unlink($extraComposeFilesFile); + } else { + file_put_contents($extraComposeFilesFile, implode("\n", $extraComposeFilesNormalized) . "\n"); + } + } + // Set compose file discovery mode $useDefaultComposeFilesFile = "$compose_root/$script/use_default_compose_files"; if ($useDefaultComposeFiles) { diff --git a/source/compose.manager/include/Util.php b/source/compose.manager/include/Util.php index 170adcda..6ec5889f 100644 --- a/source/compose.manager/include/Util.php +++ b/source/compose.manager/include/Util.php @@ -1764,6 +1764,40 @@ private function getAdditionalComposeFilesFromEnv(): array return array_values(array_unique($files)); } + /** + * Parse additional compose file paths from the `extra_compose_files` + * metadata file (one path per line; `#` comments allowed). + * + * Paths may be absolute or relative to the compose source folder. + * Missing files are skipped with a warning so a stale entry cannot + * break stack operations. + * + * @return string[] + */ + private function getExtraComposeFiles(): array + { + $raw = $this->readMetadata('extra_compose_files'); + if ($raw === null || $raw === '') { + return []; + } + + $files = []; + foreach (preg_split('/\R/', $raw) as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#')) { + continue; + } + $path = Path::isAbsolutePath($line) ? $line : $this->composeSource . '/' . $line; + if (!is_file($path)) { + composeLogger("Ignoring missing extra compose file for stack $this->projectFolder", ['path' => $path], 'user', 'warning', 'stack'); + continue; + } + $files[] = $path; + } + + return $files; + } + /** * Normalize compose file paths for deduplication. * @@ -1820,6 +1854,26 @@ private static function splitComposeFileValue(string $value): array return $entries; } + /** + * Compose files that are directly editable in the stack editor: every + * existing file that contributes to the compose command, in `-f` order + * (main compose file, override file, COMPOSE_FILE env entries, and + * `extra_compose_files` metadata). + * + * @return string[] + */ + public function getEditableComposeFiles(): array + { + $files = []; + foreach ($this->getComposeFilePaths() as $path) { + if (!is_file($path)) { + continue; + } + $files[] = $path; + } + return $files; + } + private function getComposeFilePaths(): array { $paths = []; @@ -1835,6 +1889,10 @@ private function getComposeFilePaths(): array $paths[] = $extraFile; } + foreach ($this->getExtraComposeFiles() as $extraFile) { + $paths[] = $extraFile; + } + $normalized = []; $unique = []; foreach ($paths as $path) { @@ -1875,8 +1933,8 @@ public function useDefaultComposeFileDiscovery(): bool /** * Determine whether this stack has manual settings that require explicit mode. * - * Explicit env-path and indirect-file selections are treated as explicit-mode - * signals, so default file discovery is bypassed. + * Explicit env-path, extra compose files, and indirect-file selections are + * treated as explicit-mode signals, so default file discovery is bypassed. * * @return bool */ @@ -1887,6 +1945,11 @@ private function hasManualComposePathOverrides(): bool return true; } + $extraComposeFiles = $this->readMetadata('extra_compose_files'); + if ($extraComposeFiles !== null && $extraComposeFiles !== '') { + return true; + } + return $this->indirectMode === 'file'; } diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js index 8ea6981c..600072bc 100644 --- a/source/compose.manager/javascript/composeManagerMain.js +++ b/source/compose.manager/javascript/composeManagerMain.js @@ -91,15 +91,14 @@ function parseMemUsagePair(memStr) { } } - var hasEnvPath = ($('#settings-env-path').val() || '').trim() !== ''; - var hasExternalComposeFilePath = ($('#settings-external-compose-file').val() || '').trim() !== ''; + var manualOverrideReasons = []; + if (($('#settings-env-path').val() || '').trim() !== '') manualOverrideReasons.push('Env File Path'); + if (($('#settings-external-compose-file').val() || '').trim() !== '') manualOverrideReasons.push('External Compose File'); + if (getExtraComposeFilesValue() !== '') manualOverrideReasons.push('Additional Compose Files'); - if (hasEnvPath || hasExternalComposeFilePath) { - var reason = hasEnvPath && hasExternalComposeFilePath - ? 'Default discovery disabled because Env File Path and External Compose File are set.' - : (hasEnvPath - ? 'Default discovery disabled because Env File Path is set.' - : 'Default discovery disabled because External Compose File is set.'); + if (manualOverrideReasons.length > 0) { + var reason = 'Default discovery disabled because ' + manualOverrideReasons.join(' and ') + + (manualOverrideReasons.length > 1 ? ' are set.' : ' is set.'); if ($checkbox.is(':checked')) { $checkbox.prop('checked', false); @@ -115,6 +114,76 @@ function parseMemUsagePair(memStr) { } } +// ---- Additional Compose Files settings UI ---- + +// Combine checked folder candidates and external lines into the +// newline-separated value stored in the extra_compose_files metadata file. +function getExtraComposeFilesValue() { + var lines = []; + $('#settings-extra-compose-candidates input[type="checkbox"]:checked').each(function() { + lines.push($(this).val()); + }); + ($('#settings-extra-compose-external').val() || '').split('\n').forEach(function(line) { + line = line.trim(); + if (line) lines.push(line); + }); + return lines.join('\n'); +} + +// Render the candidate checkbox list and route non-candidate entries +// (external/absolute paths) into the advanced textarea. +function renderExtraComposeFiles(candidates, rawValue) { + var $list = $('#settings-extra-compose-candidates'); + var $none = $('#settings-extra-compose-none'); + var $external = $('#settings-extra-compose-external'); + + var selected = []; + (rawValue || '').split('\n').forEach(function(line) { + line = line.trim(); + if (line && line.indexOf('#') !== 0) selected.push(line); + }); + + candidates = candidates || []; + var externalLines = selected.filter(function(entry) { + return candidates.indexOf(entry) === -1; + }); + + $list.empty(); + candidates.forEach(function(name) { + var $label = $('
'; html += '
'; return html; @@ -371,7 +585,17 @@ if (action === 'all-left') moveAll(scope, false); }); - $(document).on('keydown', '.compose-transfer-btn', function(e) { + $(document).on('click', '.compose-reorder-btn', function() { + var scope = $(this).data('scope'); + var action = $(this).data('action'); + + if (!scope || !action) return; + + if (action === 'move-up') moveColumnInOrder(scope, 'up'); + if (action === 'move-down') moveColumnInOrder(scope, 'down'); + }); + + $(document).on('keydown', '.compose-transfer-btn, .compose-reorder-btn', function(e) { if (e.key !== 'Enter' && e.key !== ' ') return; e.preventDefault(); $(this).trigger('click'); diff --git a/source/compose.manager/sheets/ComboButton.css b/source/compose.manager/sheets/ComboButton.css index 167e9ab9..c47153a1 100644 --- a/source/compose.manager/sheets/ComboButton.css +++ b/source/compose.manager/sheets/ComboButton.css @@ -546,7 +546,7 @@ .compose-transfer-wrap { display: grid; - grid-template-columns: 1fr auto 1fr; + grid-template-columns: 1fr auto 1fr auto; gap: 6px; align-items: stretch; } @@ -584,6 +584,14 @@ min-width: 22px; } + .compose-reorder-actions { + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + min-width: 22px; + } + .compose-transfer-btn { min-width: 22px; height: 20px; @@ -612,6 +620,34 @@ outline-offset: 1px; } + .compose-reorder-btn { + min-width: 22px; + height: 20px; + margin: 0; + padding: 0; + border: 0; + background: transparent; + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 700; + line-height: 1; + font-size: 14px; + color: var(--brand-orange); + cursor: pointer; + border-radius: 3px; + user-select: none; + } + + .compose-reorder-btn:hover { + background: color-mix(in srgb, var(--brand-orange) 16%, transparent); + } + + .compose-reorder-btn:focus-visible { + outline: 1px solid var(--brand-orange); + outline-offset: 1px; + } + .compose-col-checkbox-label { display: flex; align-items: center; @@ -676,6 +712,11 @@ flex-direction: row; flex-wrap: wrap; } + + .compose-reorder-actions { + flex-direction: row; + flex-wrap: wrap; + } } /* Column hiding via hide-col-* classes */ From 0eb6d6a6df989ddc2549880d7922f6bdc2bcc6a9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 18 Jun 2026 20:37:32 +0000 Subject: [PATCH 66/67] chore: update changelog for v2026.06.18.1637 [skip ci] --- compose.manager.plg | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/compose.manager.plg b/compose.manager.plg index 6bafeef5..f16d3b61 100644 --- a/compose.manager.plg +++ b/compose.manager.plg @@ -36,15 +36,18 @@ > -###2026.06.15.1000 -- Features: refactor column customization UI with transfer lists and responsive design -- Features: enhance stack toggle functionality with batching and user intent tracking -- Features: add expand/collapse all functionality for compose stacks -- Features: promote col customizer and remove basic/advanced toggle -- Features: Implement snapshot loading for compose metrics and enhance data publishing to ensure data shows during progressive loading -- Features: Add column visibility customization for stack and service tables +###2026.06.18.1637 +- Features (compose): enhance column customization with reorder functionality and UI updates +- Features (compose): implement stack row striping synchronization for improved UI consistency +- Features (compose): add health status column and related functionality to stack and container views +- Features: add custom interval settings for compose stats and enhance nchan publisher +- Bug Fixes (composeStats): enhance memory value parsing and sanitize docker stats fields +- Performance (compose): throttle dockerload cache paints during websocket bursts +- Performance (compose): avoid full-table applyListView during progressive row inserts +- Performance (compose): debounce composeListRefreshed during progressive row load +- Performance: implement parallel project loading with request queuing and progress tracking - [PR #120](https://github.com/mstrhakr/compose_plugin/pull/120) -- [beta release diff](https://github.com/mstrhakr/compose_plugin/compare/v2026.06.06...v2026.06.15.1000) +- [beta release diff](https://github.com/mstrhakr/compose_plugin/compare/v2026.06.06...v2026.06.18.1637) From 8fbe44b8a6ee9a36c898c3ce7ef974a3e86b9ed9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 18 Jun 2026 20:38:16 +0000 Subject: [PATCH 67/67] Release v2026.06.18.1637 [skip ci] --- compose.manager.plg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compose.manager.plg b/compose.manager.plg index f16d3b61..ab9ed38e 100644 --- a/compose.manager.plg +++ b/compose.manager.plg @@ -2,11 +2,11 @@ - + - - + +