From af28854b59d7f1f3b7d3294ea5b592ccc467258c Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Fri, 3 Apr 2026 18:44:44 -0400 Subject: [PATCH 1/6] fix(compose): add checks for existing containers and networks before showing Compose Down action in context menu while the stack is up/partial --- .../php/compose_manager_main.php | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index a8f096f..b2112fb 100755 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -5600,6 +5600,35 @@ function addComposeStackContext(elementId) { var profiles = $row.data('profiles') || []; var webuiUrl = $row.data('webui') || ''; var hasBuild = $row.data('hasbuild') == "1"; + var hasExistingContainers = false; + var hasKnownNetworks = false; + + // Prefer the rendered row data first; this reflects current known stack containers. + try { + var rowContainers = JSON.parse($row.attr('data-containers') || '[]'); + hasExistingContainers = Array.isArray(rowContainers) && rowContainers.length > 0; + } catch (e) { + hasExistingContainers = false; + } + + // Fallback to cached short IDs if data-containers is empty/unavailable. + if (!hasExistingContainers) { + var ctidsAttr = ($row.attr('data-ctids') || '').trim(); + hasExistingContainers = ctidsAttr.length > 0; + } + + // If details were loaded, use container network attachments as an additional signal. + // This also keeps behavior resilient during in-page state transitions. + try { + var cachedContainers = stackContainersCache[stackId] || []; + hasKnownNetworks = cachedContainers.some(function(c) { + return Array.isArray(c.networks) && c.networks.length > 0; + }); + } catch (e) { + hasKnownNetworks = false; + } + + var canComposeDownStopped = hasExistingContainers || hasKnownNetworks; // Check if updates are available for this stack var hasUpdates = false; @@ -5738,6 +5767,22 @@ function addComposeStackContext(elementId) { } }); + // Compose Down (only when there are existing resources to remove) + if (canComposeDownStopped) { + opts.push({ + text: 'Compose Down', + icon: 'fa-stop', + action: function(e) { + e.preventDefault(); + if (profiles.length > 0) { + showProfileSelector('down', path, profiles); + } else { + ComposeDown(path); + } + } + }); + } + opts.push({ divider: true }); // Pull/Build only (without starting) From 5f6964d750c9ce6027ac66133e851fde8930fd86 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Wed, 8 Apr 2026 21:06:01 -0400 Subject: [PATCH 2/6] feat(sortable): implement sortable stacks with lock functionality and save order feature --- source/compose.manager/Compose.page | 1 + source/compose.manager/compose.manager.page | 1 + .../compose.manager.settings.page | 67 ++++++++ source/compose.manager/php/compose_list.php | 1 + .../php/compose_manager_main.php | 151 ++++++++++++++++++ source/compose.manager/php/defines.php | 1 + source/compose.manager/php/exec.php | 38 +++++ source/compose.manager/php/util.php | 64 ++++++++ 8 files changed, 324 insertions(+) diff --git a/source/compose.manager/Compose.page b/source/compose.manager/Compose.page index aeb2f41..8f81e0e 100644 --- a/source/compose.manager/Compose.page +++ b/source/compose.manager/Compose.page @@ -3,6 +3,7 @@ Type="xmenu" Title="Docker Compose" Tag="fa-cubes" Code="f1b3" +Lock="true" Nchan="docker_load" Cond="$var['fsState'] == 'Started' && exec('/etc/rc.d/rc.docker status | grep -v \"not\"') && exec(\"grep '^SHOW_COMPOSE_IN_HEADER_MENU=' /boot/config/plugins/compose.manager/compose.manager.cfg 2>/dev/null | grep 'true'\")" --- diff --git a/source/compose.manager/compose.manager.page b/source/compose.manager/compose.manager.page index 93856ea..fa48101 100644 --- a/source/compose.manager/compose.manager.page +++ b/source/compose.manager/compose.manager.page @@ -2,6 +2,7 @@ Author="dcflachs" Title="Compose" Type="php" Menu="Docker:2" +Lock="true" Nchan="docker_load" Cond="$var['fsState'] == 'Started' && exec('/etc/rc.d/rc.docker status | grep -v \"not\"') && (!file_exists('/boot/config/plugins/compose.manager/compose.manager.cfg') ? true : exec(\"grep '^SHOW_COMPOSE_IN_HEADER_MENU=' /boot/config/plugins/compose.manager/compose.manager.cfg 2>/dev/null | grep -v 'true'\"))" --- diff --git a/source/compose.manager/compose.manager.settings.page b/source/compose.manager/compose.manager.settings.page index 2b68f9c..8ce82e0 100755 --- a/source/compose.manager/compose.manager.settings.page +++ b/source/compose.manager/compose.manager.settings.page @@ -3,6 +3,7 @@ Icon="cubes" Author="mstrhakr" Title="Compose Manager Plus" Type="xmenu" +Lock="true" --- _(Loading stacks...)_'); $.post('/plugins/compose.manager/php/exec.php', { @@ -1574,6 +1637,8 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' off_label: '' }); }); + + initUpdatesSortable(); // Sync toggle-all state updateToggleAllState(); }, 'json').fail(function() { @@ -1641,6 +1706,8 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' // Initialize Updates tab when it's shown $(function() { + updateLockButtonUI(); + // Apply default schedule/time to all rows $('#apply-default').on('click', function() { var s = $('#default-schedule').val(); diff --git a/source/compose.manager/php/compose_list.php b/source/compose.manager/php/compose_list.php index 548a4cb..8f6c5fa 100755 --- a/source/compose.manager/php/compose_list.php +++ b/source/compose.manager/php/compose_list.php @@ -192,6 +192,7 @@ // Arrow column $o .= ""; $o .= ""; + $o .= ""; $o .= ""; // Icon column diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index b2112fb..3d6e72a 100755 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -100,6 +100,23 @@ function compose_manager_cpu_spec_count($cpuSpec) padding: 0; } + #compose_stacks td.col-arrow { + text-align: center; + white-space: nowrap; + } + + #compose_stacks td.col-arrow i { + vertical-align: middle; + } + + #compose_stacks td.col-arrow .mover { + color: var(--link-color); + } + + #compose_list.compose-sort-enabled tr.compose-sortable { + cursor: move; + } + #compose_stacks thead th.col-icon { width: 30px; padding: 0; @@ -602,6 +619,130 @@ function composeLoadlist() { }); } + function isComposeSortModeEnabled() { + return $.cookie('lockbutton') != null; + } + + function updateComposeLockButtonUI() { + var unlocked = isComposeSortModeEnabled(); + var $button = $('div.nav-item.LockButton'); + if (!$button.length) { + return; + } + + if (unlocked) { + $button.find('a').prop('title', 'Lock sortable items'); + $button.find('b').removeClass('icon-u-lock green-text').addClass('icon-u-lock-open red-text'); + $button.find('span').text('Lock sortable items'); + } else { + $button.find('a').prop('title', 'Unlock sortable items'); + $button.find('b').removeClass('icon-u-lock-open red-text').addClass('icon-u-lock green-text'); + $button.find('span').text('Unlock sortable items'); + } + } + + function saveComposeSortOrder() { + var projects = $('#compose_list > tr.compose-sortable').map(function() { + return $(this).data('project'); + }).get(); + + return $.post(caURL, { + action: 'saveStackOrder', + projects: projects + }).fail(function(xhr) { + composeClientDebug('[saveComposeSortOrder] failed', { + status: xhr.status, + response: xhr.responseText + }, 'daemon', 'error'); + }); + } + + function getComposeDetailsRowForItem($item) { + if (!$item || !$item.length) { + return $(); + } + + var rowId = $item.attr('id') || ''; + if (rowId.indexOf('stack-row-') !== 0) { + return $(); + } + + return $('#details-row-' + rowId.replace('stack-row-', '')); + } + + function reattachComposeDetailsRow($item) { + var $detailsRow = $item.data('compose-details-row'); + if ($detailsRow && $detailsRow.length) { + $detailsRow.insertAfter($item); + $item.removeData('compose-details-row'); + } + } + + function initComposeSortable() { + var $tbody = $('#compose_list'); + if (!$tbody.length) { + return; + } + + if ($tbody.hasClass('ui-sortable')) { + $tbody.sortable('destroy'); + } + + if (!isComposeSortModeEnabled()) { + $tbody.removeClass('compose-sort-enabled'); + return; + } + + $tbody.addClass('compose-sort-enabled'); + $tbody.sortable({ + helper: 'clone', + items: '> tr.compose-sortable', + cursor: 'grab', + axis: 'y', + containment: 'parent', + cancel: '[data-stackid], .compose-updatecolumn a, .compose-updatecolumn .exec, .auto_start, .switchButton, a, button, input', + delay: 100, + opacity: 0.5, + zIndex: 9999, + forcePlaceholderSize: true, + start: function(event, ui) { + var $detailsRow = getComposeDetailsRowForItem(ui.item); + if ($detailsRow.length) { + ui.item.data('compose-details-row', $detailsRow.detach()); + } + }, + update: function() { + saveComposeSortOrder(); + }, + stop: function(event, ui) { + reattachComposeDetailsRow(ui.item); + } + }); + } + + function syncComposeSortModeUI() { + var unlocked = isComposeSortModeEnabled(); + $('#compose_stacks tr.compose-sortable').each(function() { + var $row = $(this); + $row.find('.expand-icon').toggle(!unlocked); + $row.find('.mover').toggle(unlocked); + }); + + $('#compose_list').toggleClass('compose-sort-enabled', unlocked); + updateComposeLockButtonUI(); + initComposeSortable(); + } + + function LockButton() { + if (isComposeSortModeEnabled()) { + $.removeCookie('lockbutton'); + } else { + $.cookie('lockbutton', 'lockbutton'); + } + + syncComposeSortModeUI(); + } + // Initialize UI components after stack list is loaded function initStackListUI() { // Initialize autostart switches - scope to compose_list to avoid conflict with Docker tab @@ -653,6 +794,8 @@ function initStackListUI() { // Load saved update status after list is loaded loadSavedUpdateStatus(); + + syncComposeSortModeUI(); } // Load external stylesheets (non-critical styles — critical ones are inline above) @@ -5897,6 +6040,10 @@ function addComposeStackContext(elementId) { // Row click handler - expand/collapse stack details $(document).on('click', 'tr.compose-sortable[id^="stack-row-"]', function(e) { + if (isComposeSortModeEnabled()) { + return; + } + var $target = $(e.target); // Don't expand if clicking on interactive elements @@ -5920,6 +6067,10 @@ function addComposeStackContext(elementId) { // Right-click anywhere on a stack row opens the stack context menu $(document).on('contextmenu', 'tr.compose-sortable[id^="stack-row-"]', function(e) { + if (isComposeSortModeEnabled()) { + return; + } + var $icon = $(this).find('[data-stackid]').first(); if ($icon.length) { e.preventDefault(); diff --git a/source/compose.manager/php/defines.php b/source/compose.manager/php/defines.php index dc3c680..20c1b20 100644 --- a/source/compose.manager/php/defines.php +++ b/source/compose.manager/php/defines.php @@ -18,6 +18,7 @@ function locate_compose_root($name) { // Centralised file-path constants — avoid scattering identical literals define('COMPOSE_UPDATE_STATUS_FILE', '/boot/config/plugins/compose.manager/update-status.json'); +define('COMPOSE_STACK_ORDER_FILE', '/boot/config/plugins/compose.manager/stack-order.json'); define('UNRAID_UPDATE_STATUS_FILE', '/var/lib/docker/unraid-update-status.json'); define('PENDING_RECHECK_FILE', '/boot/config/plugins/compose.manager/pending-recheck.json'); diff --git a/source/compose.manager/php/exec.php b/source/compose.manager/php/exec.php index dfe82ec..8b4ba44 100644 --- a/source/compose.manager/php/exec.php +++ b/source/compose.manager/php/exec.php @@ -227,6 +227,44 @@ function getPostScript(): string echo json_encode(['result' => 'success', 'message' => '']); break; + case 'saveStackOrder': + $projects = $_POST['projects'] ?? []; + if (!is_array($projects)) { + echo json_encode(['result' => 'error', 'message' => 'Invalid projects payload']); + break; + } + + $availableProjects = StackInfo::listProjectFolders($compose_root); + $availableLookup = array_fill_keys($availableProjects, true); + $orderedProjects = []; + + foreach ($projects as $project) { + if (!is_string($project)) { + continue; + } + + $project = basename($project); + if (!isset($availableLookup[$project]) || in_array($project, $orderedProjects, true)) { + continue; + } + + $orderedProjects[] = $project; + } + + foreach ($availableProjects as $project) { + if (!in_array($project, $orderedProjects, true)) { + $orderedProjects[] = $project; + } + } + + if (!saveComposeStackOrder($compose_root, $orderedProjects)) { + echo json_encode(['result' => 'error', 'message' => 'Failed to save stack order']); + break; + } + + echo json_encode(['result' => 'success']); + break; + case 'runPatch': $cmd = isset($_POST['cmd']) ? $_POST['cmd'] : 'apply'; if (!in_array($cmd, ['apply', 'remove'])) { diff --git a/source/compose.manager/php/util.php b/source/compose.manager/php/util.php index 9124b60..48fe1d3 100644 --- a/source/compose.manager/php/util.php +++ b/source/compose.manager/php/util.php @@ -56,6 +56,56 @@ function sanitizeLogText(string $text): string return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } +function getComposeStackOrderKey(string $composeRoot): string +{ + $normalized = realpath($composeRoot); + if ($normalized === false || $normalized === null) { + $normalized = rtrim($composeRoot, '/'); + } + return (string) $normalized; +} + +function getComposeStackOrderMap(): array +{ + if (!is_file(COMPOSE_STACK_ORDER_FILE)) { + return []; + } + + $json = @file_get_contents(COMPOSE_STACK_ORDER_FILE); + if ($json === false || $json === '') { + return []; + } + + $decoded = json_decode($json, true); + return is_array($decoded) ? $decoded : []; +} + +function getComposeStackOrder(string $composeRoot): array +{ + $map = getComposeStackOrderMap(); + $key = getComposeStackOrderKey($composeRoot); + $order = $map[$key] ?? []; + return is_array($order) ? array_values(array_filter($order, 'is_string')) : []; +} + +function saveComposeStackOrder(string $composeRoot, array $projects): bool +{ + $map = getComposeStackOrderMap(); + $map[getComposeStackOrderKey($composeRoot)] = array_values($projects); + + $dir = dirname(COMPOSE_STACK_ORDER_FILE); + if (!is_dir($dir) && !@mkdir($dir, 0777, true) && !is_dir($dir)) { + return false; + } + + $json = json_encode($map, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($json === false) { + return false; + } + + return @file_put_contents(COMPOSE_STACK_ORDER_FILE, $json . "\n", LOCK_EX) !== false; +} + /** * Find the first compose file in a directory using Docker Compose spec priority. * @@ -1981,6 +2031,20 @@ public static function listProjectFolders(string $composeRoot): array $result[] = $entry; } } + + $savedOrder = getComposeStackOrder($composeRoot); + if ($savedOrder) { + $positions = array_flip($savedOrder); + usort($result, function ($left, $right) use ($positions) { + $leftPos = $positions[$left] ?? PHP_INT_MAX; + $rightPos = $positions[$right] ?? PHP_INT_MAX; + if ($leftPos === $rightPos) { + return strnatcasecmp($left, $right); + } + return $leftPos <=> $rightPos; + }); + } + return $result; } From b567e8497dcd0ae73c3b9adb93490687bd748fd7 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Wed, 8 Apr 2026 21:30:52 -0400 Subject: [PATCH 3/6] fix(sortable): extract js to own file --- .../javascript/composeSortable.js | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 source/compose.manager/javascript/composeSortable.js diff --git a/source/compose.manager/javascript/composeSortable.js b/source/compose.manager/javascript/composeSortable.js new file mode 100644 index 0000000..5b6400c --- /dev/null +++ b/source/compose.manager/javascript/composeSortable.js @@ -0,0 +1,159 @@ +/** + * Compose Manager – Stack sortable (drag-and-drop reordering) + * + * Depends on globals provided by the page: + * caURL – AJAX endpoint for exec.php + * composeClientDebug – debug/logging helper (common.js) + * $.cookie / $.removeCookie – jquery.cookie plugin + * $.fn.sortable – jQuery UI Sortable + */ + +// ── Sort-mode state ──────────────────────────────────────────────── + +function isComposeSortModeEnabled() { + return $.cookie('lockbutton') != null; +} + +// ── Lock / Unlock button UI ──────────────────────────────────────── + +function updateComposeLockButtonUI() { + var unlocked = isComposeSortModeEnabled(); + var $button = $('div.nav-item.LockButton'); + if (!$button.length) { + return; + } + + if (unlocked) { + $button.find('a').prop('title', 'Lock sortable items'); + $button.find('b').removeClass('icon-u-lock green-text').addClass('icon-u-lock-open red-text'); + $button.find('span').text('Lock sortable items'); + } else { + $button.find('a').prop('title', 'Unlock sortable items'); + $button.find('b').removeClass('icon-u-lock-open red-text').addClass('icon-u-lock green-text'); + $button.find('span').text('Unlock sortable items'); + } +} + +// ── Persist sort order ───────────────────────────────────────────── + +function saveComposeSortOrder() { + var projects = $('#compose_list > tr.compose-sortable').map(function() { + return $(this).data('project'); + }).get(); + + return $.post(caURL, { + action: 'saveStackOrder', + projects: projects + }).fail(function(xhr) { + composeClientDebug('[saveComposeSortOrder] failed', { + status: xhr.status, + response: xhr.responseText + }, 'daemon', 'error'); + }); +} + +// ── Details-row helpers (detach during drag, reattach on drop) ───── + +function getComposeDetailsRowForItem($item) { + if (!$item || !$item.length) { + return $(); + } + + var rowId = $item.attr('id') || ''; + if (rowId.indexOf('stack-row-') !== 0) { + return $(); + } + + return $('#details-row-' + rowId.replace('stack-row-', '')); +} + +function reattachComposeDetailsRow($item) { + var $detailsRow = $item.data('compose-details-row'); + if ($detailsRow && $detailsRow.length) { + $detailsRow.insertAfter($item); + $item.removeData('compose-details-row'); + } +} + +// ── jQuery UI Sortable initialisation ────────────────────────────── + +function initComposeSortable() { + var $tbody = $('#compose_list'); + if (!$tbody.length) { + return; + } + + if ($tbody.hasClass('ui-sortable')) { + $tbody.sortable('destroy'); + } + + if (!isComposeSortModeEnabled()) { + $tbody.removeClass('compose-sort-enabled'); + return; + } + + $tbody.addClass('compose-sort-enabled'); + $tbody.sortable({ + helper: 'clone', + items: '> tr.compose-sortable', + cursor: 'grab', + axis: 'y', + containment: 'parent', + cancel: '[data-stackid], .compose-updatecolumn a, .compose-updatecolumn .exec, .auto_start, .switchButton, a, button, input', + delay: 100, + opacity: 0.5, + zIndex: 9999, + forcePlaceholderSize: true, + start: function(event, ui) { + var $detailsRow = getComposeDetailsRowForItem(ui.item); + if ($detailsRow.length) { + ui.item.data('compose-details-row', $detailsRow.detach()); + } + }, + update: function() { + saveComposeSortOrder(); + }, + stop: function(event, ui) { + reattachComposeDetailsRow(ui.item); + } + }); +} + +// ── Sync all sort-mode UI (icons, class, sortable instance) ──────── + +function syncComposeSortModeUI() { + var unlocked = isComposeSortModeEnabled(); + $('#compose_stacks tr.compose-sortable').each(function() { + var $row = $(this); + $row.find('.expand-icon').toggle(!unlocked); + $row.find('.mover').toggle(unlocked); + }); + + $('#compose_list').toggleClass('compose-sort-enabled', unlocked); + updateComposeLockButtonUI(); + initComposeSortable(); +} + +// ── Global entry-point called by Unraid navigation lock button ───── +// When embedded in the Docker tab, DockerContainers.page defines its own +// LockButton(). We save the previous implementation (if any) and chain +// to it so both Docker containers AND Compose stacks react to the button. + +var _composePrevLockButton = typeof LockButton === 'function' ? LockButton : null; + +function LockButton() { + if (_composePrevLockButton) { + // Let the Docker (or other) handler run first — it toggles the + // cookie and manages its own sortable / UI state. + _composePrevLockButton(); + } else { + // Standalone page (header-menu mode) — we own the cookie. + if (isComposeSortModeEnabled()) { + $.removeCookie('lockbutton'); + } else { + $.cookie('lockbutton', 'lockbutton'); + } + } + + syncComposeSortModeUI(); +} From 50e52ae3630547f8c79a32ae8e81170f73d06c08 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Wed, 8 Apr 2026 21:31:00 -0400 Subject: [PATCH 4/6] feat(sortable): load sortable functions from external composeSortable.js --- .../php/compose_manager_main.php | 125 +----------------- 1 file changed, 2 insertions(+), 123 deletions(-) diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index 3d6e72a..5c9040e 100755 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -278,6 +278,7 @@ function compose_manager_cpu_spec_count($cpuSpec) +