From 5644e4b8f8521591b84322ceba806c3fd83aac75 Mon Sep 17 00:00:00 2001 From: Chad Condon Date: Sun, 31 May 2026 19:18:00 -0700 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 += '
Changelog
'; + html += '
Changelog
'; } } 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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']);