diff --git a/deploy.ps1 b/deploy.ps1 index cc33005..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 or IP. + 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. @@ -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 diff --git a/source/compose.manager/Compose.page b/source/compose.manager/Compose.page index 208680a..d207752 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="../../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.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) { diff --git a/source/compose.manager/compose.manager.page b/source/compose.manager/compose.manager.page index ee3b627..ca9d12e 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="../../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'\"))" --- 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,18 @@ $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 .= ""; + } + $o .= ""; + // Description column (advanced only) $o .= ""; if ($hasInvalidIndirect) { @@ -254,7 +271,7 @@ // Expandable details row $o .= ""; - $o .= ""; + $o .= ""; $o .= "
"; $o .= " Loading containers..."; $o .= "
"; @@ -264,7 +281,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 330a80a..9a5d853 100755 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -18,6 +18,46 @@ // Get Docker Compose CLI version $composeVersion = trim(shell_exec('docker compose version --short 2>/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. +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 = 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 ?> @@ -85,7 +125,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 +151,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 +220,35 @@ 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 .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; + 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 composeSystemMemBytes = ; + var composeCpuCount = ; + + // 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]); + 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; + } + } + + // 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'; + 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 @@ -353,6 +477,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(); @@ -457,7 +584,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); } @@ -1365,6 +1492,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'); @@ -1596,6 +1727,215 @@ function wrapLoadlist() { }); } })(); + + // ── 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. + 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; + + // 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; + 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 = []; + $('#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; + composeLoadById = {}; + }); + + window.composeDockerLoadToggle = function(enable) { + if (enable && !composeDockerLoadRunning) { + composeDockerLoad.start(); + composeDockerLoadRunning = true; + } else if (!enable && composeDockerLoadRunning) { + composeDockerLoad.stop(); + composeDockerLoadRunning = false; + } + }; + + function pruneStaleLoadEntries(now) { + var staleIds = []; + for (var knownId in composeLoadById) { + if ((now - composeLoadById[knownId].ts) > composeLoadStaleMs) { + staleIds.push(knownId); + } + } + 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; + if (!composeStackIndex || composeStackIndex.length !== currentRowCount) { + buildComposeStackIndex(); + } + + composeStackIndex.forEach(function(entry) { + // Primary: short IDs baked into the row by compose_list.php + var idList = entry.containerIds.slice(); + + // 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[entry.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 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 = 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); + $('.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(); + 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); + } }); function addStack() { @@ -4353,6 +4693,7 @@ function renderContainerDetails(stackId, containers, project) { html += 'Tag'; html += 'Network'; html += 'Container IP'; + html += 'CPU & Memory load'; html += 'Container Port'; html += 'LAN IP:Port'; html += ''; @@ -4503,6 +4844,18 @@ function renderContainerDetails(stackId, containers, project) { // Container IP 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 += ''; + // Container Port html += '' + containerPorts.map(composeEscapeHtml).join('
') + '
'; @@ -5311,6 +5664,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) { @@ -5362,6 +5733,7 @@ function addComposeStackContext(elementId) { Update Containers Uptime + CPU & Memory load Description Path Autostart @@ -5369,7 +5741,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..12502b8 100644 --- a/source/compose.manager/styles/comboButton.css +++ b/source/compose.manager/styles/comboButton.css @@ -280,27 +280,31 @@ /* Column width distribution (totals ~100%) */ .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 { 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); + } +}