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);
+ }
+}