Skip to content
Merged
10 changes: 10 additions & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions source/compose.manager/include/Exec.php
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
43 changes: 43 additions & 0 deletions source/compose.manager/javascript/composeManagerMain.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -3753,6 +3793,9 @@ function renderStackActionDialog(action, displayName, path, profile, containers,
html += ' <i class="fa fa-arrow-right compose-status-success" style="margin:0 4px;"></i> ';
html += '<span class="compose-status-success" title="' + composeEscapeAttr(remoteSha) + '">' + composeEscapeHtml(remoteSha.substring(0, 8)) + '</span>';
html += '</div>';
if (dockerVersionsInstalled) {
html += '<div style="margin-top:4px;"><a href="#" data-changelog-container="' + composeEscapeAttr(containerName) + '" data-changelog-path="' + composeEscapeAttr(path) + '" data-changelog-profile="' + composeEscapeAttr(profile || '') + '" onclick="showComposeChangelog(this.dataset.changelogContainer,this.dataset.changelogPath,this.dataset.changelogProfile);return false;" style="font-size:0.85em;"><i class="fa fa-list" style="margin-right:3px;"></i>Changelog</a></div>';
}
} else if (localSha) {
// No update - just show current SHA (greyed)
html += '<div style="font-family:var(--font-bitstream);font-size:0.9em;margin-top:2px;" title="' + composeEscapeAttr(localSha) + '"><span class="compose-text-muted">' + composeEscapeHtml(localSha.substring(0, 8)) + '</span></div>';
Expand Down
87 changes: 87 additions & 0 deletions tests/unit/ExecActionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
// ===========================================
Expand Down
Loading