Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions source/compose.manager/Compose.page
Original file line number Diff line number Diff line change
Expand Up @@ -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'\")"
---
Expand Down
1 change: 1 addition & 0 deletions source/compose.manager/compose.manager.page
Original file line number Diff line number Diff line change
Expand Up @@ -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'\"))"
---
Expand Down
67 changes: 67 additions & 0 deletions source/compose.manager/compose.manager.settings.page
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ Icon="cubes"
Author="mstrhakr"
Title="Compose Manager Plus"
Type="xmenu"
Lock="true"
---
<?php
include "/usr/local/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php";
Expand Down Expand Up @@ -1520,6 +1521,68 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js'
}
}

function updateLockButtonUI() {
var unlocked = $.cookie('lockbutton') != null;
var $lockNav = $('div.nav-item.LockButton');

if (!$lockNav.length) return;

if (unlocked) {
$lockNav.find('a').prop('title', "_(Lock sortable items)_");
$lockNav.find('b').removeClass('icon-u-lock green-text').addClass('icon-u-lock-open red-text');
$lockNav.find('span').text("_(Lock sortable items)_");
} else {
$lockNav.find('a').prop('title', "_(Unlock sortable items)_");
$lockNav.find('b').removeClass('icon-u-lock-open red-text').addClass('icon-u-lock green-text');
$lockNav.find('span').text("_(Unlock sortable items)_");
}
}

function initUpdatesSortable() {
var $tbody = $('#updates-tbody');
if (!$tbody.length || typeof $.fn.sortable !== 'function') return;

if ($tbody.hasClass('ui-sortable')) {
$tbody.sortable('destroy');
}

var unlocked = $.cookie('lockbutton') != null;
if (!unlocked) {
$tbody.find('tr.sortable').css({
cursor: 'default'
});
return;
}

$tbody.find('tr.sortable').css({
cursor: 'move'
});

$tbody.sortable({
helper: 'clone',
items: 'tr.sortable',
cursor: 'grab',
axis: 'y',
containment: 'parent',
cancel: 'input,select,button,a,label,.switch-button-background',
delay: 100,
opacity: 0.5,
zIndex: 9999,
forcePlaceholderSize: true
});
}

function LockButton() {
if ($.cookie('lockbutton') == null) {
$.cookie('lockbutton', 'lockbutton');
} else {
$.removeCookie('lockbutton');
}

updateLockButtonUI();
initUpdatesSortable();
}

function loadUpdatesUI() {
$('#updates-tbody').html('<tr><td colspan="5" class="compose-text-muted" style="text-align:center;padding:12px;">_(Loading stacks...)_</td></tr>');
$.post('/plugins/compose.manager/php/exec.php', {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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();
Expand Down
162 changes: 162 additions & 0 deletions source/compose.manager/javascript/composeSortable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**
* 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');
}

Comment on lines +80 to +89
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initComposeSortable() calls sortable() unconditionally. In other plugin code (e.g. settings page) there’s an explicit guard for typeof $.fn.sortable === 'function', implying jQuery UI Sortable may not always be available. Add the same guard here to prevent runtime JS errors on pages where the sortable plugin isn’t loaded.

Copilot uses AI. Check for mistakes.
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.
// IMPORTANT: We use window.LockButton assignment (not a function declaration)
// to avoid hoisting — a hoisted function declaration would overwrite the
// global LockButton before we can capture it.

var _composePrevLockButton = window.LockButton || null;

window.LockButton = 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();
};
1 change: 1 addition & 0 deletions source/compose.manager/php/compose_list.php
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@
// Arrow column
$o .= "<td class='col-arrow'>";
$o .= "<i class='fa fa-chevron-right expand-icon' id='expand-icon-$id' onclick='toggleStackDetails(\"$id\");event.stopPropagation();' style='cursor:pointer;'></i>";
$o .= "<i class='fa fa-arrows-v mover orange-text' aria-hidden='true' style='display:none;cursor:move;'></i>";
$o .= "</td>";

// Icon column
Expand Down
Loading
Loading