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/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..2cb3a534 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,42 @@ function mergeUpdateStatus(containers, project) { return containers; } +// 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 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 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. + // 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); + // 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); +} + // Unified stack action dialog - handles up, down, and update actions function showStackActionDialog(action, path, profile) { var stackName = basename(path); @@ -3753,6 +3793,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)) + '
'; diff --git a/tests/unit/ExecActionsTest.php b/tests/unit/ExecActionsTest.php index 24cdf870..5c1e8c89 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,92 @@ 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); + + // 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']); + $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 // ===========================================