From f6655f1a0d15fa4e7618607c3caf336250d15a72 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 29 Mar 2026 14:25:00 -0400 Subject: [PATCH 01/25] dev(deploy): update RemoteHost parameter to accept multiple values and enhance examples --- deploy.ps1 | 185 +++++++++++++++++++++++++++++------------------------ 1 file changed, 102 insertions(+), 83 deletions(-) diff --git a/deploy.ps1 b/deploy.ps1 index cc33005..3277195 100644 --- a/deploy.ps1 +++ b/deploy.ps1 @@ -14,7 +14,7 @@ Generate a development build with timestamp: YYYY.MM.DD.HHmm .PARAMETER RemoteHost - Remote hostname or IP. + Remote hostname(s) or IP(s). Accepts a single value or a comma-separated list. .PARAMETER User SSH username. @@ -44,6 +44,9 @@ .EXAMPLE ./deploy.ps1 -Dev -RemoteHost "saturn" +.EXAMPLE + ./deploy.ps1 -Dev -RemoteHost "saturn","jupiter" + .EXAMPLE ./deploy.ps1 -SkipBuild -RemoteHost "saturn" @@ -58,7 +61,7 @@ param( [string]$Version, [switch]$Dev, - [string]$RemoteHost = "", + [string[]]$RemoteHost = @(), [string]$User = "root", [string]$RemoteDir = "/tmp", [string]$PackagePath, @@ -75,7 +78,7 @@ $scriptDir = $PSScriptRoot $archiveDir = Join-Path $scriptDir "archive" if ($Quick) { - if ([string]::IsNullOrWhiteSpace($RemoteHost)) { + if (-not $RemoteHost -or $RemoteHost.Count -eq 0) { throw "RemoteHost is required when using -Quick" } @@ -83,21 +86,14 @@ if ($Quick) { Write-Host "Quick mode ignores -Version, -Dev, -PackagePath, and -SkipBuild." -ForegroundColor DarkYellow } - $remoteTarget = "$User@$RemoteHost" - $quickPrefix = "source/compose.manager/" - $quickRemoteRoot = "/usr/local/emhttp/plugins/compose.manager" - $repoRoot = (& git -C $scriptDir rev-parse --show-toplevel 2>$null) if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($repoRoot)) { throw "Unable to resolve git repository root from $scriptDir" } $repoRoot = $repoRoot.Trim() - Write-Host "Quick deploy mode (tracked staged+unstaged files):" -ForegroundColor Green - Write-Host " Repo root : $repoRoot" -ForegroundColor Gray - Write-Host " Source scope : source/compose.manager" -ForegroundColor Gray - Write-Host " Remote root : $quickRemoteRoot" -ForegroundColor Gray - Write-Host " Remote target : $remoteTarget" -ForegroundColor Gray + $quickPrefix = "source/compose.manager/" + $quickRemoteRoot = "/usr/local/emhttp/plugins/compose.manager" $statUnstaged = (& git -C $repoRoot diff --stat -- source/compose.manager) $statStaged = (& git -C $repoRoot diff --cached --stat -- source/compose.manager) @@ -120,7 +116,7 @@ if ($Quick) { if (-not $changedFiles -or $changedFiles.Count -eq 0) { Write-Host "No tracked staged/unstaged file changes found under source/compose.manager." -ForegroundColor Yellow return @{ - Host = $RemoteHost + Hosts = $RemoteHost User = $User Quick = $true FileCount = 0 @@ -132,56 +128,64 @@ if ($Quick) { Write-Host "Files queued for quick sync ($($changedFiles.Count)):" -ForegroundColor Green $changedFiles | ForEach-Object { Write-Host " $_" -ForegroundColor Gray } - $syncedFiles = @() - foreach ($relativePath in $changedFiles) { - if (-not $relativePath.StartsWith($quickPrefix, [System.StringComparison]::Ordinal)) { - continue - } - - $subPath = $relativePath.Substring($quickPrefix.Length) - if ([string]::IsNullOrWhiteSpace($subPath)) { - continue - } + $allResults = @() + foreach ($host_ in $RemoteHost) { + $remoteTarget = "$User@$host_" + Write-Host "`nQuick deploy to $remoteTarget :" -ForegroundColor Green - $localPath = Join-Path $repoRoot ($relativePath -replace '/', [IO.Path]::DirectorySeparatorChar) - if (-not (Test-Path -Path $localPath -PathType Leaf)) { - Write-Host "Skipping missing local file: $relativePath" -ForegroundColor DarkYellow - continue - } + $syncedFiles = @() + foreach ($relativePath in $changedFiles) { + if (-not $relativePath.StartsWith($quickPrefix, [System.StringComparison]::Ordinal)) { + continue + } - $remoteFile = "$quickRemoteRoot/$subPath" - $remoteParent = ($remoteFile -replace '/[^/]+$','') + $subPath = $relativePath.Substring($quickPrefix.Length) + if ([string]::IsNullOrWhiteSpace($subPath)) { + continue + } - $syncAction = "Upload changed file via SCP" - if ($PSCmdlet.ShouldProcess("$remoteTarget`:$remoteFile", $syncAction)) { - ssh -- "$remoteTarget" "mkdir -p '$remoteParent'" - if ($LASTEXITCODE -ne 0) { - throw "Failed to create remote directory $remoteParent (exit code $LASTEXITCODE)" + $localPath = Join-Path $repoRoot ($relativePath -replace '/', [IO.Path]::DirectorySeparatorChar) + if (-not (Test-Path -Path $localPath -PathType Leaf)) { + Write-Host "Skipping missing local file: $relativePath" -ForegroundColor DarkYellow + continue } - scp -- "$localPath" "$remoteTarget`:$remoteFile" - if ($LASTEXITCODE -ne 0) { - throw "Failed to upload $relativePath to $remoteFile (exit code $LASTEXITCODE)" + $remoteFile = "$quickRemoteRoot/$subPath" + $remoteParent = ($remoteFile -replace '/[^/]+$','') + + $syncAction = "Upload changed file via SCP" + if ($PSCmdlet.ShouldProcess("$remoteTarget`:$remoteFile", $syncAction)) { + ssh -- "$remoteTarget" "mkdir -p '$remoteParent'" + if ($LASTEXITCODE -ne 0) { + throw "Failed to create remote directory $remoteParent on $host_ (exit code $LASTEXITCODE)" + } + + scp -- "$localPath" "$remoteTarget`:$remoteFile" + if ($LASTEXITCODE -ne 0) { + throw "Failed to upload $relativePath to $remoteFile on $host_ (exit code $LASTEXITCODE)" + } } + + $syncedFiles += $relativePath } - $syncedFiles += $relativePath + $allResults += @{ + Host = $host_ + User = $User + Quick = $true + FileCount = $syncedFiles.Count + Files = $syncedFiles + WhatIf = [bool]$WhatIfPreference + } } if ($WhatIfPreference) { Write-Host "WhatIf simulation complete (quick mode)." -ForegroundColor Green } else { - Write-Host "Quick deployment complete." -ForegroundColor Green + Write-Host "`nQuick deployment complete to $($RemoteHost.Count) host(s)." -ForegroundColor Green } - return @{ - Host = $RemoteHost - User = $User - Quick = $true - FileCount = $syncedFiles.Count - Files = $syncedFiles - WhatIf = [bool]$WhatIfPreference - } + return $allResults } # Generate dev version with timestamp if -Dev flag is used @@ -255,7 +259,16 @@ if (-not $PackagePath) { } $packageName = Split-Path -Leaf $PackagePath -$remotePackage = "$RemoteDir/$packageName" + +if (-not $RemoteHost -or $RemoteHost.Count -eq 0) { + Write-Host "No RemoteHost specified — build only, skipping deploy." -ForegroundColor Yellow + return @{ + Hosts = @() + User = $User + PackagePath = $PackagePath + WhatIf = [bool]$WhatIfPreference + } +} # Prefer plugin manifest generated by build.ps1 for this exact package; fallback to repository source .plg if ($buildInfo -and $buildInfo.PluginPath -and (Test-Path -Path $buildInfo.PluginPath -PathType Leaf)) { @@ -267,50 +280,56 @@ if (-not (Test-Path -Path $pluginPath -PathType Leaf)) { throw "Plugin file not found: $pluginPath" } $pluginName = Split-Path -Leaf $pluginPath -$remotePlugin = "$RemoteDir/$pluginName" $installScriptLocal = Join-Path $scriptDir "install.sh" if (-not (Test-Path -Path $installScriptLocal -PathType Leaf)) { throw "Install script not found: $installScriptLocal" } -$remoteInstallScript = "$RemoteDir/install.sh" -$remoteTarget = "$User@$RemoteHost" - -Write-Host "Deploying package, plugin manifest, and install.sh:" -ForegroundColor Green -Write-Host " Local package : $PackagePath" -ForegroundColor Gray -Write-Host " Local .plg : $pluginPath" -ForegroundColor Gray -Write-Host " Local install : $installScriptLocal" -ForegroundColor Gray -Write-Host " Remote target : ${remoteTarget}:$RemoteDir" -ForegroundColor Gray - -$uploadAction = "Upload package + .plg + install.sh via SCP" -if ($PSCmdlet.ShouldProcess("${remoteTarget}:$RemoteDir/", $uploadAction)) { - Write-Host "Uploading package, .plg and install.sh via SCP..." -ForegroundColor Yellow - scp -- "$PackagePath" "$remoteTarget`:$RemoteDir/" - scp -- "$pluginPath" "$remoteTarget`:$RemoteDir/" - scp -- "$installScriptLocal" "$remoteTarget`:$remoteInstallScript" - if ($LASTEXITCODE -ne 0) { - throw "SCP upload failed with exit code $LASTEXITCODE" + +$allResults = @() +foreach ($host_ in $RemoteHost) { + $remoteTarget = "$User@$host_" + $remotePackage = "$RemoteDir/$packageName" + $remotePlugin = "$RemoteDir/$pluginName" + $remoteInstallScript = "$RemoteDir/install.sh" + + Write-Host "`nDeploying to $remoteTarget :" -ForegroundColor Green + Write-Host " Local package : $PackagePath" -ForegroundColor Gray + Write-Host " Local .plg : $pluginPath" -ForegroundColor Gray + Write-Host " Local install : $installScriptLocal" -ForegroundColor Gray + Write-Host " Remote target : ${remoteTarget}:$RemoteDir" -ForegroundColor Gray + + $uploadAction = "Upload package + .plg + install.sh via SCP" + if ($PSCmdlet.ShouldProcess("${remoteTarget}:$RemoteDir/", $uploadAction)) { + Write-Host "Uploading package, .plg and install.sh via SCP..." -ForegroundColor Yellow + scp -- "$PackagePath" "$remoteTarget`:$RemoteDir/" + scp -- "$pluginPath" "$remoteTarget`:$RemoteDir/" + scp -- "$installScriptLocal" "$remoteTarget`:$remoteInstallScript" + if ($LASTEXITCODE -ne 0) { + throw "SCP upload to $host_ failed with exit code $LASTEXITCODE" + } } -} -$installAction = "Execute remote install script" -if ($PSCmdlet.ShouldProcess($remoteTarget, $installAction)) { - Write-Host "Executing remote install script..." -ForegroundColor Yellow - ssh -- "$remoteTarget" "bash '$remoteInstallScript' '$remotePackage' '$remotePlugin' && rm -f '$remoteInstallScript'" - if ($LASTEXITCODE -ne 0) { - throw "Remote install script failed with exit code $LASTEXITCODE" + $installAction = "Execute remote install script" + if ($PSCmdlet.ShouldProcess($remoteTarget, $installAction)) { + Write-Host "Executing remote install script..." -ForegroundColor Yellow + ssh -- "$remoteTarget" "bash '$remoteInstallScript' '$remotePackage' '$remotePlugin' && rm -f '$remoteInstallScript'" + if ($LASTEXITCODE -ne 0) { + throw "Remote install script on $host_ failed with exit code $LASTEXITCODE" + } } -} + $allResults += @{ + Host = $host_ + User = $User + PackagePath = $PackagePath + RemotePackage = $remotePackage + WhatIf = [bool]$WhatIfPreference + } +} if ($WhatIfPreference) { Write-Host "WhatIf simulation complete." -ForegroundColor Green } else { - Write-Host "Deployment complete." -ForegroundColor Green + Write-Host "`nDeployment complete to $($RemoteHost.Count) host(s)." -ForegroundColor Green } -return @{ - Host = $RemoteHost - User = $User - PackagePath = $PackagePath - RemotePackage = $remotePackage - WhatIf = [bool]$WhatIfPreference -} \ No newline at end of file +return $allResults \ No newline at end of file From 8318cd7945fc71bf42f376b0802812ae1ce77481 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 29 Mar 2026 14:35:59 -0400 Subject: [PATCH 02/25] feat(compose): add context menu on right-click for stacks and containers --- .../php/compose_manager_main.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index 330a80a..cb37620 100755 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -5311,6 +5311,24 @@ function addComposeStackContext(elementId) { } }); + // Right-click anywhere on a stack row opens the stack context menu + $(document).on('contextmenu', 'tr.compose-sortable[id^="stack-row-"]', function(e) { + var $icon = $(this).find('[data-stackid]').first(); + if ($icon.length) { + e.preventDefault(); + $icon.trigger($.Event('click', { pageX: e.pageX, pageY: e.pageY })); + } + }); + + // Right-click anywhere on a container detail row opens the container context menu + $(document).on('contextmenu', '#compose_stacks tr[data-container][data-stackid]', function(e) { + var $icon = $(this).find('.hand[id^="ct-"]').first(); + if ($icon.length) { + e.preventDefault(); + $icon.trigger($.Event('click', { pageX: e.pageX, pageY: e.pageY })); + } + }); + // Close actions menu when clicking outside $(document).on('click', function(e) { if (!$(e.target).closest('#stack-actions-modal, .stack-kebab-btn').length) { From 4be7b4978f36b9687609d6483b5f0a6d487863b7 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 29 Mar 2026 14:36:07 -0400 Subject: [PATCH 03/25] feat(dashboard): add context menu on right-click for stacks and containers --- .../compose.manager.dashboard.page | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/source/compose.manager/compose.manager.dashboard.page b/source/compose.manager/compose.manager.dashboard.page index ddec59a..a04d7b5 100644 --- a/source/compose.manager/compose.manager.dashboard.page +++ b/source/compose.manager/compose.manager.dashboard.page @@ -696,6 +696,26 @@ $script .= <<<'EOT' ); }); + // Right-click anywhere on a dashboard stack row opens the stack context menu + $(document).on('contextmenu', '.compose-dash-stack', function(e) { + var $icon = $(this).find('.compose-dash-icon').first(); + if ($icon.length) { + e.preventDefault(); + e.stopPropagation(); + $icon.trigger($.Event('click', { pageX: e.pageX, pageY: e.pageY })); + } + }); + + // Right-click anywhere on a dashboard container row opens the container context menu + $(document).on('contextmenu', '.compose-dash-container', function(e) { + var $icon = $(this).find('.compose-dash-ct-icon').first(); + if ($icon.length) { + e.preventDefault(); + e.stopPropagation(); + $icon.trigger($.Event('click', { pageX: e.pageX, pageY: e.pageY })); + } + }); + // Check if any stacks are visible (for "no stacks" message) function noStacks() { if ($('#compose_dash_content .compose-dash-stack:visible').length === 0 && $('#compose_dash_content .compose-dash-stack').length > 0) { From dfb6726cf23e134a6b0e12d6368cbeb92d1d8491 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 29 Mar 2026 15:44:30 -0400 Subject: [PATCH 04/25] feat(load): implement CPU and memory load display for containers in advanced view --- source/compose.manager/Compose.page | 3 +- source/compose.manager/php/compose_list.php | 18 ++- .../php/compose_manager_main.php | 149 +++++++++++++++++- source/compose.manager/php/util.php | 14 +- source/compose.manager/styles/comboButton.css | 20 ++- 5 files changed, 188 insertions(+), 16 deletions(-) diff --git a/source/compose.manager/Compose.page b/source/compose.manager/Compose.page index 208680a..9cdb4a6 100644 --- a/source/compose.manager/Compose.page +++ b/source/compose.manager/Compose.page @@ -3,7 +3,8 @@ Type="xmenu" Title="Docker Compose" Tag="fa-cubes" Code="f1b3" -Cond="$var['fsState'] == 'Started' && exec('/etc/rc.d/rc.docker status | grep -v "not"') && exec(\"grep '^SHOW_COMPOSE_IN_HEADER_MENU=' /boot/config/plugins/compose.manager/compose.manager.cfg 2>/dev/null | grep 'true'\")" +Nchan="docker_load" +Cond="$var['fsState'] == 'Started' && exec('/etc/rc.d/rc.docker status | grep -v \"not\"') && exec(\"grep '^SHOW_COMPOSE_IN_HEADER_MENU=' /boot/config/plugins/compose.manager/compose.manager.cfg 2>/dev/null | grep 'true'\")" --- diff --git a/source/compose.manager/php/compose_list.php b/source/compose.manager/php/compose_list.php index 50c5776..b7531b3 100755 --- a/source/compose.manager/php/compose_list.php +++ b/source/compose.manager/php/compose_list.php @@ -57,11 +57,16 @@ // Collect container names for the hide-from-docker feature (data attribute) $containerNamesList = []; + // Collect short container IDs for CPU/MEM load mapping (docker stats uses 12-char short IDs) + $containerIdsList = []; foreach ($projectContainers as $ct) { $n = $ct['Names'] ?? ''; if ($n) $containerNamesList[] = $n; + $ctId = $ct['ID'] ?? ''; + if ($ctId) $containerIdsList[] = substr($ctId, 0, 12); } $containerNamesAttr = htmlspecialchars(json_encode($containerNamesList), ENT_QUOTES, 'UTF-8'); + $containerIdsAttr = htmlspecialchars(implode(',', $containerIdsList), ENT_QUOTES, 'UTF-8'); // Determine states $isrunning = $runningCount > 0; @@ -186,7 +191,7 @@ $hasBuild = $stackInfo->hasBuildConfig() ? '1' : '0'; // Main row - Docker tab structure with expand arrow on left - $o .= ""; + $o .= ""; // Arrow column $o .= ""; @@ -235,6 +240,13 @@ $uptimeClass = $isrunning ? 'green-text' : 'grey-text'; $o .= "$uptimeDisplay"; + // CPU & Memory column (advanced only) — populated in real-time via dockerload WebSocket + $o .= ""; + $o .= "0%"; + $o .= "
"; + $o .= "
0b"; + $o .= ""; + // Description column (advanced only) $o .= ""; if ($hasInvalidIndirect) { @@ -254,7 +266,7 @@ // Expandable details row $o .= ""; - $o .= ""; + $o .= ""; $o .= "
"; $o .= " Loading containers..."; $o .= "
"; @@ -264,7 +276,7 @@ // If no stacks found, show a message if ($stackCount === 0) { - $o = "No Docker Compose stacks found. Click 'Add New Stack' to create one."; + $o = "No Docker Compose stacks found. Click 'Add New Stack' to create one."; } // Output the HTML diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index cb37620..01fc031 100755 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -18,6 +18,10 @@ // Get Docker Compose CLI version $composeVersion = trim(shell_exec('docker compose version --short 2>/dev/null') ?? ''); +// CPU count for load normalization (matches Docker manager's cpu_list approach) +$cpus = function_exists('cpu_list') ? cpu_list() : []; +$cpuCount = count($cpus) > 0 ? count($cpus) * count(preg_split('/[,-]/', $cpus[0])) : (int)trim(shell_exec('nproc 2>/dev/null') ?: '1'); + // Note: Stack list is now loaded asynchronously via compose_list.php // This improves page load time by deferring expensive docker commands ?> @@ -85,7 +89,7 @@ width: 15%; } - /* Advanced-view column widths (9 visible columns) + /* 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: 1%; @@ -111,12 +115,16 @@ width: 6% } + #compose_stacks.cm-advanced-view thead th.col-load { + width: 12% + } + #compose_stacks.cm-advanced-view thead th.col-description { - width: 28% + width: 22% } #compose_stacks.cm-advanced-view thead th.col-path { - width: 28% + width: 22% } #compose_stacks.cm-advanced-view thead th.col-autostart { @@ -176,6 +184,28 @@ z-index: 100 !important; } + /* CPU & Memory load display (matches Docker manager usage-disk style) */ + .compose-load-cell { + white-space: nowrap; + font-size: 0.9em; + } + .compose-load-cell .usage-disk.mm { + height: 3px; + margin: 3px 20px 0 0; + position: relative; + background-color: var(--usage-disk-background-color, #e0e0e0); + } + .compose-load-cell .usage-disk.mm > span:first-child { + position: absolute; + left: 0; + height: 3px; + background-color: var(--gray-400, #888); + } + .compose-load-cell .usage-disk.mm > span:last-child { + position: relative; + z-index: 1; + } + ; var hideComposeFromDocker = ; var composeCliVersion = ; + var composeCpuCount = ; + + // Parse docker stats memory string "123.4MiB / 2GiB" to bytes (first value only) + function parseMemToBytes(memStr) { + if (!memStr) return 0; + var parts = memStr.split('/'); + var val = (parts[0] || '').trim(); + var match = val.match(/([\d.]+)\s*(KiB|MiB|GiB|TiB|B)/i); + if (!match) return 0; + var num = parseFloat(match[1]); + switch (match[2].toLowerCase()) { + case 'tib': return num * 1099511627776; + case 'gib': return num * 1073741824; + case 'mib': return num * 1048576; + case 'kib': return num * 1024; + default: return num; + } + } + + // Format bytes to human-readable string + function formatBytes(bytes) { + if (bytes <= 0) return '0B'; + if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1) + 'GiB'; + if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + 'MiB'; + if (bytes >= 1024) return (bytes / 1024).toFixed(1) + 'KiB'; + return bytes + 'B'; + } // ═══════════════════════════════════════════════════════════════════ // Standard factory functions for container and stack identity objects @@ -457,7 +514,7 @@ function composeLoadlist() { }, 'daemon', 'error'); 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 { reject({xhr: xhr, status: status, error: error}); } catch (e) { reject(error); } @@ -1596,6 +1653,79 @@ function wrapLoadlist() { }); } })(); + + // ── CPU & Memory load via dockerload Nchan channel ───────────── + // Subscribe to the same dockerload WebSocket that the Docker tab uses. + // Data format per line: "shortID;CPU%;MemUsage" + if (typeof NchanSubscriber === 'function') { + var composeDockerLoad = new NchanSubscriber('/sub/dockerload', {subscriber: 'websocket'}); + composeDockerLoad.on('message', function(msg) { + var data = msg.split('\n'); + // Build a map of shortID -> {cpu, mem} for quick lookup + var loadMap = {}; + for (var i = 0, row; row = data[i]; i++) { + var parts = row.split(';'); + if (parts.length >= 3) { + var cpuRaw = parseFloat(parts[1]) || 0; + var cpuNorm = Math.round(Math.min(cpuRaw / composeCpuCount, 100) * 100) / 100; + loadMap[parts[0]] = {cpu: cpuNorm, cpuText: cpuNorm + '%', mem: parts[2]}; + } + } + + // Update per-container CPU & MEM elements in expanded detail tables + for (var shortId in loadMap) { + var info = loadMap[shortId]; + $('.compose-cpu-' + shortId).text(info.cpuText); + $('.compose-mem-' + shortId).text(info.mem); + $('#compose-cpu-' + shortId).css('width', info.cpuText); + } + + // Aggregate per-stack totals and update stack-level cells. + // Use data-ctids embedded at render time so aggregation works + // even before the stack details have been expanded. + $('#compose_stacks tr.compose-sortable').each(function() { + var stackId = ($(this).attr('id') || '').replace('stack-row-', ''); + if (!stackId) return; + + // Primary: short IDs baked into the row by compose_list.php + var ctidsAttr = $(this).attr('data-ctids') || ''; + var idList = ctidsAttr ? ctidsAttr.split(',') : []; + + // Fallback: if detail panel was expanded, stackContainersCache + // may have more/different IDs (e.g. after a compose up added a service) + if (idList.length === 0) { + var containers = stackContainersCache[stackId]; + if (containers && containers.length > 0) { + containers.forEach(function(ct) { + var ctId = String(ct.id || '').substring(0, 12); + if (ctId) idList.push(ctId); + }); + } + } + if (idList.length === 0) return; + + var totalCpu = 0; + var totalMemBytes = 0; + var matched = 0; + idList.forEach(function(ctId) { + if (ctId && loadMap[ctId]) { + totalCpu += loadMap[ctId].cpu; + totalMemBytes += parseMemToBytes(loadMap[ctId].mem); + matched++; + } + }); + + if (matched > 0) { + var aggCpu = Math.round(totalCpu * 100) / 100 + '%'; + var aggMem = formatBytes(totalMemBytes); + $('.compose-stack-cpu-' + stackId).text(aggCpu); + $('#compose-stack-cpu-' + stackId).css('width', aggCpu); + $('.compose-stack-mem-' + stackId).text(aggMem); + } + }); + }); + composeDockerLoad.start(); + } }); function addStack() { @@ -4353,6 +4483,7 @@ function renderContainerDetails(stackId, containers, project) { html += 'Tag'; html += 'Network'; html += 'Container IP'; + html += 'CPU & Memory'; html += 'Container Port'; html += 'LAN IP:Port'; html += ''; @@ -4503,6 +4634,13 @@ function renderContainerDetails(stackId, containers, project) { // Container IP html += '' + ipAddresses.map(composeEscapeHtml).join('
') + '
'; + // CPU & Memory load (advanced only) — populated by dockerload WebSocket + html += ''; + html += '0%'; + html += '
'; + html += '
0 / 0'; + html += ''; + // Container Port html += '' + containerPorts.map(composeEscapeHtml).join('
') + '
'; @@ -5380,6 +5518,7 @@ function addComposeStackContext(elementId) { Update Containers Uptime + CPU & Memory Description Path Autostart @@ -5387,7 +5526,7 @@ function addComposeStackContext(elementId) { - + diff --git a/source/compose.manager/php/util.php b/source/compose.manager/php/util.php index 5b7d675..adeee13 100644 --- a/source/compose.manager/php/util.php +++ b/source/compose.manager/php/util.php @@ -1747,9 +1747,12 @@ public static function listProjectFolders(string $composeRoot): array * silently skipping folders with no compose file or invalid structure. * * @param string $composeRoot Compose projects root directory + * @param bool $skipDocker If true, skip the batch docker ps preload + * (returns stacks with empty container lists + * for fast skeleton rendering). * @return self[] */ - public static function allFromRoot(string $composeRoot): array + public static function allFromRoot(string $composeRoot, bool $skipDocker = false): array { $stacks = []; foreach (self::listProjectFolders($composeRoot) as $project) { @@ -1761,6 +1764,15 @@ public static function allFromRoot(string $composeRoot): array } } + if ($skipDocker) { + // Set empty container lists so getContainerList() won't trigger + // per-stack docker calls. + foreach ($stacks as $stack) { + $stack->setContainerList([]); + } + return $stacks; + } + // Batch-preload container data with a single docker ps call to avoid // O(n) docker invocations when callers iterate getContainerList(). $containersByProject = []; diff --git a/source/compose.manager/styles/comboButton.css b/source/compose.manager/styles/comboButton.css index 0152a74..e0eae79 100644 --- a/source/compose.manager/styles/comboButton.css +++ b/source/compose.manager/styles/comboButton.css @@ -279,28 +279,36 @@ } /* Column width distribution (totals ~100%) */ + .compose-ct-table .ct-col-icon { + width: 3%; + } + .compose-ct-table .ct-col-name { - width: 18%; + width: 13%; } .compose-ct-table .ct-col-update { - width: 13%; + width: 12%; } .compose-ct-table .ct-col-source { - width: 17%; + width: 14%; } .compose-ct-table .ct-col-tag { - width: 12%; + width: 10%; } .compose-ct-table .ct-col-net { - width: 10%; + width: 8%; } .compose-ct-table .ct-col-ip { - width: 10%; + width: 8%; + } + + .compose-ct-table .ct-col-load { + width: 12%; } .compose-ct-table .ct-col-cport { From 964333f3e8561f2623a3595c739e8c80ce8c3a95 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 29 Mar 2026 15:50:10 -0400 Subject: [PATCH 05/25] fix(cpu): improve CPU count calculation for load normalization --- source/compose.manager/php/compose_manager_main.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index 01fc031..8c98428 100755 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -20,7 +20,9 @@ // CPU count for load normalization (matches Docker manager's cpu_list approach) $cpus = function_exists('cpu_list') ? cpu_list() : []; -$cpuCount = count($cpus) > 0 ? count($cpus) * count(preg_split('/[,-]/', $cpus[0])) : (int)trim(shell_exec('nproc 2>/dev/null') ?: '1'); +$cpuCount = (!empty($cpus) && isset($cpus[0])) + ? count($cpus) * count(preg_split('/[,-]/', $cpus[0])) + : (int)trim(shell_exec('nproc 2>/dev/null') ?: '1'); // Note: Stack list is now loaded asynchronously via compose_list.php // This improves page load time by deferring expensive docker commands From b74181e7c04cf40fbbae62ce6705ef08b08ec8ee Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 29 Mar 2026 15:50:56 -0400 Subject: [PATCH 06/25] fix(ui): correct memory display unit from '0b' to '0B' in advanced view --- source/compose.manager/php/compose_list.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/compose.manager/php/compose_list.php b/source/compose.manager/php/compose_list.php index b7531b3..9e5ee84 100755 --- a/source/compose.manager/php/compose_list.php +++ b/source/compose.manager/php/compose_list.php @@ -244,7 +244,7 @@ $o .= ""; $o .= "0%"; $o .= "
"; - $o .= "
0b"; + $o .= "
0B"; $o .= ""; // Description column (advanced only) From d840f887b580e7c620e9c8823715e787769c0342 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 29 Mar 2026 15:51:00 -0400 Subject: [PATCH 07/25] fix(load): optimize load map construction for container metrics --- source/compose.manager/php/compose_manager_main.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index 8c98428..588fcbd 100755 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -1663,15 +1663,19 @@ function wrapLoadlist() { var composeDockerLoad = new NchanSubscriber('/sub/dockerload', {subscriber: 'websocket'}); composeDockerLoad.on('message', function(msg) { var data = msg.split('\n'); - // Build a map of shortID -> {cpu, mem} for quick lookup - var loadMap = {}; - for (var i = 0, row; row = data[i]; i++) { + 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 / composeCpuCount, 100) * 100) / 100; loadMap[parts[0]] = {cpu: cpuNorm, cpuText: cpuNorm + '%', mem: parts[2]}; } + i++; + row = data[i]; + loadMap[parts[0]] = {cpu: cpuNorm, cpuText: cpuNorm + '%', mem: parts[2]}; + } } // Update per-container CPU & MEM elements in expanded detail tables From 8ee2f932be431d136d83394ad7d968c38219ec3e Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 29 Mar 2026 15:51:08 -0400 Subject: [PATCH 08/25] fix(load): correct CPU normalization calculation in load map --- source/compose.manager/php/compose_manager_main.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index 588fcbd..88ba53f 100755 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -1667,7 +1667,7 @@ function wrapLoadlist() { var row = data[i]; while (row) { var parts = row.split(';'); - if (parts.length >= 3) { + var cpuNorm = Math.round(Math.min(cpuRaw / Math.max(composeCpuCount, 1), 100) * 100) / 100; var cpuRaw = parseFloat(parts[1]) || 0; var cpuNorm = Math.round(Math.min(cpuRaw / composeCpuCount, 100) * 100) / 100; loadMap[parts[0]] = {cpu: cpuNorm, cpuText: cpuNorm + '%', mem: parts[2]}; From 950fab1090952f57842206ff6ffa7edd390b708d Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 29 Mar 2026 16:11:58 -0400 Subject: [PATCH 09/25] compose_manager_main(nchan): fix garbled load map construction loop - Add missing 'var loadMap = {}' declaration - Fix cpuRaw used before declaration (moved above cpuNorm) - Remove duplicate cpuNorm/loadMap assignments and orphan braces - Add parts.length >= 3 guard to skip malformed rows - Use Math.max(composeCpuCount, 1) to prevent division by zero --- source/compose.manager/php/compose_manager_main.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index 88ba53f..537a823 100755 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -1663,19 +1663,18 @@ function wrapLoadlist() { var composeDockerLoad = new NchanSubscriber('/sub/dockerload', {subscriber: 'websocket'}); composeDockerLoad.on('message', function(msg) { var data = msg.split('\n'); + var loadMap = {}; var i = 0; var row = data[i]; while (row) { var parts = row.split(';'); - var cpuNorm = Math.round(Math.min(cpuRaw / Math.max(composeCpuCount, 1), 100) * 100) / 100; + if (parts.length >= 3) { var cpuRaw = parseFloat(parts[1]) || 0; - var cpuNorm = Math.round(Math.min(cpuRaw / composeCpuCount, 100) * 100) / 100; + var cpuNorm = Math.round(Math.min(cpuRaw / Math.max(composeCpuCount, 1), 100) * 100) / 100; loadMap[parts[0]] = {cpu: cpuNorm, cpuText: cpuNorm + '%', mem: parts[2]}; } i++; row = data[i]; - loadMap[parts[0]] = {cpu: cpuNorm, cpuText: cpuNorm + '%', mem: parts[2]}; - } } // Update per-container CPU & MEM elements in expanded detail tables From a02bd37f70b3785ab48a3a719ee7aa52b17fa40b Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 29 Mar 2026 16:16:40 -0400 Subject: [PATCH 10/25] comboButton.css(cleanup): remove dead ct-col-icon rule The .ct-col-icon class is defined in CSS but never used in any HTML output. Container detail tables have no icon column. --- source/compose.manager/styles/comboButton.css | 4 ---- 1 file changed, 4 deletions(-) diff --git a/source/compose.manager/styles/comboButton.css b/source/compose.manager/styles/comboButton.css index e0eae79..12502b8 100644 --- a/source/compose.manager/styles/comboButton.css +++ b/source/compose.manager/styles/comboButton.css @@ -279,10 +279,6 @@ } /* Column width distribution (totals ~100%) */ - .compose-ct-table .ct-col-icon { - width: 3%; - } - .compose-ct-table .ct-col-name { width: 13%; } From 000812fcda4df91f7e5fc460c129d199ffb4267b Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 29 Mar 2026 16:17:27 -0400 Subject: [PATCH 11/25] compose_list+main(ux): show dash for stopped stack/container load Stopped stacks and containers will never receive docker stats data, so showing '0%' / '0B' is misleading. Show '-' instead and hide the usage bar and memory span for non-running entries. --- source/compose.manager/php/compose_list.php | 11 ++++++++--- source/compose.manager/php/compose_manager_main.php | 11 ++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/source/compose.manager/php/compose_list.php b/source/compose.manager/php/compose_list.php index 9e5ee84..642f91f 100755 --- a/source/compose.manager/php/compose_list.php +++ b/source/compose.manager/php/compose_list.php @@ -242,9 +242,14 @@ // CPU & Memory column (advanced only) — populated in real-time via dockerload WebSocket $o .= ""; - $o .= "0%"; - $o .= "
"; - $o .= "
0B"; + if ($isrunning) { + $o .= "0%"; + $o .= "
"; + $o .= "
0B"; + } else { + $o .= "-"; + $o .= ""; + } $o .= ""; // Description column (advanced only) diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index 537a823..f5b17f1 100755 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -4641,9 +4641,14 @@ function renderContainerDetails(stackId, containers, project) { // CPU & Memory load (advanced only) — populated by dockerload WebSocket html += ''; - html += '0%'; - html += '
'; - html += '
0 / 0'; + if (state === 'running') { + html += '0%'; + html += '
'; + html += '
0 / 0'; + } else { + html += '-'; + html += ''; + } html += ''; // Container Port From 440759a7a5f14731093cbef9be0f5435f5f9f2df Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 29 Mar 2026 20:34:26 -0400 Subject: [PATCH 12/25] deploy.ps1(multi-host): fix RemoteHost help text to show PowerShell array syntax --- deploy.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy.ps1 b/deploy.ps1 index 3277195..31c6ff9 100644 --- a/deploy.ps1 +++ b/deploy.ps1 @@ -14,7 +14,7 @@ Generate a development build with timestamp: YYYY.MM.DD.HHmm .PARAMETER RemoteHost - Remote hostname(s) or IP(s). Accepts a single value or a comma-separated list. + Remote hostname(s) or IP(s). Accepts a single value or multiple values using PowerShell array syntax (e.g. "saturn","jupiter"). .PARAMETER User SSH username. From 026cdec1e8a976bc8c64d2e5f1994a5e4a332b21 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 29 Mar 2026 20:35:01 -0400 Subject: [PATCH 13/25] compose_manager_main.php(cpu-count): expand thread_siblings_list ranges for accurate CPU count --- .../php/compose_manager_main.php | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index f5b17f1..cbb9496 100755 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -18,10 +18,30 @@ // Get Docker Compose CLI version $composeVersion = trim(shell_exec('docker compose version --short 2>/dev/null') ?? ''); -// CPU count for load normalization (matches Docker manager's cpu_list approach) +// CPU count for load normalization (matches Docker manager's cpu_list approach). +// cpu_list() returns thread_siblings_list entries (e.g. "0-3,8-11"). +// We expand each range segment so "0-3" counts as 4, not 2 endpoints. +function compose_manager_cpu_spec_count($cpuSpec) +{ + $count = 0; + foreach (explode(',', trim((string)$cpuSpec)) as $segment) { + $segment = trim($segment); + if ($segment === '') continue; + if (strpos($segment, '-') !== false) { + [$start, $end] = explode('-', $segment, 2); + $start = (int)$start; + $end = (int)$end; + if ($end < $start) [$start, $end] = [$end, $start]; + $count += max(0, $end - $start + 1); + } else { + $count += 1; + } + } + return $count; +} $cpus = function_exists('cpu_list') ? cpu_list() : []; $cpuCount = (!empty($cpus) && isset($cpus[0])) - ? count($cpus) * count(preg_split('/[,-]/', $cpus[0])) + ? count($cpus) * compose_manager_cpu_spec_count($cpus[0]) : (int)trim(shell_exec('nproc 2>/dev/null') ?: '1'); // Note: Stack list is now loaded asynchronously via compose_list.php From fe81032db16cbf699d67f7f925cfe0d9a456f281 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 29 Mar 2026 20:35:34 -0400 Subject: [PATCH 14/25] compose_manager_main.php(dockerload): gate WebSocket subscription on advanced view mode --- .../php/compose_manager_main.php | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index cbb9496..7c3e4ce 100755 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -1444,6 +1444,10 @@ function isComposeAdvancedMode() { // 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'); @@ -1677,10 +1681,23 @@ function wrapLoadlist() { })(); // ── CPU & Memory load via dockerload Nchan channel ───────────── - // Subscribe to the same dockerload WebSocket that the Docker tab uses. - // Data format per line: "shortID;CPU%;MemUsage" + // 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. if (typeof NchanSubscriber === 'function') { var composeDockerLoad = new NchanSubscriber('/sub/dockerload', {subscriber: 'websocket'}); + var composeDockerLoadRunning = false; + + window.composeDockerLoadToggle = function(enable) { + if (enable && !composeDockerLoadRunning) { + composeDockerLoad.start(); + composeDockerLoadRunning = true; + } else if (!enable && composeDockerLoadRunning) { + composeDockerLoad.stop(); + composeDockerLoadRunning = false; + } + }; + composeDockerLoad.on('message', function(msg) { var data = msg.split('\n'); var loadMap = {}; @@ -1749,7 +1766,11 @@ function wrapLoadlist() { } }); }); - composeDockerLoad.start(); + // Start immediately if already in advanced view + if (isComposeAdvancedMode()) { + composeDockerLoad.start(); + composeDockerLoadRunning = true; + } } }); From f4cd13f17b584890a4f644da4716fc2d51ef24e4 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 29 Mar 2026 20:36:36 -0400 Subject: [PATCH 15/25] compose_manager_main.php(dockerload): cache stackId->containerIds index to avoid per-tick DOM traversal --- .../php/compose_manager_main.php | 53 ++++++++++++++----- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index 7c3e4ce..52537ea 100755 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -432,6 +432,9 @@ function composeLoadlist() { // Insert the loaded content $('#compose_list').html(data); + // Signal load subscribers (e.g. dockerload cache) that the list changed + $(document).trigger('composeListRefreshed'); + // Initialize UI components for the newly loaded content initStackListUI(); @@ -1688,6 +1691,30 @@ function wrapLoadlist() { var composeDockerLoad = new NchanSubscriber('/sub/dockerload', {subscriber: 'websocket'}); var composeDockerLoadRunning = false; + // Cache of { stackId, containerIds[] } built from the DOM once after + // composeLoadlist() and reused until the row count changes. + // Avoids O(stacks) DOM traversal + string splits on every stats tick. + var composeStackIndex = null; + + function buildComposeStackIndex() { + composeStackIndex = []; + $('#compose_stacks tr.compose-sortable').each(function() { + var stackId = ($(this).attr('id') || '').replace('stack-row-', ''); + if (!stackId) return; + var ctidsAttr = $(this).attr('data-ctids') || ''; + composeStackIndex.push({ + stackId: stackId, + containerIds: ctidsAttr ? ctidsAttr.split(',') : [] + }); + }); + } + + // Invalidate the cache when the list refreshes so that added/removed + // stacks are picked up on the next stats tick. + $(document).on('composeListRefreshed', function() { + composeStackIndex = null; + }); + window.composeDockerLoadToggle = function(enable) { if (enable && !composeDockerLoadRunning) { composeDockerLoad.start(); @@ -1723,20 +1750,20 @@ function wrapLoadlist() { } // Aggregate per-stack totals and update stack-level cells. - // Use data-ctids embedded at render time so aggregation works - // even before the stack details have been expanded. - $('#compose_stacks tr.compose-sortable').each(function() { - var stackId = ($(this).attr('id') || '').replace('stack-row-', ''); - if (!stackId) return; + // Build (or reuse) the stack→container index. + var currentRowCount = $('#compose_stacks tr.compose-sortable').length; + if (!composeStackIndex || composeStackIndex.length !== currentRowCount) { + buildComposeStackIndex(); + } + composeStackIndex.forEach(function(entry) { // Primary: short IDs baked into the row by compose_list.php - var ctidsAttr = $(this).attr('data-ctids') || ''; - var idList = ctidsAttr ? ctidsAttr.split(',') : []; + var idList = entry.containerIds.slice(); - // Fallback: if detail panel was expanded, stackContainersCache - // may have more/different IDs (e.g. after a compose up added a service) + // Fallback: if the detail panel was expanded, stackContainersCache + // may have fresher IDs (e.g. after a compose up added a service) if (idList.length === 0) { - var containers = stackContainersCache[stackId]; + var containers = stackContainersCache[entry.stackId]; if (containers && containers.length > 0) { containers.forEach(function(ct) { var ctId = String(ct.id || '').substring(0, 12); @@ -1760,9 +1787,9 @@ function wrapLoadlist() { if (matched > 0) { var aggCpu = Math.round(totalCpu * 100) / 100 + '%'; var aggMem = formatBytes(totalMemBytes); - $('.compose-stack-cpu-' + stackId).text(aggCpu); - $('#compose-stack-cpu-' + stackId).css('width', aggCpu); - $('.compose-stack-mem-' + stackId).text(aggMem); + $('.compose-stack-cpu-' + entry.stackId).text(aggCpu); + $('#compose-stack-cpu-' + entry.stackId).css('width', aggCpu); + $('.compose-stack-mem-' + entry.stackId).text(aggMem); } }); }); From 8a16afa6aa2ecacde91fcedfeae611246c0264fd Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 29 Mar 2026 20:49:29 -0400 Subject: [PATCH 16/25] fix(cpu-mem): show aggregate memory as used/avail with robust unit parsing --- source/compose.manager/php/compose_list.php | 2 +- .../php/compose_manager_main.php | 58 ++++++++++++++----- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/source/compose.manager/php/compose_list.php b/source/compose.manager/php/compose_list.php index 642f91f..03662dd 100755 --- a/source/compose.manager/php/compose_list.php +++ b/source/compose.manager/php/compose_list.php @@ -245,7 +245,7 @@ if ($isrunning) { $o .= "0%"; $o .= "
"; - $o .= "
0B"; + $o .= "
0B / 0B"; } else { $o .= "-"; $o .= ""; diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index 52537ea..96c560f 100755 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -264,23 +264,46 @@ function compose_manager_cpu_spec_count($cpuSpec) var composeCliVersion = ; var composeCpuCount = ; - // Parse docker stats memory string "123.4MiB / 2GiB" to bytes (first value only) - function parseMemToBytes(memStr) { - if (!memStr) return 0; - var parts = memStr.split('/'); - var val = (parts[0] || '').trim(); - var match = val.match(/([\d.]+)\s*(KiB|MiB|GiB|TiB|B)/i); + // Parse a single memory value (for example "123.4MiB" or "512MB") to bytes. + // 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(); + if (!cleaned) return 0; + var match = cleaned.match(/([\d.]+)\s*([kmgt]?i?b)?/i); if (!match) return 0; + var num = parseFloat(match[1]); - switch (match[2].toLowerCase()) { + if (!isFinite(num)) return 0; + var unit = (match[2] || 'b').toLowerCase(); + + switch (unit) { + case 'tb': return num * 1000000000000; case 'tib': return num * 1099511627776; + case 'gb': return num * 1000000000; case 'gib': return num * 1073741824; + case 'mb': return num * 1000000; case 'mib': return num * 1048576; + case 'kb': return num * 1000; case 'kib': return num * 1024; - default: return num; + default: return num; } } + // Parse docker stats memory string "used / limit" into bytes. + function parseMemUsagePair(memStr) { + if (!memStr) return {used: 0, limit: 0}; + var parts = String(memStr).split('/'); + var used = parseMemValueToBytes(parts[0] || ''); + var limit = parseMemValueToBytes(parts[1] || ''); + return {used: used, limit: limit}; + } + + // Backward-compatible helper used by existing code paths. + function parseMemToBytes(memStr) { + return parseMemUsagePair(memStr).used; + } + // Format bytes to human-readable string function formatBytes(bytes) { if (bytes <= 0) return '0B'; @@ -1735,7 +1758,14 @@ function buildComposeStackIndex() { if (parts.length >= 3) { var cpuRaw = parseFloat(parts[1]) || 0; var cpuNorm = Math.round(Math.min(cpuRaw / Math.max(composeCpuCount, 1), 100) * 100) / 100; - loadMap[parts[0]] = {cpu: cpuNorm, cpuText: cpuNorm + '%', mem: parts[2]}; + var memPair = parseMemUsagePair(parts[2]); + loadMap[parts[0]] = { + cpu: cpuNorm, + cpuText: cpuNorm + '%', + mem: parts[2], + memUsedBytes: memPair.used, + memLimitBytes: memPair.limit + }; } i++; row = data[i]; @@ -1774,19 +1804,21 @@ function buildComposeStackIndex() { if (idList.length === 0) return; var totalCpu = 0; - var totalMemBytes = 0; + var totalMemUsedBytes = 0; + var totalMemLimitBytes = 0; var matched = 0; idList.forEach(function(ctId) { if (ctId && loadMap[ctId]) { totalCpu += loadMap[ctId].cpu; - totalMemBytes += parseMemToBytes(loadMap[ctId].mem); + totalMemUsedBytes += loadMap[ctId].memUsedBytes || 0; + totalMemLimitBytes += loadMap[ctId].memLimitBytes || 0; matched++; } }); if (matched > 0) { var aggCpu = Math.round(totalCpu * 100) / 100 + '%'; - var aggMem = formatBytes(totalMemBytes); + var aggMem = formatBytes(totalMemUsedBytes) + ' / ' + formatBytes(totalMemLimitBytes); $('.compose-stack-cpu-' + entry.stackId).text(aggCpu); $('#compose-stack-cpu-' + entry.stackId).css('width', aggCpu); $('.compose-stack-mem-' + entry.stackId).text(aggMem); @@ -4712,7 +4744,7 @@ function renderContainerDetails(stackId, containers, project) { if (state === 'running') { html += '0%'; html += '
'; - html += '
0 / 0'; + html += '
0B / 0B'; } else { html += '-'; html += ''; From d0148f34f5011c259c971e21ebb9b4c0f37192ba Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 29 Mar 2026 20:49:46 -0400 Subject: [PATCH 17/25] fix(cpu-mem): retry dockerload subscriber init for standalone compose tab --- .../php/compose_manager_main.php | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index 96c560f..727e560 100755 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -1710,7 +1710,15 @@ function wrapLoadlist() { // 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. - if (typeof NchanSubscriber === 'function') { + function initComposeDockerLoadSubscriber() { + if (typeof NchanSubscriber !== 'function') { + return false; + } + if (window.composeDockerLoadInitialized) { + return true; + } + window.composeDockerLoadInitialized = true; + var composeDockerLoad = new NchanSubscriber('/sub/dockerload', {subscriber: 'websocket'}); var composeDockerLoadRunning = false; @@ -1830,6 +1838,19 @@ function buildComposeStackIndex() { composeDockerLoad.start(); composeDockerLoadRunning = true; } + return true; + } + + // Standalone compose mode can race script load order; retry briefly + // so delayed NchanSubscriber availability still initializes dockerload. + if (!initComposeDockerLoadSubscriber()) { + var composeDockerLoadInitAttempts = 0; + var composeDockerLoadInitTimer = setInterval(function() { + composeDockerLoadInitAttempts++; + if (initComposeDockerLoadSubscriber() || composeDockerLoadInitAttempts >= 40) { + clearInterval(composeDockerLoadInitTimer); + } + }, 250); } }); From 7130e3ec3bdb6ca8796de24e4a196447d516a79c Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 29 Mar 2026 20:51:20 -0400 Subject: [PATCH 18/25] perf(cpu-mem): add stale timeout fallback and visibility-aware processing --- .../php/compose_manager_main.php | 127 +++++++++++++----- 1 file changed, 93 insertions(+), 34 deletions(-) diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index 727e560..80ddf75 100755 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -1726,6 +1726,24 @@ function initComposeDockerLoadSubscriber() { // composeLoadlist() and reused until the row count changes. // Avoids O(stacks) DOM traversal + string splits on every stats tick. var composeStackIndex = null; + var composeLoadById = {}; + var composeLoadStaleMs = 15000; + + function isComposeLoadVisible() { + if (!isComposeAdvancedMode()) return false; + if (document.visibilityState === 'hidden') return false; + var $table = $('#compose_stacks'); + if (!$table.length) return false; + var $tabPanel = $table.closest('[role="tabpanel"]'); + if ($tabPanel.length && $tabPanel[0].style.display === 'none') return false; + return true; + } + + function clearContainerLoad(shortId) { + $('.compose-cpu-' + shortId).addClass('compose-text-muted').text('-'); + $('#compose-cpu-' + shortId).css('width', '0'); + $('.compose-mem-' + shortId).hide(); + } function buildComposeStackIndex() { composeStackIndex = []; @@ -1744,6 +1762,7 @@ function buildComposeStackIndex() { // stacks are picked up on the next stats tick. $(document).on('composeListRefreshed', function() { composeStackIndex = null; + composeLoadById = {}; }); window.composeDockerLoadToggle = function(enable) { @@ -1756,37 +1775,21 @@ function buildComposeStackIndex() { } }; - composeDockerLoad.on('message', function(msg) { - var data = msg.split('\n'); - var loadMap = {}; - 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]); - loadMap[parts[0]] = { - cpu: cpuNorm, - cpuText: cpuNorm + '%', - mem: parts[2], - memUsedBytes: memPair.used, - memLimitBytes: memPair.limit - }; + function pruneStaleLoadEntries(now) { + var staleIds = []; + for (var knownId in composeLoadById) { + if ((now - composeLoadById[knownId].ts) > composeLoadStaleMs) { + staleIds.push(knownId); } - i++; - row = data[i]; - } - - // Update per-container CPU & MEM elements in expanded detail tables - for (var shortId in loadMap) { - var info = loadMap[shortId]; - $('.compose-cpu-' + shortId).text(info.cpuText); - $('.compose-mem-' + shortId).text(info.mem); - $('#compose-cpu-' + shortId).css('width', info.cpuText); } + staleIds.forEach(function(staleId) { + delete composeLoadById[staleId]; + clearContainerLoad(staleId); + }); + return staleIds.length > 0; + } + function renderStackAggregates() { // Aggregate per-stack totals and update stack-level cells. // Build (or reuse) the stack→container index. var currentRowCount = $('#compose_stacks tr.compose-sortable').length; @@ -1816,10 +1819,10 @@ function buildComposeStackIndex() { var totalMemLimitBytes = 0; var matched = 0; idList.forEach(function(ctId) { - if (ctId && loadMap[ctId]) { - totalCpu += loadMap[ctId].cpu; - totalMemUsedBytes += loadMap[ctId].memUsedBytes || 0; - totalMemLimitBytes += loadMap[ctId].memLimitBytes || 0; + if (ctId && composeLoadById[ctId]) { + totalCpu += composeLoadById[ctId].cpu; + totalMemUsedBytes += composeLoadById[ctId].memUsedBytes || 0; + totalMemLimitBytes += composeLoadById[ctId].memLimitBytes || 0; matched++; } }); @@ -1827,12 +1830,68 @@ function buildComposeStackIndex() { if (matched > 0) { var aggCpu = Math.round(totalCpu * 100) / 100 + '%'; var aggMem = formatBytes(totalMemUsedBytes) + ' / ' + formatBytes(totalMemLimitBytes); - $('.compose-stack-cpu-' + entry.stackId).text(aggCpu); + $('.compose-stack-cpu-' + entry.stackId).removeClass('compose-text-muted').text(aggCpu); $('#compose-stack-cpu-' + entry.stackId).css('width', aggCpu); - $('.compose-stack-mem-' + entry.stackId).text(aggMem); + $('.compose-stack-mem-' + entry.stackId).show().text(aggMem); + } 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(); } }); + } + + composeDockerLoad.on('message', function(msg) { + if (!isComposeLoadVisible()) { + return; + } + + 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: cpuNorm + '%', + mem: parts[2], + memUsedBytes: memPair.used, + memLimitBytes: memPair.limit, + ts: now + }; + } + i++; + row = data[i]; + } + + pruneStaleLoadEntries(now); + + // 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(); }); + + // If dockerload pauses/stalls, drop stale values on a timer so the UI + // falls back to placeholders instead of showing frozen metrics forever. + setInterval(function() { + if (!isComposeLoadVisible()) { + return; + } + if (pruneStaleLoadEntries(Date.now())) { + renderStackAggregates(); + } + }, 3000); // Start immediately if already in advanced view if (isComposeAdvancedMode()) { composeDockerLoad.start(); From 70e8d344fb0e70df57e03e3df42464d9b1c994be Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 29 Mar 2026 20:57:35 -0400 Subject: [PATCH 19/25] fix(cpu-mem): use dockerload avail text for stack aggregate denominator --- source/compose.manager/php/compose_manager_main.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index 80ddf75..99ce3ad 100755 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -1816,20 +1816,22 @@ function renderStackAggregates() { var totalCpu = 0; var totalMemUsedBytes = 0; - var totalMemLimitBytes = 0; + var aggregateMemAvailText = ''; 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; + if (!aggregateMemAvailText && composeLoadById[ctId].memAvailText) { + aggregateMemAvailText = composeLoadById[ctId].memAvailText; + } matched++; } }); if (matched > 0) { var aggCpu = Math.round(totalCpu * 100) / 100 + '%'; - var aggMem = formatBytes(totalMemUsedBytes) + ' / ' + formatBytes(totalMemLimitBytes); + var aggMem = formatBytes(totalMemUsedBytes) + ' / ' + (aggregateMemAvailText || '0B'); $('.compose-stack-cpu-' + entry.stackId).removeClass('compose-text-muted').text(aggCpu); $('#compose-stack-cpu-' + entry.stackId).css('width', aggCpu); $('.compose-stack-mem-' + entry.stackId).show().text(aggMem); @@ -1856,12 +1858,14 @@ function renderStackAggregates() { 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 memParts = String(parts[2] || '').split('/'); + var memAvailText = (memParts[1] || '').trim(); composeLoadById[parts[0]] = { cpu: cpuNorm, cpuText: cpuNorm + '%', mem: parts[2], memUsedBytes: memPair.used, - memLimitBytes: memPair.limit, + memAvailText: memAvailText, ts: now }; } From 7cbab6ac782e8a648bd2d6dc0c79fc54c814ef0d Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 29 Mar 2026 21:13:49 -0400 Subject: [PATCH 20/25] fix(compose-load): start docker_load and use system memory total --- source/compose.manager/compose.manager.page | 1 + .../compose.manager/php/compose_manager_main.php | 15 ++++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/source/compose.manager/compose.manager.page b/source/compose.manager/compose.manager.page index ee3b627..43f4654 100644 --- a/source/compose.manager/compose.manager.page +++ b/source/compose.manager/compose.manager.page @@ -2,6 +2,7 @@ Author="dcflachs" Title="Compose" Type="php" Menu="Docker:2" +Nchan="docker_load" Cond="$var['fsState'] == 'Started' && exec('/etc/rc.d/rc.docker status | grep -v \"not\"') && (!file_exists('/boot/config/plugins/compose.manager/compose.manager.cfg') ? true : exec(\"grep '^SHOW_COMPOSE_IN_HEADER_MENU=' /boot/config/plugins/compose.manager/compose.manager.cfg 2>/dev/null | grep -v 'true'\"))" --- /dev/null') ?? ''); +// Host total memory in bytes for stack-level memory denominator. +$composeSystemMemBytes = 0; +$memKbRaw = trim(shell_exec("awk '/^MemTotal:/ {print \$2}' /proc/meminfo 2>/dev/null") ?? ''); +if (is_numeric($memKbRaw)) { + $composeSystemMemBytes = (int)$memKbRaw * 1024; +} + // CPU count for load normalization (matches Docker manager's cpu_list approach). // cpu_list() returns thread_siblings_list entries (e.g. "0-3,8-11"). // We expand each range segment so "0-3" counts as 4, not 2 endpoints. @@ -262,6 +269,7 @@ function compose_manager_cpu_spec_count($cpuSpec) var showComposeOnTop = ; var hideComposeFromDocker = ; var composeCliVersion = ; + var composeSystemMemBytes = ; var composeCpuCount = ; // Parse a single memory value (for example "123.4MiB" or "512MB") to bytes. @@ -1816,22 +1824,19 @@ function renderStackAggregates() { var totalCpu = 0; var totalMemUsedBytes = 0; - var aggregateMemAvailText = ''; var matched = 0; idList.forEach(function(ctId) { if (ctId && composeLoadById[ctId]) { totalCpu += composeLoadById[ctId].cpu; totalMemUsedBytes += composeLoadById[ctId].memUsedBytes || 0; - if (!aggregateMemAvailText && composeLoadById[ctId].memAvailText) { - aggregateMemAvailText = composeLoadById[ctId].memAvailText; - } matched++; } }); if (matched > 0) { var aggCpu = Math.round(totalCpu * 100) / 100 + '%'; - var aggMem = formatBytes(totalMemUsedBytes) + ' / ' + (aggregateMemAvailText || '0B'); + var stackMemTotal = composeSystemMemBytes > 0 ? formatBytes(composeSystemMemBytes) : '0B'; + var aggMem = formatBytes(totalMemUsedBytes) + ' / ' + stackMemTotal; $('.compose-stack-cpu-' + entry.stackId).removeClass('compose-text-muted').text(aggCpu); $('#compose-stack-cpu-' + entry.stackId).css('width', aggCpu); $('.compose-stack-mem-' + entry.stackId).show().text(aggMem); From 4c6a820d5f1df22e93a00c0cc50600110572b399 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 29 Mar 2026 21:19:16 -0400 Subject: [PATCH 21/25] fix(nchan): reuse docker manager docker_load worker --- source/compose.manager/Compose.page | 2 +- source/compose.manager/compose.manager.page | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/source/compose.manager/Compose.page b/source/compose.manager/Compose.page index 9cdb4a6..d207752 100644 --- a/source/compose.manager/Compose.page +++ b/source/compose.manager/Compose.page @@ -3,7 +3,7 @@ Type="xmenu" Title="Docker Compose" Tag="fa-cubes" Code="f1b3" -Nchan="docker_load" +Nchan="../../dynamix.docker.manager/nchan/docker_load" Cond="$var['fsState'] == 'Started' && exec('/etc/rc.d/rc.docker status | grep -v \"not\"') && exec(\"grep '^SHOW_COMPOSE_IN_HEADER_MENU=' /boot/config/plugins/compose.manager/compose.manager.cfg 2>/dev/null | grep 'true'\")" --- diff --git a/source/compose.manager/compose.manager.page b/source/compose.manager/compose.manager.page index 43f4654..ca9d12e 100644 --- a/source/compose.manager/compose.manager.page +++ b/source/compose.manager/compose.manager.page @@ -2,7 +2,7 @@ Author="dcflachs" Title="Compose" Type="php" Menu="Docker:2" -Nchan="docker_load" +Nchan="../../dynamix.docker.manager/nchan/docker_load" Cond="$var['fsState'] == 'Started' && exec('/etc/rc.d/rc.docker status | grep -v \"not\"') && (!file_exists('/boot/config/plugins/compose.manager/compose.manager.cfg') ? true : exec(\"grep '^SHOW_COMPOSE_IN_HEADER_MENU=' /boot/config/plugins/compose.manager/compose.manager.cfg 2>/dev/null | grep -v 'true'\"))" --- Date: Sun, 29 Mar 2026 21:24:51 -0400 Subject: [PATCH 22/25] fix(compose-load): update CSS classes for CPU and memory usage display --- source/compose.manager/php/compose_list.php | 8 ++++---- source/compose.manager/php/compose_manager_main.php | 7 +++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/source/compose.manager/php/compose_list.php b/source/compose.manager/php/compose_list.php index 03662dd..41724f3 100755 --- a/source/compose.manager/php/compose_list.php +++ b/source/compose.manager/php/compose_list.php @@ -243,12 +243,12 @@ // CPU & Memory column (advanced only) — populated in real-time via dockerload WebSocket $o .= ""; if ($isrunning) { - $o .= "0%"; + $o .= "0%"; $o .= "
"; - $o .= "
0B / 0B"; + $o .= "0B / 0B"; } else { - $o .= "-"; - $o .= ""; + $o .= "-"; + $o .= ""; } $o .= ""; diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index eea3c6d..5280598 100755 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -218,6 +218,13 @@ function compose_manager_cpu_spec_count($cpuSpec) white-space: nowrap; font-size: 0.9em; } + .compose-load-cell .compose-load-cpu, + .compose-load-cell .compose-load-mem { + display: block; + } + .compose-load-cell .compose-load-mem { + margin-top: 2px; + } .compose-load-cell .usage-disk.mm { height: 3px; margin: 3px 20px 0 0; From d70b71c9b2b1a2df4e1365ef0f14ef351f9f4627 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 29 Mar 2026 21:27:44 -0400 Subject: [PATCH 23/25] fix(compose-ui): align CPU & Memory load labels --- source/compose.manager/php/compose_manager_main.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index 5280598..b6d8f96 100755 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -4684,7 +4684,7 @@ function renderContainerDetails(stackId, containers, project) { html += 'Tag'; html += 'Network'; html += 'Container IP'; - html += 'CPU & Memory'; + html += 'CPU & Memory load'; html += 'Container Port'; html += 'LAN IP:Port'; html += ''; @@ -5724,7 +5724,7 @@ function addComposeStackContext(elementId) { Update Containers Uptime - CPU & Memory + CPU & Memory load Description Path Autostart From 8be7d9e2f1bfbcfd3ae5f3eacd203376aeed76fe Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 29 Mar 2026 21:33:32 -0400 Subject: [PATCH 24/25] fix(compose-load): normalize cpu count and stack mem limits --- .../php/compose_manager_main.php | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index b6d8f96..9a5d853 100755 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -47,9 +47,16 @@ function compose_manager_cpu_spec_count($cpuSpec) return $count; } $cpus = function_exists('cpu_list') ? cpu_list() : []; -$cpuCount = (!empty($cpus) && isset($cpus[0])) - ? count($cpus) * compose_manager_cpu_spec_count($cpus[0]) - : (int)trim(shell_exec('nproc 2>/dev/null') ?: '1'); +$cpuCount = 0; +foreach ($cpus as $cpuSpec) { + $cpuCount += compose_manager_cpu_spec_count($cpuSpec); +} +if ($cpuCount <= 0) { + $cpuCount = (int)trim(shell_exec('nproc 2>/dev/null') ?: '1'); +} +if ($cpuCount <= 0) { + $cpuCount = 1; +} // Note: Stack list is now loaded asynchronously via compose_list.php // This improves page load time by deferring expensive docker commands @@ -1831,18 +1838,22 @@ function renderStackAggregates() { var totalCpu = 0; var totalMemUsedBytes = 0; + var totalMemLimitBytes = 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; matched++; } }); if (matched > 0) { var aggCpu = Math.round(totalCpu * 100) / 100 + '%'; - var stackMemTotal = composeSystemMemBytes > 0 ? formatBytes(composeSystemMemBytes) : '0B'; + var stackMemTotal = totalMemLimitBytes > 0 + ? formatBytes(totalMemLimitBytes) + : (composeSystemMemBytes > 0 ? formatBytes(composeSystemMemBytes) : '0B'); var aggMem = formatBytes(totalMemUsedBytes) + ' / ' + stackMemTotal; $('.compose-stack-cpu-' + entry.stackId).removeClass('compose-text-muted').text(aggCpu); $('#compose-stack-cpu-' + entry.stackId).css('width', aggCpu); @@ -1870,14 +1881,12 @@ function renderStackAggregates() { 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 memParts = String(parts[2] || '').split('/'); - var memAvailText = (memParts[1] || '').trim(); composeLoadById[parts[0]] = { cpu: cpuNorm, cpuText: cpuNorm + '%', mem: parts[2], memUsedBytes: memPair.used, - memAvailText: memAvailText, + memLimitBytes: memPair.limit, ts: now }; } From fc9c40c8f545948e40bcaff0909ac1cd7f9ea2d3 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 29 Mar 2026 21:34:49 -0400 Subject: [PATCH 25/25] test(compose-load): cover load column and cpu/mem source logic --- tests/unit/ComposeListHtmlTest.php | 10 +++ tests/unit/ComposeManagerMainSourceTest.php | 68 +++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 tests/unit/ComposeManagerMainSourceTest.php diff --git a/tests/unit/ComposeListHtmlTest.php b/tests/unit/ComposeListHtmlTest.php index ec2459c..d6b69b0 100644 --- a/tests/unit/ComposeListHtmlTest.php +++ b/tests/unit/ComposeListHtmlTest.php @@ -95,6 +95,7 @@ public function testStackRowHasDataAttributes(): void $this->assertStringContainsString("data-project=", $source); $this->assertStringContainsString("data-projectname=", $source); $this->assertStringContainsString("data-isup=", $source); + $this->assertStringContainsString("data-ctids=", $source); } // =========================================== @@ -108,6 +109,14 @@ public function testAdvancedColumnsUseCmAdvancedClass(): void $this->assertMatchesRegularExpression("/class='[^']*\\bcm-advanced\\b[^']*'/", $source); } + public function testAdvancedLoadColumnMarkupExists(): void + { + $source = $this->getPageSource(); + $this->assertStringContainsString("col-load compose-load-cell", $source); + $this->assertStringContainsString("compose-stack-cpu-", $source); + $this->assertStringContainsString("compose-stack-mem-", $source); + } + // =========================================== // Container Count Display Tests // =========================================== @@ -129,6 +138,7 @@ public function testNoStacksMessageExists(): void $source = $this->getPageSource(); $this->assertStringContainsString('No Docker Compose stacks found', $source); $this->assertStringContainsString('Add New Stack', $source); + $this->assertStringContainsString("colspan='10'", $source); } // =========================================== diff --git a/tests/unit/ComposeManagerMainSourceTest.php b/tests/unit/ComposeManagerMainSourceTest.php new file mode 100644 index 0000000..452153c --- /dev/null +++ b/tests/unit/ComposeManagerMainSourceTest.php @@ -0,0 +1,68 @@ +mainPagePath = __DIR__ . '/../../source/compose.manager/php/compose_manager_main.php'; + $this->assertFileExists($this->mainPagePath, 'compose_manager_main.php must exist'); + } + + private function getPageSource(): string + { + return file_get_contents($this->mainPagePath); + } + + public function testCpuSpecCountHelperExists(): void + { + $source = $this->getPageSource(); + $this->assertStringContainsString('function compose_manager_cpu_spec_count($cpuSpec)', $source); + $this->assertStringContainsString('explode(\',\', trim((string)$cpuSpec))', $source); + } + + public function testCpuCountSumsAllCpuSpecs(): void + { + $source = $this->getPageSource(); + $this->assertStringContainsString('$cpuCount = 0;', $source); + $this->assertStringContainsString('foreach ($cpus as $cpuSpec)', $source); + $this->assertStringContainsString('$cpuCount += compose_manager_cpu_spec_count($cpuSpec);', $source); + } + + public function testCpuCountHasFallbackGuards(): void + { + $source = $this->getPageSource(); + $this->assertStringContainsString("trim(shell_exec('nproc 2>/dev/null') ?: '1')", $source); + $this->assertStringContainsString('if ($cpuCount <= 0) {', $source); + $this->assertStringContainsString('$cpuCount = 1;', $source); + } + + public function testStackAggregationTracksMemoryLimits(): void + { + $source = $this->getPageSource(); + $this->assertStringContainsString('var totalMemLimitBytes = 0;', $source); + $this->assertStringContainsString('totalMemLimitBytes += composeLoadById[ctId].memLimitBytes || 0;', $source); + $this->assertStringContainsString('var stackMemTotal = totalMemLimitBytes > 0', $source); + } + + public function testDockerLoadMapStoresParsedLimitBytes(): void + { + $source = $this->getPageSource(); + $this->assertStringContainsString('var memPair = parseMemUsagePair(parts[2]);', $source); + $this->assertStringContainsString('memLimitBytes: memPair.limit,', $source); + } +}