";
// Arrow column
$o .= "";
@@ -183,6 +220,13 @@
$o .= "$projectNameHtml ";
$o .= "$statusLabel ";
if ($hasInvalidIndirect) {
+ composeLogger('Rendering invalid indirect warning in stack list', [
+ 'project' => $stackInfo->projectFolder,
+ 'projectPath' => $stackInfo->path,
+ 'invalidIndirectPath' => $invalidIndirectPath,
+ 'isIndirect' => $stackInfo->isIndirect,
+ 'composeSource' => $stackInfo->composeSource,
+ ], 'user', 'debug', 'stack-list');
$o .= " ";
}
$o .= "";
@@ -210,18 +254,25 @@
$uptimeClass = $isrunning ? 'green-text' : 'grey-text';
$o .= "
$uptimeDisplay ";
- // CPU & Memory column (advanced only) — populated in real-time via dockerload WebSocket
- $o .= "
";
- if ($isrunning) {
- $o .= "0% ";
- $o .= "
";
- $o .= "0B / 0B ";
- } else {
- $o .= "- ";
- $o .= " ";
- }
+ // Health column (updated from detailed inspect data by frontend; initial fallback here)
+ $healthDisplay = $isrunning ? 'n/a' : 'stopped';
+ $healthClass = $isrunning ? 'compose-text-muted' : 'grey-text';
+ $o .= " $healthDisplay ";
+
+ // Metric columns (advanced only)
+ $o .= "
";
+ $o .= "- ";
+ $o .= "
";
$o .= " ";
+ $o .= "
";
+ $o .= "- ";
+ $o .= "
";
+ $o .= " ";
+
+ $o .= "
- ";
+ $o .= "
- ";
+
// Description column (advanced only)
$o .= "
";
if ($hasInvalidIndirect) {
@@ -241,7 +292,7 @@
// Expandable details row
$o .= " ";
- $o .= "";
+ $o .= " ";
$o .= "";
$o .= " Loading containers...";
$o .= "
";
@@ -250,9 +301,16 @@
}
// If no stacks found, show a message
-if ($stackCount === 0) {
- $o = " No Docker Compose stacks found. Click 'Add New Stack' to create one. ";
+if ($mode !== 'row' && $stackCount === 0) {
+ $o = "
No Docker Compose stacks found. Click 'Add New Stack' to create one. ";
}
// Output the HTML
-echo $o;
+if ($mode === 'row') {
+ echo json_encode([
+ 'result' => 'success',
+ 'html' => $o,
+ ]);
+} else {
+ echo $o;
+}
diff --git a/source/compose.manager/include/ComposeManager.php b/source/compose.manager/include/ComposeManager.php
index 2e0258d2..9359d202 100755
--- a/source/compose.manager/include/ComposeManager.php
+++ b/source/compose.manager/include/ComposeManager.php
@@ -73,7 +73,23 @@ function compose_manager_cpu_spec_count($cpuSpec)
/* Table structure — always fixed layout */
#compose_stacks {
width: 100%;
- table-layout: fixed
+ table-layout: fixed;
+ /* Single source of truth for stack-table column widths. */
+ --cm-col-arrow-px: 24px;
+ --cm-col-icon-px: 48px;
+ --cm-col-fixed-px: calc(var(--cm-col-arrow-px) + var(--cm-col-icon-px));
+ --cm-col-name-frac: 0.188524590;
+ --cm-col-update-frac: 0.131147541;
+ --cm-col-containers-frac: 0.065573770;
+ --cm-col-uptime-frac: 0.073770492;
+ --cm-col-health-frac: 0.073770492;
+ --cm-col-cpu-frac: 0.081967213;
+ --cm-col-memory-frac: 0.106557377;
+ --cm-col-net-io-frac: 0.000000000;
+ --cm-col-block-io-frac: 0.000000000;
+ --cm-col-description-frac: 0.114754098;
+ --cm-col-path-frac: 0.098360656;
+ --cm-col-autostart-frac: 0.065573770;
}
/* Stabilize header row height across basic/advanced toggle transitions */
@@ -89,9 +105,13 @@ function compose_manager_cpu_spec_count($cpuSpec)
text-align: left;
}
- /* Clip overflowing content in fixed-layout cells */
+ /* Clip overflowing content in fixed-layout cells.
+ border-box is REQUIRED so the percentage column widths below include
+ their own padding; otherwise padding is added on top of the computed
+ width and the columns overflow/shrink, leaving a gap at the right. */
#compose_stacks th,
#compose_stacks td {
+ box-sizing: border-box;
overflow: hidden;
text-overflow: ellipsis
}
@@ -99,10 +119,54 @@ function compose_manager_cpu_spec_count($cpuSpec)
/* Basic-view column widths (7 visible columns)
Arrow + Icon are fixed px (small fixed content); rest are % of table. */
#compose_stacks thead th.col-arrow {
- width: 15px;
+ width: var(--cm-col-arrow-px);
padding: 0;
}
+ #compose_stacks thead th.col-arrow .compose-stack-toggle-all {
+ width: 24px;
+ height: 24px;
+ margin: 0 auto;
+ padding: 0;
+ border: 0;
+ background: none;
+ appearance: none;
+ -webkit-appearance: none;
+ border-radius: 4px;
+ color: var(--dynamix-tablesorter-thead-th-text-color);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ line-height: 1;
+ opacity: 0.95;
+ }
+
+ #compose_stacks thead th.col-arrow .compose-stack-toggle-all i {
+ transition: transform 0.2s ease;
+ transform: rotate(0deg);
+ }
+
+ #compose_stacks thead th.col-arrow .compose-stack-toggle-all.is-expanded i {
+ transform: rotate(180deg);
+ }
+
+ #compose_stacks thead th.col-arrow .compose-stack-toggle-all:hover {
+ color: var(--orange-text, var(--brand-orange));
+ opacity: 1;
+ }
+
+ #compose_stacks thead th.col-arrow .compose-stack-toggle-all:focus-visible {
+ outline: 1px solid var(--brand-orange);
+ outline-offset: 2px;
+ }
+
+ #compose_stacks thead th.col-arrow .compose-stack-toggle-all:disabled {
+ cursor: default;
+ opacity: 0.45;
+ }
+
#compose_stacks td.col-arrow {
text-align: center;
white-space: nowrap;
@@ -117,70 +181,56 @@ function compose_manager_cpu_spec_count($cpuSpec)
}
#compose_stacks thead th.col-icon {
- width: 30px;
+ width: var(--cm-col-icon-px);
padding: 0;
}
#compose_stacks thead th.col-name {
- width: 12%;
+ width: calc((100% - var(--cm-col-fixed-px)) * var(--cm-col-name-frac));
}
#compose_stacks thead th.col-update {
- width: 25%;
+ width: calc((100% - var(--cm-col-fixed-px)) * var(--cm-col-update-frac));
}
#compose_stacks thead th.col-containers {
- width: 25%;
+ width: calc((100% - var(--cm-col-fixed-px)) * var(--cm-col-containers-frac));
}
#compose_stacks thead th.col-uptime {
- width: 25%;
- }
-
- #compose_stacks thead th.col-autostart {
- width: 15%;
- }
-
- /* Advanced-view column widths (10 visible columns)
- Arrow + Icon stay fixed px; Description + Path get the most %. */
- #compose_stacks.cm-advanced-view thead th.col-arrow {
- width: 15px;
- }
-
- #compose_stacks.cm-advanced-view thead th.col-icon {
- width: 30px;
+ width: calc((100% - var(--cm-col-fixed-px)) * var(--cm-col-uptime-frac));
}
- #compose_stacks.cm-advanced-view thead th.col-name {
- width: 12%;
+ #compose_stacks thead th.col-health {
+ width: calc((100% - var(--cm-col-fixed-px)) * var(--cm-col-health-frac));
}
- #compose_stacks.cm-advanced-view thead th.col-update {
- width: 10%
+ #compose_stacks thead th.col-autostart {
+ width: calc((100% - var(--cm-col-fixed-px)) * var(--cm-col-autostart-frac));
}
- #compose_stacks.cm-advanced-view thead th.col-containers {
- width: 5%
+ #compose_stacks thead th.col-cpu {
+ width: calc((100% - var(--cm-col-fixed-px)) * var(--cm-col-cpu-frac))
}
- #compose_stacks.cm-advanced-view thead th.col-uptime {
- width: 6%
+ #compose_stacks thead th.col-memory {
+ width: calc((100% - var(--cm-col-fixed-px)) * var(--cm-col-memory-frac))
}
- #compose_stacks.cm-advanced-view thead th.col-load {
- width: 12%
+ #compose_stacks thead th.col-net_io {
+ width: calc((100% - var(--cm-col-fixed-px)) * var(--cm-col-net-io-frac))
}
- #compose_stacks.cm-advanced-view thead th.col-description {
- width: 22%
+ #compose_stacks thead th.col-block_io {
+ width: calc((100% - var(--cm-col-fixed-px)) * var(--cm-col-block-io-frac))
}
- #compose_stacks.cm-advanced-view thead th.col-path {
- width: 22%
+ #compose_stacks thead th.col-description {
+ width: calc((100% - var(--cm-col-fixed-px)) * var(--cm-col-description-frac))
}
- #compose_stacks.cm-advanced-view thead th.col-autostart {
- width: 8%
+ #compose_stacks thead th.col-path {
+ width: calc((100% - var(--cm-col-fixed-px)) * var(--cm-col-path-frac))
}
/* Center the Containers column */
@@ -204,24 +254,16 @@ function compose_manager_cpu_spec_count($cpuSpec)
vertical-align: middle
}
- /* Advanced/basic visibility — CSS-only so no flash of hidden content */
- #compose_stacks .cm-advanced {
- display: none
- }
-
- #compose_stacks.cm-advanced-view .cm-advanced {
- display: table-cell
- }
-
- #compose_stacks.cm-advanced-view div.cm-advanced {
- display: block
- }
-
/* Detail row */
#compose_stacks .stack-details-cell {
width: auto !important
}
+ #compose_stacks tbody tr.compose-sortable.compose-stack-row-alt,
+ #compose_stacks tbody tr.compose-sortable.compose-stack-row-alt td {
+ background-color: var(--dynamix-tablesorter-tbody-row-alt-bg-color)
+ }
+
#compose_stacks tbody tr.stack-details-row {
background-color: var(--dynamix-sb-body-bg-color) !important
}
@@ -236,10 +278,11 @@ function compose_manager_cpu_spec_count($cpuSpec)
z-index: 100 !important;
}
- /* Keep long context menus visible above fixed bottom UI bars */
+ /* Keep context menus above the fixed footer (z-index must out-rank the
+ .dropdown-menu !important rule above); the scroll spacer appended in
+ fixContextDropdownOverflow() keeps them reachable (unraid/webgui#2639) */
.dropdown-context:not(.dropdown-context-sub) {
- max-height: calc(100vh - 72px);
- overflow-y: auto;
+ z-index: 10001 !important;
}
/* CPU & Memory load display (matches Docker manager usage-disk style) */
@@ -289,6 +332,12 @@ function compose_manager_cpu_spec_count($cpuSpec)
+
+
+
+
+
+
+
@@ -352,13 +402,21 @@ function compose_manager_cpu_spec_count($cpuSpec)
-
+
+
+
+
+
Stack
Update
Containers
Uptime
- CPU & Memory load
+ Health
+ CPU
+ Memory
+ Net I/O
+ Disk I/O
Description
Path
Autostart
@@ -366,7 +424,7 @@ function compose_manager_cpu_spec_count($cpuSpec)
-
+
@@ -471,6 +529,10 @@ function compose_manager_cpu_spec_count($cpuSpec)
+
+ File:
+
+
@@ -610,6 +672,17 @@ function compose_manager_cpu_spec_count($cpuSpec)
Path to a specific external compose file (e.g., /mnt/user/appdata/myapp/custom.compose.yml). Leave empty to use folder mode or local project files.
+
+
Additional Compose Files
+
+
+
+
Selected files are appended as additional -f flags after the main compose and override files (e.g., GPU or environment overrides). Setting this disables default file discovery.
+
+
External ENV File Path
@@ -629,6 +702,15 @@ function compose_manager_cpu_spec_count($cpuSpec)
+
+
Remove Orphans by Default
+
+ >
+ Enable --remove-orphans by default for Compose Up/Down actions
+
+
When enabled, the Remove orphans option is pre-checked in Compose Up/Down dialogs and bulk start/stop dialogs. Useful when a stack is edited while containers still exist.
+
+
Compose File Selection
diff --git a/source/compose.manager/include/ComposeUtil.php b/source/compose.manager/include/ComposeUtil.php
index dd8dd1db..66d4431f 100644
--- a/source/compose.manager/include/ComposeUtil.php
+++ b/source/compose.manager/include/ComposeUtil.php
@@ -10,25 +10,42 @@
require_once("/usr/local/emhttp/plugins/compose.manager/include/Helpers.php");
$background = isset($_POST['background']) && $_POST['background'] == '1';
+$removeOrphans = isset($_POST['removeOrphans']) && $_POST['removeOrphans'] == '1';
switch ($_POST['action']) {
case 'composeUp':
- echoComposeCommand('up', false, $background);
+ echoComposeCommand('up', [
+ 'background' => $background,
+ 'removeOrphans' => $removeOrphans
+ ]);
break;
case 'composeUpRecreate':
- echoComposeCommand('up', true, $background);
+ echoComposeCommand('up', [
+ 'recreate' => true,
+ 'background' => $background,
+ 'removeOrphans' => $removeOrphans
+ ]);
break;
case 'composeDown':
- echoComposeCommand('down', false, $background);
+ echoComposeCommand('down', [
+ 'background' => $background,
+ 'removeOrphans' => $removeOrphans
+ ]);
break;
case 'composeUpPullBuild':
- echoComposeCommand('update', false, $background);
+ echoComposeCommand('update', [
+ 'background' => $background
+ ]);
break;
case 'composePull':
- echoComposeCommand('pull', false, $background);
+ echoComposeCommand('pull', [
+ 'background' => $background
+ ]);
break;
case 'composeStop':
- echoComposeCommand('stop', false, $background);
+ echoComposeCommand('stop', [
+ 'background' => $background
+ ]);
break;
case 'composeLogs':
echoComposeCommand('logs');
@@ -36,19 +53,31 @@
case 'composeUpMultiple':
$paths = isset($_POST['paths']) ? json_decode($_POST['paths'], true) : array();
if (!empty($paths)) {
- echoComposeCommandMultiple('up', $paths, $background);
+ echoComposeCommandMultiple('up', [
+ 'paths' => $paths,
+ 'background' => $background,
+ 'removeOrphans' => $removeOrphans
+ ]);
}
break;
case 'composeDownMultiple':
$paths = isset($_POST['paths']) ? json_decode($_POST['paths'], true) : array();
if (!empty($paths)) {
- echoComposeCommandMultiple('down', $paths, $background);
+ echoComposeCommandMultiple('down', [
+ 'paths' => $paths,
+ 'background' => $background,
+ 'removeOrphans' => $removeOrphans
+ ]);
}
break;
case 'composeUpdateMultiple':
$paths = isset($_POST['paths']) ? json_decode($_POST['paths'], true) : array();
if (!empty($paths)) {
- echoComposeCommandMultiple('update', $paths, $background);
+ echoComposeCommandMultiple('update', [
+ 'paths' => $paths,
+ 'background' => $background,
+ 'removeOrphans' => $removeOrphans
+ ]);
}
break;
case 'containerConsole':
diff --git a/source/compose.manager/include/Exec.php b/source/compose.manager/include/Exec.php
index 63bcfa9b..2ba19248 100644
--- a/source/compose.manager/include/Exec.php
+++ b/source/compose.manager/include/Exec.php
@@ -63,6 +63,89 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool
}
}
+if (!function_exists('resolveRequestedComposeFile')) {
+ /**
+ * Resolve an explicitly requested compose file (`file` POST param)
+ * against the stack's editable compose files.
+ *
+ * @return string|false|null Matched path, false when the request is
+ * invalid, or null when no file was requested.
+ */
+ function resolveRequestedComposeFile(StackInfo $stackInfo)
+ {
+ $requested = isset($_POST['file']) ? trim((string) $_POST['file']) : '';
+ if ($requested === '') {
+ return null;
+ }
+ foreach ($stackInfo->getEditableComposeFiles() as $candidate) {
+ if (Path::refersToSamePath($candidate, $requested)) {
+ return $candidate;
+ }
+ }
+ return false;
+ }
+}
+
+if (!function_exists('composeLoadPersistentContainerCache')) {
+ function composeLoadPersistentContainerCache(): array
+ {
+ $cacheFile = '/boot/config/plugins/compose.manager/containers.cache.json';
+ if (!is_file($cacheFile)) {
+ return [];
+ }
+ $decoded = json_decode(file_get_contents($cacheFile), true);
+ return is_array($decoded) ? $decoded : [];
+ }
+}
+
+if (!function_exists('composeSavePersistentContainerCache')) {
+ function composeSavePersistentContainerCache(array $cache): void
+ {
+ $cacheFile = '/boot/config/plugins/compose.manager/containers.cache.json';
+ file_put_contents($cacheFile, json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
+ }
+}
+
+if (!function_exists('composeResolveContainerIcon')) {
+ /**
+ * Resolve container icon using stack cache first, then docker inspect fallback.
+ *
+ * @param string $containerName
+ * @param string $service
+ * @param array $iconByService
+ * @param array $iconByName
+ * @param array $inspectIconCache Per-request inspect memoization by container name
+ */
+ function composeResolveContainerIcon(string $containerName, string $service, array $iconByService, array $iconByName, array &$inspectIconCache): string
+ {
+ if ($service !== '' && isset($iconByService[$service]) && $iconByService[$service] !== '') {
+ return $iconByService[$service];
+ }
+
+ if ($containerName !== '' && isset($iconByName[$containerName]) && $iconByName[$containerName] !== '') {
+ return $iconByName[$containerName];
+ }
+
+ if ($containerName === '') {
+ return '';
+ }
+
+ if (isset($inspectIconCache[$containerName])) {
+ return $inspectIconCache[$containerName];
+ }
+
+ global $docker_label_icon;
+ $labelTemplate = '{{ index .Config.Labels "' . $docker_label_icon . '" }}';
+ $cmd = 'docker inspect ' . escapeshellarg($containerName) . ' --format ' . escapeshellarg($labelTemplate) . ' 2>/dev/null';
+ $icon = trim((string) shell_exec($cmd));
+ if ($icon === '') {
+ $icon = '';
+ }
+ $inspectIconCache[$containerName] = $icon;
+ return $icon;
+ }
+}
+
switch ($_POST['action']) {
case 'composeLogger':
$message = $_POST['msg'] ?? '';
@@ -76,6 +159,181 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool
$cfg = @parse_ini_file("/boot/config/plugins/compose.manager/compose.manager.cfg", true, INI_SCANNER_NORMAL);
echo json_encode(['result' => 'success', 'config' => $cfg]);
break;
+ case 'getColumnVisibility':
+ $prefFile = '/boot/config/plugins/compose.manager/column_visibility.json';
+ $defaults = [
+ 'stack' => [
+ 'update' => true,
+ 'containers' => true,
+ 'uptime' => true,
+ 'health' => true,
+ 'cpu' => true,
+ 'memory' => true,
+ 'net_io' => false,
+ 'block_io' => false,
+ 'description' => true,
+ 'path' => true,
+ ],
+ 'service' => [
+ 'update' => true,
+ 'health' => true,
+ 'cpu' => true,
+ 'memory' => true,
+ 'net_io' => false,
+ 'block_io' => false,
+ 'source' => true,
+ 'tag' => true,
+ 'net' => true,
+ 'ip' => true,
+ 'cport' => true,
+ 'lport' => true,
+ ],
+ ];
+ $defaultOrder = [
+ 'stackOrder' => array_keys(array_filter($defaults['stack'])),
+ 'serviceOrder' => array_keys(array_filter($defaults['service'])),
+ ];
+
+ $visibility = array_merge($defaults, $defaultOrder);
+ if (is_file($prefFile)) {
+ $raw = @file_get_contents($prefFile);
+ $saved = json_decode((string)$raw, true);
+ if (is_array($saved)) {
+ foreach (['stack', 'service'] as $scope) {
+ if (!isset($saved[$scope]) || !is_array($saved[$scope])) continue;
+ foreach ($defaults[$scope] as $key => $defaultVal) {
+ if (array_key_exists($key, $saved[$scope])) {
+ $visibility[$scope][$key] = (bool)$saved[$scope][$key];
+ }
+ }
+ }
+
+ foreach (['stackOrder', 'serviceOrder'] as $orderKey) {
+ if (!isset($saved[$orderKey]) || !is_array($saved[$orderKey])) continue;
+
+ $scope = $orderKey === 'stackOrder' ? 'stack' : 'service';
+ $allowed = array_keys($defaults[$scope]);
+ $normalizedOrder = [];
+
+ foreach ($saved[$orderKey] as $col) {
+ if (in_array($col, $allowed, true) && $visibility[$scope][$col] && !in_array($col, $normalizedOrder, true)) {
+ $normalizedOrder[] = $col;
+ }
+ }
+
+ foreach ($allowed as $col) {
+ if ($visibility[$scope][$col] && !in_array($col, $normalizedOrder, true)) {
+ $normalizedOrder[] = $col;
+ }
+ }
+
+ $visibility[$orderKey] = $normalizedOrder;
+ }
+ }
+ }
+ echo json_encode(['result' => 'success', 'visibility' => $visibility]);
+ break;
+ case 'saveColumnVisibility':
+ $prefFile = '/boot/config/plugins/compose.manager/column_visibility.json';
+ $prefDir = dirname($prefFile);
+ $defaults = [
+ 'stack' => [
+ 'update' => true,
+ 'containers' => true,
+ 'uptime' => true,
+ 'health' => true,
+ 'cpu' => true,
+ 'memory' => true,
+ 'net_io' => false,
+ 'block_io' => false,
+ 'description' => true,
+ 'path' => true,
+ ],
+ 'service' => [
+ 'update' => true,
+ 'health' => true,
+ 'cpu' => true,
+ 'memory' => true,
+ 'net_io' => false,
+ 'block_io' => false,
+ 'source' => true,
+ 'tag' => true,
+ 'net' => true,
+ 'ip' => true,
+ 'cport' => true,
+ 'lport' => true,
+ ],
+ ];
+ $defaultOrder = [
+ 'stackOrder' => array_keys(array_filter($defaults['stack'])),
+ 'serviceOrder' => array_keys(array_filter($defaults['service'])),
+ ];
+
+ $raw = $_POST['visibility'] ?? '';
+ $parsed = json_decode((string)$raw, true);
+ if (!is_array($parsed)) {
+ echo json_encode(['result' => 'error', 'message' => 'Invalid visibility payload.']);
+ break;
+ }
+
+ $normalized = array_merge($defaults, $defaultOrder);
+ foreach (['stack', 'service'] as $scope) {
+ if (!isset($parsed[$scope]) || !is_array($parsed[$scope])) continue;
+ foreach ($defaults[$scope] as $key => $defaultVal) {
+ if (array_key_exists($key, $parsed[$scope])) {
+ $normalized[$scope][$key] = (bool)$parsed[$scope][$key];
+ }
+ }
+ }
+
+ foreach (['stackOrder', 'serviceOrder'] as $orderKey) {
+ $scope = $orderKey === 'stackOrder' ? 'stack' : 'service';
+ $allowed = array_keys($defaults[$scope]);
+ $savedOrder = isset($parsed[$orderKey]) && is_array($parsed[$orderKey]) ? $parsed[$orderKey] : [];
+ $normalizedOrder = [];
+
+ foreach ($savedOrder as $col) {
+ if (in_array($col, $allowed, true) && $normalized[$scope][$col] && !in_array($col, $normalizedOrder, true)) {
+ $normalizedOrder[] = $col;
+ }
+ }
+
+ foreach ($allowed as $col) {
+ if ($normalized[$scope][$col] && !in_array($col, $normalizedOrder, true)) {
+ $normalizedOrder[] = $col;
+ }
+ }
+
+ $normalized[$orderKey] = $normalizedOrder;
+ }
+
+ if (!is_dir($prefDir)) {
+ @mkdir($prefDir, 0777, true);
+ }
+
+ $tmp = $prefFile . '.tmp';
+ $ok = @file_put_contents($tmp, json_encode($normalized, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
+ if ($ok === false || !@rename($tmp, $prefFile)) {
+ @unlink($tmp);
+ echo json_encode(['result' => 'error', 'message' => 'Failed to persist column visibility.']);
+ break;
+ }
+
+ echo json_encode(['result' => 'success', 'visibility' => $normalized]);
+ break;
+ case 'getPersistentContainerCache':
+ $cacheFile = '/boot/config/plugins/compose.manager/containers.cache.json';
+ $cache = is_file($cacheFile) ? json_decode(file_get_contents($cacheFile), true) : [];
+ echo json_encode(['result' => 'success', 'cache' => is_array($cache) ? $cache : []]);
+ break;
+ case 'getComposeLoadSnapshot':
+ $snapshotFile = '/tmp/compose_info_nchan.snapshot.json';
+ $snapshot = is_file($snapshotFile) ? json_decode((string)file_get_contents($snapshotFile), true) : [];
+ echo json_encode([
+ 'result' => 'success',
+ 'snapshot' => is_array($snapshot) ? $snapshot : [],
+ ]);
+ break;
case 'addStack':
// Validate optional indirect inputs (folder or specific compose file)
$indirectDir = isset($_POST['stackPath']) ? trim($_POST['stackPath']) : '';
@@ -279,6 +537,16 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool
$stackInfo = StackInfo::fromProject($compose_root, $script);
$composeFilePath = $stackInfo->composeFilePath ?? ($stackInfo->composeSource . '/compose.yaml');
+ // Optional explicit file selection (e.g. additional compose files)
+ $requestedFile = resolveRequestedComposeFile($stackInfo);
+ if ($requestedFile === false) {
+ echo json_encode(['result' => 'error', 'message' => 'Requested file is not part of this stack.']);
+ break;
+ }
+ if ($requestedFile !== null) {
+ $composeFilePath = $requestedFile;
+ }
+
// Check file existence consistently regardless of how path was resolved
if (is_file($composeFilePath)) {
$scriptContents = file_get_contents($composeFilePath);
@@ -431,12 +699,24 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool
$stackInfo = StackInfo::fromProject($compose_root, $script);
$composeFilePath = $stackInfo->composeFilePath ?? ($stackInfo->composeSource . '/' . COMPOSE_FILE_NAMES[0]);
+ // Optional explicit file selection (e.g. additional compose files)
+ $requestedFile = resolveRequestedComposeFile($stackInfo);
+ if ($requestedFile === false) {
+ echo json_encode(['result' => 'error', 'message' => 'Requested file is not part of this stack.']);
+ break;
+ }
+ $isMainComposeFile = $requestedFile === null || Path::refersToSamePath($requestedFile, $composeFilePath);
+ if ($requestedFile !== null) {
+ $composeFilePath = $requestedFile;
+ }
+
if (rejectStaleClientPath($composeFilePath, 'compose file')) {
break;
}
- // Before saving, detect service renames and migrate override entries in the project override only
- if (is_file($composeFilePath)) {
+ // Before saving, detect service renames and migrate override entries in the project override only.
+ // Rename migration only applies to the main compose file.
+ if ($isMainComposeFile && is_file($composeFilePath)) {
$oldContent = file_get_contents($composeFilePath);
$stackInfo->overrideInfo->migrateOnRename($oldContent, $scriptContents);
}
@@ -701,6 +981,31 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool
$defaultProfileFile = "$compose_root/$script/default_profile";
$defaultProfile = is_file($defaultProfileFile) ? trim(file_get_contents($defaultProfileFile)) : "";
+ // Get additional compose files (one path per line)
+ $extraComposeFilesFile = "$compose_root/$script/extra_compose_files";
+ $extraComposeFiles = is_file($extraComposeFilesFile) ? trim(file_get_contents($extraComposeFilesFile)) : "";
+
+ // Candidate compose files in the compose source folder for the
+ // Additional Compose Files selector (*compose*.y(a)ml, excluding the
+ // main compose file and override files)
+ $composeFileCandidates = [];
+ $mainComposeBase = $stackInfo->composeFilePath !== null ? basename($stackInfo->composeFilePath) : '';
+ $candidateGlob = glob($stackInfo->composeSource . '/*.{yml,yaml}', GLOB_BRACE) ?: [];
+ foreach ($candidateGlob as $candidatePath) {
+ $candidateBase = basename($candidatePath);
+ if (stripos($candidateBase, 'compose') === false) {
+ continue;
+ }
+ if ($candidateBase === $mainComposeBase) {
+ continue;
+ }
+ if (stripos($candidateBase, '.override.') !== false) {
+ continue;
+ }
+ $composeFileCandidates[] = $candidateBase;
+ }
+ sort($composeFileCandidates);
+
// Get labels tab view mode (per-stack)
$labelsViewModeFile = "$compose_root/$script/labels_view_mode";
$labelsViewMode = is_file($labelsViewModeFile) ? strtolower(trim(file_get_contents($labelsViewModeFile))) : 'basic';
@@ -763,6 +1068,9 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool
'iconUrl' => $iconUrl,
'webuiUrl' => $webuiUrl,
'defaultProfile' => $defaultProfile,
+ 'extraComposeFiles' => $extraComposeFiles,
+ 'composeFileCandidates' => $composeFileCandidates,
+ 'editableComposeFiles' => $stackInfo->getEditableComposeFiles(),
'labelsViewMode' => $labelsViewMode,
'useDefaultComposeFiles' => $useDefaultComposeFiles,
'indirectMode' => $stackInfo->indirectMode,
@@ -896,6 +1204,43 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool
}
}
+ // Additional compose files (one path per line, absolute or relative to compose source).
+ // Only processed when the client sends the field: a stale UI session
+ // that predates this setting must not silently clear it.
+ $extraComposeFilesProvided = isset($_POST['extraComposeFiles']);
+ $extraComposeFiles = $extraComposeFilesProvided ? trim($_POST['extraComposeFiles']) : "";
+ $extraComposeFilesNormalized = [];
+ $extraComposeFilesError = '';
+ foreach (preg_split('/\R/', $extraComposeFiles) as $extraLine) {
+ $extraLine = trim($extraLine);
+ if ($extraLine === '' || strpos($extraLine, '#') === 0) {
+ continue;
+ }
+ if (preg_match('/\.ya?ml$/i', $extraLine) !== 1) {
+ $extraComposeFilesError = 'Additional compose file must be a .yml or .yaml file: ' . $extraLine;
+ break;
+ }
+ if (Path::isAbsolutePath($extraLine)) {
+ $realExtra = realpath($extraLine);
+ if ($realExtra === false || !is_file($realExtra)) {
+ $extraComposeFilesError = 'Additional compose file does not exist: ' . $extraLine;
+ break;
+ }
+ if (!Path::isAllowedPath($realExtra, ['/mnt', '/boot/config'])) {
+ $extraComposeFilesError = 'Additional compose files must be under /mnt/ or /boot/config/.';
+ break;
+ }
+ } elseif (strpos($extraLine, '..') !== false) {
+ $extraComposeFilesError = 'Relative additional compose file paths must not contain "..": ' . $extraLine;
+ break;
+ }
+ $extraComposeFilesNormalized[] = $extraLine;
+ }
+ if ($extraComposeFilesError !== '') {
+ echo json_encode(['result' => 'error', 'message' => $extraComposeFilesError]);
+ break;
+ }
+
// --- All validation passed, now write everything ---
// Set env path
@@ -934,6 +1279,17 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool
file_put_contents($defaultProfileFile, $defaultProfile);
}
+ // Set additional compose files (skipped entirely when the field was not sent)
+ if ($extraComposeFilesProvided) {
+ $extraComposeFilesFile = "$compose_root/$script/extra_compose_files";
+ if (empty($extraComposeFilesNormalized)) {
+ if (is_file($extraComposeFilesFile))
+ @unlink($extraComposeFilesFile);
+ } else {
+ file_put_contents($extraComposeFilesFile, implode("\n", $extraComposeFilesNormalized) . "\n");
+ }
+ }
+
// Set compose file discovery mode
$useDefaultComposeFilesFile = "$compose_root/$script/use_default_compose_files";
if ($useDefaultComposeFiles) {
@@ -1065,6 +1421,7 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool
$rawContainer['Image'] = $inspect['Config']['Image'] ?? '';
$rawContainer['Created'] = $inspect['Created'] ?? '';
$rawContainer['StartedAt'] = $inspect['State']['StartedAt'] ?? '';
+ $rawContainer['Health'] = $inspect['State']['Health']['Status'] ?? '';
// Get ports (raw bindings - IP resolved below after network detection)
$ports = [];
@@ -1290,6 +1647,25 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool
$updateResults = [];
$DockerUpdate = new DockerUpdate();
+ $persistentContainerCache = composeLoadPersistentContainerCache();
+ $stackContainerCache = $persistentContainerCache[$script] ?? [];
+ $iconByService = [];
+ $iconByName = [];
+ foreach ($stackContainerCache as $cachedService => $cachedContainer) {
+ if (!is_array($cachedContainer)) {
+ continue;
+ }
+ $cachedIcon = trim((string) ($cachedContainer['icon'] ?? ''));
+ if ($cachedIcon !== '') {
+ $iconByService[(string) $cachedService] = $cachedIcon;
+ }
+ $cachedName = trim((string) ($cachedContainer['name'] ?? ''));
+ if ($cachedName !== '' && $cachedIcon !== '') {
+ $iconByName[$cachedName] = $cachedIcon;
+ }
+ }
+ $inspectIconCache = [];
+ $stackCacheDirty = false;
// Load the update status file to get SHA values
$dockerManPaths = [
@@ -1320,10 +1696,32 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool
// Second pass: check updates and collect results
foreach ($rows as $container) {
- $containerName = $container['Name'] ?? '';
- $image = $container['Image'] ?? '';
+ $containerLower = array_change_key_case($container, CASE_LOWER);
+ $containerName = trim((string) ($containerLower['name'] ?? $containerLower['names'] ?? ''));
+ $service = trim((string) ($containerLower['service'] ?? ''));
+ $image = trim((string) ($containerLower['image'] ?? ''));
if ($containerName && $image) {
+ $icon = composeResolveContainerIcon($containerName, $service, $iconByService, $iconByName, $inspectIconCache);
+
+ if ($service !== '' && $icon !== '') {
+ if (!isset($stackContainerCache[$service]) || !is_array($stackContainerCache[$service])) {
+ $stackContainerCache[$service] = [];
+ }
+ if (($stackContainerCache[$service]['icon'] ?? '') !== $icon) {
+ $stackContainerCache[$service]['icon'] = $icon;
+ $stackCacheDirty = true;
+ }
+ if (($stackContainerCache[$service]['name'] ?? '') === '') {
+ $stackContainerCache[$service]['name'] = $containerName;
+ $stackCacheDirty = true;
+ }
+ if (($stackContainerCache[$service]['service'] ?? '') === '') {
+ $stackContainerCache[$service]['service'] = $service;
+ $stackCacheDirty = true;
+ }
+ }
+
// Normalize image name (strip docker.io/ prefix, @sha256: digest, add library/ for official images)
$image = ContainerInfo::normalizeImageForUpdateCheck($image);
@@ -1354,7 +1752,9 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool
$updateResults[] = ContainerInfo::fromUpdateResponse([
'container' => $containerName,
+ 'service' => $service,
'image' => $image,
+ 'icon' => $icon,
'hasUpdate' => $hasUpdate,
'status' => $statusText,
'localSha' => $localSha,
@@ -1362,6 +1762,11 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool
])->toUpdateArray();
}
}
+
+ if ($stackCacheDirty) {
+ $persistentContainerCache[$script] = $stackContainerCache;
+ composeSavePersistentContainerCache($persistentContainerCache);
+ }
}
echo json_encode(['result' => 'success', 'updates' => $updateResults, 'projectName' => $projectName]);
@@ -1390,6 +1795,9 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool
$allUpdates = [];
$DockerUpdate = new DockerUpdate();
+ $persistentContainerCache = composeLoadPersistentContainerCache();
+ $persistentCacheDirty = false;
+ $inspectIconCache = [];
// Path to update status file
$dockerManPaths = [
@@ -1399,6 +1807,23 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool
foreach (StackInfo::allFromRoot($compose_root) as $stackInfoItem) {
$stackName = $stackInfoItem->projectFolder;
$projectName = $stackInfoItem->projectFolder;
+ $stackContainerCache = $persistentContainerCache[$stackName] ?? [];
+ $iconByService = [];
+ $iconByName = [];
+ foreach ($stackContainerCache as $cachedService => $cachedContainer) {
+ if (!is_array($cachedContainer)) {
+ continue;
+ }
+ $cachedIcon = trim((string) ($cachedContainer['icon'] ?? ''));
+ if ($cachedIcon !== '') {
+ $iconByService[(string) $cachedService] = $cachedIcon;
+ }
+ $cachedName = trim((string) ($cachedContainer['name'] ?? ''));
+ if ($cachedName !== '' && $cachedIcon !== '') {
+ $iconByName[$cachedName] = $cachedIcon;
+ }
+ }
+ $stackCacheDirty = false;
$rows = $stackInfoItem->getContainerList();
@@ -1434,11 +1859,32 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool
foreach ($rows as $container) {
$containerLower = array_change_key_case($container, CASE_LOWER);
$containerName = trim($containerLower['name'] ?? $containerLower['names'] ?? '');
+ $service = trim((string) ($containerLower['service'] ?? ''));
$image = trim($containerLower['image'] ?? '');
$state = strtolower(trim($containerLower['state'] ?? ''));
// Only check updates for running containers
if ($containerName && $image && $state === 'running') {
+ $icon = composeResolveContainerIcon($containerName, $service, $iconByService, $iconByName, $inspectIconCache);
+
+ if ($service !== '' && $icon !== '') {
+ if (!isset($stackContainerCache[$service]) || !is_array($stackContainerCache[$service])) {
+ $stackContainerCache[$service] = [];
+ }
+ if (($stackContainerCache[$service]['icon'] ?? '') !== $icon) {
+ $stackContainerCache[$service]['icon'] = $icon;
+ $stackCacheDirty = true;
+ }
+ if (($stackContainerCache[$service]['name'] ?? '') === '') {
+ $stackContainerCache[$service]['name'] = $containerName;
+ $stackCacheDirty = true;
+ }
+ if (($stackContainerCache[$service]['service'] ?? '') === '') {
+ $stackContainerCache[$service]['service'] = $service;
+ $stackCacheDirty = true;
+ }
+ }
+
$image = ContainerInfo::normalizeImageForUpdateCheck($image);
$DockerUpdate->reloadUpdateStatus($image);
@@ -1467,7 +1913,9 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool
$stackUpdates[] = ContainerInfo::fromUpdateResponse([
'container' => $containerName,
+ 'service' => $service,
'image' => $image,
+ 'icon' => $icon,
'hasUpdate' => $hasUpdate,
'status' => ($updateStatus === null) ? 'unknown' : ($updateStatus ? 'up-to-date' : 'update-available'),
'localSha' => $localSha,
@@ -1475,6 +1923,11 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool
])->toUpdateArray();
}
}
+
+ if ($stackCacheDirty) {
+ $persistentContainerCache[$stackName] = $stackContainerCache;
+ $persistentCacheDirty = true;
+ }
}
$allUpdates[$stackName] = [
@@ -1484,6 +1937,10 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool
];
}
+ if ($persistentCacheDirty) {
+ composeSavePersistentContainerCache($persistentContainerCache);
+ }
+
// Save the update status for all stacks
$savedStatus = $allUpdates;
foreach ($savedStatus as $stackKey => &$stackData) {
@@ -1504,15 +1961,17 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool
case 'getSavedUpdateStatus':
// Load saved update status from file
$composeUpdateStatusFile = COMPOSE_UPDATE_STATUS_FILE;
+ // Check if docker.versions plugin directory exists (where changelog assets are expected)
+ $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':
diff --git a/source/compose.manager/include/Helpers.php b/source/compose.manager/include/Helpers.php
index 254a0ea3..05cf8056 100644
--- a/source/compose.manager/include/Helpers.php
+++ b/source/compose.manager/include/Helpers.php
@@ -130,10 +130,9 @@ function appendComposeEnvFileArg(array &$composeCommand, array $args): void
* Build and echo a compose command for a single stack.
*
* @param string $action The compose action (up, down, update, pull, stop, logs)
- * @param bool $recreate Whether to force recreate containers (adds --force-recreate flag)
- * @param bool $background Whether to run in the background (no terminal window; sends notification on finish)
+ * @param array $options Command options: recreate, background, removeOrphans
*/
-function echoComposeCommand($action, $recreate = false, $background = false)
+function echoComposeCommand($action, array $options = [])
{
/**
* Note: This function is called from an AJAX endpoint and must be careful to only echo the intended command or JSON response.
@@ -152,12 +151,15 @@ function echoComposeCommand($action, $recreate = false, $background = false)
$debug = $cfg['DEBUG_TO_LOG'] == "true";
$path = isset($_POST['path']) ? trim($_POST['path']) : "";
$profile = isset($_POST['profile']) ? trim($_POST['profile']) : "";
+ $recreate = !empty($options['recreate']);
+ $background = !empty($options['background']);
+ $removeOrphans = !empty($options['removeOrphans']);
$unRaidVars = parse_ini_file("/var/local/emhttp/var.ini");
if ($unRaidVars['mdState'] != "STARTED") {
echo $plugin_root . "/scripts/arrayNotStarted.sh";
composeLogger("Cannot perform action: array not started", ['action' => $action, 'path' => $path], 'user', 'debug', 'compose');
} else {
- composeLogger("Preparing compose command", ['action' => $action, 'path' => $path, 'profile' => $profile, 'recreate' => $recreate, 'background' => $background], 'user', 'debug', 'compose');
+ composeLogger("Preparing compose command", ['action' => $action, 'path' => $path, 'profile' => $profile, 'recreate' => $recreate, 'background' => $background, 'removeOrphans' => $removeOrphans], 'user', 'debug', 'compose');
$composeCommand = array($plugin_root . "scripts/compose.sh");
// Resolve stack identity via StackInfo
@@ -179,6 +181,10 @@ function echoComposeCommand($action, $recreate = false, $background = false)
$stackInfo->pruneOrphanOverrideServices();
}
+ if ($removeOrphans && ($action === 'up' || $action === 'down')) {
+ $composeCommand[] = '--remove-orphans';
+ }
+
appendComposeEnvFileArg($composeCommand, $args);
// Support multiple profiles (comma-separated)
@@ -256,10 +262,9 @@ function echoComposeCommand($action, $recreate = false, $background = false)
* Build and echo a compose command for multiple stacks.
*
* @param string $action The compose action (up, down, update)
- * @param array $paths Array of stack paths
- * @param bool $background Whether to run in the background (no terminal window; sends notification on finish)
+ * @param array $options Command options: paths, background, removeOrphans
*/
-function echoComposeCommandMultiple($action, $paths, $background = false)
+function echoComposeCommandMultiple($action, array $options = [])
{
global $plugin_root;
global $sName;
@@ -267,6 +272,9 @@ function echoComposeCommandMultiple($action, $paths, $background = false)
$cfg = parse_plugin_cfg($sName);
$debug = $cfg['DEBUG_TO_LOG'] == "true";
$unRaidVars = parse_ini_file("/var/local/emhttp/var.ini");
+ $paths = $options['paths'] ?? [];
+ $background = !empty($options['background']);
+ $removeOrphans = !empty($options['removeOrphans']);
if ($unRaidVars['mdState'] != "STARTED") {
echo $plugin_root . "/scripts/arrayNotStarted.sh";
@@ -303,6 +311,10 @@ function echoComposeCommandMultiple($action, $paths, $background = false)
$stackInfo->pruneOrphanOverrideServices();
}
+ if ($removeOrphans && ($action === 'up' || $action === 'down')) {
+ $composeCommand[] = '--remove-orphans';
+ }
+
appendComposeEnvFileArg($composeCommand, $args);
// Profile selection per action:
diff --git a/source/compose.manager/include/Util.php b/source/compose.manager/include/Util.php
index 170adcda..e4ab04cd 100644
--- a/source/compose.manager/include/Util.php
+++ b/source/compose.manager/include/Util.php
@@ -580,8 +580,113 @@ function pruneOverrideContentServices(string $overrideContent, array $validServi
// Safety check: if ALL services in the override would be removed, don't prune.
// This likely indicates a rename scenario rather than genuine orphans.
// Wiping to "services: {}" would destroy user data (icons, webui labels, etc).
+ // Exception: if every orphaned service block is clearly auto-managed by this
+ // plugin, it is safe to remove all of them.
if (count($removedRanges) === count($serviceRanges)) {
- return ['content' => $overrideContent, 'removed' => [], 'changed' => false];
+ $allAutoManaged = false;
+ $singleServiceOrphan = count($removedRanges) === 1;
+
+ if (function_exists('yaml_parse')) {
+ $parsedOverride = @yaml_parse($overrideContent);
+ if (is_array($parsedOverride) && isset($parsedOverride['services']) && is_array($parsedOverride['services'])) {
+ $allowedLabelKeys = [
+ 'net.unraid.docker.managed' => true,
+ 'net.unraid.docker.icon' => true,
+ 'net.unraid.docker.webui' => true,
+ 'net.unraid.docker.shell' => true,
+ ];
+
+ $allAutoManaged = true;
+ foreach ($removedRanges as $range) {
+ $serviceDef = $parsedOverride['services'][$range['name']] ?? null;
+ if (!is_array($serviceDef)) {
+ $allAutoManaged = false;
+ break;
+ }
+
+ $serviceKeys = array_keys($serviceDef);
+ if ($serviceKeys !== ['labels']) {
+ $allAutoManaged = false;
+ break;
+ }
+
+ $labels = $serviceDef['labels'] ?? null;
+ if (!is_array($labels) || empty($labels)) {
+ $allAutoManaged = false;
+ break;
+ }
+
+ $hasManagedLabel = array_key_exists('net.unraid.docker.managed', $labels);
+ if (!$hasManagedLabel && !$singleServiceOrphan) {
+ $allAutoManaged = false;
+ break;
+ }
+
+ foreach (array_keys($labels) as $labelKey) {
+ if (!isset($allowedLabelKeys[(string) $labelKey])) {
+ $allAutoManaged = false;
+ break 2;
+ }
+ }
+ }
+ }
+ }
+
+ if (!$allAutoManaged) {
+ $allAutoManaged = true;
+ foreach ($removedRanges as $range) {
+ $serviceBlockLines = array_slice($lines, $range['start'], $range['end'] - $range['start'] + 1);
+ $serviceBlock = implode("\n", $serviceBlockLines);
+
+ if (strpos($serviceBlock, 'net.unraid.docker.managed') !== false) {
+ continue;
+ }
+
+ if (!$singleServiceOrphan) {
+ $allAutoManaged = false;
+ break;
+ }
+
+ $hasOnlyAllowedLabels = false;
+ $sawLabelsSection = false;
+ foreach ($serviceBlockLines as $offset => $blockLine) {
+ if ($offset === 0) {
+ continue;
+ }
+
+ if (trim($blockLine) === '' || preg_match('/^\s*#/', $blockLine)) {
+ continue;
+ }
+
+ if (preg_match('/^\s{4}labels\s*:\s*(?:#.*)?$/', $blockLine)) {
+ $sawLabelsSection = true;
+ $hasOnlyAllowedLabels = true;
+ continue;
+ }
+
+ if (preg_match('/^\s{6}(net\.unraid\.docker\.(?:icon|webui|shell))\s*:\s*/', $blockLine)) {
+ if (!$sawLabelsSection) {
+ $hasOnlyAllowedLabels = false;
+ break;
+ }
+ $hasOnlyAllowedLabels = true;
+ continue;
+ }
+
+ $hasOnlyAllowedLabels = false;
+ break;
+ }
+
+ if (!$hasOnlyAllowedLabels) {
+ $allAutoManaged = false;
+ break;
+ }
+ }
+ }
+
+ if (!$allAutoManaged) {
+ return ['content' => $overrideContent, 'removed' => [], 'changed' => false];
+ }
}
// Only prune specific orphaned services while preserving others
@@ -594,6 +699,10 @@ function pruneOverrideContentServices(string $overrideContent, array $validServi
$newLines = [];
foreach ($lines as $lineIndex => $line) {
+ if (count($removedRanges) === count($serviceRanges) && $lineIndex === $servicesStart) {
+ $newLines[] = 'services: {}';
+ continue;
+ }
if (!isset($removeByLine[$lineIndex])) {
$newLines[] = $line;
}
@@ -1041,6 +1150,7 @@ public static function parseServicesFromYaml(string $yamlContent): array
* @property array $volumes Volume mounts [{source, destination, type}]
* @property string $created ISO datetime when container was created
* @property string $startedAt ISO datetime when container was started
+ * @property string $health Container health status (healthy/unhealthy/starting/empty)
* @method static ContainerInfo fromDockerInspect(array $raw) Create a ContainerInfo from a docker inspect + compose ps result array
* @method static ContainerInfo fromUpdateResponse(array $raw) Create a ContainerInfo from an update-check response element
*
@@ -1087,6 +1197,8 @@ class ContainerInfo
public string $created = '';
/** @var string ISO datetime when container was started */
public string $startedAt = '';
+ /** @var string Container health status (healthy/unhealthy/starting/empty) */
+ public string $health = '';
private function __construct() {}
@@ -1133,6 +1245,7 @@ public static function fromDockerInspect(array $raw): self
$info->volumes = $raw['Volumes'] ?? $raw['volumes'] ?? [];
$info->created = $raw['Created'] ?? $raw['created'] ?? '';
$info->startedAt = $raw['StartedAt'] ?? $raw['startedAt'] ?? '';
+ $info->health = strtolower((string)($raw['Health'] ?? $raw['health'] ?? ''));
// Normalize update status (accept PascalCase or camelCase)
$info->updateStatus = $raw['updateStatus'] ?? $raw['UpdateStatus'] ?? $raw['status'] ?? 'unknown';
@@ -1163,6 +1276,7 @@ public static function fromUpdateResponse(array $raw): self
$info->id = $raw['ID'] ?? $raw['Id'] ?? $raw['id'] ?? '';
$info->service = $raw['service'] ?? $raw['Service'] ?? '';
$info->image = $raw['image'] ?? $raw['Image'] ?? '';
+ $info->icon = $raw['icon'] ?? $raw['Icon'] ?? '';
$info->hasUpdate = $raw['hasUpdate'] ?? false;
$info->updateStatus = $raw['status'] ?? $raw['updateStatus'] ?? 'unknown';
$info->localSha = $raw['localSha'] ?? '';
@@ -1227,6 +1341,7 @@ public function toArray(): array
'volumes' => $this->volumes,
'created' => $this->created,
'startedAt' => $this->startedAt,
+ 'health' => $this->health,
];
}
@@ -1239,7 +1354,9 @@ public function toUpdateArray(): array
{
return [
'name' => $this->name,
+ 'service' => $this->service,
'image' => $this->image,
+ 'icon' => $this->icon,
'hasUpdate' => $this->hasUpdate,
'updateStatus' => $this->updateStatus,
'localSha' => $this->localSha,
@@ -1424,6 +1541,15 @@ private function __construct(string $composeRoot, string $projectFolder)
if ($this->invalidIndirectPath !== null) {
// Stack has a broken indirect reference — allow degraded construction
// so the user can fix it in the Settings editor.
+ composeLogger('Preparing degraded stack load due to invalid indirect path', [
+ 'project' => $this->projectFolder,
+ 'projectPath' => $this->path,
+ 'indirectMode' => $this->indirectMode,
+ 'indirectPath' => $this->indirectPath,
+ 'invalidIndirectPath' => $this->invalidIndirectPath,
+ 'composeSource' => $this->composeSource,
+ 'composeFilePath' => $this->composeFilePath,
+ ], 'user', 'debug', 'stack');
composeLogger("Stack $this->projectFolder has an invalid indirect path; loading in degraded mode", null, 'user', 'warning', 'stack');
$this->overrideInfo = OverrideInfo::fromStackInfo($this);
return;
@@ -1513,6 +1639,16 @@ private function isIndirect(): bool
|| Path::hasTraversal($indirectPath)
) {
// Path is structurally invalid — ignore it and keep stack local without mutating files.
+ composeLogger('Invalid indirect metadata rejected during structural validation', [
+ 'project' => $this->projectFolder,
+ 'indirectFile' => $this->path . '/indirect',
+ 'rawIndirectPath' => $indirectPath,
+ 'isEmpty' => ($indirectPath === ''),
+ 'hasNewline' => Path::hasNewline($indirectPath),
+ 'hasSeparator' => Path::hasSeparator($indirectPath),
+ 'hasWindowsStylePath' => Path::hasWindowsStylePath($indirectPath),
+ 'hasTraversal' => Path::hasTraversal($indirectPath),
+ ], 'user', 'debug', 'stack');
composeLogger("Ignoring structurally invalid indirect path at $this->path/indirect: " . sanitizeLogText($indirectPath), null, 'user', 'warning', 'stack');
return false;
}
@@ -1600,6 +1736,9 @@ public static function sanitizeProjectString(string $rawProjectString): string
$sanitizedProjectString = compose_manager_sanitize_project_name($rawProjectString, $wasEmpty);
if ($wasEmpty) {
+ composeLogger('Project name sanitization produced empty output; fallback name will be used', [
+ 'input' => $rawProjectString,
+ ], 'user', 'debug', 'stack');
composeLogger("Sanitized project string is empty after processing; defaulting to 'compose'", ['input' => $rawProjectString], 'user', 'warning', 'stack');
}
@@ -1621,6 +1760,11 @@ public function getDisplayName(): string
// If no display name is set, initialize it from the project folder name.
$displayName = $this->projectFolder;
$this->writeMetadata('name', $displayName);
+ composeLogger('Missing display name metadata detected; initializing from project folder', [
+ 'project' => $this->projectFolder,
+ 'displayName' => $displayName,
+ 'metadataFile' => $this->path . '/name',
+ ], 'user', 'debug', 'stack');
composeLogger("Initialized missing display name from project folder: '$displayName'", ['project' => $this->projectFolder, 'displayName' => $displayName], 'user', 'warning', 'stack');
}
$this->displayName = $displayName;
@@ -1701,6 +1845,11 @@ private function getExplicitEnvFilePath(): ?string
return realpath($rawEnvPath) ?: $rawEnvPath;
}
+ composeLogger('Explicit envpath is set but not resolvable to a file; falling back to default env resolution', [
+ 'project' => $this->projectFolder,
+ 'envpath' => $rawEnvPath,
+ 'composeSource' => $this->composeSource,
+ ], 'user', 'debug', 'stack');
composeLogger("Ignoring invalid envpath for stack $this->projectFolder: file not found", ['envpath' => $rawEnvPath], 'user', 'warning', 'stack');
return null;
}
@@ -1764,6 +1913,40 @@ private function getAdditionalComposeFilesFromEnv(): array
return array_values(array_unique($files));
}
+ /**
+ * Parse additional compose file paths from the `extra_compose_files`
+ * metadata file (one path per line; `#` comments allowed).
+ *
+ * Paths may be absolute or relative to the compose source folder.
+ * Missing files are skipped with a warning so a stale entry cannot
+ * break stack operations.
+ *
+ * @return string[]
+ */
+ private function getExtraComposeFiles(): array
+ {
+ $raw = $this->readMetadata('extra_compose_files');
+ if ($raw === null || $raw === '') {
+ return [];
+ }
+
+ $files = [];
+ foreach (preg_split('/\R/', $raw) as $line) {
+ $line = trim($line);
+ if ($line === '' || str_starts_with($line, '#')) {
+ continue;
+ }
+ $path = Path::isAbsolutePath($line) ? $line : $this->composeSource . '/' . $line;
+ if (!is_file($path)) {
+ composeLogger("Ignoring missing extra compose file for stack $this->projectFolder", ['path' => $path], 'user', 'warning', 'stack');
+ continue;
+ }
+ $files[] = $path;
+ }
+
+ return $files;
+ }
+
/**
* Normalize compose file paths for deduplication.
*
@@ -1820,6 +2003,26 @@ private static function splitComposeFileValue(string $value): array
return $entries;
}
+ /**
+ * Compose files that are directly editable in the stack editor: every
+ * existing file that contributes to the compose command, in `-f` order
+ * (main compose file, override file, COMPOSE_FILE env entries, and
+ * `extra_compose_files` metadata).
+ *
+ * @return string[]
+ */
+ public function getEditableComposeFiles(): array
+ {
+ $files = [];
+ foreach ($this->getComposeFilePaths() as $path) {
+ if (!is_file($path)) {
+ continue;
+ }
+ $files[] = $path;
+ }
+ return $files;
+ }
+
private function getComposeFilePaths(): array
{
$paths = [];
@@ -1835,6 +2038,10 @@ private function getComposeFilePaths(): array
$paths[] = $extraFile;
}
+ foreach ($this->getExtraComposeFiles() as $extraFile) {
+ $paths[] = $extraFile;
+ }
+
$normalized = [];
$unique = [];
foreach ($paths as $path) {
@@ -1875,8 +2082,8 @@ public function useDefaultComposeFileDiscovery(): bool
/**
* Determine whether this stack has manual settings that require explicit mode.
*
- * Explicit env-path and indirect-file selections are treated as explicit-mode
- * signals, so default file discovery is bypassed.
+ * Explicit env-path, extra compose files, and indirect-file selections are
+ * treated as explicit-mode signals, so default file discovery is bypassed.
*
* @return bool
*/
@@ -1887,6 +2094,11 @@ private function hasManualComposePathOverrides(): bool
return true;
}
+ $extraComposeFiles = $this->readMetadata('extra_compose_files');
+ if ($extraComposeFiles !== null && $extraComposeFiles !== '') {
+ return true;
+ }
+
return $this->indirectMode === 'file';
}
diff --git a/source/compose.manager/javascript/composeColumnCustomizer.js b/source/compose.manager/javascript/composeColumnCustomizer.js
new file mode 100644
index 00000000..dec9d03f
--- /dev/null
+++ b/source/compose.manager/javascript/composeColumnCustomizer.js
@@ -0,0 +1,666 @@
+/**
+ * Column customization for stack + service tables.
+ * Preferences are persisted server-side in a JSON file.
+ */
+(function() {
+ 'use strict';
+
+ var STACK_COLS = {
+ update: 'Update',
+ containers: 'Containers',
+ uptime: 'Uptime',
+ health: 'Health',
+ cpu: 'CPU %',
+ memory: 'Memory',
+ net_io: 'Network I/O',
+ block_io: 'Disk I/O',
+ description: 'Description',
+ path: 'Path'
+ };
+
+ var SERVICE_COLS = {
+ update: 'Update',
+ health: 'Health',
+ cpu: 'CPU %',
+ memory: 'Memory',
+ net_io: 'Network I/O',
+ block_io: 'Disk I/O',
+ source: 'Source',
+ tag: 'Tag',
+ net: 'Network',
+ ip: 'IP',
+ cport: 'Container Port',
+ lport: 'LAN IP:Port'
+ };
+
+ var defaults = {
+ stack: {
+ update: true,
+ containers: true,
+ uptime: true,
+ health: true,
+ cpu: true,
+ memory: true,
+ net_io: false,
+ block_io: false,
+ description: true,
+ path: true
+ },
+ service: {
+ update: true,
+ health: true,
+ cpu: true,
+ memory: true,
+ net_io: false,
+ block_io: false,
+ source: true,
+ tag: true,
+ net: true,
+ ip: true,
+ cport: true,
+ lport: true
+ }
+ };
+
+ var prefs = {
+ stack: $.extend({}, defaults.stack),
+ service: $.extend({}, defaults.service),
+ stackOrder: Object.keys(defaults.stack).filter(function(key) { return defaults.stack[key]; }),
+ serviceOrder: Object.keys(defaults.service).filter(function(key) { return defaults.service[key]; })
+ };
+
+ var STACK_WIDTH_WEIGHTS = {
+ name: 23,
+ update: 16,
+ containers: 8,
+ uptime: 9,
+ health: 9,
+ cpu: 10,
+ memory: 13,
+ net_io: 10,
+ block_io: 10,
+ description: 14,
+ path: 12,
+ autostart: 8
+ };
+
+ var STACK_DEFAULT_VISIBLE = {
+ name: true,
+ autostart: true
+ };
+
+ var STACK_CELL_CLASS_MAP = {
+ update: 'col-update',
+ containers: 'col-containers',
+ uptime: 'col-uptime',
+ health: 'col-health',
+ cpu: 'col-cpu',
+ memory: 'col-memory',
+ net_io: 'col-net_io',
+ block_io: 'col-block_io',
+ description: 'col-description',
+ path: 'col-path'
+ };
+
+ var SERVICE_HEADER_CLASS_MAP = {
+ update: 'ct-col-update',
+ health: 'ct-col-health',
+ cpu: 'ct-col-cpu',
+ memory: 'ct-col-memory',
+ net_io: 'ct-col-net_io',
+ block_io: 'ct-col-block_io',
+ source: 'ct-col-source',
+ tag: 'ct-col-tag',
+ net: 'ct-col-net',
+ ip: 'ct-col-ip',
+ cport: 'ct-col-cport',
+ lport: 'ct-col-lport'
+ };
+
+ var SERVICE_BODY_CLASS_MAP = {
+ update: 'ct-updatecolumn',
+ health: 'ct-col-health',
+ cpu: 'ct-col-cpu',
+ memory: 'ct-col-memory',
+ net_io: 'ct-col-net_io',
+ block_io: 'ct-col-block_io',
+ source: 'ct-col-source',
+ tag: 'ct-col-tag',
+ net: 'ct-col-net',
+ ip: 'ct-col-ip',
+ cport: 'ct-col-cport-cell',
+ lport: 'ct-col-lport-cell'
+ };
+
+ function normalizePrefs(incoming) {
+ var out = {
+ stack: $.extend({}, defaults.stack),
+ service: $.extend({}, defaults.service),
+ stackOrder: Object.keys(defaults.stack).filter(function(key) { return defaults.stack[key]; }),
+ serviceOrder: Object.keys(defaults.service).filter(function(key) { return defaults.service[key]; })
+ };
+ if (!incoming || typeof incoming !== 'object') return out;
+
+ ['stack', 'service'].forEach(function(scope) {
+ if (!incoming[scope] || typeof incoming[scope] !== 'object') return;
+ Object.keys(out[scope]).forEach(function(key) {
+ if (Object.prototype.hasOwnProperty.call(incoming[scope], key)) {
+ out[scope][key] = !!incoming[scope][key];
+ }
+ });
+ });
+
+ // Handle order arrays
+ var scopeMap = { stack: 'stackOrder', service: 'serviceOrder' };
+ ['stack', 'service'].forEach(function(scope) {
+ var orderKey = scopeMap[scope];
+ if (Array.isArray(incoming[orderKey])) {
+ var visibleCols = [];
+ incoming[orderKey].forEach(function(col) {
+ if (out[scope].hasOwnProperty(col) && out[scope][col]) {
+ visibleCols.push(col);
+ }
+ });
+ // Ensure all visible columns are in the order, add missing ones at end
+ Object.keys(out[scope]).forEach(function(col) {
+ if (out[scope][col] && visibleCols.indexOf(col) === -1) {
+ visibleCols.push(col);
+ }
+ });
+ out[orderKey] = visibleCols;
+ }
+ });
+
+ return out;
+ }
+
+ function applyScope(scope) {
+ var selector = scope === 'stack' ? '#compose_stacks' : '.compose-ct-table';
+ var $tables = $(selector);
+
+ if (scope === 'stack') {
+ reorderStackTable();
+ } else {
+ reorderServiceTables();
+ }
+
+ Object.keys(prefs[scope]).forEach(function(col) {
+ $tables.toggleClass('hide-col-' + col, !prefs[scope][col]);
+ });
+ if (scope === 'stack') {
+ applyStackWidthMath();
+ syncStackColspans();
+ }
+ forceTableLayoutReflow($tables);
+ }
+
+ // Number of physically rendered stack columns. Arrow, icon, name and
+ // autostart are structural and always visible; all other stack columns are
+ // user-toggleable.
+ function getVisibleStackColCount() {
+ var count = 4; // arrow, icon, name, autostart
+ Object.keys(STACK_COLS).forEach(function(col) {
+ if (prefs.stack[col]) count++;
+ });
+ return count;
+ }
+
+ // Full-width rows (detail rows, progress/empty/error rows) are authored with
+ // a static colspan that assumes every column exists. Under table-layout:fixed
+ // an oversized colspan keeps the display:none columns' slots alive, so they
+ // steal width from the visible columns. Clamp colspan to the live column count.
+ function syncStackColspans() {
+ var count = getVisibleStackColCount();
+ $('#compose_stacks').find('td[colspan]').attr('colspan', count);
+ }
+
+ function applyStackWidthMath() {
+ var $table = $('#compose_stacks');
+ if (!$table.length) return;
+
+ var visible = $.extend({}, STACK_DEFAULT_VISIBLE);
+ Object.keys(STACK_COLS).forEach(function(col) {
+ visible[col] = !!prefs.stack[col];
+ });
+
+ var totalWeight = 0;
+ Object.keys(STACK_WIDTH_WEIGHTS).forEach(function(col) {
+ if (visible[col]) {
+ totalWeight += STACK_WIDTH_WEIGHTS[col];
+ }
+ });
+ if (totalWeight <= 0) return;
+
+ var tableEl = $table[0];
+ Object.keys(STACK_WIDTH_WEIGHTS).forEach(function(col) {
+ var fraction = visible[col] ? (STACK_WIDTH_WEIGHTS[col] / totalWeight) : 0;
+ var cssVar = '--cm-col-' + col.replace(/_/g, '-') + '-frac';
+ tableEl.style.setProperty(cssVar, String(fraction));
+ });
+ }
+
+ function forceTableLayoutReflow($tables) {
+ if (!$tables || !$tables.length) return;
+
+ // Toggling display on columns already triggers a fixed-layout recompute;
+ // we only need a read to flush it synchronously. No width mutation (that
+ // caused a visible "snap"). Column widths come purely from CSS vars.
+ $tables.each(function() {
+ if (this) void this.offsetWidth;
+ });
+ }
+
+ function getScopeRenderOrder(scope) {
+ var map = getScopeMap(scope);
+ var orderKey = scope === 'stack' ? 'stackOrder' : 'serviceOrder';
+ var order = Array.isArray(prefs[orderKey]) ? prefs[orderKey].slice() : [];
+
+ Object.keys(map).forEach(function(col) {
+ if (order.indexOf(col) === -1) {
+ order.push(col);
+ }
+ });
+
+ return order;
+ }
+
+ function reorderStackTable() {
+ var order = getScopeRenderOrder('stack');
+
+ $('#compose_stacks tr').each(function() {
+ var $row = $(this);
+ if ($row.children('[colspan]').length) return;
+
+ var $autostart = $row.children('.col-autostart').first();
+ if (!$autostart.length) return;
+
+ order.forEach(function(col) {
+ var className = STACK_CELL_CLASS_MAP[col];
+ if (!className) return;
+
+ var $cell = $row.children('.' + className).first();
+ if ($cell.length) {
+ $autostart.before($cell);
+ }
+ });
+ });
+ }
+
+ function reorderServiceTables() {
+ var order = getScopeRenderOrder('service');
+
+ $('.compose-ct-table').each(function() {
+ var $table = $(this);
+
+ $table.find('tr').each(function() {
+ var $row = $(this);
+ var $anchor = $row.children('.ct-col-name, .ct-name').first();
+ if (!$anchor.length) return;
+
+ order.forEach(function(col) {
+ var className = $row.closest('thead').length ? SERVICE_HEADER_CLASS_MAP[col] : SERVICE_BODY_CLASS_MAP[col];
+ if (!className) return;
+
+ var $cell = $row.children('.' + className).first();
+ if ($cell.length) {
+ $anchor.after($cell);
+ $anchor = $cell;
+ }
+ });
+ });
+ });
+ }
+
+ function setTransferSelection(scope, side, values, shouldFocus) {
+ var normalizedValues = Array.isArray(values) ? values.filter(Boolean) : (values ? [values] : []);
+ var $select = $(scopeSelectId(scope, side));
+ if (!$select.length) return;
+
+ $select.val(normalizedValues);
+ $select.find('option').prop('selected', false);
+ normalizedValues.forEach(function(value) {
+ $select.find('option[value="' + value + '"]').prop('selected', true);
+ });
+
+ if (shouldFocus) {
+ $select.trigger('focus');
+ }
+ }
+
+ function applyAll() {
+ applyScope('stack');
+ applyScope('service');
+ }
+
+ function getScopeMap(scope) {
+ return scope === 'stack' ? STACK_COLS : SERVICE_COLS;
+ }
+
+ function scopeSelectId(scope, side) {
+ return '#compose-col-' + scope + '-' + side;
+ }
+
+ function renderScopeTransfer(scope) {
+ var map = getScopeMap(scope);
+ var $hidden = $(scopeSelectId(scope, 'hidden'));
+ var $visible = $(scopeSelectId(scope, 'visible'));
+
+ if (!$hidden.length || !$visible.length) return;
+
+ var hiddenHtml = '';
+ var visibleHtml = '';
+ var hiddenSelection = getSelectedTransferKeys(scope, 'hidden');
+ var visibleSelection = getSelectedTransferKeys(scope, 'visible');
+ var order = getScopeRenderOrder(scope);
+
+ // Build visible list in order
+ order.forEach(function(col) {
+ if (prefs[scope] && prefs[scope][col]) {
+ var optionHtml = '' + map[col] + ' ';
+ visibleHtml += optionHtml;
+ }
+ });
+
+ // Build hidden list
+ Object.keys(map).forEach(function(col) {
+ if (!prefs[scope] || !prefs[scope][col]) {
+ var optionHtml = '' + map[col] + ' ';
+ hiddenHtml += optionHtml;
+ }
+ });
+
+ if (!hiddenHtml) {
+ hiddenHtml = '(none) ';
+ }
+ if (!visibleHtml) {
+ visibleHtml = '(none) ';
+ }
+
+ $hidden.html(hiddenHtml);
+ $visible.html(visibleHtml);
+ setTransferSelection(scope, 'hidden', hiddenSelection, false);
+ setTransferSelection(scope, 'visible', visibleSelection, false);
+ }
+
+ function renderTransferLists() {
+ renderScopeTransfer('stack');
+ renderScopeTransfer('service');
+ }
+
+ function setScopeColumnVisibility(scope, keys, isVisible) {
+ if (!keys || !keys.length || !prefs[scope]) return;
+
+ var orderKey = scope === 'stack' ? 'stackOrder' : 'serviceOrder';
+ var order = prefs[orderKey] || [];
+
+ keys.forEach(function(col) {
+ if (Object.prototype.hasOwnProperty.call(prefs[scope], col)) {
+ prefs[scope][col] = isVisible;
+ // Update order: add if visible and not in order, remove if hidden
+ var idx = order.indexOf(col);
+ if (isVisible && idx === -1) {
+ order.push(col);
+ } else if (!isVisible && idx !== -1) {
+ order.splice(idx, 1);
+ }
+ }
+ });
+
+ applyScope(scope);
+ renderScopeTransfer(scope);
+ }
+
+ function moveColumnInOrder(scope, direction) {
+ var orderKey = scope === 'stack' ? 'stackOrder' : 'serviceOrder';
+ var order = prefs[orderKey] || [];
+ var selectedValues = getSelectedTransferKeys(scope, 'visible');
+ if (!selectedValues.length) return;
+
+ var col = selectedValues[0];
+ var idx = order.indexOf(col);
+ if (idx === -1) return;
+
+ var newIdx;
+ if (direction === 'up' && idx > 0) {
+ newIdx = idx - 1;
+ } else if (direction === 'down' && idx < order.length - 1) {
+ newIdx = idx + 1;
+ } else {
+ return;
+ }
+
+ // Swap positions
+ var temp = order[idx];
+ order[idx] = order[newIdx];
+ order[newIdx] = temp;
+
+ if (scope === 'stack') {
+ reorderStackTable();
+ } else {
+ reorderServiceTables();
+ }
+ applyScope(scope);
+ renderScopeTransfer(scope);
+ setTransferSelection(scope, 'visible', [col], true);
+ }
+
+ function getSelectedTransferKeys(scope, side) {
+ var values = $(scopeSelectId(scope, side)).val();
+ if (!values) return [];
+ return Array.isArray(values) ? values : [values];
+ }
+
+ function moveSelected(scope, toVisible) {
+ var side = toVisible ? 'hidden' : 'visible';
+ var keys = getSelectedTransferKeys(scope, side).filter(function(key) {
+ return key !== '';
+ });
+ setScopeColumnVisibility(scope, keys, toVisible);
+ setTransferSelection(scope, toVisible ? 'visible' : 'hidden', keys, false);
+ }
+
+ function moveAll(scope, toVisible) {
+ var map = getScopeMap(scope);
+ var keys = [];
+
+ Object.keys(map).forEach(function(col) {
+ var currentlyVisible = !!(prefs[scope] && prefs[scope][col]);
+ if (toVisible && !currentlyVisible) keys.push(col);
+ if (!toVisible && currentlyVisible) keys.push(col);
+ });
+
+ setScopeColumnVisibility(scope, keys, toVisible);
+ }
+
+ function buildTransferSection(scope, title) {
+ var html = '';
+ html += '
' + title + '
';
+ html += '
';
+ html += '
';
+ html += 'Hidden ';
+ html += ' ';
+ html += '
';
+ html += '
';
+ html += '
>
';
+ html += '
>>
';
+ html += '
<
';
+ html += '
<<
';
+ html += '
';
+ html += '
';
+ html += 'Visible ';
+ html += ' ';
+ html += '
';
+ html += '
';
+ html += '
↑
';
+ html += '
↓
';
+ html += '
';
+ html += '
';
+ html += '
';
+ return html;
+ }
+
+ function buildModal() {
+ var html = '';
+ html += '
';
+ html += '
';
+ html += '';
+ html += '
';
+ html += buildTransferSection('stack', 'Stack Columns');
+ html += buildTransferSection('service', 'Service Columns');
+ html += '
';
+ html += '
';
+ return html;
+ }
+
+ function syncModalFromPrefs() {
+ renderTransferLists();
+ }
+
+ function fetchPrefs(cb) {
+ $.post(caURL, {
+ action: 'getColumnVisibility'
+ }, function(resp) {
+ var parsed = resp;
+ if (typeof parsed === 'string') {
+ try {
+ parsed = JSON.parse(parsed);
+ } catch (e) {
+ parsed = null;
+ }
+ }
+ if (parsed && parsed.result === 'success' && parsed.visibility) {
+ prefs = normalizePrefs(parsed.visibility);
+ } else {
+ prefs = normalizePrefs(null);
+ }
+ if (typeof cb === 'function') cb();
+ }).fail(function() {
+ prefs = normalizePrefs(null);
+ if (typeof cb === 'function') cb();
+ });
+ }
+
+ function savePrefs(cb) {
+ $.post(caURL, {
+ action: 'saveColumnVisibility',
+ visibility: JSON.stringify(prefs)
+ }, function(resp) {
+ var parsed = resp;
+ if (typeof parsed === 'string') {
+ try {
+ parsed = JSON.parse(parsed);
+ } catch (e) {
+ parsed = null;
+ }
+ }
+ if (parsed && parsed.result === 'success' && parsed.visibility) {
+ prefs = normalizePrefs(parsed.visibility);
+ }
+ if (typeof cb === 'function') cb();
+ }).fail(function() {
+ if (typeof cb === 'function') cb();
+ });
+ }
+
+ window.composeColCustomizer = {
+ init: function() {
+ if (!$('#compose-col-settings-modal').length) {
+ $('body').append(buildModal());
+ }
+
+ $(document).on('click', '.compose-transfer-btn', function() {
+ var scope = $(this).data('scope');
+ var action = $(this).data('action');
+
+ if (!scope || !action) return;
+
+ if (action === 'selected-right') moveSelected(scope, true);
+ if (action === 'all-right') moveAll(scope, true);
+ if (action === 'selected-left') moveSelected(scope, false);
+ if (action === 'all-left') moveAll(scope, false);
+ });
+
+ $(document).on('click', '.compose-reorder-btn', function() {
+ var scope = $(this).data('scope');
+ var action = $(this).data('action');
+
+ if (!scope || !action) return;
+
+ if (action === 'move-up') moveColumnInOrder(scope, 'up');
+ if (action === 'move-down') moveColumnInOrder(scope, 'down');
+ });
+
+ $(document).on('keydown', '.compose-transfer-btn, .compose-reorder-btn', function(e) {
+ if (e.key !== 'Enter' && e.key !== ' ') return;
+ e.preventDefault();
+ $(this).trigger('click');
+ });
+
+ this.addToolbarButton();
+ fetchPrefs(function() {
+ syncModalFromPrefs();
+ applyAll();
+ });
+ },
+
+ reapply: function() {
+ applyAll();
+ },
+
+ syncColspans: function() {
+ syncStackColspans();
+ },
+
+ openModal: function() {
+ syncModalFromPrefs();
+ $('#compose-col-settings-modal').fadeIn(150);
+ $('body').css('overflow', 'hidden');
+ },
+
+ closeModal: function() {
+ $('#compose-col-settings-modal').fadeOut(150);
+ $('body').css('overflow', '');
+ },
+
+ saveAndClose: function() {
+ var self = this;
+ savePrefs(function() {
+ applyAll();
+ self.closeModal();
+ });
+ },
+
+ addToolbarButton: function() {
+ if ($('#compose-col-launcher-wrap').length) return;
+
+ var launcherHtml = '' +
+ '';
+
+ var $launcher = $(launcherHtml);
+ var $tableWrapper = $('#compose_stacks').closest('.TableContainer');
+ if ($tableWrapper.length) {
+ $tableWrapper.before($launcher);
+ } else if ($('#compose_stacks').length) {
+ $('#compose_stacks').before($launcher);
+ } else if ($('.tabs').length) {
+ $('.tabs').append($launcher);
+ } else {
+ $('body').prepend($launcher);
+ }
+ }
+ };
+
+ $(function() {
+ window.composeColCustomizer.init();
+ });
+})();
diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js
index 8ea6981c..7701c569 100644
--- a/source/compose.manager/javascript/composeManagerMain.js
+++ b/source/compose.manager/javascript/composeManagerMain.js
@@ -3,7 +3,7 @@
// Supports both IEC (KiB, MiB, GiB, TiB) and SI (kB, MB, GB, TB) suffixes.
function parseMemValueToBytes(memVal) {
if (!memVal) return 0;
- var cleaned = String(memVal).trim();
+ var cleaned = sanitizeStatsValue(memVal);
if (!cleaned) return 0;
var match = cleaned.match(/([\d.]+)\s*([kmgt]?i?b)?/i);
if (!match) return 0;
@@ -34,6 +34,15 @@ function parseMemValueToBytes(memVal) {
}
}
+// Remove terminal escape residue from docker stats fields (for example trailing "[K").
+function sanitizeStatsValue(raw) {
+ var value = String(raw || '');
+ value = value.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '');
+ value = value.replace(/[\x00-\x1F\x7F-\x9F]/g, '');
+ value = value.replace(/\[[A-Za-z]$/g, '');
+ return value.trim();
+}
+
// Parse docker stats memory string "used / limit" into bytes.
function parseMemUsagePair(memStr) {
if (!memStr) return {
@@ -48,6 +57,31 @@ function parseMemUsagePair(memStr) {
limit: limit
};
}
+
+// Remove terminal control bytes and normalize docker IDs used as DOM keys.
+function composeNormalizeContainerKey(rawId) {
+ var value = String(rawId || '');
+ // Strip ANSI CSI sequences and remaining control bytes.
+ value = value.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '');
+ value = value.replace(/[\x00-\x1F\x7F-\x9F]/g, '');
+ value = value.trim();
+
+ // Prefer canonical hex ID if present.
+ var hexMatch = value.match(/[a-f0-9]{12,64}/i);
+ if (hexMatch && hexMatch[0]) {
+ return hexMatch[0].toLowerCase();
+ }
+ return value;
+}
+
+// Safe fragment for jQuery/CSS selector concatenation.
+function composeEscapeSelectorFragment(value) {
+ var normalized = composeNormalizeContainerKey(value);
+ if (typeof CSS !== 'undefined' && CSS && typeof CSS.escape === 'function') {
+ return CSS.escape(normalized);
+ }
+ return normalized.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
+}
function updateAddStackDefaultComposeDiscoveryState() {
var $checkbox = $('#compose-stack-use-default-compose-files');
var $notice = $('#compose-stack-default-compose-files-disabled-note');
@@ -91,15 +125,14 @@ function parseMemUsagePair(memStr) {
}
}
- var hasEnvPath = ($('#settings-env-path').val() || '').trim() !== '';
- var hasExternalComposeFilePath = ($('#settings-external-compose-file').val() || '').trim() !== '';
+ var manualOverrideReasons = [];
+ if (($('#settings-env-path').val() || '').trim() !== '') manualOverrideReasons.push('Env File Path');
+ if (($('#settings-external-compose-file').val() || '').trim() !== '') manualOverrideReasons.push('External Compose File');
+ if (getExtraComposeFilesValue() !== '') manualOverrideReasons.push('Additional Compose Files');
- if (hasEnvPath || hasExternalComposeFilePath) {
- var reason = hasEnvPath && hasExternalComposeFilePath
- ? 'Default discovery disabled because Env File Path and External Compose File are set.'
- : (hasEnvPath
- ? 'Default discovery disabled because Env File Path is set.'
- : 'Default discovery disabled because External Compose File is set.');
+ if (manualOverrideReasons.length > 0) {
+ var reason = 'Default discovery disabled because ' + manualOverrideReasons.join(' and ')
+ + (manualOverrideReasons.length > 1 ? ' are set.' : ' is set.');
if ($checkbox.is(':checked')) {
$checkbox.prop('checked', false);
@@ -115,6 +148,76 @@ function parseMemUsagePair(memStr) {
}
}
+// ---- Additional Compose Files settings UI ----
+
+// Combine checked folder candidates and external lines into the
+// newline-separated value stored in the extra_compose_files metadata file.
+function getExtraComposeFilesValue() {
+ var lines = [];
+ $('#settings-extra-compose-candidates input[type="checkbox"]:checked').each(function() {
+ lines.push($(this).val());
+ });
+ ($('#settings-extra-compose-external').val() || '').split('\n').forEach(function(line) {
+ line = line.trim();
+ if (line) lines.push(line);
+ });
+ return lines.join('\n');
+}
+
+// Render the candidate checkbox list and route non-candidate entries
+// (external/absolute paths) into the advanced textarea.
+function renderExtraComposeFiles(candidates, rawValue) {
+ var $list = $('#settings-extra-compose-candidates');
+ var $none = $('#settings-extra-compose-none');
+ var $external = $('#settings-extra-compose-external');
+
+ var selected = [];
+ (rawValue || '').split('\n').forEach(function(line) {
+ line = line.trim();
+ if (line && line.indexOf('#') !== 0) selected.push(line);
+ });
+
+ candidates = candidates || [];
+ var externalLines = selected.filter(function(entry) {
+ return candidates.indexOf(entry) === -1;
+ });
+
+ $list.empty();
+ candidates.forEach(function(name) {
+ var $label = $('').css({ display: 'flex', 'align-items': 'center', gap: '8px', 'font-weight': 'normal' });
+ var $cb = $(' ').val(name).prop('checked', selected.indexOf(name) !== -1);
+ $label.append($cb).append($('').text(name));
+ $list.append($label);
+ });
+ $list.toggle(candidates.length > 0);
+ $none.toggle(candidates.length === 0);
+ $external.val(externalLines.join('\n'));
+ if (externalLines.length > 0) {
+ $('#settings-extra-compose-external-wrap').attr('open', '');
+ }
+}
+
+function onExtraComposeFilesChanged() {
+ var currentValue = getExtraComposeFilesValue();
+ var originalValue = editorModal.originalSettings['extra-compose-files'] || '';
+
+ if (currentValue !== originalValue) {
+ editorModal.modifiedSettings.add('extra-compose-files');
+ } else {
+ editorModal.modifiedSettings.delete('extra-compose-files');
+ }
+
+ updateSaveButtonState();
+ updateTabModifiedState();
+ updateSettingsDefaultComposeDiscoveryState();
+}
+
+function resetExtraComposeFilesUI() {
+ $('#settings-extra-compose-candidates').empty().hide();
+ $('#settings-extra-compose-none').hide();
+ $('#settings-extra-compose-external').val('');
+ $('#settings-extra-compose-external-wrap').removeAttr('open');
+}
// Backward-compatible helper used by existing code paths.
function parseMemToBytes(memStr) {
@@ -140,6 +243,81 @@ function formatMemUsageText(usedBytes, limitBytes) {
return formatBytes(usedBytes) + ' / ' + formatBytes(limitBytes);
}
+function formatInOutHtml(inVal, outVal, inLabel, outLabel) {
+ var inTitle = composeEscapeAttr(inLabel || 'in');
+ var outTitle = composeEscapeAttr(outLabel || 'out');
+ var inText = composeEscapeHtml(inVal || '-');
+ var outText = composeEscapeHtml(outVal || '-');
+ return ' ' + inText + ' ' +
+ ' ' + outText + ' ';
+}
+
+function composeNormalizeHealthStatus(rawHealth, state) {
+ var health = String(rawHealth || '').toLowerCase().trim();
+ var normalizedState = String(state || '').toLowerCase().trim();
+
+ if (health === 'healthy' || health === 'unhealthy' || health === 'starting') {
+ return health;
+ }
+ if (normalizedState !== 'running') {
+ return 'stopped';
+ }
+ return 'none';
+}
+
+function composeHealthMeta(status) {
+ var normalized = composeNormalizeHealthStatus(status, 'running');
+ switch (normalized) {
+ case 'healthy':
+ return { icon: 'check-circle', textClass: 'green-text', label: 'healthy' };
+ case 'unhealthy':
+ return { icon: 'times-circle', textClass: 'red-text', label: 'unhealthy' };
+ case 'starting':
+ return { icon: 'refresh fa-spin', textClass: 'orange-text', label: 'starting' };
+ case 'stopped':
+ return { icon: 'stop-circle', textClass: 'grey-text', label: 'stopped' };
+ case 'none':
+ return { icon: 'minus-circle', textClass: 'compose-text-muted', label: 'n/a' };
+ default:
+ return { icon: 'question-circle', textClass: 'compose-text-muted', label: 'unknown' };
+ }
+}
+
+function composeRenderHealthBadge(status, state) {
+ var normalized = composeNormalizeHealthStatus(status, state);
+ var meta = composeHealthMeta(normalized);
+ return ' ' + meta.label + ' ';
+}
+
+function composeAggregateStackHealth(containers) {
+ var list = Array.isArray(containers) ? containers : [];
+ if (list.length === 0) {
+ return 'stopped';
+ }
+
+ var runningStates = [];
+ list.forEach(function(ct) {
+ var state = String((ct && ct.state) || '').toLowerCase().trim();
+ if (state === 'running') {
+ runningStates.push(composeNormalizeHealthStatus(ct ? ct.health : '', state));
+ }
+ });
+
+ if (runningStates.length === 0) {
+ return 'stopped';
+ }
+ if (runningStates.indexOf('unhealthy') !== -1) {
+ return 'unhealthy';
+ }
+ if (runningStates.indexOf('starting') !== -1) {
+ return 'starting';
+ }
+ if (runningStates.indexOf('healthy') !== -1) {
+ return 'healthy';
+ }
+ return 'none';
+}
+
// ═══════════════════════════════════════════════════════════════════
// Standard factory functions for container and stack identity objects
// ═══════════════════════════════════════════════════════════════════
@@ -174,6 +352,7 @@ function createContainerInfo(raw) {
icon: raw.icon || raw.Icon || '',
shell: raw.shell || raw.Shell || '/bin/bash',
webUI: raw.webUI || raw.WebUI || '',
+ health: raw.health || raw.Health || '',
ports: raw.ports || raw.Ports || [],
networks: raw.networks || raw.Networks || [],
volumes: raw.volumes || raw.Volumes || [],
@@ -255,6 +434,26 @@ function mergeStackUpdateStatus(stackInfo, prevStatus) {
// Timers for async operations (plugin-specific to avoid collision with Unraid's global timers)
var composeTimers = {};
+var composeListRefreshedDebounceTimer = null;
+
+function triggerComposeListRefreshedDebounced(delayMs) {
+ var waitMs = typeof delayMs === 'number' ? delayMs : 150;
+ if (composeListRefreshedDebounceTimer) {
+ clearTimeout(composeListRefreshedDebounceTimer);
+ }
+ composeListRefreshedDebounceTimer = setTimeout(function() {
+ composeListRefreshedDebounceTimer = null;
+ $(document).trigger('composeListRefreshed');
+ }, waitMs);
+}
+
+function flushComposeListRefreshedDebounce() {
+ if (composeListRefreshedDebounceTimer) {
+ clearTimeout(composeListRefreshedDebounceTimer);
+ composeListRefreshedDebounceTimer = null;
+ }
+ $(document).trigger('composeListRefreshed');
+}
function showComposeSpinner() {
var $spinner = $('div.spinner.fixed');
@@ -279,120 +478,307 @@ function hideComposeSpinner() {
function composeLoadlist() {
// Return a Promise so callers can reliably .then() / .catch() on completion
return new Promise(function(resolve, reject) {
+ // Bind autostart change handler early, before any rows are rendered or become interactive
+ // during progressive loading. Use delegated event handler so it works for dynamically added rows.
+ $('#compose_list').off('change', '.auto_start').on('change', '.auto_start', function() {
+ var script = $(this).attr("data-scriptname");
+ var auto = $(this).prop('checked');
+ $.post(caURL, {
+ action: 'updateAutostart',
+ script: script,
+ autostart: auto
+ });
+ });
+
composeTimers.load = setTimeout(function() {
showComposeSpinner('Loading stack list...');
}, 500);
- $.get('/plugins/compose.manager/include/ComposeList.php')
- .done(function(data) {
- clearTimeout(composeTimers.load);
+ function finalizeComposeLoadlist(resolvePayload) {
+ clearTimeout(composeTimers.load);
- // Insert the loaded content
- $('#compose_list').html(data);
+ // Signal load subscribers (e.g. dockerload cache) that the list changed
+ flushComposeListRefreshedDebounce();
- // Signal load subscribers (e.g. dockerload cache) that the list changed
- $(document).trigger('composeListRefreshed');
+ // Initialize UI components for the newly loaded content
+ initStackListUI();
+
+ // Debug: log initial per-stack rendered status icons (data-status attribute)
+ try {
+ var initialStatuses = [];
+ $('#compose_stacks tr.compose-sortable').each(function() {
+ var project = $(this).data('project');
+ var icon = $(this).find('.compose-status-icon').first();
+ var status = icon.attr('data-status') || icon.attr('class') || '';
+ initialStatuses.push({
+ project: project,
+ status: status
+ });
+ });
+ composeLogger('initial-stack-statuses', {
+ stacks: initialStatuses
+ }, 'user', 'debug', 'composeLoadlist');
+ } catch (e) {}
- // Initialize UI components for the newly loaded content
- initStackListUI();
+ // Normalize icons based on state text to ensure server-side render and
+ // client-side update logic agree (workaround for caching or older server HTML)
+ try {
+ $('#compose_stacks tr.compose-sortable').each(function() {
+ var $row = $(this);
+ var stateText = $row.find('.state').text().toLowerCase();
+ var $icon = $row.find('.compose-status-icon').first();
+ if (!$icon.length) return;
+ var desiredShape = null;
+ var desiredColor = 'grey-text';
+ if (stateText.indexOf('partial') !== -1) {
+ desiredShape = 'exclamation-circle';
+ desiredColor = 'orange-text';
+ } else if (stateText.indexOf('started') !== -1) {
+ desiredShape = 'play';
+ desiredColor = 'green-text';
+ } else if (stateText.indexOf('paused') !== -1) {
+ desiredShape = 'pause';
+ desiredColor = 'orange-text';
+ } else {
+ desiredShape = 'square';
+ desiredColor = 'grey-text';
+ }
- // Debug: log initial per-stack rendered status icons (data-status attribute)
- try {
- var initialStatuses = [];
- $('#compose_stacks tr.compose-sortable').each(function() {
- var project = $(this).data('project');
- var icon = $(this).find('.compose-status-icon').first();
- var status = icon.attr('data-status') || icon.attr('class') || '';
- initialStatuses.push({
- project: project,
- status: status
- });
+ // If icon already matches, skip
+ if (($icon.hasClass('fa-' + desiredShape) && $icon.hasClass(desiredColor))) return;
+
+ composeLogger('normalize-icon', {
+ project: $row.data('project'),
+ stateText: stateText,
+ desiredShape: desiredShape,
+ desiredColor: desiredColor,
+ before: $icon.attr('class')
+ }, 'user', 'debug', 'composeLoadlist');
+ // Remove old fa-* classes, color classes and apply desired ones
+ $icon.removeClass(function(i, cls) {
+ return (cls.match(/fa-[^\s]+/g) || []).join(' ');
});
- composeLogger('initial-stack-statuses', {
- stacks: initialStatuses
+ $icon.removeClass('green-text orange-text grey-text cyan-text');
+ $icon.addClass('fa fa-' + desiredShape + ' ' + desiredColor + ' compose-status-icon');
+ composeLogger('normalize-icon-done', {
+ project: $row.data('project'),
+ after: $icon.attr('class')
}, 'user', 'debug', 'composeLoadlist');
- } catch (e) {}
+ });
+ } catch (e) {}
- // Normalize icons based on state text to ensure server-side render and
- // client-side update logic agree (workaround for caching or older server HTML)
- try {
- $('#compose_stacks tr.compose-sortable').each(function() {
- var $row = $(this);
- var stateText = $row.find('.state').text().toLowerCase();
- var $icon = $row.find('.compose-status-icon').first();
- if (!$icon.length) return;
- var desiredShape = null;
- var desiredColor = 'grey-text';
- if (stateText.indexOf('partial') !== -1) {
- desiredShape = 'exclamation-circle';
- desiredColor = 'orange-text';
- } else if (stateText.indexOf('started') !== -1) {
- desiredShape = 'play';
- desiredColor = 'green-text';
- } else if (stateText.indexOf('paused') !== -1) {
- desiredShape = 'pause';
- desiredColor = 'orange-text';
- } else {
- desiredShape = 'square';
- desiredColor = 'grey-text';
- }
+ // Cleanup any temporary per-container spinners or leftover in-progress state
+ try {
+ $('#compose_list').find('.compose-container-spinner').each(function() {
+ var $sp = $(this);
+ var $wrap = $sp.closest('.hand');
+ $sp.remove();
+ $wrap.find('img').css('opacity', 1);
+ });
+ // Restore any state text preserved by setStackActionInProgress
+ $('#compose_stacks .state').each(function() {
+ var $s = $(this);
+ if ($s.data('orig-text')) {
+ $s.text($s.data('orig-text'));
+ $s.removeData('orig-text');
+ }
+ });
+ } catch (e) {}
- // If icon already matches, skip
- if (($icon.hasClass('fa-' + desiredShape) && $icon.hasClass(desiredColor))) return;
+ // Hide compose spinner overlay
+ hideComposeSpinner();
- composeLogger('normalize-icon', {
- project: $row.data('project'),
- stateText: stateText,
- desiredShape: desiredShape,
- desiredColor: desiredColor,
- before: $icon.attr('class')
- }, 'user', 'debug', 'composeLoadlist');
- // Remove old fa-* classes, color classes and apply desired ones
- $icon.removeClass(function(i, cls) {
- return (cls.match(/fa-[^\s]+/g) || []).join(' ');
- });
- $icon.removeClass('green-text orange-text grey-text cyan-text');
- $icon.addClass('fa fa-' + desiredShape + ' ' + desiredColor + ' compose-status-icon');
- composeLogger('normalize-icon-done', {
- project: $row.data('project'),
- after: $icon.attr('class')
- }, 'user', 'debug', 'composeLoadlist');
- });
- } catch (e) {}
+ // Show buttons now that content is loaded
+ $('input[type=button]').show();
- // Cleanup any temporary per-container spinners or leftover in-progress state
- try {
- $('#compose_list').find('.compose-container-spinner').each(function() {
- var $sp = $(this);
- var $wrap = $sp.closest('.hand');
- $sp.remove();
- $wrap.find('img').css('opacity', 1);
- });
- // Restore any state text preserved by setStackActionInProgress
- $('#compose_stacks .state').each(function() {
- var $s = $(this);
- if ($s.data('orig-text')) {
- $s.text($s.data('orig-text'));
- $s.removeData('orig-text');
- }
- });
- } catch (e) {}
+ // Notify other features (e.g. hide-from-docker) that compose list is ready
+ $(document).trigger('compose-list-loaded');
+
+ try {
+ resolve(resolvePayload);
+ } catch (e) {
+ resolve();
+ }
+ }
+
+ $.get('/plugins/compose.manager/include/ComposeList.php', {
+ mode: 'list'
+ })
+ .done(function(metaRaw) {
+ var meta = tryParseJson(metaRaw);
+ if (!meta || meta.result !== 'success' || !Array.isArray(meta.projects)) {
+ composeLogger('Invalid stack meta response', {
+ response: metaRaw
+ }, 'user', 'error', 'composeLoadlist');
+ $('#compose_list').html('Failed to load stack list. Please refresh the page. ');
+ clearTimeout(composeTimers.load);
+ hideComposeSpinner();
+ reject(new Error('Invalid stack list response'));
+ return;
+ }
- // Hide compose spinner overlay
+ var projects = meta.projects;
+ if (projects.length === 0) {
+ $('#compose_list').html('No Docker Compose stacks found. Click \"Add New Stack\" to create one. ');
+ finalizeComposeLoadlist('');
+ return;
+ }
+
+ // Ensure hidden-column classes and table geometry are applied
+ // before progressive rows begin rendering.
+ if (window.composeColCustomizer && typeof window.composeColCustomizer.reapply === 'function') {
+ window.composeColCustomizer.reapply();
+ }
+
+ // Warm expansion settings once so progressive row insertions can expand immediately.
+ ensureComposeDefaultExpandSettings();
+
+ // Start loading stack container details in the background immediately.
+ // UI rendering stays top-down; this just gets data ahead of the visual pipeline.
+ prefetchStackDetailsInBackground(projects);
+
+ var progressHtml = '' +
+ '' +
+ '' +
+ ' ' +
+ 'Loading stacks... 0/' + projects.length + ' ' +
+ ' ' +
+ ' ' +
+ ' ';
+ $('#compose_list').html(progressHtml);
+
+ // The progress row uses a static full-width colspan; clamp it to the
+ // live column count so hidden columns don't reserve width.
+ if (window.composeColCustomizer && typeof window.composeColCustomizer.syncColspans === 'function') {
+ window.composeColCustomizer.syncColspans();
+ }
+
+ // Prime cached update status early so rows can render status as soon as they land.
+ if (!savedUpdateStatusLoaded) {
+ loadSavedUpdateStatus();
+ }
+
+ // Progressive mode shows in-table placeholders, so remove global overlay spinner.
+ clearTimeout(composeTimers.load);
hideComposeSpinner();
- // Show buttons now that content is loaded
- $('input[type=button]').show();
+ var completed = 0;
+ var queueIndex = 0;
+ var commitIndex = 0;
+ var activeRequests = 0;
+ var maxParallelRequests = 4;
+ var pendingRowsByIndex = {};
+ var commitInProgress = false;
+ var stackLoadTimers = {};
+
+ function updateProgress() {
+ $('#compose-load-progress-count').text(completed + '/' + projects.length);
+ if (completed >= projects.length) {
+ $('#compose-load-progress-row').remove();
+ finalizeComposeLoadlist('progressive');
+ }
+ }
- // Notify other features (e.g. hide-from-docker) that compose list is ready
- $(document).trigger('compose-list-loaded');
+ function queueProjectRequest(index) {
+ var project = projects[index];
+ stackLoadTimers[project] = Date.now();
- // Resolve the promise so callers know the list has been loaded
- try {
- resolve(data);
- } catch (e) {
- resolve();
+ composeLogger('progressive stack load start', {
+ project: project,
+ position: index + 1,
+ total: projects.length
+ }, 'user', 'debug', 'composeLoadlist');
+
+ activeRequests++;
+ $.get('/plugins/compose.manager/include/ComposeList.php', {
+ mode: 'row',
+ project: project
+ })
+ .done(function(rowRaw) {
+ var rowResp = tryParseJson(rowRaw);
+ var elapsedMs = Date.now() - (stackLoadTimers[project] || Date.now());
+ pendingRowsByIndex[index] = {
+ ok: !!(rowResp && rowResp.result === 'success' && rowResp.html),
+ rowResp: rowResp,
+ project: project,
+ position: index + 1,
+ elapsedMs: elapsedMs
+ };
+ })
+ .fail(function() {
+ var failElapsedMs = Date.now() - (stackLoadTimers[project] || Date.now());
+ pendingRowsByIndex[index] = {
+ ok: false,
+ rowResp: null,
+ project: project,
+ position: index + 1,
+ elapsedMs: failElapsedMs
+ };
+ })
+ .always(function() {
+ activeRequests--;
+ delete stackLoadTimers[project];
+ flushCommittedRowsInOrder();
+ dispatchProjectRequests();
+ });
+ }
+
+ function dispatchProjectRequests() {
+ while (activeRequests < maxParallelRequests && queueIndex < projects.length) {
+ queueProjectRequest(queueIndex);
+ queueIndex++;
+ }
+ }
+
+ function flushCommittedRowsInOrder() {
+ if (commitInProgress) {
+ return;
+ }
+
+ if (!Object.prototype.hasOwnProperty.call(pendingRowsByIndex, commitIndex)) {
+ return;
+ }
+
+ var entry = pendingRowsByIndex[commitIndex];
+ delete pendingRowsByIndex[commitIndex];
+ commitInProgress = true;
+
+ var waitForExpansion = Promise.resolve();
+ if (entry.ok) {
+ composeLogger('progressive stack load success', {
+ project: entry.project,
+ position: entry.position,
+ total: projects.length,
+ elapsedMs: entry.elapsedMs
+ }, 'user', 'debug', 'composeLoadlist');
+
+ var $rowChunk = $(entry.rowResp.html);
+ $('#compose-load-progress-row').before($rowChunk);
+ initializeProgressiveLoadedRows($rowChunk);
+ if (typeof window.composeDockerLoadRenderCached === 'function' && composeShouldEnableDockerLoad()) {
+ window.composeDockerLoadRenderCached();
+ }
+ waitForExpansion = composeDefaultExpandQueue;
+ } else {
+ composeLogger('progressive stack load failed', {
+ project: entry.project,
+ position: entry.position,
+ total: projects.length,
+ elapsedMs: entry.elapsedMs
+ }, 'user', 'warn', 'composeLoadlist');
+ $('#compose-load-progress-row').before('Failed to load ' + composeEscapeHtml(entry.project) + '. ');
+ }
+
+ waitForExpansion.finally(function() {
+ commitIndex++;
+ completed++;
+ commitInProgress = false;
+ updateProgress();
+ flushCommittedRowsInOrder();
+ });
}
+
+ dispatchProjectRequests();
})
.fail(function(xhr, status, error) {
composeLogger('failed', {
@@ -401,7 +787,7 @@ function composeLoadlist() {
}, 'user', 'error', 'composeLoadlist');
clearTimeout(composeTimers.load);
hideComposeSpinner();
- $('#compose_list').html('Failed to load stack list. Please refresh the page. ');
+ $('#compose_list').html('Failed to load stack list. Please refresh the page. ');
// Reject the promise so callers can handle the error
try {
@@ -434,16 +820,8 @@ function initStackListUI() {
});
$el.data('switchbutton-initialized', true);
});
- // Ensure change handler is bound only once
- $('#compose_list').off('change', '.auto_start').on('change', '.auto_start', function() {
- var script = $(this).attr("data-scriptname");
- var auto = $(this).prop('checked');
- $.post(caURL, {
- action: 'updateAutostart',
- script: script,
- autostart: auto
- });
- });
+ // Note: change handler for .auto_start is now bound early in composeLoadlist()
+ // before any rows are added, so it's ready for both progressive and final renders.
// Initialize context menus for stack icons
$('[id^="stack-"][data-stackid]').each(function() {
@@ -462,26 +840,137 @@ function initStackListUI() {
// Apply current view mode (advanced/basic) with centralized logic
applyListView(false);
+ // Recompute stack-row striping after all stack rows are present so
+ // hidden detail rows do not skew alternating backgrounds.
+ syncComposeStackRowStriping();
+
// Seed expandedStacks from any rows rendered expanded server-side
$('.stack-details-row:visible').each(function() {
var stackId = this.id.replace('details-row-', '');
expandedStacks[stackId] = true;
});
- // Load saved update status after list is loaded
- loadSavedUpdateStatus();
+ // Load saved update status after list is loaded (skip if already primed)
+ if (!savedUpdateStatusLoaded) {
+ loadSavedUpdateStatus();
+ }
syncComposeSortModeUI();
}
+function initializeProgressiveLoadedRows($rowChunk) {
+ if (!$rowChunk || !$rowChunk.length) return;
+
+ var $rows = $rowChunk.filter('tr.compose-sortable');
+ if (!$rows.length) {
+ $rows = $rowChunk.find('tr.compose-sortable');
+ }
+ if (!$rows.length) return;
+
+ // Initialize autostart toggles only for newly appended rows.
+ $rows.find('.auto_start').each(function() {
+ var $el = $(this);
+ if ($el.data('switchbutton-initialized')) return;
+ $el.switchButton({
+ labels_placement: 'right',
+ on_label: 'On',
+ off_label: 'Off',
+ clear: false
+ });
+ $el.data('switchbutton-initialized', true);
+ });
+
+ // Initialize context menus for only newly added stack icons.
+ $rows.find('[id^="stack-"][data-stackid]').each(function() {
+ addComposeStackContext(this.id);
+ });
+
+ // Keep description truncation behavior consistent for new rows.
+ $rows.find('.docker_readmore').not('.stack-details-container .docker_readmore').each(function() {
+ var $el = $(this);
+ $el.readmore('destroy');
+ $el.readmore({
+ maxHeight: 32,
+ moreLink: " ",
+ lessLink: " "
+ });
+ });
+
+ // Skip full-table applyListView here; new rows already get row-local
+ // readmore/context/toggle setup above, and global apply runs at finalize.
+
+ // Apply cached update status immediately for new rows when available.
+ $rows.each(function() {
+ var project = $(this).data('project');
+ if (project && stackUpdateStatus[project]) {
+ updateStackUpdateUI(project, stackUpdateStatus[project]);
+ }
+ });
+ updateUpdateAllButton();
+
+ // If background prefetch already has this stack, hydrate parent-row summary
+ // immediately (health, containers, uptime/state) without waiting for expansion.
+ $rows.each(function() {
+ var $row = $(this);
+ var project = $row.data('project');
+ if (!project || !stackDetailsPrefetchCache[project]) return;
+ applyStackSummaryFromResponse(project, stackDetailsPrefetchCache[project]);
+ });
+
+ // Apply default expansion policy per row as soon as it is inserted.
+ applyDefaultExpansionToRows($rows);
+
+ // Keep stack striping stable while chunks stream in.
+ syncComposeStackRowStriping();
+
+ // Newly inserted rows include detail rows with a static full-width colspan;
+ // clamp it to the live column count so hidden columns don't reserve width.
+ if (window.composeColCustomizer && typeof window.composeColCustomizer.syncColspans === 'function') {
+ window.composeColCustomizer.syncColspans();
+ }
+
+ // Notify dockerload/hide-from-docker listeners; debounce to avoid
+ // repeated expensive invalidation work during progressive append bursts.
+ triggerComposeListRefreshedDebounced(150);
+ if (typeof window.composeDockerLoadToggle === 'function') {
+ window.composeDockerLoadToggle(composeShouldEnableDockerLoad());
+ }
+ if (typeof window.composeDockerLoadRenderCached === 'function' && composeShouldEnableDockerLoad()) {
+ window.composeDockerLoadRenderCached();
+ }
+
+ updateStackToggleAllButtonState();
+}
+
+function applyStackSummaryFromResponse(project, response) {
+ if (!project || !response || response.result !== 'success') return;
+
+ var $stackRow = $('#compose_stacks tr.compose-sortable[data-project="' + project + '"]');
+ if (!$stackRow.length) return;
+
+ var rowId = String($stackRow.attr('id') || '');
+ var stackId = rowId.indexOf('stack-row-') === 0 ? rowId.substring('stack-row-'.length) : '';
+ if (!stackId) return;
+
+ var containers = (response.containers || []).map(createContainerInfo).filter(Boolean);
+ mergeUpdateStatus(containers, project);
+ stackContainersCache[stackId] = containers;
+ if (response.startedAt) {
+ stackStartedAtCache[stackId] = response.startedAt;
+ }
+
+ updateParentStackFromContainers(stackId, project);
+}
+
// Load external stylesheets (non-critical styles — critical ones are inline above)
(function() {
var base = composeBootstrap.comboButtonCss || '';
var editor = composeBootstrap.editorModalCss || '';
if (base && !$('link[href="' + base + '"]').length)
$('head').append($(' ').attr('href', base));
- if (editor && !$('link[href="' + editor + '"]').length)
+ if (editor && !$('link[href="' + editor + '"]').length) {
$('head').append($(' ').attr('href', editor));
+ }
})();
function basename(path) {
@@ -502,6 +991,15 @@ function tryParseJson(str) {
}
}
+function syncComposeStackRowStriping() {
+ var $rows = $('#compose_stacks tr.compose-sortable');
+ if (!$rows.length) return;
+
+ $rows.each(function(index) {
+ $(this).toggleClass('compose-stack-row-alt', index % 2 === 0);
+ });
+}
+
// Editor modal state
var editorModal = {
editors: {},
@@ -668,6 +1166,11 @@ function initEditorModal() {
}
});
+ // Additional compose files: combined change tracking for the candidate
+ // checkboxes (dynamic) and the external paths textarea.
+ $('#settings-extra-compose-candidates').on('change', 'input[type="checkbox"]', onExtraComposeFilesChanged);
+ $('#settings-extra-compose-external').on('input change', onExtraComposeFilesChanged);
+
// Override management mode is a saveable setting (deferred persist).
$('#settings-override-management').on('change', function() {
var mode = $(this).is(':checked') ? 'basic' : 'advanced';
@@ -965,6 +1468,103 @@ function updateTabModifiedState() {
// Update status cache per stack
var stackUpdateStatus = {};
+var savedUpdateStatusLoaded = false;
+
+var composeDefaultExpandSettings = null;
+var composeDefaultExpandSettingsPromise = null;
+var composeDefaultExpandQueue = Promise.resolve();
+
+function ensureComposeDefaultExpandSettings() {
+ if (composeDefaultExpandSettings !== null) {
+ return Promise.resolve(composeDefaultExpandSettings);
+ }
+ if (composeDefaultExpandSettingsPromise) {
+ return composeDefaultExpandSettingsPromise;
+ }
+
+ composeDefaultExpandSettingsPromise = getConfig().then(function(config) {
+ composeDefaultExpandSettings = {
+ enabled: config && config['STACKS_DEFAULT_EXPANDED'] == 'true',
+ onlyRunning: config && config['ONLY_EXPAND_RUNNING_STACKS'] == 'true'
+ };
+ return composeDefaultExpandSettings;
+ }).catch(function() {
+ composeDefaultExpandSettings = {
+ enabled: false,
+ onlyRunning: false
+ };
+ return composeDefaultExpandSettings;
+ });
+
+ return composeDefaultExpandSettingsPromise;
+}
+
+function applyDefaultExpansionToRows($rows) {
+ if (!$rows || !$rows.length) return;
+
+ ensureComposeDefaultExpandSettings().then(function(settings) {
+ if (!settings || !settings.enabled) return;
+
+ $rows.each(function() {
+ var $row = $(this);
+ var stackId = ($row.attr('id') || '').replace('stack-row-', '');
+ if (!stackId) return;
+
+ if (settings.onlyRunning && $row.data('isup') != '1') {
+ return;
+ }
+
+ if (expandedStacks[stackId] || $('#details-row-' + stackId).is(':visible')) {
+ return;
+ }
+
+ composeDefaultExpandQueue = composeDefaultExpandQueue.then(function() {
+ return expandStackDetailsSequential(stackId);
+ }).catch(function() {
+ return null;
+ });
+ });
+
+ composeDefaultExpandQueue = composeDefaultExpandQueue.then(function() {
+ updateStackToggleAllButtonState();
+ });
+ });
+}
+
+function expandStackDetailsSequential(stackId) {
+ var $row = $('#stack-row-' + stackId);
+ var $detailsRow = $('#details-row-' + stackId);
+ var $expandIcon = $('#expand-icon-' + stackId);
+ var project = $row.data('project');
+
+ if (!$row.length || !$detailsRow.length) {
+ return Promise.resolve();
+ }
+
+ // Already expanded/visible: if a load is in progress, wait for it; otherwise skip.
+ if (expandedStacks[stackId] || $detailsRow.is(':visible')) {
+ if (stackDetailsLoading[stackId]) {
+ return loadStackContainerDetails(stackId, project);
+ }
+ return Promise.resolve();
+ }
+
+ stackDetailsDesiredExpanded[stackId] = true;
+ $expandIcon.addClass('expanded');
+ expandedStacks[stackId] = true;
+
+ if (stackContainersCache[stackId]) {
+ renderContainerDetails(stackId, stackContainersCache[stackId], project);
+ return new Promise(function(resolve) {
+ $detailsRow.stop(true, true).slideDown(200, resolve);
+ });
+ }
+
+ return loadStackContainerDetails(stackId, project);
+}
+
+// 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
@@ -978,6 +1578,10 @@ function loadSavedUpdateStatus() {
var response = JSON.parse(data);
if (response.result === 'success' && response.stacks) {
stackUpdateStatus = response.stacks;
+ if (response.dockerVersionsInstalled) dockerVersionsInstalled = true;
+
+ // Mark as loaded only after successful callback
+ savedUpdateStatusLoaded = true;
// Update the UI for each stack with saved status
for (var stackName in response.stacks) {
@@ -1022,6 +1626,10 @@ function loadSavedUpdateStatus() {
}
});
}
+ }).fail(function(xhr, status, error) {
+ // Reset flag on transport failure so retries can work
+ savedUpdateStatusLoaded = false;
+ composeLogger('Failed to load saved update status', { status: status, error: error }, 'user', 'error', 'update-check');
});
}
@@ -1041,13 +1649,13 @@ function checkPendingRechecks(callback) {
hadPendingRechecks = true;
composeLogger('checkPendingRechecks:found', {
pendingStacks: pendingStacks
- }, 'user', 'info', 'update-check');
+ }, 'user', 'debug', 'update-check');
// Check each pending stack
pendingStacks.forEach(function(stackName) {
composeLogger('Running recheck for recently updated stack', {
stackName: stackName
- }, 'user', 'info', 'update-check');
+ }, 'user', 'debug', 'update-check');
checkStackUpdates(stackName);
});
}
@@ -1081,11 +1689,11 @@ function checkAutoUpdateIfNeeded(stacks) {
// If we have a lastChecked time and it's within the interval, don't check
if (latestCheck > 0 && (now - latestCheck) < intervalSeconds) {
needsCheck = false;
- composeLogger('Last check was ' + Math.round((now - latestCheck) / 60) + ' minutes ago, interval is ' + Math.round(intervalSeconds / 60) + ' minutes. Skipping.', null, 'user', 'info', 'update-check');
+ composeLogger('Last check was ' + Math.round((now - latestCheck) / 60) + ' minutes ago, interval is ' + Math.round(intervalSeconds / 60) + ' minutes. Skipping.', null, 'user', 'debug', 'update-check');
}
if (needsCheck) {
- composeLogger('Running automatic update check...', null, 'user', 'info', 'update-check');
+ composeLogger('Running automatic update check...', null, 'user', 'debug', 'update-check');
checkAllUpdates();
}
}
@@ -1327,7 +1935,7 @@ function updateStackUpdateUI(stackName, stackInfo) {
// Update the stack row's update column (match Docker tab style)
if (updateCount > 0) {
// Updates available - orange "update ready" style with clickable link and SHA info
- var updateHtml = '';
+ var updateHtml = ' ';
updateHtml += ' ' + updateCount + ' update' + (updateCount > 1 ? 's' : '') + ' ';
updateHtml += ' ';
@@ -1365,14 +1973,14 @@ function updateStackUpdateUI(stackName, stackInfo) {
// Some containers pinned, rest up-to-date
var html = ' up-to-date ';
html += ' ' + pinnedCount + ' pinned
';
- html += '';
+ html += '';
$updateCell.html(html);
} else {
// No updates, no pinned - green "up-to-date" style (like Docker tab)
// Basic view: just shows up-to-date
// Advanced view: shows force update link
var html = ' up-to-date ';
- html += '';
+ html += '';
$updateCell.html(html);
}
} else {
@@ -1417,7 +2025,7 @@ function updateStackUpdateUI(stackName, stackInfo) {
stackName: stackName,
loading: !!stackDetailsLoading[stackId],
justRendered: !!stackDetailsJustRendered[stackId]
- }, 'user', 'info', 'update-check');
+ }, 'user', 'debug', 'update-check');
} else {
loadStackContainerDetails(stackId, stackName);
}
@@ -1488,25 +2096,56 @@ function isValidIconSrc(src) {
function loadPersistentContainerCache() {
return new Promise(function(resolve) {
- $.get('/plugins/compose.manager/include/ContainerCache.php')
- .done(function(data) {
- try {
- persistentContainerCache = (typeof data === 'string' ? JSON.parse(data) : data) || {};
- } catch (e) {
- persistentContainerCache = {};
- composeLogger('Failed to parse persistent container cache', {
- error: e && e.toString()
- }, 'user', 'warn', 'loadPersistentContainerCache');
- }
+ $.ajax({
+ url: caURL,
+ method: 'POST',
+ dataType: 'json',
+ data: {
+ action: 'getPersistentContainerCache'
+ }
+ })
+ .done(function(response) {
+ composeLogger('Loaded persistent container cache', response, 'user', 'debug', 'load-cache');
+ persistentContainerCache = (response && response.cache) ? response.cache : {};
resolve(persistentContainerCache);
})
.fail(function() {
+ composeLogger('Failed to load persistent container cache', null, 'user', 'error', 'load-cache');
persistentContainerCache = {};
resolve(persistentContainerCache);
});
});
}
+function loadComposeLoadSnapshot() {
+ return new Promise(function(resolve) {
+ $.ajax({
+ url: caURL,
+ method: 'POST',
+ dataType: 'json',
+ data: {
+ action: 'getComposeLoadSnapshot'
+ }
+ })
+ .done(function(response) {
+ composeLogger('Loaded compose load snapshot', response, 'user', 'debug', 'load-snapshot');
+ var snapshot = (response && response.snapshot && typeof response.snapshot === 'object') ? response.snapshot : {};
+ window.composeLoadSnapshotRaw = typeof snapshot.raw === 'string' ? snapshot.raw : '';
+ window.composeLoadSnapshotTs = Number(snapshot.ts || 0);
+ if (typeof window.composeDockerLoadSeedSnapshot === 'function' && window.composeLoadSnapshotRaw) {
+ window.composeDockerLoadSeedSnapshot(window.composeLoadSnapshotRaw, window.composeLoadSnapshotTs);
+ }
+ resolve(snapshot);
+ })
+ .fail(function() {
+ composeLogger('Failed to load compose load snapshot', null, 'user', 'error', 'load-snapshot');
+ window.composeLoadSnapshotRaw = '';
+ window.composeLoadSnapshotTs = 0;
+ resolve({});
+ });
+ });
+}
+
function getPersistentContainerInfo(project, service) {
if (!project || !service || !persistentContainerCache[project]) return null;
return persistentContainerCache[project][service] || null;
@@ -1528,82 +2167,35 @@ function processWebUIUrl(url) {
}
function isComposeAdvancedMode() {
- return $.cookie('compose_listview_mode') === 'advanced';
+ return true;
}
-// Apply advanced/basic view based on cookie (used after async load)
-// Scoped to compose_stacks to avoid affecting Docker tab when tabs are joined.
-// When animate=true (user clicked toggle), run a simple symmetric transition.
-// When false (page load), instant class toggle.
-function applyListView(animate) {
- // Sync the dockerload WebSocket with the view mode.
- if (typeof window.composeDockerLoadToggle === 'function') {
- window.composeDockerLoadToggle(isComposeAdvancedMode());
- }
- var advanced = isComposeAdvancedMode();
- var $table = $('#compose_stacks');
- var $advanced = $table.find('.cm-advanced');
+function composeHasVisibleLoadColumns() {
+ var selector = [
+ '#compose_stacks td.col-cpu',
+ '#compose_stacks td.col-memory',
+ '#compose_stacks td.col-net_io',
+ '#compose_stacks td.col-block_io',
+ '#compose_stacks td.ct-col-cpu',
+ '#compose_stacks td.ct-col-memory',
+ '#compose_stacks td.ct-col-net_io',
+ '#compose_stacks td.ct-col-block_io'
+ ].join(',');
+ return $(selector).filter(':visible').length > 0;
+}
- var setClass = function(enabled) {
- if (enabled) {
- $table.addClass('cm-advanced-view');
- } else {
- $table.removeClass('cm-advanced-view');
- }
- };
+function composeShouldEnableDockerLoad() {
+ if (document.visibilityState === 'hidden') {
+ return false;
+ }
+ return composeHasVisibleLoadColumns();
+}
- if (!animate) {
- setClass(advanced);
- $table.css({
- height: '',
- overflow: ''
- });
- $advanced.css({
- opacity: '',
- display: ''
- });
- } else {
- if (advanced) {
- // basic -> advanced: enable class first, then fade in advanced cells
- setClass(true);
- $table.css({
- height: $table.outerHeight(),
- overflow: 'hidden'
- });
- $advanced.stop(true, true).css({
- opacity: 0
- }).animate({
- opacity: 1
- }, 300, function() {
- $table.css({
- height: '',
- overflow: ''
- });
- $advanced.css({
- opacity: '',
- display: ''
- });
- });
- } else {
- // advanced -> basic: fade out then disable class to avoid flicker
- $table.css({
- height: $table.outerHeight(),
- overflow: 'hidden'
- });
- $advanced.stop(true, true).animate({
- opacity: 0
- }, 300, function() {
- setClass(false);
- $table.css({
- height: '',
- overflow: ''
- });
- $advanced.css({
- opacity: '',
- display: ''
- });
- });
- }
+// Legacy compatibility shim. Basic/advanced toggle was removed in favor of
+// column customizer visibility controls.
+function applyListView(animate) {
+ if (typeof window.composeDockerLoadToggle === 'function') {
+ window.composeDockerLoadToggle(composeShouldEnableDockerLoad());
}
// Apply readmore to descriptions — exclude container detail rows; destroy first to avoid nested wrappers
@@ -1653,81 +2245,40 @@ $(function() {
}
});
- // Add Advanced View toggle (like Docker tab)
- // Use compose-specific class to avoid conflict with Docker tab's advancedview when tabs are joined
- var toggleHtml = ' ';
-
- // In tabbed mode we must keep the toggle inside the compose content pane
- // so it does not leak into the global tab bar, and in standalone mode it
- // also appears above the compose stacks table.
- var $toggleContainer = $('
').html(toggleHtml);
- var $tableWrapper = $('#compose_stacks').closest('.TableContainer');
- if ($tableWrapper.length) {
- $tableWrapper.before($toggleContainer);
- } else if ($('#compose_stacks').length) {
- $('#compose_stacks').before($toggleContainer);
- } else if ($('.tabs').length) {
- // Fallback for unusual layout: inject into tabs as a last resort
- $('.tabs').append($toggleContainer);
- } else {
- $('body').prepend($toggleContainer);
- }
-
-
- // Initialize the Advanced/Basic view toggle.
- // labels_placement:'left' puts both labels to the left of the slider.
- // The plugin shows only the active label: "Basic View" (white) when
- // unchecked, "Advanced View" (blue / class 'on') when checked.
- var isAdvanced = $.cookie('compose_listview_mode') === 'advanced';
- $('.compose-advancedview').switchButton({
- labels_placement: 'left',
- on_label: 'Advanced View',
- off_label: 'Basic View',
- checked: isAdvanced
- });
- // Apply the current cookie state immediately so columns match the toggle.
+ // Refresh per-row UI helpers after initial DOM is ready.
applyListView();
- $('.compose-advancedview').change(function() {
- // Persist selection and apply view consistently via applyListView()
- $.cookie('compose_listview_mode', $('.compose-advancedview').is(':checked') ? 'advanced' : 'basic', {
- expires: 3650
- });
- applyListView(true);
- });
// ebox observer removed; pending update checks are now processed from
// refreshStackRow and processPendingComposeReloads directly.
// Gate dockerload socket start until the stack list DOM is ready.
var composeListReady = false;
+ var composeListLoadStartedAt = Date.now();
// Load the persistent container cache before the stack list.
// This ensures dialog merge logic can use last-known container metadata.
loadPersistentContainerCache().then(function() {
- composeLoadlist().then(function() {
+ return loadComposeLoadSnapshot();
+ }).then(function() {
+ composeListLoadStartedAt = Date.now();
+ composeLoadlist().then(function(loadMode) {
composeListReady = true;
- composeLogger('composeListReady=true, rows=' + $('#compose_stacks tr.compose-sortable').length, null, 'user', 'debug', 'dockerload');
+ composeLogger('compose list ready', {
+ rows: $('#compose_stacks tr.compose-sortable').length,
+ elapsedMs: Date.now() - composeListLoadStartedAt,
+ mode: loadMode || 'standard',
+ composeListReady: true
+ }, 'user', 'info', 'composeLoadlist');
// Start the dockerload socket now that the DOM has rows with data-ctids.
if (typeof window.composeDockerLoadToggle === 'function') {
- composeLogger('triggering composeDockerLoadToggle, advancedMode=' + isComposeAdvancedMode(), null, 'user', 'debug', 'dockerload');
- window.composeDockerLoadToggle(isComposeAdvancedMode());
+ var shouldRunComposeStats = composeShouldEnableDockerLoad();
+ composeLogger('triggering composeDockerLoadToggle, enabled=' + shouldRunComposeStats, null, 'user', 'debug', 'composeLiveStats');
+ window.composeDockerLoadToggle(shouldRunComposeStats);
} else {
- composeLogger('composeDockerLoadToggle not available yet at composeLoadlist completion', null, 'user', 'debug', 'dockerload');
+ composeLogger('composeDockerLoadToggle not available yet at composeLoadlist completion', null, 'user', 'debug', 'composeLiveStats');
}
- getConfig().then(function(config) {
- if (config['STACKS_DEFAULT_EXPANDED'] == 'true') {
- // Expand all stacks if the default is set to expanded
- $('#compose_stacks tr.compose-sortable').each(function() {
- if ($(this).data('isup') != "1" && config['ONLY_EXPAND_RUNNING_STACKS'] == 'true') {
- return; // Skip stopped stacks if ONLY_EXPAND_RUNNING_STACKS is true
- }
- var stackId = $(this).attr('id').replace('stack-row-', '');
- toggleStackDetails(stackId);
- });
- }
- });
});
// ── Cross-widget sync ──────────────────────────────────────────
// On the Docker page the Compose stacks list is rendered below
@@ -1775,7 +2326,7 @@ $(function() {
}
};
window.loadlist._composeTabHooked = true;
- composeLogger('hooked loadlist() for cross-widget sync, tabbed=' + isTabbed, null, 'user', 'info', 'hookLoadlist');
+ composeLogger('hooked loadlist() for cross-widget sync, tabbed=' + isTabbed, null, 'user', 'debug', 'hookLoadlist');
return true;
}
return false;
@@ -1810,9 +2361,8 @@ $(function() {
})();
// ── CPU & Memory load via dockerload Nchan channel ─────────────
- // Only runs in advanced view (load column is hidden in basic view).
// composeDockerLoadToggle(true/false) is called from applyListView()
- // so the socket starts/stops whenever the user switches view modes.
+ // and column visibility handlers so the socket follows visible load columns.
function initComposeDockerLoadSubscriber() {
if (typeof NchanSubscriber !== 'function') {
composeLogger('NchanSubscriber not available yet', null, 'user', 'debug', 'dockerload');
@@ -1823,7 +2373,7 @@ $(function() {
// AJAX navigation preserves window globals but old closures/sockets
// become stale). Always create a fresh subscriber.
if (window._composeDockerLoad) {
- composeLogger('tearing down previous subscriber', null, 'user', 'info', 'dockerload');
+ composeLogger('tearing down previous subscriber', null, 'user', 'debug', 'dockerload');
try {
window._composeDockerLoad.stop();
} catch (e) {}
@@ -1831,6 +2381,10 @@ $(function() {
if (window._composeDockerLoadStaleTimer) {
clearInterval(window._composeDockerLoadStaleTimer);
}
+ if (window._composeDockerLoadRenderTimer) {
+ clearTimeout(window._composeDockerLoadRenderTimer);
+ window._composeDockerLoadRenderTimer = null;
+ }
if (window._composeDockerLoadVisHandler) {
document.removeEventListener('visibilitychange', window._composeDockerLoadVisHandler);
}
@@ -1839,9 +2393,9 @@ $(function() {
}
$(document).off('composeListRefreshed.dockerload');
- composeLogger('initializing subscriber, composeListReady=' + composeListReady, null, 'user', 'info', 'dockerload');
+ composeLogger('initializing subscriber, composeListReady=' + composeListReady, null, 'user', 'debug', 'dockerload');
- var composeDockerLoad = new NchanSubscriber('/sub/dockerload', {
+ var composeDockerLoad = new NchanSubscriber('/sub/composeinfo', {
subscriber: 'websocket',
reconnectTimeout: 5000
});
@@ -1853,11 +2407,14 @@ $(function() {
// composeLoadlist() and reused until the row count changes.
// Avoids O(stacks) DOM traversal + string splits on every stats tick.
var composeStackIndex = null;
+ var composeStackSignature = '';
var composeLoadById = {};
var composeLoadStaleMs = 15000;
+ var composeLoadRenderMinIntervalMs = 120;
+ var composeLoadLastRenderMs = 0;
function isComposeLoadVisible() {
- if (!isComposeAdvancedMode()) return false;
+ if (!composeHasVisibleLoadColumns()) return false;
if (document.visibilityState === 'hidden') return false;
var $table = $('#compose_stacks');
if (!$table.length) return false;
@@ -1867,9 +2424,39 @@ $(function() {
}
function clearContainerLoad(shortId) {
- $('.compose-cpu-' + shortId).addClass('compose-text-muted').text('-');
- $('#compose-cpu-' + shortId).css('width', '0');
- $('.compose-mem-' + shortId).hide();
+ var selectorId = composeEscapeSelectorFragment(shortId);
+ $('.compose-cpu-' + selectorId).addClass('compose-text-muted').text('-');
+ $('#compose-cpu-bar-' + selectorId).css('width', '0');
+ $('.compose-mem-' + selectorId).addClass('compose-text-muted').text('-');
+ $('#compose-mem-bar-' + selectorId).css('width', '0');
+ $('.compose-netio-' + selectorId).addClass('compose-text-muted').html('-');
+ $('.compose-blockio-' + selectorId).addClass('compose-text-muted').html('-');
+ }
+
+ // Build a lightweight fingerprint of stack rows + their container ids.
+ // Used to avoid invalidating caches when composeListRefreshed fires but
+ // the actual stack structure did not change.
+ function getComposeStackSignatureFromDom() {
+ var parts = [];
+ $('#compose_stacks tr.compose-sortable').each(function() {
+ var stackId = ($(this).attr('id') || '').replace('stack-row-', '');
+ if (!stackId) return;
+ var ctidsAttr = $(this).attr('data-ctids') || '';
+ parts.push(stackId + ':' + ctidsAttr);
+ });
+ return parts.join('|');
+ }
+
+ function getComposeContainerIdSetFromDom() {
+ var idSet = {};
+ $('#compose_stacks tr.compose-sortable').each(function() {
+ var ctidsAttr = $(this).attr('data-ctids') || '';
+ if (!ctidsAttr) return;
+ ctidsAttr.split(',').forEach(function(id) {
+ if (id) idSet[id] = true;
+ });
+ });
+ return idSet;
}
function buildComposeStackIndex() {
@@ -1883,29 +2470,197 @@ $(function() {
containerIds: ctidsAttr ? ctidsAttr.split(',') : []
});
});
- composeLogger('buildComposeStackIndex complete, stacks=' + composeStackIndex.length, null, 'user', 'debug', 'dockerload');
+ composeStackSignature = getComposeStackSignatureFromDom();
}
- // Invalidate the cache when the list refreshes so that added/removed
- // stacks are picked up on the next stats tick.
+ // Invalidate only when stack/container structure changed.
+ // composeListRefreshed may fire for UI-only updates where cache can be reused.
$(document).on('composeListRefreshed.dockerload', function() {
- composeLogger('composeListRefreshed — invalidating stack index and load cache', null, 'user', 'debug', 'dockerload');
+ var newSignature = getComposeStackSignatureFromDom();
+ var structureChanged = (newSignature !== composeStackSignature);
+
+ // No structural change: keep index/cache intact.
+ if (!structureChanged) {
+ if (composeListReady && composeStackIndex) {
+ composeLogger('composeListRefreshed — structure unchanged, keeping cache', null, 'user', 'debug', 'dockerload');
+ }
+ return;
+ }
+
+ var hadIndex = !!composeStackIndex;
+ var hadLoadCache = Object.keys(composeLoadById).length > 0;
+ if (composeListReady && (hadIndex || hadLoadCache)) {
+ composeLogger('composeListRefreshed — structure changed, invalidating stack index', null, 'user', 'debug', 'dockerload');
+ }
+
+ // Rebuild index lazily on next render tick.
composeStackIndex = null;
- composeLoadById = {};
+ composeStackSignature = newSignature;
+
+ // During progressive initial load, the DOM only contains a prefix of
+ // the total stack list. Preserve preloaded snapshot/SSE cache entries
+ // for rows that have not been inserted yet; otherwise later rows lose
+ // their warm-start metrics before they ever render.
+ if (!composeListReady) {
+ return;
+ }
+
+ // Keep live load values for unchanged containers; drop removed ids.
+ var currentIds = getComposeContainerIdSetFromDom();
+ Object.keys(composeLoadById).forEach(function(id) {
+ if (!currentIds[id]) {
+ delete composeLoadById[id];
+ clearContainerLoad(id);
+ }
+ });
});
window.composeDockerLoadToggle = function(enable) {
if (enable && !composeDockerLoadRunning) {
- composeLogger('starting WebSocket', null, 'user', 'info', 'dockerload');
+ composeLogger('starting WebSocket', null, 'user', 'debug', 'dockerload');
composeDockerLoad.start();
composeDockerLoadRunning = true;
} else if (!enable && composeDockerLoadRunning) {
- composeLogger('stopping WebSocket', null, 'user', 'info', 'dockerload');
+ composeLogger('stopping WebSocket', null, 'user', 'debug', 'dockerload');
composeDockerLoad.stop();
composeDockerLoadRunning = false;
}
};
+ function renderComposeLoadCache() {
+ if (!isComposeLoadVisible()) {
+ return;
+ }
+
+ for (var shortId in composeLoadById) {
+ var info = composeLoadById[shortId];
+ var selectorId = composeEscapeSelectorFragment(shortId);
+ $('.compose-cpu-' + selectorId).removeClass('compose-text-muted').text(info.cpuText + ' / 100%');
+ $('.compose-mem-' + selectorId).removeClass('compose-text-muted').text(info.mem);
+ $('.compose-netio-' + selectorId).removeClass('compose-text-muted').html(formatInOutHtml(info.netInput, info.netOutput, 'in', 'out'));
+ $('.compose-blockio-' + selectorId).removeClass('compose-text-muted').html(formatInOutHtml(info.blockInput, info.blockOutput, 'read', 'write'));
+ $('#compose-cpu-bar-' + selectorId).css('width', info.cpuText);
+ $('#compose-mem-bar-' + selectorId).css('width', Math.min(info.memPercent || 0, 100).toFixed(2) + '%');
+ }
+
+ renderStackAggregates();
+ composeLoadLastRenderMs = Date.now();
+ }
+
+ function scheduleComposeLoadRender(forceImmediate) {
+ if (!isComposeLoadVisible()) {
+ return;
+ }
+ if (forceImmediate) {
+ if (window._composeDockerLoadRenderTimer) {
+ clearTimeout(window._composeDockerLoadRenderTimer);
+ window._composeDockerLoadRenderTimer = null;
+ }
+ renderComposeLoadCache();
+ return;
+ }
+ if (window._composeDockerLoadRenderTimer) {
+ return;
+ }
+
+ var now = Date.now();
+ var elapsed = now - composeLoadLastRenderMs;
+ var delay = Math.max(0, composeLoadRenderMinIntervalMs - elapsed);
+ window._composeDockerLoadRenderTimer = setTimeout(function() {
+ window._composeDockerLoadRenderTimer = null;
+ renderComposeLoadCache();
+ }, delay);
+ }
+
+ window.composeDockerLoadRenderCached = renderComposeLoadCache;
+
+ function ingestComposeLoadPayload(msg, now) {
+ var data = String(msg || '').split('\n');
+ var i = 0;
+ var row = data[i];
+ while (row) {
+ var parts = row.split(';');
+ if (parts.length >= 3) {
+ var normalizedId = composeNormalizeContainerKey(parts[0]);
+ if (!normalizedId) {
+ i++;
+ row = data[i];
+ continue;
+ }
+ var cpuRaw = parseFloat(parts[1]) || 0;
+ var cpuNorm = Math.round(Math.min(cpuRaw / Math.max(composeCpuCount, 1), 100) * 100) / 100;
+ var memPair = parseMemUsagePair(parts[2]);
+
+ var memPercent = 0;
+ if (parts.length > 3) {
+ memPercent = parseFloat(parts[3]) || 0;
+ }
+ if (memPercent <= 0 && memPair.limit > 0) {
+ memPercent = (memPair.used / memPair.limit) * 100;
+ }
+ var netInput = null;
+ var netOutput = null;
+ var blockInput = null;
+ var blockOutput = null;
+
+ if (parts.length > 7) {
+ netInput = sanitizeStatsValue(parts[4]);
+ netOutput = sanitizeStatsValue(parts[5]);
+ blockInput = sanitizeStatsValue(parts[6]);
+ blockOutput = sanitizeStatsValue(parts[7]);
+ } else {
+ if (parts.length > 4) {
+ var netPair = String(parts[4] || '').split('/');
+ netInput = sanitizeStatsValue(netPair[0] || '');
+ netOutput = sanitizeStatsValue(netPair[1] || '');
+ }
+ if (parts.length > 5) {
+ var blockPair = String(parts[5] || '').split('/');
+ blockInput = sanitizeStatsValue(blockPair[0] || '');
+ blockOutput = sanitizeStatsValue(blockPair[1] || '');
+ }
+ }
+ var netInputBytes = parseMemValueToBytes(netInput || '0');
+ var netOutputBytes = parseMemValueToBytes(netOutput || '0');
+ var blockInputBytes = parseMemValueToBytes(blockInput || '0');
+ var blockOutputBytes = parseMemValueToBytes(blockOutput || '0');
+
+ composeLoadById[normalizedId] = {
+ cpu: cpuNorm,
+ cpuText: formatCpuPercent(cpuNorm),
+ mem: formatMemUsageText(memPair.used, memPair.limit),
+ memUsedBytes: memPair.used,
+ memLimitBytes: memPair.limit,
+ memPercent: memPercent,
+ memPercentText: memPercent > 0 ? memPercent.toFixed(2) + '%' : '-',
+ netInput: netInput || '-',
+ netOutput: netOutput || '-',
+ blockInput: blockInput || '-',
+ blockOutput: blockOutput || '-',
+ netInputBytes: netInputBytes,
+ netOutputBytes: netOutputBytes,
+ blockInputBytes: blockInputBytes,
+ blockOutputBytes: blockOutputBytes,
+ ts: now
+ };
+ }
+ i++;
+ row = data[i];
+ }
+ }
+
+ window.composeDockerLoadSeedSnapshot = function(raw, ts) {
+ if (!raw) return;
+ ingestComposeLoadPayload(raw, ts ? ts * 1000 : Date.now());
+ if (isComposeLoadVisible()) {
+ scheduleComposeLoadRender(true);
+ }
+ };
+
+ if (window.composeLoadSnapshotRaw) {
+ window.composeDockerLoadSeedSnapshot(window.composeLoadSnapshotRaw, window.composeLoadSnapshotTs);
+ }
+
function pruneStaleLoadEntries(now) {
var staleIds = [];
for (var knownId in composeLoadById) {
@@ -1953,12 +2708,20 @@ $(function() {
var totalCpu = 0;
var totalMemUsedBytes = 0;
var totalMemLimitBytes = 0;
+ var totalNetInBytes = 0;
+ var totalNetOutBytes = 0;
+ var totalBlockInBytes = 0;
+ var totalBlockOutBytes = 0;
var matched = 0;
idList.forEach(function(ctId) {
if (ctId && composeLoadById[ctId]) {
totalCpu += composeLoadById[ctId].cpu;
totalMemUsedBytes += composeLoadById[ctId].memUsedBytes || 0;
totalMemLimitBytes += composeLoadById[ctId].memLimitBytes || 0;
+ totalNetInBytes += composeLoadById[ctId].netInputBytes || 0;
+ totalNetOutBytes += composeLoadById[ctId].netOutputBytes || 0;
+ totalBlockInBytes += composeLoadById[ctId].blockInputBytes || 0;
+ totalBlockOutBytes += composeLoadById[ctId].blockOutputBytes || 0;
matched++;
}
});
@@ -1974,40 +2737,30 @@ $(function() {
stackMemTotalBytes = composeSystemMemBytes;
}
var aggMem = formatMemUsageText(totalMemUsedBytes, stackMemTotalBytes);
- $('.compose-stack-cpu-' + entry.stackId).removeClass('compose-text-muted').text(aggCpu);
- $('#compose-stack-cpu-' + entry.stackId).css('width', Math.min(totalCpu, 100).toFixed(2) + '%');
- $('.compose-stack-mem-' + entry.stackId).show().text(aggMem);
+ var aggMemPct = stackMemTotalBytes > 0 ? ((totalMemUsedBytes / stackMemTotalBytes) * 100).toFixed(2) + '%' : '-';
+ $('.compose-stack-cpu-' + entry.stackId).removeClass('compose-text-muted').text(aggCpu + ' / 100%');
+ $('#compose-stack-cpu-bar-' + entry.stackId).css('width', Math.min(totalCpu, 100).toFixed(2) + '%');
+ $('.compose-stack-mem-' + entry.stackId).removeClass('compose-text-muted').text(aggMem);
+ $('#compose-stack-mem-bar-' + entry.stackId).css('width', stackMemTotalBytes > 0 ? Math.min((totalMemUsedBytes / stackMemTotalBytes) * 100, 100).toFixed(2) + '%' : '0');
+ $('.compose-stack-netio-' + entry.stackId).removeClass('compose-text-muted').html(formatInOutHtml(formatBytes(totalNetInBytes), formatBytes(totalNetOutBytes), 'in', 'out'));
+ $('.compose-stack-blockio-' + entry.stackId).removeClass('compose-text-muted').html(formatInOutHtml(formatBytes(totalBlockInBytes), formatBytes(totalBlockOutBytes), 'read', 'write'));
} else {
$('.compose-stack-cpu-' + entry.stackId).addClass('compose-text-muted').text('-');
- $('#compose-stack-cpu-' + entry.stackId).css('width', '0');
- $('.compose-stack-mem-' + entry.stackId).hide();
+ $('#compose-stack-cpu-bar-' + entry.stackId).css('width', '0');
+ $('.compose-stack-mem-' + entry.stackId).addClass('compose-text-muted').text('-');
+ $('#compose-stack-mem-bar-' + entry.stackId).css('width', '0');
+ $('.compose-stack-netio-' + entry.stackId).addClass('compose-text-muted').html('-');
+ $('.compose-stack-blockio-' + entry.stackId).addClass('compose-text-muted').html('-');
}
});
}
composeDockerLoad.on('message', function(msg) {
var now = Date.now();
- var data = msg.split('\n');
- var i = 0;
- var row = data[i];
- while (row) {
- var parts = row.split(';');
- if (parts.length >= 3) {
- var cpuRaw = parseFloat(parts[1]) || 0;
- var cpuNorm = Math.round(Math.min(cpuRaw / Math.max(composeCpuCount, 1), 100) * 100) / 100;
- var memPair = parseMemUsagePair(parts[2]);
- composeLoadById[parts[0]] = {
- cpu: cpuNorm,
- cpuText: formatCpuPercent(cpuNorm),
- mem: formatMemUsageText(memPair.used, memPair.limit),
- memUsedBytes: memPair.used,
- memLimitBytes: memPair.limit,
- ts: now
- };
- }
- i++;
- row = data[i];
- }
+ ingestComposeLoadPayload(msg, now);
+
+ window.composeLoadSnapshotRaw = msg;
+ window.composeLoadSnapshotTs = Math.floor(now / 1000);
pruneStaleLoadEntries(now);
@@ -2018,15 +2771,7 @@ $(function() {
return;
}
- // Update per-container CPU & MEM elements in expanded detail tables
- for (var shortId in composeLoadById) {
- var info = composeLoadById[shortId];
- $('.compose-cpu-' + shortId).removeClass('compose-text-muted').text(info.cpuText);
- $('.compose-mem-' + shortId).show().text(info.mem);
- $('#compose-cpu-' + shortId).css('width', info.cpuText);
- }
-
- renderStackAggregates();
+ scheduleComposeLoadRender(false);
});
composeDockerLoad.on('error', function(code, desc) {
@@ -2061,13 +2806,7 @@ $(function() {
// Immediately render the cached load data so the UI
// shows current metrics without waiting for the next tick.
- for (var shortId in composeLoadById) {
- var info = composeLoadById[shortId];
- $('.compose-cpu-' + shortId).removeClass('compose-text-muted').text(info.cpuText);
- $('.compose-mem-' + shortId).show().text(info.mem);
- $('#compose-cpu-' + shortId).css('width', info.cpuText);
- }
- renderStackAggregates();
+ scheduleComposeLoadRender(true);
}
};
document.addEventListener('visibilitychange', window._composeDockerLoadVisHandler);
@@ -2083,7 +2822,7 @@ $(function() {
if ($loadTabPanel[0].style.display !== 'none' && composeDockerLoadRunning) {
composeLogger('compose tab became visible — invalidating stack index', {
'listReady': composeListReady,
- 'advanced': isComposeAdvancedMode()
+ 'loadColumnsVisible': composeHasVisibleLoadColumns()
}, 'user', 'debug', 'dockerload');
composeStackIndex = null;
}
@@ -2094,20 +2833,19 @@ $(function() {
});
}
- // Only auto-start the socket if the stack list is already
- // loaded (composeListReady is true). Otherwise the
- // composeLoadlist().then() callback will start it.
- if (composeListReady && isComposeAdvancedMode()) {
+ // Auto-start the socket immediately when load columns are visible so the
+ // cache can warm while progressive row loading is still in flight.
+ if (composeShouldEnableDockerLoad()) {
composeLogger('auto-starting socket', {
'listReady': composeListReady,
- 'advanced': isComposeAdvancedMode()
- }, 'user', 'info', 'dockerload');
+ 'loadColumnsVisible': composeHasVisibleLoadColumns()
+ }, 'user', 'debug', 'dockerload');
composeDockerLoad.start();
composeDockerLoadRunning = true;
} else {
composeLogger('deferring socket start', {
'listReady': composeListReady,
- 'advanced': isComposeAdvancedMode()
+ 'loadColumnsVisible': composeHasVisibleLoadColumns()
}, 'user', 'debug', 'dockerload');
}
return true;
@@ -2121,7 +2859,7 @@ $(function() {
var composeDockerLoadInitTimer = setInterval(function() {
composeDockerLoadInitAttempts++;
if (initComposeDockerLoadSubscriber()) {
- composeLogger('subscriber initialized on retry #' + composeDockerLoadInitAttempts, null, 'user', 'info', 'dockerload');
+ composeLogger('subscriber initialized on retry #' + composeDockerLoadInitAttempts, null, 'user', 'debug', 'dockerload');
clearInterval(composeDockerLoadInitTimer);
} else if (composeDockerLoadInitAttempts >= 40) {
composeLogger('subscriber init gave up after ' + composeDockerLoadInitAttempts + ' attempts', null, 'user', 'warn', 'dockerload');
@@ -2449,6 +3187,57 @@ function loadComposeYaml(content) {
return yamlLib.load(input);
}
+function getComposeServiceNamesFromContent(content) {
+ if (!content || !content.trim()) {
+ return [];
+ }
+
+ try {
+ var doc = loadComposeYaml(content) || {};
+ var services = doc.services || {};
+ return Object.keys(services);
+ } catch (e) {
+ return [];
+ }
+}
+
+function getRemovedComposeServices() {
+ var originalContent = editorModal.originalContent['compose'] || '';
+ var currentEditor = editorModal.editors['compose'];
+
+ if (!originalContent || !currentEditor) {
+ return [];
+ }
+
+ var originalServices = getComposeServiceNamesFromContent(originalContent);
+ var currentServices = getComposeServiceNamesFromContent(currentEditor.getValue());
+
+ if (originalServices.length === 0 || currentServices.length === 0) {
+ return [];
+ }
+
+ var currentServiceSet = {};
+ currentServices.forEach(function(serviceName) {
+ currentServiceSet[serviceName] = true;
+ });
+
+ return originalServices.filter(function(serviceName) {
+ return !currentServiceSet[serviceName];
+ });
+}
+
+function isStackRunning(project) {
+ var $stackRow = $('#compose_stacks tr.compose-sortable[data-project="' + project + '"]');
+ return $stackRow.length > 0 && $stackRow.data('isup') == '1';
+}
+
+function buildRemoveOrphansCheckboxHtml(checkboxId) {
+ return '' +
+ ' ' +
+ 'Remove orphans ' +
+ '
';
+}
+
function applyDesc(myID) {
var newDesc = $("#newDesc" + myID).val();
var project = $("#" + myID).attr("data-scriptname");
@@ -2578,10 +3367,28 @@ function editStackSettings(myID) {
}
// Unified update warning dialog - called from stack row and container table
-function showUpdateWarning(project, stackId) {
+function showUpdateWarning(project, stackId, updateAction) {
var path = compose_root + '/' + project;
- // Use the existing UpdateStack function which already has the warning dialog
- UpdateStack(path, "");
+ // Use row metadata so profile selection behavior matches context menu actions.
+ var $stackRow = $('#compose_stacks tr.compose-sortable[data-project="' + project + '"]');
+ var profiles = [];
+ var runningProfile = '';
+ var defaultProfile = '';
+ updateAction = updateAction || 'update';
+
+ if ($stackRow.length > 0) {
+ profiles = $stackRow.data('profiles') || [];
+ runningProfile = $stackRow.attr('data-running-profile') || '';
+ defaultProfile = $stackRow.attr('data-default-profile') || '';
+ }
+
+ if (profiles.length > 0) {
+ showProfileSelector(updateAction, path, profiles, runningProfile, defaultProfile);
+ } else if (updateAction === 'forceUpdate') {
+ ForceUpdateStack(path);
+ } else {
+ UpdateStack(path);
+ }
}
// Show a brief swal when a background command is dispatched
@@ -2693,6 +3500,9 @@ function performComposeAction(opts) {
}
payload.background = background ? 1 : 0;
+ if (Object.prototype.hasOwnProperty.call(payload, 'removeOrphans')) {
+ payload.removeOrphans = payload.removeOrphans ? 1 : 0;
+ }
$.post(requestUrl, payload, function(data) {
var parsed = tryParseJson(data);
@@ -2770,7 +3580,8 @@ function confirmedComposeAction(path, opts) {
}
// Confirmed action handlers (no dialog, just execute)
-function ComposeUpConfirmed(path, profile = "", background = false, suppressBackgroundNotification = false) {
+function ComposeUpConfirmed(path, opts) {
+ opts = opts || {};
confirmedComposeAction(path, {
actionName: 'up',
titlePrefix: 'Compose Up',
@@ -2778,10 +3589,11 @@ function ComposeUpConfirmed(path, profile = "", background = false, suppressBack
payload: {
action: 'composeUp',
path: path,
- profile: profile
+ profile: opts.profile || '',
+ removeOrphans: !!opts.removeOrphans
},
- background: background,
- suppressBackgroundNotification: suppressBackgroundNotification,
+ background: !!opts.background,
+ suppressBackgroundNotification: !!opts.suppressBackgroundNotification,
pendingReload: true
});
}
@@ -2806,7 +3618,8 @@ function ComposeUp(path, profile = "") {
showStackActionDialog('up', path, profile);
}
-function ComposeDownConfirmed(path, profile = "", background = false, suppressBackgroundNotification = false) {
+function ComposeDownConfirmed(path, opts) {
+ opts = opts || {};
confirmedComposeAction(path, {
actionName: 'down',
titlePrefix: 'Compose Down',
@@ -2814,10 +3627,11 @@ function ComposeDownConfirmed(path, profile = "", background = false, suppressBa
payload: {
action: 'composeDown',
path: path,
- profile: profile
+ profile: opts.profile || '',
+ removeOrphans: !!opts.removeOrphans
},
- background: background,
- suppressBackgroundNotification: suppressBackgroundNotification,
+ background: !!opts.background,
+ suppressBackgroundNotification: !!opts.suppressBackgroundNotification,
pendingReload: true
});
}
@@ -2827,7 +3641,8 @@ function ComposeDown(path, profile = "") {
}
// Stop stack without removing containers
-function ComposeStopConfirmed(path, profile = "", background = false, suppressBackgroundNotification = false) {
+function ComposeStopConfirmed(path, opts) {
+ opts = opts || {};
confirmedComposeAction(path, {
actionName: 'stop',
titlePrefix: 'Compose Stop',
@@ -2835,10 +3650,10 @@ function ComposeStopConfirmed(path, profile = "", background = false, suppressBa
payload: {
action: 'composeStop',
path: path,
- profile: profile
+ profile: opts.profile || ''
},
- background: background,
- suppressBackgroundNotification: suppressBackgroundNotification,
+ background: !!opts.background,
+ suppressBackgroundNotification: !!opts.suppressBackgroundNotification,
pendingReload: true
});
}
@@ -2848,7 +3663,8 @@ function ComposeStop(path, profile = "") {
}
// Restart stack (recreate containers without pulling)
-function ComposeRestartConfirmed(path, profile = "", background = false, suppressBackgroundNotification = false) {
+function ComposeRestartConfirmed(path, opts) {
+ opts = opts || {};
confirmedComposeAction(path, {
actionName: 'restart',
titlePrefix: 'Compose Restart',
@@ -2856,10 +3672,10 @@ function ComposeRestartConfirmed(path, profile = "", background = false, suppres
payload: {
action: 'composeUpRecreate',
path: path,
- profile: profile
+ profile: opts.profile || ''
},
- background: background,
- suppressBackgroundNotification: suppressBackgroundNotification,
+ background: !!opts.background,
+ suppressBackgroundNotification: !!opts.suppressBackgroundNotification,
pendingReload: true,
refreshDelayMs: 1000
});
@@ -2870,7 +3686,8 @@ function ComposeRestart(path, profile = "") {
}
// Force update stack (pull and rebuild even without detected updates)
-function ForceUpdateStackConfirmed(path, profile = "", background = false, suppressBackgroundNotification = false) {
+function ForceUpdateStackConfirmed(path, opts) {
+ opts = opts || {};
var stackName = basename(path);
if (pendingUpdateCheckStacks.indexOf(stackName) === -1) {
pendingUpdateCheckStacks.push(stackName);
@@ -2889,10 +3706,10 @@ function ForceUpdateStackConfirmed(path, profile = "", background = false, suppr
payload: {
action: 'composeUpPullBuild',
path: path,
- profile: profile
+ profile: opts.profile || ''
},
- background: background,
- suppressBackgroundNotification: suppressBackgroundNotification,
+ background: !!opts.background,
+ suppressBackgroundNotification: !!opts.suppressBackgroundNotification,
pendingReload: true
});
}
@@ -2913,9 +3730,9 @@ function promptRecreateContainers(closeAfterSave) {
title: "Saved!",
text: "All changes have been saved.",
type: "success",
- timer: 1500,
showConfirmButton: false
});
+ setTimeout(function() { swal.close(); }, 1500);
return;
}
@@ -2943,11 +3760,12 @@ function promptRecreateContainers(closeAfterSave) {
title: "Saved!",
text: "Container labels were saved. Recreate or restart containers to apply the changes.",
type: "info",
- timer: 2000,
showConfirmButton: false
- }, function() {
- refreshStackByProject(project);
});
+ setTimeout(function() {
+ swal.close();
+ refreshStackByProject(project);
+ }, 2000);
return;
}
@@ -2978,11 +3796,12 @@ function promptRecreateContainers(closeAfterSave) {
title: "Saved!",
text: "Changes saved. Remember to restart or recreate containers to apply label changes.",
type: "info",
- timer: 2000,
- showConfirmButton: false
- }, function() {
- refreshStackByProject(project);
- });
+showConfirmButton: false
+ });
+ setTimeout(function() {
+ swal.close();
+ refreshStackByProject(project);
+ }, 2000);
}
});
}
@@ -3014,7 +3833,7 @@ function processPendingUpdateChecks() {
deferredStacks.push(stackName);
composeLogger('Deferring pending update check while action is in progress', {
stack: stackName
- }, 'user', 'info', 'update-check');
+ }, 'user', 'debug', 'update-check');
} else {
checkStackUpdates(stackName);
}
@@ -3215,7 +4034,8 @@ function setStackActionInProgress(stackName, inProgress, text) {
}
}
-function UpdateStackConfirmed(path, profile = "", background = false, suppressBackgroundNotification = false) {
+function UpdateStackConfirmed(path, opts) {
+ opts = opts || {};
var stackName = basename(path);
if (pendingUpdateCheckStacks.indexOf(stackName) === -1) {
pendingUpdateCheckStacks.push(stackName);
@@ -3234,10 +4054,10 @@ function UpdateStackConfirmed(path, profile = "", background = false, suppressBa
payload: {
action: 'composeUpPullBuild',
path: path,
- profile: profile
+ profile: opts.profile || ''
},
- background: background,
- suppressBackgroundNotification: suppressBackgroundNotification,
+ background: !!opts.background,
+ suppressBackgroundNotification: !!opts.suppressBackgroundNotification,
pendingReload: true
});
}
@@ -3294,20 +4114,27 @@ function startAllStacks() {
' ' +
'Run in background ' +
' ';
+ var removeOrphansHtml = buildRemoveOrphansCheckboxHtml('swal-remove-orphans-startall');
getConfig().then(function(pluginCfg) {
var bgDefault = pluginCfg && pluginCfg.RUN_IN_BACKGROUND_DEFAULT === 'true';
+ var removeOrphansDefault = pluginCfg && pluginCfg.REMOVE_ORPHANS_DEFAULT === 'true';
var disableWarnings = pluginCfg && pluginCfg.DISABLE_ACTION_WARNINGS === 'true';
if (disableWarnings) {
- executeStartAllStacks(stacks, bgDefault, bgDefault);
+ executeStartAllStacks({
+ stacks: stacks,
+ background: bgDefault,
+ suppressBackgroundNotification: bgDefault,
+ removeOrphans: removeOrphansDefault
+ });
return;
}
swal({
title: title,
html: true,
- text: '
The following stacks will be started:
' + stackNames + '
' + bgCheckboxHtml + '
',
+ text: '
The following stacks will be started:
' + stackNames + '
' + removeOrphansHtml + bgCheckboxHtml + '
',
type: 'warning',
showCancelButton: true,
confirmButtonText: confirmText,
@@ -3315,18 +4142,31 @@ function startAllStacks() {
}, function(confirmed) {
if (confirmed) {
var runInBackground = $('#swal-run-bg-startall').is(':checked');
- executeStartAllStacks(stacks, runInBackground);
+ var removeOrphans = $('#swal-remove-orphans-startall').is(':checked');
+ executeStartAllStacks({
+ stacks: stacks,
+ background: runInBackground,
+ suppressBackgroundNotification: false,
+ removeOrphans: removeOrphans
+ });
}
});
setTimeout(function() {
var $cb = $('#swal-run-bg-startall');
if ($cb.length) $cb.prop('checked', bgDefault);
+ var $removeCb = $('#swal-remove-orphans-startall');
+ if ($removeCb.length) $removeCb.prop('checked', removeOrphansDefault);
}, 50);
});
}
-function executeStartAllStacks(stacks, background, suppressBackgroundNotification = false) {
+function executeStartAllStacks(opts) {
+ opts = opts || {};
+ var stacks = opts.stacks || [];
+ var background = !!opts.background;
+ var suppressBackgroundNotification = !!opts.suppressBackgroundNotification;
+ var removeOrphans = !!opts.removeOrphans;
var height = 800;
var width = 1200;
@@ -3349,7 +4189,8 @@ function executeStartAllStacks(stacks, background, suppressBackgroundNotificatio
$.post(compURL, {
action: 'composeUpMultiple',
paths: JSON.stringify(paths),
- background: background ? 1 : 0
+ background: background ? 1 : 0,
+ removeOrphans: removeOrphans ? 1 : 0
}, function(data) {
var parsed = tryParseJson(data);
if (parsed && parsed.background) {
@@ -3411,20 +4252,27 @@ function stopAllStacks() {
'
' +
'
Run in background ' +
'
';
+ var removeOrphansHtml = buildRemoveOrphansCheckboxHtml('swal-remove-orphans-stopall');
getConfig().then(function(pluginCfg) {
var bgDefault = pluginCfg && pluginCfg.RUN_IN_BACKGROUND_DEFAULT === 'true';
+ var removeOrphansDefault = pluginCfg && pluginCfg.REMOVE_ORPHANS_DEFAULT === 'true';
var disableWarnings = pluginCfg && pluginCfg.DISABLE_ACTION_WARNINGS === 'true';
if (disableWarnings) {
- executeStopAllStacks(stacks, bgDefault, bgDefault);
+ executeStopAllStacks({
+ stacks: stacks,
+ background: bgDefault,
+ suppressBackgroundNotification: bgDefault,
+ removeOrphans: removeOrphansDefault
+ });
return;
}
swal({
title: title,
html: true,
- text: 'The following stacks will be stopped:
' + stackNames + '
Containers will be stopped and removed. Data in volumes will be preserved.
' + bgCheckboxHtml + '
',
+ text: 'The following stacks will be stopped:
' + stackNames + '
Containers will be stopped and removed. Data in volumes will be preserved.
' + removeOrphansHtml + bgCheckboxHtml + '
',
type: 'warning',
showCancelButton: true,
confirmButtonText: confirmText,
@@ -3432,18 +4280,31 @@ function stopAllStacks() {
}, function(confirmed) {
if (confirmed) {
var runInBackground = $('#swal-run-bg-stopall').is(':checked');
- executeStopAllStacks(stacks, runInBackground);
+ var removeOrphans = $('#swal-remove-orphans-stopall').is(':checked');
+ executeStopAllStacks({
+ stacks: stacks,
+ background: runInBackground,
+ suppressBackgroundNotification: false,
+ removeOrphans: removeOrphans
+ });
}
});
setTimeout(function() {
var $cb = $('#swal-run-bg-stopall');
if ($cb.length) $cb.prop('checked', bgDefault);
+ var $removeCb = $('#swal-remove-orphans-stopall');
+ if ($removeCb.length) $removeCb.prop('checked', removeOrphansDefault);
}, 50);
});
}
-function executeStopAllStacks(stacks, background, suppressBackgroundNotification = false) {
+function executeStopAllStacks(opts) {
+ opts = opts || {};
+ var stacks = opts.stacks || [];
+ var background = !!opts.background;
+ var suppressBackgroundNotification = !!opts.suppressBackgroundNotification;
+ var removeOrphans = !!opts.removeOrphans;
var height = 800;
var width = 1200;
@@ -3466,7 +4327,8 @@ function executeStopAllStacks(stacks, background, suppressBackgroundNotification
$.post(compURL, {
action: 'composeDownMultiple',
paths: JSON.stringify(paths),
- background: background ? 1 : 0
+ background: background ? 1 : 0,
+ removeOrphans: removeOrphans ? 1 : 0
}, function(data) {
var parsed = tryParseJson(data);
if (parsed && parsed.background) {
@@ -3490,17 +4352,58 @@ function mergeUpdateStatus(containers, project) {
var cInfo = createContainerInfo(container);
stackUpdateStatus[project].containers.forEach(function(update) {
var uInfo = createContainerInfo(update);
- if (cInfo.name === uInfo.name) {
+ var sameName = cInfo.name && uInfo.name && cInfo.name === uInfo.name;
+ var sameService = cInfo.service && uInfo.service && cInfo.service === uInfo.service;
+ if (sameName || sameService) {
container.hasUpdate = uInfo.hasUpdate;
container.updateStatus = uInfo.updateStatus;
container.localSha = uInfo.localSha;
container.remoteSha = uInfo.remoteSha;
+ if ((!container.icon || !isValidIconSrc(container.icon)) && uInfo.icon) {
+ container.icon = uInfo.icon;
+ }
}
});
});
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);
@@ -3579,13 +4482,16 @@ function showStackActionDialog(action, path, profile) {
containers.push(container);
});
+ var detectedMismatch = cachedContainers.length !== profileServices.length;
+
composeLogger('profile/unified AJAX done', {
profileServices,
containers,
- cachedContainers
+ cachedContainers,
+ detectedMismatch
}, 'user', 'debug', 'showStackActionDialog');
containers = mergeUpdateStatus(containers, project);
- renderStackActionDialog(action, displayName, path, profile, containers, hasBuild);
+ renderStackActionDialog(action, displayName, path, profile, containers, hasBuild, detectedMismatch);
}).fail(function(xhr, status, error) {
// Fallback: if profile resolution fails, show cached container metadata
composeLogger('getProfileServices AJAX fail', {
@@ -3603,12 +4509,12 @@ function showStackActionDialog(action, path, profile) {
});
containers = mergeUpdateStatus(containers, project);
}
- renderStackActionDialog(action, displayName, path, profile, containers, hasBuild);
+ renderStackActionDialog(action, displayName, path, profile, containers, hasBuild, cachedContainers.length > 0);
});
return;
}
-function renderStackActionDialog(action, displayName, path, profile, containers, hasBuild) {
+function renderStackActionDialog(action, displayName, path, profile, containers, hasBuild, showRemoveOrphans) {
hasBuild = hasBuild || false;
// Action-specific configuration
var pullLabel = hasBuild ? 'Build' : 'Pull';
@@ -3622,6 +4528,7 @@ function renderStackActionDialog(action, displayName, path, profile, containers,
warningColor: window.getComputedStyle(document.documentElement).getPropertyValue('--dynamix-ui-dropdownchecklist-color'),
confirmText: 'Compose Up',
showVersionArrow: false,
+ showRemoveOrphans: true,
confirmedFn: ComposeUpConfirmed
},
'down': {
@@ -3633,6 +4540,7 @@ function renderStackActionDialog(action, displayName, path, profile, containers,
warningColor: window.getComputedStyle(document.documentElement).getPropertyValue('--dynamix-sb-message-link-color'),
confirmText: 'Compose Down',
showVersionArrow: false,
+ showRemoveOrphans: true,
confirmedFn: ComposeDownConfirmed
},
'stop': {
@@ -3753,6 +4661,9 @@ function renderStackActionDialog(action, displayName, path, profile, containers,
html += ' ';
html += '' + composeEscapeHtml(remoteSha.substring(0, 8)) + ' ';
html += '';
+ if (dockerVersionsInstalled) {
+ html += '';
+ }
} else if (localSha) {
// No update - just show current SHA (greyed)
html += '' + composeEscapeHtml(localSha.substring(0, 8)) + '
';
@@ -3767,6 +4678,15 @@ function renderStackActionDialog(action, displayName, path, profile, containers,
// Warning/info text
html += ' ' + cfg.warning + '
';
+ var removeOrphansDefault = false;
+
+ if (cfg.showRemoveOrphans) {
+ html += '' +
+ buildRemoveOrphansCheckboxHtml('swal-remove-orphans-checkbox') +
+ '
Adds --remove-orphans to the compose command.
' +
+ '
';
+ }
+
// Run-in-background checkbox (appended after config is fetched below)
var bgCheckboxHtml = '' +
'
' +
@@ -3778,14 +4698,23 @@ function renderStackActionDialog(action, displayName, path, profile, containers,
// Fetch config to determine default checkbox state, then show swal (or skip warnings)
getConfig().then(function(pluginCfg) {
var bgDefault = pluginCfg && pluginCfg.RUN_IN_BACKGROUND_DEFAULT === 'true';
+ removeOrphansDefault = pluginCfg && pluginCfg.REMOVE_ORPHANS_DEFAULT === 'true';
var disableWarnings = pluginCfg && pluginCfg.DISABLE_ACTION_WARNINGS === 'true';
if (disableWarnings) {
// In default background mode (warnings disabled and background enabled), don't show toast if background is used
- cfg.confirmedFn(path, profile, bgDefault, bgDefault);
+ cfg.confirmedFn(path, {
+ profile: profile,
+ background: bgDefault,
+ suppressBackgroundNotification: bgDefault,
+ removeOrphans: removeOrphansDefault
+ });
return;
}
+ var removeOrphansChecked = removeOrphansDefault || showRemoveOrphans;
+ var showRemoveOrphansOption = cfg.showRemoveOrphans && (removeOrphansChecked || showRemoveOrphans);
+
// Use native swal (SweetAlert 1.x) with callback style
swal({
title: cfg.title,
@@ -3799,8 +4728,14 @@ function renderStackActionDialog(action, displayName, path, profile, containers,
if (confirmed) {
// Capture checkbox state before swal destroys the DOM
var runInBackground = $('#swal-run-bg-checkbox').is(':checked');
+ var removeOrphans = $('#swal-remove-orphans-checkbox').is(':checked');
// when running in background, suppress the extra notifyBackgroundStarted popup
- cfg.confirmedFn(path, profile, runInBackground, runInBackground);
+ cfg.confirmedFn(path, {
+ profile: profile,
+ background: runInBackground,
+ suppressBackgroundNotification: runInBackground,
+ removeOrphans: removeOrphans
+ });
}
});
@@ -3810,6 +4745,11 @@ function renderStackActionDialog(action, displayName, path, profile, containers,
if ($cb.length) {
$cb.prop('checked', bgDefault);
}
+ var $orphanCb = $('#swal-remove-orphans-checkbox');
+ if ($orphanCb.length) {
+ $orphanCb.prop('checked', removeOrphansChecked);
+ $('#swal-remove-orphans-wrap').toggle(showRemoveOrphansOption);
+ }
}, 50);
});
}
@@ -3900,11 +4840,67 @@ var persistentContainerCache = {}; // Persistent cache loaded from disk
var stackStartedAtCache = {}; // Cache for stack-level started_at timestamps
// Track stacks currently loading details to prevent concurrent reloads
var stackDetailsLoading = {};
+var stackDetailsLoadPromises = {};
+var stackDetailsPrefetchPromises = {};
+var stackDetailsPrefetchCache = {};
// Suppress immediate refresh after a render to avoid loops
var stackDetailsJustRendered = {};
+// Track user intent during async loads so late responses do not reopen collapsed rows.
+var stackDetailsDesiredExpanded = {};
+// Batch/safeguard toggle-all updates under rapid clicking.
+var stackToggleAllBatching = false;
+var stackToggleAllBusy = false;
+var stackToggleAllQueuedForceExpand = null;
+var stackToggleAllQueuedToggle = false;
+
+function prefetchStackDetailsInBackground(projects) {
+ if (!Array.isArray(projects) || projects.length === 0) return;
+
+ var maxParallel = 4;
+ var next = 0;
+ var active = 0;
+
+ function schedule() {
+ while (active < maxParallel && next < projects.length) {
+ (function(project) {
+ next++;
+
+ if (!project || stackDetailsPrefetchPromises[project] || stackDetailsPrefetchCache[project]) {
+ schedule();
+ return;
+ }
-function openStackActionsMenu(event, stackId) {
- event.stopPropagation();
+ active++;
+ stackDetailsPrefetchPromises[project] = new Promise(function(resolve) {
+ $.post(caURL, {
+ action: 'getStackContainers',
+ script: project
+ }, function(data) {
+ var parsed = tryParseJson(data);
+ if (parsed && parsed.result === 'success') {
+ stackDetailsPrefetchCache[project] = parsed;
+ // If the row already exists, bubble prefetched summary
+ // data into parent columns during initial progressive load.
+ applyStackSummaryFromResponse(project, parsed);
+ }
+ resolve();
+ }).fail(function() {
+ resolve();
+ }).always(function() {
+ active--;
+ delete stackDetailsPrefetchPromises[project];
+ schedule();
+ });
+ });
+ })(projects[next]);
+ }
+ }
+
+ schedule();
+}
+
+function openStackActionsMenu(event, stackId) {
+ event.stopPropagation();
currentStackId = stackId;
var $row = $('#stack-row-' + stackId);
@@ -3956,6 +4952,8 @@ function executeStackAction(action) {
var projectName = $row.data('projectname');
var path = $row.data('path');
var profiles = $row.data('profiles') || [];
+ var runningProfile = $row.data('running-profile') || '';
+ var defaultProfile = $row.data('default-profile') || '';
var isUp = $row.data('isup') == "1";
closeStackActionsMenu();
@@ -3963,7 +4961,7 @@ function executeStackAction(action) {
// Handle profile selection if profiles exist and action supports it
var profileSupportedActions = ['up', 'down', 'update', 'pull', 'logs'];
if (profiles.length > 0 && profileSupportedActions.includes(action)) {
- showProfileSelector(action, path, profiles);
+ showProfileSelector(action, path, profiles, runningProfile, defaultProfile);
return;
}
@@ -3997,7 +4995,13 @@ function executeStackAction(action) {
}
}
-function showProfileSelector(action, path, profiles) {
+function showProfileSelector(action, path, profiles, runningProfile, defaultProfile) {
+ if (typeof runningProfile === 'undefined') {
+ runningProfile = '';
+ }
+ if (typeof defaultProfile === 'undefined') {
+ defaultProfile = '';
+ }
var actionNames = {
'up': 'Compose Up',
'down': 'Compose Down',
@@ -4017,12 +5021,42 @@ function showProfileSelector(action, path, profiles) {
profileHtml += '
';
profileHtml += ' Default services (no profile) ';
profileHtml += '
';
+
+ function parseProfileList(rawValue) {
+ if (!rawValue) return [];
+ return rawValue.split(',').map(function(p) {
+ return p.trim();
+ }).filter(function(p) {
+ return p !== '';
+ });
+ }
+
+ // Selection priority: running profiles -> default profiles -> all (*)
+ var runningProfileSet = parseProfileList(runningProfile);
+ var defaultProfileSet = parseProfileList(defaultProfile);
+ var preselectedProfiles = [];
+ var showAllProfilesChecked = false;
+
+ if (runningProfileSet.indexOf('*') !== -1) {
+ showAllProfilesChecked = true;
+ } else if (runningProfileSet.length > 0) {
+ preselectedProfiles = runningProfileSet;
+ } else if (defaultProfileSet.indexOf('*') !== -1) {
+ showAllProfilesChecked = true;
+ } else if (defaultProfileSet.length > 0) {
+ preselectedProfiles = defaultProfileSet;
+ } else {
+ showAllProfilesChecked = true;
+ }
+ var profileCheckboxDisabled = showAllProfilesChecked ? 'disabled' : '';
+
profileHtml += '
';
- profileHtml += ' All profile-based services (*) ';
+ profileHtml += ' All profile-based services (*) ';
profileHtml += '
';
profileHtml += '
';
profiles.forEach(function(profile) {
- profileHtml += ' ' + composeEscapeHtml(profile) + ' ';
+ var isChecked = !showAllProfilesChecked && preselectedProfiles.indexOf(profile) >= 0;
+ profileHtml += ' ' + composeEscapeHtml(profile) + ' ';
});
profileHtml += '
';
profileHtml += '
Default services are always included. Select multiple profiles to include profile-based services.
';
@@ -4082,6 +5116,14 @@ function toggleAllProfiles(checkbox) {
$('.profile_checkbox').prop('disabled', disabled).prop('checked', false);
}
+function updateProfileCheckboxes() {
+ // If any individual profile is checked, uncheck "all profiles"
+ var anyIndividualChecked = $('.profile_checkbox:checked').length > 0;
+ if (anyIndividualChecked) {
+ $('#profile_all_profiles').prop('checked', false);
+ }
+}
+
function openEditorModalByProject(project, projectName, initialTab) {
if (typeof ace === 'undefined') {
swal({
@@ -4216,6 +5258,79 @@ function loadEditorFiles(project) {
});
}
+// ---- Compose file switcher (main + additional compose files) ----
+
+// Populate the Compose tab file selector; hidden unless the stack has
+// more than one editable compose file.
+function populateComposeFileSelector(files) {
+ var $wrap = $('#compose-file-selector-wrap');
+ var $sel = $('#compose-file-selector');
+ editorModal.composeFiles = files || [];
+
+ $sel.empty();
+ if (!files || files.length < 2) {
+ $wrap.hide();
+ return;
+ }
+ files.forEach(function(path) {
+ var base = path.split('/').pop();
+ $sel.append($('
').val(path).text(base).attr('title', path));
+ });
+ $sel.val(editorModal.filePaths.compose || files[0]);
+ $wrap.css('display', 'flex');
+}
+
+// Switch the Compose tab editor to another file of the stack.
+function switchComposeFile(path) {
+ if (!path || path === editorModal.filePaths.compose) return;
+
+ var doSwitch = function() {
+ $.post(caURL, {
+ action: 'getYml',
+ script: editorModal.currentProject,
+ file: path
+ }).then(function(data) {
+ if (!data) return;
+ var response = jQuery.parseJSON(data);
+ if (response.result !== 'success') {
+ swal({ type: 'error', title: 'Load Failed', text: response.message || 'Unable to load file.' });
+ $('#compose-file-selector').val(editorModal.filePaths.compose);
+ return;
+ }
+ editorModal.filePaths.compose = response.fileName || path;
+ editorModal.originalContent['compose'] = response.content || '';
+ if (editorModal.editors['compose']) editorModal.editors['compose'].setValue(response.content || '', -1);
+ editorModal.modifiedTabs.delete('compose');
+ updateSaveButtonState();
+ updateTabModifiedState();
+ updateEditorFileInfo();
+ validateYaml('compose', response.content || '');
+ }).fail(function() {
+ swal({ type: 'error', title: 'Load Failed', text: 'Unable to load file (network error).' });
+ $('#compose-file-selector').val(editorModal.filePaths.compose);
+ });
+ };
+
+ if (editorModal.modifiedTabs.has('compose')) {
+ swal({
+ title: 'Discard changes?',
+ text: 'The current compose file has unsaved changes that will be lost when switching files.',
+ type: 'warning',
+ showCancelButton: true,
+ confirmButtonText: 'Discard',
+ cancelButtonText: 'Cancel'
+ }, function(confirmed) {
+ if (confirmed) {
+ doSwitch();
+ } else {
+ $('#compose-file-selector').val(editorModal.filePaths.compose);
+ }
+ });
+ } else {
+ doSwitch();
+ }
+}
+
// Load settings data into the settings panel
function loadSettingsData(project, projectName) {
// Set the name from projectName (display name)
@@ -4301,12 +5416,27 @@ function loadSettingsData(project, projectName) {
$('#settings-env-path').val(envPath);
editorModal.originalSettings['env-path'] = envPath;
+ // Additional compose files
+ renderExtraComposeFiles(response.composeFileCandidates || [], response.extraComposeFiles || '');
+ // Store the normalized (comment-free) value so change tracking compares like-for-like
+ editorModal.originalSettings['extra-compose-files'] = getExtraComposeFilesValue();
+
+ // Compose file selector (main + additional files)
+ populateComposeFileSelector(response.editableComposeFiles || []);
+
// External compose path
var externalComposePath = response.externalComposePath || '';
var externalComposeFilePath = response.externalComposeFilePath || '';
var invalidIndirectPath = response.invalidIndirectPath || '';
var indirectMode = response.indirectMode || '';
if (!externalComposePath && !externalComposeFilePath && invalidIndirectPath) {
+ composeLogger('settings-invalid-indirect-warning-show', {
+ project: project,
+ invalidIndirectPath: invalidIndirectPath,
+ indirectMode: indirectMode,
+ externalComposePath: externalComposePath,
+ externalComposeFilePath: externalComposeFilePath
+ }, 'user', 'debug', 'stack-settings');
// Pre-populate with the broken path so the user can fix it
if (indirectMode === 'file') {
$('#settings-external-compose-path').val('');
@@ -4324,6 +5454,14 @@ function loadSettingsData(project, projectName) {
$('#settings-invalid-indirect-warning').show();
$('#settings-external-compose-info').hide();
} else {
+ if ($('#settings-invalid-indirect-warning').is(':visible')) {
+ composeLogger('settings-invalid-indirect-warning-hide', {
+ project: project,
+ invalidIndirectPath: invalidIndirectPath,
+ externalComposePath: externalComposePath,
+ externalComposeFilePath: externalComposeFilePath
+ }, 'user', 'debug', 'stack-settings');
+ }
$('#settings-external-compose-path').val(externalComposePath);
$('#settings-external-compose-file').val(externalComposeFilePath);
editorModal.originalSettings['external-compose-path'] = externalComposePath;
@@ -4381,6 +5519,7 @@ function loadSettingsData(project, projectName) {
$('#settings-icon-url').val('');
$('#settings-webui-url').val('');
$('#settings-env-path').val('');
+ resetExtraComposeFilesUI();
$('#settings-default-profile').val('');
$('#settings-external-compose-path').val('');
$('#settings-external-compose-file').val('');
@@ -4389,6 +5528,7 @@ function loadSettingsData(project, projectName) {
editorModal.originalSettings['icon-url'] = '';
editorModal.originalSettings['webui-url'] = '';
editorModal.originalSettings['env-path'] = '';
+ editorModal.originalSettings['extra-compose-files'] = '';
editorModal.originalSettings['default-profile'] = '';
editorModal.originalSettings['external-compose-path'] = '';
editorModal.originalSettings['external-compose-file'] = '';
@@ -4880,6 +6020,7 @@ function hasPathSensitiveSettingsChanges() {
return editorModal.modifiedSettings.has('env-path') ||
editorModal.modifiedSettings.has('external-compose-path') ||
editorModal.modifiedSettings.has('external-compose-file') ||
+ editorModal.modifiedSettings.has('extra-compose-files') ||
editorModal.modifiedSettings.has('use-default-compose-files');
}
@@ -5126,6 +6267,10 @@ function saveTab(tabName, saveErrors) {
if (tabName === 'override') {
savePayload.managed = editorModal.labelsViewMode === 'basic' ? 1 : 0;
}
+ if (tabName === 'compose' && editorModal.filePaths.compose) {
+ // Save to the currently selected compose file (main or additional)
+ savePayload.file = editorModal.filePaths.compose;
+ }
return $.post(caURL, savePayload).then(function(data) {
var saveTarget = tabName === 'override' ? 'override file' : (tabName + ' file');
@@ -5156,131 +6301,6 @@ function saveTab(tabName, saveErrors) {
});
}
-// Save all modified changes (files, settings, and labels)
-function saveAllChanges(closeAfterSave) {
- if (typeof closeAfterSave === 'undefined') {
- closeAfterSave = false;
- }
-
- var savePromises = [];
- var saveErrors = [];
- var skippedManualLabels = false;
- var totalChanges = editorModal.modifiedTabs.size + editorModal.modifiedSettings.size + editorModal.modifiedLabels.size;
- var pathSensitiveSettingsChanged = hasPathSensitiveSettingsChanges();
- var pathDependentEdits = hasPathDependentEdits();
-
- if (totalChanges === 0) {
- return;
- }
-
- if (pathSensitiveSettingsChanged && pathDependentEdits) {
- swal({
- title: 'Reload Required',
- text: 'Compose path or discovery settings changed. Save settings and reload the editor before saving compose, .env, override, or label edits.',
- type: 'warning',
- showCancelButton: true,
- confirmButtonText: 'Save Settings Only',
- cancelButtonText: 'Cancel'
- }, function(confirmed) {
- if (confirmed) {
- saveSettingsWithOptionalReload(closeAfterSave);
- }
- });
- return;
- }
-
- if (pathSensitiveSettingsChanged) {
- saveSettingsWithOptionalReload(closeAfterSave);
- return;
- }
-
- // Track if labels are being modified in Automatic mode (need to offer recreate)
- var labelsWereModified = editorModal.labelsViewMode === 'basic' && editorModal.modifiedLabels.size > 0;
-
- // Save modified file tabs (compose, env, and override editor)
- editorModal.modifiedTabs.forEach(function(tabName) {
- savePromises.push(saveTab(tabName, saveErrors));
- });
-
- // Save settings if modified
- if (editorModal.modifiedSettings.size > 0) {
- savePromises.push(saveSettings(saveErrors));
- }
-
- // Save labels only in Automatic mode.
- if (editorModal.modifiedLabels.size > 0) {
- if (editorModal.labelsViewMode === 'basic') {
- savePromises.push(saveLabels(saveErrors));
- } else {
- skippedManualLabels = true;
- }
- }
-
- $.when.apply($, savePromises).then(function() {
- var results = Array.prototype.slice.call(arguments);
- var allSucceeded = results.every(function(result) {
- return result === true;
- });
-
- if (allSucceeded) {
- if (skippedManualLabels) {
- swal({
- title: "Partially Saved",
- text: "Non-label changes were saved. WebUI label form edits were not saved because Override File Management is set to Manual. Switch to Automatic to save Labels form changes.",
- type: "warning"
- });
- updateTabModifiedState();
- updateSaveButtonState();
- return;
- }
-
- // Check if we should offer to recreate containers
- if (labelsWereModified) {
- promptRecreateContainers(closeAfterSave);
- } else {
- var project = editorModal.currentProject;
- if (closeAfterSave) {
- doCloseEditorModal();
- }
- swal({
- title: "Saved!",
- text: "All changes have been saved.",
- type: "success",
- timer: 1500,
- showConfirmButton: false
- });
- setTimeout(function() {
- if (project) {
- refreshStackByProject(project);
- }
- }, 1600);
- }
- } else {
- var filteredErrors = saveErrors.filter(function(message) {
- return message !== '__STALE_PATH__';
- });
- if (filteredErrors.length === 0 && saveErrors.indexOf('__STALE_PATH__') !== -1) {
- return;
- }
- var errorText = filteredErrors.length > 0 ?
- filteredErrors.join('\n') :
- 'Some items could not be saved. Please try again.';
- swal({
- title: "Save Failed",
- text: errorText,
- type: "error"
- });
- }
- }).fail(function() {
- swal({
- title: "Save Failed",
- text: "An error occurred while saving. Please try again.",
- type: "error"
- });
- });
-}
-
-// Save settings
function saveSettings(saveErrors) {
var project = editorModal.currentProject;
var savePromises = [];
@@ -5325,7 +6345,7 @@ function saveSettings(saveErrors) {
}
// Save icon URL, webui URL, env path, default profile, and external compose settings if any are modified
- if (editorModal.modifiedSettings.has('icon-url') || editorModal.modifiedSettings.has('webui-url') || editorModal.modifiedSettings.has('env-path') || editorModal.modifiedSettings.has('default-profile') || editorModal.modifiedSettings.has('external-compose-path') || editorModal.modifiedSettings.has('external-compose-file') || editorModal.modifiedSettings.has('use-default-compose-files')) {
+ if (editorModal.modifiedSettings.has('icon-url') || editorModal.modifiedSettings.has('webui-url') || editorModal.modifiedSettings.has('env-path') || editorModal.modifiedSettings.has('extra-compose-files') || editorModal.modifiedSettings.has('default-profile') || editorModal.modifiedSettings.has('external-compose-path') || editorModal.modifiedSettings.has('external-compose-file') || editorModal.modifiedSettings.has('use-default-compose-files')) {
var iconUrl = $('#settings-icon-url').val();
var webuiUrl = $('#settings-webui-url').val();
if (webuiUrl && !isValidWebUIUrl(webuiUrl)) {
@@ -5345,6 +6365,7 @@ function saveSettings(saveErrors) {
return $.Deferred().resolve(false).promise();
}
var envPath = $('#settings-env-path').val();
+ var extraComposeFiles = getExtraComposeFilesValue();
var defaultProfile = $('#settings-default-profile').val();
var externalComposePath = $('#settings-external-compose-path').val();
var externalComposeFilePath = $('#settings-external-compose-file').val();
@@ -5373,6 +6394,7 @@ function saveSettings(saveErrors) {
iconUrl: iconUrl,
webuiUrl: webuiUrl,
envPath: envPath,
+ extraComposeFiles: extraComposeFiles,
defaultProfile: defaultProfile,
externalComposePath: externalComposePath,
externalComposeFilePath: externalComposeFilePath,
@@ -5389,6 +6411,7 @@ function saveSettings(saveErrors) {
editorModal.originalSettings['icon-url'] = iconUrl;
editorModal.originalSettings['webui-url'] = webuiUrl;
editorModal.originalSettings['env-path'] = envPath;
+ editorModal.originalSettings['extra-compose-files'] = extraComposeFiles;
editorModal.originalSettings['default-profile'] = defaultProfile;
editorModal.originalSettings['external-compose-path'] = externalComposePath;
editorModal.originalSettings['external-compose-file'] = externalComposeFilePath;
@@ -5396,15 +6419,14 @@ function saveSettings(saveErrors) {
editorModal.modifiedSettings.delete('icon-url');
editorModal.modifiedSettings.delete('webui-url');
editorModal.modifiedSettings.delete('env-path');
+ editorModal.modifiedSettings.delete('extra-compose-files');
editorModal.modifiedSettings.delete('default-profile');
editorModal.modifiedSettings.delete('external-compose-path');
editorModal.modifiedSettings.delete('external-compose-file');
editorModal.modifiedSettings.delete('use-default-compose-files');
return true;
- } else {
- // Collect error message from server
- if (saveErrors) saveErrors.push(response.message || 'Failed to save stack settings.');
}
+ if (saveErrors) saveErrors.push(response.message || 'Failed to save stack settings.');
}
return false;
}).fail(function() {
@@ -5461,6 +6483,147 @@ function saveSettings(saveErrors) {
});
}
+// Save all modified changes (files, settings, and labels)
+function saveAllChanges(closeAfterSave) {
+ if (typeof closeAfterSave === 'undefined') {
+ closeAfterSave = false;
+ }
+
+ var savePromises = [];
+ var saveErrors = [];
+ var skippedManualLabels = false;
+ var totalChanges = editorModal.modifiedTabs.size + editorModal.modifiedSettings.size + editorModal.modifiedLabels.size;
+ var pathSensitiveSettingsChanged = hasPathSensitiveSettingsChanges();
+ var pathDependentEdits = hasPathDependentEdits();
+ var project = editorModal.currentProject;
+ var removedServices = [];
+
+ if (totalChanges === 0) {
+ return;
+ }
+
+ if (project && editorModal.modifiedTabs.has('compose') && isStackRunning(project)) {
+ removedServices = getRemovedComposeServices();
+ }
+
+ function continueSave() {
+ // Track if labels are being modified in Automatic mode (need to offer recreate)
+ var labelsWereModified = editorModal.labelsViewMode === 'basic' && editorModal.modifiedLabels.size > 0;
+
+ // Save modified file tabs (compose, env, and override editor)
+ editorModal.modifiedTabs.forEach(function(tabName) {
+ savePromises.push(saveTab(tabName, saveErrors));
+ });
+
+ // Save settings if modified
+ if (editorModal.modifiedSettings.size > 0) {
+ savePromises.push(saveSettings(saveErrors));
+ }
+
+ // Save labels only in Automatic mode.
+ if (editorModal.modifiedLabels.size > 0) {
+ if (editorModal.labelsViewMode === 'basic') {
+ savePromises.push(saveLabels(saveErrors));
+ } else {
+ skippedManualLabels = true;
+ }
+ }
+
+ $.when.apply($, savePromises).then(function() {
+ var results = Array.prototype.slice.call(arguments);
+ var allSucceeded = results.every(function(result) {
+ return result === true;
+ });
+
+ if (allSucceeded) {
+ if (skippedManualLabels) {
+ swal({
+ title: "Partially Saved",
+ text: "Non-label changes were saved. WebUI label form edits were not saved because Override File Management is set to Manual. Switch to Automatic to save Labels form changes.",
+ type: "warning"
+ });
+ updateTabModifiedState();
+ updateSaveButtonState();
+ return;
+ }
+
+ // Check if we should offer to recreate containers
+ if (labelsWereModified) {
+ promptRecreateContainers(closeAfterSave);
+ } else {
+ var saveProject = editorModal.currentProject;
+ if (closeAfterSave) {
+ doCloseEditorModal();
+ }
+ swal({
+ title: "Saved!",
+ text: "All changes have been saved.",
+ type: "success",
+ showConfirmButton: false
+ });
+ setTimeout(function() {
+ swal.close();
+ if (saveProject) {
+ refreshStackByProject(saveProject);
+ }
+ }, 1500);
+ }
+ } else {
+ var filteredErrors = saveErrors.filter(function(message) {
+ return message !== '__STALE_PATH__';
+ });
+ if (filteredErrors.length === 0 && saveErrors.indexOf('__STALE_PATH__') !== -1) {
+ return;
+ }
+ swal({
+ title: 'Save Failed',
+ text: filteredErrors.join('\n') || 'An unknown error occurred while saving.',
+ type: 'error'
+ });
+ }
+ });
+ }
+
+ if (pathSensitiveSettingsChanged && pathDependentEdits) {
+ swal({
+ title: 'Reload Required',
+ text: 'Compose path or discovery settings changed. Save settings and reload the editor before saving compose, .env, override, or label edits.',
+ type: 'warning',
+ showCancelButton: true,
+ confirmButtonText: 'Save Settings Only',
+ cancelButtonText: 'Cancel'
+ }, function(confirmed) {
+ if (confirmed) {
+ saveSettingsWithOptionalReload(closeAfterSave);
+ }
+ });
+ return;
+ }
+
+ if (pathSensitiveSettingsChanged) {
+ saveSettingsWithOptionalReload(closeAfterSave);
+ return;
+ }
+
+ if (removedServices.length > 0) {
+ swal({
+ title: 'Running Stack Changed',
+ text: 'This stack is running and the compose file no longer defines: ' + removedServices.join(', ') + '. Saving now can leave orphaned containers behind. Stop the stack first, or use Remove orphans from the Compose Up/Down dialog to clean them up.',
+ type: 'warning',
+ showCancelButton: true,
+ confirmButtonText: 'Save Anyway',
+ cancelButtonText: 'Cancel'
+ }, function(confirmed) {
+ if (confirmed) {
+ continueSave();
+ }
+ });
+ return;
+ }
+
+ continueSave();
+}
+
// Save labels to override file
function saveLabels(saveErrors) {
var project = editorModal.currentProject;
@@ -5616,12 +6779,18 @@ function doCloseEditorModal() {
toggleLabelsViewMode(false, true);
$('#editor-validation-override').html(' Ready').removeClass('valid error warning');
+ // Reset compose file selector
+ $('#compose-file-selector-wrap').hide();
+ $('#compose-file-selector').empty();
+ editorModal.composeFiles = [];
+
// Reset settings fields
$('#settings-name').val('');
$('#settings-description').val('');
$('#settings-icon-url').val('');
$('#settings-webui-url').val('');
$('#settings-env-path').val('');
+ resetExtraComposeFilesUI();
$('#settings-default-profile').val('');
$('#settings-external-compose-path').val('');
$('#settings-external-compose-file').val('');
@@ -5703,10 +6872,12 @@ function toggleStackDetails(stackId) {
if (expandedStacks[stackId]) {
// Collapse
+ stackDetailsDesiredExpanded[stackId] = false;
$detailsRow.slideUp(200);
$expandIcon.removeClass('expanded');
expandedStacks[stackId] = false;
} else {
+ stackDetailsDesiredExpanded[stackId] = true;
$expandIcon.addClass('expanded');
expandedStacks[stackId] = true;
@@ -5721,94 +6892,253 @@ function toggleStackDetails(stackId) {
loadStackContainerDetails(stackId, project);
}
}
+
+ if (!stackToggleAllBatching) {
+ updateStackToggleAllButtonState();
+ }
+}
+
+function getComposeStackIds() {
+ return $('#compose_stacks tr.compose-sortable[id^="stack-row-"]').map(function() {
+ return this.id.replace('stack-row-', '');
+ }).get();
+}
+
+function isStackExpanded(stackId) {
+ if (stackDetailsDesiredExpanded[stackId] === false) {
+ expandedStacks[stackId] = false;
+ return false;
+ }
+
+ var $detailsRow = $('#details-row-' + stackId);
+ var isVisible = $detailsRow.is(':visible');
+
+ // Keep JS expansion state aligned with the live DOM. During async detail loads,
+ // preserve the expanded intent until the row is rendered and shown.
+ if (isVisible) {
+ stackDetailsDesiredExpanded[stackId] = true;
+ expandedStacks[stackId] = true;
+ return true;
+ }
+
+ if (!stackDetailsLoading[stackId]) {
+ expandedStacks[stackId] = false;
+ }
+
+ return !!expandedStacks[stackId];
+}
+
+function updateStackToggleAllButtonState() {
+ var $button = $('#compose-stack-toggle-all');
+ if (!$button.length) return;
+
+ var stackIds = getComposeStackIds();
+ var expandedCount = 0;
+
+ stackIds.forEach(function(stackId) {
+ if (isStackExpanded(stackId)) {
+ expandedCount++;
+ }
+ });
+
+ var hasStacks = stackIds.length > 0;
+ var allExpanded = hasStacks && expandedCount === stackIds.length;
+
+ $button.prop('disabled', !hasStacks);
+ $button.attr('title', allExpanded ? 'Collapse all stacks' : 'Expand all stacks');
+ $button.attr('aria-label', allExpanded ? 'Collapse all stacks' : 'Expand all stacks');
+ $button.toggleClass('is-expanded', allExpanded);
+}
+
+function toggleAllStackDetails(forceExpand) {
+ if (stackToggleAllBusy) {
+ if (typeof forceExpand === 'boolean') {
+ stackToggleAllQueuedForceExpand = forceExpand;
+ stackToggleAllQueuedToggle = false;
+ } else {
+ stackToggleAllQueuedToggle = true;
+ }
+ return;
+ }
+
+ var stackIds = getComposeStackIds();
+ if (!stackIds.length) return;
+
+ var anyCollapsed = stackIds.some(function(stackId) {
+ return !isStackExpanded(stackId);
+ });
+ var shouldExpand = typeof forceExpand === 'boolean' ? forceExpand : anyCollapsed;
+
+ stackToggleAllBusy = true;
+ stackToggleAllBatching = true;
+
+ stackIds.forEach(function(stackId) {
+ var expanded = isStackExpanded(stackId);
+ if (shouldExpand && !expanded) {
+ toggleStackDetails(stackId);
+ } else if (!shouldExpand && expanded) {
+ toggleStackDetails(stackId);
+ }
+ });
+
+ stackToggleAllBatching = false;
+
+ updateStackToggleAllButtonState();
+
+ // Allow animations/state to settle, then replay only the latest queued intent.
+ setTimeout(function() {
+ stackToggleAllBusy = false;
+
+ var queuedForceExpand = stackToggleAllQueuedForceExpand;
+ var queuedToggle = stackToggleAllQueuedToggle;
+ stackToggleAllQueuedForceExpand = null;
+ stackToggleAllQueuedToggle = false;
+
+ if (typeof queuedForceExpand === 'boolean') {
+ toggleAllStackDetails(queuedForceExpand);
+ } else if (queuedToggle) {
+ toggleAllStackDetails();
+ }
+ }, 240);
}
function loadStackContainerDetails(stackId, project) {
var $container = $('#details-container-' + stackId);
+ if (typeof stackDetailsDesiredExpanded[stackId] === 'undefined') {
+ stackDetailsDesiredExpanded[stackId] = !!expandedStacks[stackId] || $('#details-row-' + stackId).is(':visible');
+ }
+
+ function renderFromResponse(response, finishLoad) {
+ if (response && response.result === 'success') {
+ var containers = response.containers || [];
+
+ // Normalize all containers via factory function (PascalCase→camelCase)
+ containers = containers.map(createContainerInfo).filter(Boolean);
+
+ // Merge update status from stackUpdateStatus if available
+ mergeUpdateStatus(containers, project);
+
+ stackContainersCache[stackId] = containers;
+ if (response.startedAt) stackStartedAtCache[stackId] = response.startedAt;
+ composeLogger('success', {
+ stackId: stackId,
+ project: project,
+ containers: containers.length
+ }, 'user', 'debug', 'container-details');
+ renderContainerDetails(stackId, containers, project);
+
+ if (stackDetailsDesiredExpanded[stackId] === false) {
+ expandedStacks[stackId] = false;
+ $('#details-row-' + stackId).stop(true, true).hide();
+ finishLoad();
+ } else {
+ expandedStacks[stackId] = true;
+ $('#details-row-' + stackId).stop(true, true).slideDown(200, finishLoad);
+ }
+ return true;
+ }
+ return false;
+ }
+
+ function renderError(message, finishLoad, level) {
+ $container.html(' ' + composeEscapeHtml(message) + '
');
+ $('#details-row-' + stackId).stop(true, true).slideDown(200, finishLoad);
+ composeLogger(level || 'error', {
+ stackId: stackId,
+ project: project,
+ message: message
+ }, 'user', level || 'error', 'container-details');
+ }
+
// Prevent parallel loads for same stack
if (stackDetailsLoading[stackId]) {
composeLogger('already-loading', {
stackId: stackId,
project: project
}, 'user', 'warning', 'container-details');
- return;
+ return stackDetailsLoadPromises[stackId] || Promise.resolve();
}
stackDetailsLoading[stackId] = true;
+ stackDetailsLoadPromises[stackId] = new Promise(function(resolve) {
+ function finishLoad() {
+ stackDetailsLoading[stackId] = false;
+ delete stackDetailsLoadPromises[stackId];
+ updateStackToggleAllButtonState();
+ resolve();
+ }
+
composeLogger('start', {
stackId: stackId,
project: project
- }, 'user', 'info', 'container-details');
+ }, 'user', 'debug', 'container-details');
// Show loading state
$container.html(' Loading container details...
');
- $.post(caURL, {
- action: 'getStackContainers',
- script: project
- }, function(data) {
- if (data) {
- try {
- var response = JSON.parse(data);
- if (response.result === 'success') {
- var containers = response.containers;
+ function fetchAndRender(finishLoad) {
+ $.post(caURL, {
+ action: 'getStackContainers',
+ script: project
+ }, function(data) {
+ var parsed = tryParseJson(data);
+ if (parsed && parsed.result === 'success') {
+ // Keep prefetch cache warm with latest direct fetch too.
+ stackDetailsPrefetchCache[project] = parsed;
+ }
- // Normalize all containers via factory function (PascalCase→camelCase)
- containers = containers.map(createContainerInfo).filter(Boolean);
+ if (renderFromResponse(parsed, finishLoad)) {
+ return;
+ }
- // Merge update status from stackUpdateStatus if available
- mergeUpdateStatus(containers, project);
+ if (parsed && parsed.message) {
+ renderError(parsed.message, finishLoad, 'error');
+ } else {
+ renderError('Failed to load container details', finishLoad, 'warning');
+ }
+ }).fail(function() {
+ renderError('Failed to load container details', finishLoad, 'error');
+ });
+ }
- stackContainersCache[stackId] = containers;
- if (response.startedAt) stackStartedAtCache[stackId] = response.startedAt;
- composeLogger('success', {
- stackId: stackId,
- project: project,
- containers: containers.length
- }, 'user', 'info', 'container-details');
- renderContainerDetails(stackId, containers, project);
- // Slide down details row now that content is rendered
- $('#details-row-' + stackId).slideDown(200);
- } else {
- // Escape error message to prevent XSS
- var errorMsg = composeEscapeHtml(response.message || 'Failed to load container details');
- $container.html(' ' + errorMsg + '
');
- $('#details-row-' + stackId).slideDown(200);
- composeLogger('error', {
- stackId: stackId,
- project: project,
- message: errorMsg
- }, 'user', 'error', 'container-details');
- }
- } catch (e) {
- $container.html(' Failed to parse container details response
');
- $('#details-row-' + stackId).slideDown(200);
- composeLogger('parse-error', {
- stackId: stackId,
- project: project,
- err: e.toString()
- }, 'user', 'error', 'container-details');
+ function consumePrefetchOrFetch(finishLoad) {
+ // Fast path: prefetch already finished.
+ if (stackDetailsPrefetchCache[project]) {
+ var cached = stackDetailsPrefetchCache[project];
+ delete stackDetailsPrefetchCache[project];
+ if (!renderFromResponse(cached, finishLoad)) {
+ fetchAndRender(finishLoad);
}
- } else {
- $container.html(' Failed to load container details
');
- $('#details-row-' + stackId).slideDown(200);
- composeLogger('empty-response', {
- stackId: stackId,
- project: project
- }, 'user', 'warning', 'container-details');
+ return;
}
- stackDetailsLoading[stackId] = false;
- }).fail(function() {
- $container.html(' Failed to load container details
');
- $('#details-row-' + stackId).slideDown(200);
- stackDetailsLoading[stackId] = false;
- composeLogger('failed', {
- stackId: stackId,
- project: project
- }, 'user', 'error', 'container-details');
+
+ // If a prefetch is currently in-flight for this project, wait for it once.
+ if (stackDetailsPrefetchPromises[project]) {
+ stackDetailsPrefetchPromises[project].then(function() {
+ if (stackDetailsPrefetchCache[project]) {
+ var prefetched = stackDetailsPrefetchCache[project];
+ delete stackDetailsPrefetchCache[project];
+ if (renderFromResponse(prefetched, finishLoad)) {
+ return;
+ }
+ }
+ fetchAndRender(finishLoad);
+ });
+ return;
+ }
+
+ // No prefetch available: normal path.
+ fetchAndRender(finishLoad);
+ }
+
+ consumePrefetchOrFetch(finishLoad);
});
+
+ return stackDetailsLoadPromises[stackId];
}
+$(document).on('compose-list-loaded', updateStackToggleAllButtonState);
+
function renderContainerDetails(stackId, containers, project) {
var $container = $('#details-container-' + stackId);
@@ -5822,11 +7152,15 @@ function renderContainerDetails(stackId, containers, project) {
html += '';
html += 'Container ';
html += 'Update ';
+ html += 'Health ';
html += 'Source ';
html += 'Tag ';
html += 'Network ';
html += 'Container IP ';
- html += 'CPU & Memory load ';
+ html += 'CPU ';
+ html += 'Memory ';
+ html += 'Net I/O ';
+ html += 'Disk I/O ';
html += 'Container Port ';
html += 'LAN IP:Port ';
html += ' ';
@@ -5941,7 +7275,7 @@ function renderContainerDetails(stackId, containers, project) {
}
} else if (ctHasUpdate) {
// Update available - orange "update ready" style with SHA diff
- html += '';
+ html += ' ';
html += ' update ready ';
html += ' ';
if (ctLocalSha && ctRemoteSha) {
@@ -5965,35 +7299,38 @@ function renderContainerDetails(stackId, containers, project) {
}
html += ' ';
+ // Health column
+ html += '' + composeRenderHealthBadge(container.health || '', state) + ' ';
+
// Source (image name without tag)
- html += '' + composeEscapeHtml(imageSource) + ' ';
+ html += '' + composeEscapeHtml(imageSource) + ' ';
// Tag (image tag) — truncated with ellipsis via CSS if too long
- html += '' + composeEscapeHtml(imageTag) + ' ';
+ html += '' + composeEscapeHtml(imageTag) + ' ';
// Network
- html += '' + networkNames.map(composeEscapeHtml).join(' ') + ' ';
+ html += '' + networkNames.map(composeEscapeHtml).join(' ') + ' ';
// Container IP
- html += '' + ipAddresses.map(composeEscapeHtml).join(' ') + ' ';
+ html += '' + ipAddresses.map(composeEscapeHtml).join(' ') + ' ';
- // CPU & Memory load (advanced only) — populated by dockerload WebSocket
- html += '';
- if (state === 'running') {
- html += '0% ';
- html += '
';
- html += '0B / 0B ';
- } else {
- html += '- ';
- html += ' ';
- }
+ html += ' ';
+ var normalizedContainerId = composeNormalizeContainerKey(containerId);
+ html += '- ';
+ html += '
';
+ html += ' ';
+ html += '';
+ html += '- ';
+ html += '
';
html += ' ';
+ html += '- ';
+ html += '- ';
// Container Port
- html += '' + containerPorts.map(composeEscapeHtml).join(' ') + ' ';
+ html += '' + containerPorts.map(composeEscapeHtml).join(' ') + ' ';
// LAN IP:Port
- html += '' + lanPorts.map(composeEscapeHtml).join(' ') + ' ';
+ html += '' + lanPorts.map(composeEscapeHtml).join(' ') + ' ';
html += ' ';
});
@@ -6002,6 +7339,13 @@ function renderContainerDetails(stackId, containers, project) {
$container.html(html);
+ if (window.composeColCustomizer && typeof window.composeColCustomizer.reapply === 'function') {
+ window.composeColCustomizer.reapply();
+ }
+ if (window.composeDockerLoadRenderCached && typeof window.composeDockerLoadRenderCached === 'function') {
+ window.composeDockerLoadRenderCached();
+ }
+
// Update the parent stack row shortly after rendering so counts and status
// reflect the latest state. Use a short timeout to avoid racing with other
// DOM updates (e.g. a full list reload) that may remove the row.
@@ -6030,7 +7374,7 @@ function renderContainerDetails(stackId, containers, project) {
composeLogger('just-rendered', {
stackId: stackId,
project: project
- }, 'user', 'info', 'container-details');
+ }, 'user', 'debug', 'container-details');
// Clear the flag after a short window
setTimeout(function() {
try {
@@ -6204,6 +7548,13 @@ function updateParentStackFromContainers(stackId, project) {
$uptimeCell.html('