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 +
+ Compose Manager Plus ![GitHub Repo stars](https://img.shields.io/github/stars/mstrhakr/compose_plugin) ![GitHub branch check runs](https://img.shields.io/github/check-runs/mstrhakr/compose_plugin/main) @@ -6,6 +7,8 @@ ![GitHub Closed Issues](https://img.shields.io/github/issues-closed/mstrhakr/compose_plugin) ![GitHub Closed Pull Requests](https://img.shields.io/github/issues-pr-closed/mstrhakr/compose_plugin) +
+ 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($('