diff --git a/README.md b/README.md
index ae32bf65..c69b6944 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,5 @@
-# Compose Manager Plus
+
+



@@ -6,6 +7,8 @@


+
+
Compose Manager installs the Docker Compose CLI plugin on your unRAID server and provides a comprehensive web-based interface to create, run, update, back up, and restore Compose stacks directly from the unRAID dashboard. It includes per-stack autostart, scheduled automatic image updates, a rich context menu mirroring the native Docker tab experience, an integrated terminal for live command output, background command execution with notifications, optional UI integration to hide Compose-managed containers, and tooling for testing and CI.
## Screenshots
diff --git a/compose.manager.plg b/compose.manager.plg
index 3125d13f..4dfaec28 100644
--- a/compose.manager.plg
+++ b/compose.manager.plg
@@ -2,15 +2,15 @@
-
+
-
-
+
+
-
+
@@ -36,28 +36,11 @@
>
-###2026.09.13
-- Features: add option to not close editor modal on outside click (#126)
-- Bug Fixes: set remove-orphans checkbox checked state at render time instead of via setTimeout
-- Bug Fixes: stop forced rebuilds on compose update
-- Bug Fixes: adjust css so effective command is visible in all themes
-- Bug Fixes: enhance logging and background processing in docker_started script
-- Bug Fixes: enhance environment variable handling in build scripts and workflows
-- Bug Fixes: dynamically resolve latest infozip package from Slackware repository
-- Bug Fixes: enforce string type for path parameter in getComposeFilePath method
-- Bug Fixes: support docker-compose stacks that resolve COMPOSE_FILE from .env in default discovery mode (#149)
-- Bug Fixes: quiet routine dockerload reconnect warnings (#133)
-- Chores: update changelog for v2026.09.13.1636 [skip ci]
-- Chores: update changelog for v2026.09.07.2121 [skip ci]
-- Chores: update changelog for v2026.09.07.2056 [skip ci]
-- Chores: sync pluginURL+README for dev branch [skip ci]
-- Chores: update changelog for v2026.09.06.2116 [skip ci]
-- Chores: update changelog for v2026.09.06.1706 [skip ci]
-- Chores: update changelog for v2026.09.05.2104 [skip ci]
-- Fix #3: Add tests for indirect stack envpath metadata discovery
-- Fix: Support relative envpath values in getExplicitEnvFilePath
-- Fix: Pass stack directory to resolveProjectEnvFilePath for indirect stack envpath metadata resolution
-- [View all changes](https://github.com/mstrhakr/compose_plugin/compare/v2026.09.03...v2026.09.13)
+###2026.10.06.1617
+- Bug Fixes (loadlist): log HTTP status when a stack row request fails
+- Bug Fixes (credentials): log GitHub sign-in lifecycle and deletions
+- [PR #155](https://github.com/mstrhakr/compose_plugin/pull/155)
+- [beta release diff](https://github.com/mstrhakr/compose_plugin/compare/v2026.09.13...v2026.10.06.1617)
diff --git a/docs/configuration.md b/docs/configuration.md
index f42d2ab3..4a95a3fd 100755
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -14,6 +14,7 @@ Access settings via **Settings → Compose** in the Unraid web UI.
| **Autostart: Wait for Docker** | No | Wait for Docker's autostart containers to finish before starting compose stacks. Useful when stacks depend on non-compose Docker containers. |
| **Autostart Docker Wait Timeout** | 120 | Seconds to wait for Docker autostart containers to stabilize (applies only when "Autostart: Wait for Docker" is enabled). |
| **Autostart Timeout** | 300 | Maximum time to wait for each stack to start during autostart (seconds). |
+| **Create Missing External Networks** | No | Before a stack starts (Compose Up, Update, autostart), create any network it declares `external: true` that does not exist yet, as a plain bridge network. Docker Compose never creates external networks itself. Networks that need another driver or options (for example macvlan) must still be created by hand. |
### Display Options
diff --git a/docs/images/banner.png b/docs/images/banner.png
new file mode 100644
index 00000000..b58496a6
Binary files /dev/null and b/docs/images/banner.png differ
diff --git a/docs/images/banner.xcf b/docs/images/banner.xcf
new file mode 100644
index 00000000..50011f41
Binary files /dev/null and b/docs/images/banner.xcf differ
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.
diff --git a/source/compose.manager/compose.manager.settings.page b/source/compose.manager/compose.manager.settings.page
index 1fbb14aa..30103498 100755
--- a/source/compose.manager/compose.manager.settings.page
+++ b/source/compose.manager/compose.manager.settings.page
@@ -30,6 +30,8 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js'
+
+
');
+ }
+ $('.credential-close,.credential-cancel').on('click', closeModal);
+ $('#credential-provider').on('change', function() {
+ applyProviderDefaults();
+ });
+ $('.credential-save').on('click', saveCredential);
+ $('#credential-github-signin').on('click', function() { startGitHubSignIn(false); });
+ $('#credential-github-renew').on('click', function() { startGitHubSignIn(true); });
+ $('#credential-github-copy').on('click', function() {
+ var $copyButton = $(this);
+ copyGitHubCode($('#credential-github-code').text(), function() {
+ var original = $copyButton.html();
+ $copyButton.html(' Copied');
+ window.setTimeout(function() { $copyButton.html(original); }, 1500);
+ });
+ });
+ }
+
+ function applyProviderDefaults() {
+ var provider = $('#credential-provider').val() || 'github';
+ var config = PROVIDER_CONFIGS[provider] || PROVIDER_CONFIGS.generic;
+ var isOAuthGitHub = provider === 'github' && activeCredential.authMethod === 'oauth_device';
+ var isNewCredential = !$('#credential-id').val();
+ var isNewGitHub = provider === 'github' && isNewCredential;
+
+ $('#credential-github-oauth-section').toggle(isNewGitHub || isOAuthGitHub);
+ $('#credential-github-signin').toggle(isNewGitHub);
+ $('#credential-github-device').hide();
+ $('#credential-oauth-connected').toggle(isOAuthGitHub);
+
+ $('#credential-provider-help').toggle(!isOAuthGitHub);
+ $('#credential-provider-desc').html(config.instructions);
+ if (config.tokenUrl) {
+ $('#credential-provider-token-link').attr('href', config.tokenUrl);
+ $('#credential-provider-token-label').text(config.tokenUrlLabel);
+ $('#credential-provider-link-wrap').show();
+ } else {
+ $('#credential-provider-link-wrap').hide();
+ }
+
+ $('#credential-details').show();
+ $('#credential-secret-wrap').toggle(!isOAuthGitHub);
+ $('.credential-save').toggle(!isOAuthGitHub);
+ $('.credential-cancel').text(isOAuthGitHub ? 'Close' : 'Cancel');
+ $('#credential-provider').prop('disabled', isOAuthGitHub);
+ $('#credential-name,#credential-username').prop('readonly', isOAuthGitHub);
+
+ $('#credential-name').attr('placeholder', config.namePlaceholder);
+ $('#credential-username').attr('placeholder', config.usernamePlaceholder);
+ $('#credential-secret').attr('placeholder', isNewCredential ? config.secretPlaceholder : 'Leave empty to keep existing token');
+
+ if (config.registryReadonly) {
+ $('#credential-registry').val(config.defaultRegistry).prop('readonly', true);
+ } else {
+ $('#credential-registry').prop('readonly', false);
+ if (!$('#credential-registry').val() && config.defaultRegistry) {
+ $('#credential-registry').val(config.defaultRegistry);
+ }
+ }
+ }
+
+ function closeModal() {
+ if (githubPollTimer) window.clearTimeout(githubPollTimer);
+ githubPollTimer = null;
+ $('#compose-credential-modal').hide();
+ }
+
+ function copyGitHubCode(code, onCopied) {
+ function copyFallback() {
+ var textarea = document.createElement('textarea');
+ textarea.value = code;
+ textarea.setAttribute('readonly', '');
+ textarea.style.position = 'fixed';
+ textarea.style.left = '-9999px';
+ document.body.appendChild(textarea);
+ textarea.select();
+ var copied = false;
+ try { copied = document.execCommand('copy'); } catch (error) { copied = false; }
+ document.body.removeChild(textarea);
+ return copied;
+ }
+ function notify(copied) {
+ if (copied && typeof onCopied === 'function') onCopied();
+ }
+
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard.writeText(code).then(function() {
+ notify(true);
+ }).catch(function() {
+ notify(copyFallback());
+ });
+ return;
+ }
+ notify(copyFallback());
+ }
+
+ function renderGitHubQr(url) {
+ var $container = $('#credential-github-qr').empty();
+ if (!url || typeof window.qrcode !== 'function') {
+ $('#credential-github-qr-wrap').hide();
+ return;
+ }
+ try {
+ var qr = window.qrcode(0, 'M');
+ qr.addData(url);
+ qr.make();
+ $container.html(qr.createSvgTag(4, 4));
+ $('#credential-github-qr-wrap').show();
+ } catch (error) {
+ $('#credential-github-qr-wrap').hide();
+ }
+ }
+
+ function startGitHubSignIn(renew) {
+ var credentialId = renew && activeCredential.id ? activeCredential.id : '';
+ var $button = (credentialId ? $('#credential-github-renew') : $('#credential-github-signin')).prop('disabled', true);
+ $('#credential-modal-error').hide();
+ var payload = { action: 'startGitHubDeviceAuth' };
+ if (credentialId) payload.credentialId = credentialId;
+ $.post(window.caURL || '/plugins/compose.manager/include/Exec.php', payload).done(function(data) {
+ var response;
+ try { response = typeof data === 'string' ? JSON.parse(data) : data; } catch (error) { response = {}; }
+ if (response.result !== 'success') {
+ $('#credential-modal-error').text(response.message || 'Unable to start GitHub sign-in.').show();
+ $button.prop('disabled', false);
+ return;
+ }
+ var device = response.device;
+ $('#credential-github-code').text(device.userCode);
+ $('#credential-github-link').attr('href', device.verificationUri).text(device.verificationUri);
+ $('#credential-github-status').text('Waiting for authorization...');
+ $('#credential-github-device').show();
+ renderGitHubQr(device.verificationUriComplete || device.verificationUri);
+ copyGitHubCode(device.userCode, function() {
+ $('#credential-github-status').text('Code copied. Waiting for authorization...');
+ });
+ window.open(device.verificationUri, '_blank', 'noopener');
+ pollGitHubSignIn(device.state, device.interval || 5, credentialId);
+ }).fail(function() {
+ $('#credential-modal-error').text('Unable to reach GitHub sign-in service.').show();
+ $button.prop('disabled', false);
+ });
+ }
+
+ function pollGitHubSignIn(state, interval, credentialId) {
+ var $button = credentialId ? $('#credential-github-renew') : $('#credential-github-signin');
+ githubPollTimer = window.setTimeout(function() {
+ $.post(window.caURL || '/plugins/compose.manager/include/Exec.php', { action: 'pollGitHubDeviceAuth', state: state }).done(function(data) {
+ var response;
+ try { response = typeof data === 'string' ? JSON.parse(data) : data; } catch (error) { response = {}; }
+ if (response.result !== 'success') {
+ $('#credential-github-status').text(response.message || 'GitHub sign-in failed.');
+ $button.prop('disabled', false);
+ return;
+ }
+ var auth = response.auth || {};
+ if (auth.status === 'pending') {
+ pollGitHubSignIn(state, auth.interval || interval, credentialId);
+ return;
+ }
+ if (auth.status === 'success') {
+ var callback = onSaved;
+ loadCredentials(function() {
+ if (callback) callback(auth.credential);
+ openModal(auth.credential, null);
+ });
+ return;
+ }
+ $('#credential-github-status').text(auth.status === 'denied' ? 'Authorization was denied.' : 'Authorization expired. Try again.');
+ $button.prop('disabled', false);
+ }).fail(function() {
+ $('#credential-github-status').text('Unable to check authorization. Retrying...');
+ pollGitHubSignIn(state, interval, credentialId);
+ });
+ }, Math.max(5, interval) * 1000);
+ }
+
+ function openModal(credential, callback) {
+ ensureModal();
+ credential = credential || {};
+ activeCredential = credential;
+ onSaved = callback || null;
+ $('#credential-modal-title').text(credential.id ? 'Edit registry credential' : 'Add registry credential');
+ $('#credential-id').val(credential.id || '');
+ $('#credential-provider').val(credential.provider || 'github').prop('disabled', false);
+ $('#credential-name').val(credential.name || '');
+ $('#credential-registry').val(credential.registry || '');
+ $('#credential-username').val(credential.username || '');
+ $('#credential-secret').val('');
+ $('#credential-github-signin').prop('disabled', false);
+ $('#credential-github-renew').prop('disabled', false);
+ $('#credential-modal-error').hide().text('');
+ applyProviderDefaults();
+ if (credential.registry) $('#credential-registry').val(credential.registry);
+ $('#compose-credential-modal').css('display', 'flex');
+ $('#credential-name').trigger('focus');
+ }
+
+ function saveCredential() {
+ var $button = $('.credential-save').prop('disabled', true);
+ $.post(window.caURL || '/plugins/compose.manager/include/Exec.php', {
+ action: 'saveCredential', id: $('#credential-id').val(), name: $('#credential-name').val(),
+ provider: $('#credential-provider').val(), registry: $('#credential-registry').val(),
+ username: $('#credential-username').val(), secret: $('#credential-secret').val()
+ }).done(function(data) {
+ var response;
+ try { response = typeof data === 'string' ? JSON.parse(data) : data; } catch (error) { response = {}; }
+ if (response.result !== 'success') {
+ $('#credential-modal-error').text(response.message || 'Unable to save credential.').show();
+ return;
+ }
+ closeModal();
+ loadCredentials(function() { if (onSaved) onSaved(response.credential); });
+ }).fail(function() {
+ $('#credential-modal-error').text('Unable to reach credential service.').show();
+ }).always(function() { $button.prop('disabled', false); });
+ }
+
+ function loadCredentials(callback) {
+ $.post(window.caURL || '/plugins/compose.manager/include/Exec.php', { action: 'listCredentials' }).done(function(data) {
+ var response;
+ try { response = typeof data === 'string' ? JSON.parse(data) : data; } catch (error) { response = {}; }
+ credentials = response.result === 'success' ? (response.credentials || []) : [];
+ renderTable();
+ $(document).trigger('compose:credentials-loaded', [credentials]);
+ if (callback) callback(credentials);
+ });
+ }
+
+ function renderTable() {
+ var $body = $('#credentials-tbody');
+ if (!$body.length) return;
+ $body.empty();
+ if (!credentials.length) {
+ $body.append('| No registry credentials saved. |
');
+ return;
+ }
+ credentials.forEach(function(credential) {
+ var $row = $('');
+ var providerCfg = PROVIDER_CONFIGS[credential.provider];
+ var providerLabel = providerCfg ? providerCfg.label : (credential.provider || 'generic');
+ if (credential.authMethod === 'oauth_device') {
+ providerLabel += ' (OAuth)';
+ }
+ $row.append($('| ').text(credential.name));
+ $row.append($(' | ').text(providerLabel));
+ $row.append($(' | ').text(credential.registry));
+ $row.append($(' | ').text(credential.username));
+ $row.append($(' | ').text((credential.stacks || []).join(', ') || 'Not assigned'));
+ var $actions = $(' | ');
+ $('').on('click', function() { testCredential(credential, $(this)); }).appendTo($actions);
+ $('').on('click', function() { openModal(credential); }).appendTo($actions);
+ $('').on('click', function() { deleteCredential(credential); }).appendTo($actions);
+ $row.append($actions).appendTo($body);
+ });
+ }
+
+ function testCredential(credential, $button) {
+ var $icon = $button.prop('disabled', true).find('i').removeClass('fa-plug').addClass('fa-spinner fa-spin');
+ $.post(window.caURL || '/plugins/compose.manager/include/Exec.php', { action: 'testCredential', id: credential.id }).done(function(data) {
+ var response;
+ try { response = typeof data === 'string' ? JSON.parse(data) : data; } catch (error) { response = {}; }
+ if (response.result !== 'success') {
+ swal({ title: 'Unable to test credential', text: response.message || 'Unknown error.', type: 'error' });
+ return;
+ }
+ if (response.valid) {
+ swal({ title: 'Credential is valid', text: response.message || (credential.name + ' authenticated successfully.'), type: 'success' });
+ } else {
+ var isOAuth = credential.authMethod === 'oauth_device';
+ swal({
+ title: 'Credential rejected',
+ text: (response.message || 'The registry rejected this credential.') + ' It may have expired or been revoked.',
+ type: 'warning',
+ showCancelButton: true,
+ confirmButtonText: isOAuth ? 'Renew access' : 'Edit credential',
+ cancelButtonText: 'Later'
+ }, function(confirmed) {
+ if (!confirmed) return;
+ openModal(credential, null);
+ if (isOAuth) {
+ startGitHubSignIn(true);
+ } else {
+ $('#credential-secret').trigger('focus');
+ }
+ });
+ }
+ }).fail(function() {
+ swal({ title: 'Unable to test credential', text: 'Could not reach the credential service.', type: 'error' });
+ }).always(function() {
+ $button.prop('disabled', false);
+ $icon.removeClass('fa-spinner fa-spin').addClass('fa-plug');
+ });
+ }
+
+ function deleteCredential(credential) {
+ if ((credential.stacks || []).length) {
+ swal({ title: 'Credential is in use', text: 'Remove it from: ' + credential.stacks.join(', '), type: 'warning' });
+ return;
+ }
+ swal({ title: 'Delete credential?', text: credential.name, type: 'warning', showCancelButton: true }, function(confirmed) {
+ if (!confirmed) return;
+ $.post(window.caURL || '/plugins/compose.manager/include/Exec.php', { action: 'deleteCredential', id: credential.id }).done(function() {
+ loadCredentials();
+ });
+ });
+ }
+
+ function populateSelect($select, selectedId) {
+ $select.empty().append($(' |