Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f6655f1
dev(deploy): update RemoteHost parameter to accept multiple values an…
mstrhakr Mar 29, 2026
8318cd7
feat(compose): add context menu on right-click for stacks and containers
mstrhakr Mar 29, 2026
4be7b49
feat(dashboard): add context menu on right-click for stacks and conta…
mstrhakr Mar 29, 2026
dfb6726
feat(load): implement CPU and memory load display for containers in a…
mstrhakr Mar 29, 2026
964333f
fix(cpu): improve CPU count calculation for load normalization
mstrhakr Mar 29, 2026
b74181e
fix(ui): correct memory display unit from '0b' to '0B' in advanced view
mstrhakr Mar 29, 2026
d840f88
fix(load): optimize load map construction for container metrics
mstrhakr Mar 29, 2026
8ee2f93
fix(load): correct CPU normalization calculation in load map
mstrhakr Mar 29, 2026
950fab1
compose_manager_main(nchan): fix garbled load map construction loop
mstrhakr Mar 29, 2026
a02bd37
comboButton.css(cleanup): remove dead ct-col-icon rule
mstrhakr Mar 29, 2026
000812f
compose_list+main(ux): show dash for stopped stack/container load
mstrhakr Mar 29, 2026
440759a
deploy.ps1(multi-host): fix RemoteHost help text to show PowerShell a…
mstrhakr Mar 30, 2026
026cdec
compose_manager_main.php(cpu-count): expand thread_siblings_list rang…
mstrhakr Mar 30, 2026
fe81032
compose_manager_main.php(dockerload): gate WebSocket subscription on …
mstrhakr Mar 30, 2026
f4cd13f
compose_manager_main.php(dockerload): cache stackId->containerIds ind…
mstrhakr Mar 30, 2026
8a16afa
fix(cpu-mem): show aggregate memory as used/avail with robust unit pa…
mstrhakr Mar 30, 2026
d0148f3
fix(cpu-mem): retry dockerload subscriber init for standalone compose…
mstrhakr Mar 30, 2026
7130e3e
perf(cpu-mem): add stale timeout fallback and visibility-aware proces…
mstrhakr Mar 30, 2026
70e8d34
fix(cpu-mem): use dockerload avail text for stack aggregate denominator
mstrhakr Mar 30, 2026
7cbab6a
fix(compose-load): start docker_load and use system memory total
mstrhakr Mar 30, 2026
4c6a820
fix(nchan): reuse docker manager docker_load worker
mstrhakr Mar 30, 2026
df81a0b
fix(compose-load): update CSS classes for CPU and memory usage display
mstrhakr Mar 30, 2026
d70b71c
fix(compose-ui): align CPU & Memory load labels
mstrhakr Mar 30, 2026
8be7d9e
fix(compose-load): normalize cpu count and stack mem limits
mstrhakr Mar 30, 2026
fc9c40c
test(compose-load): cover load column and cpu/mem source logic
mstrhakr Mar 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 102 additions & 83 deletions deploy.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -44,6 +44,9 @@
.EXAMPLE
./deploy.ps1 -Dev -RemoteHost "saturn"

.EXAMPLE
./deploy.ps1 -Dev -RemoteHost "saturn","jupiter"

.EXAMPLE
./deploy.ps1 -SkipBuild -RemoteHost "saturn"

Expand All @@ -58,7 +61,7 @@
param(
[string]$Version,
[switch]$Dev,
[string]$RemoteHost = "",
[string[]]$RemoteHost = @(),
[string]$User = "root",
[string]$RemoteDir = "/tmp",
[string]$PackagePath,
Expand All @@ -75,29 +78,22 @@ $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"
}

if ($Version -or $Dev -or $PackagePath -or $SkipBuild) {
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)
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)) {
Expand All @@ -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
}
return $allResults
3 changes: 2 additions & 1 deletion source/compose.manager/Compose.page
Original file line number Diff line number Diff line change
Expand Up @@ -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'\")"
---
<link type="text/css" rel="stylesheet" href="<?autov('/webGui/styles/jquery.switchbutton.css')?>">
<link type="text/css" rel="stylesheet" href="<?autov('/webGui/styles/jquery.filetree.css')?>">
Expand Down
20 changes: 20 additions & 0 deletions source/compose.manager/compose.manager.dashboard.page
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions source/compose.manager/compose.manager.page
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ Author="dcflachs"
Title="Compose"
Type="php"
Menu="Docker:2"
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'\"))"
---
<?php
Expand Down
23 changes: 20 additions & 3 deletions source/compose.manager/php/compose_list.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -186,7 +191,7 @@
$hasBuild = $stackInfo->hasBuildConfig() ? '1' : '0';

// Main row - Docker tab structure with expand arrow on left
$o .= "<tr class='compose-sortable' id='stack-row-$id' data-project='$projectHtml' data-projectname='$projectNameHtml' data-path='$pathHtml' data-isup='$isup' data-profiles='$profilesJson' data-webui='$webuiUrlHtml' data-containers='$containerNamesAttr' data-hasbuild='$hasBuild' data-invalid-indirect='" . ($hasInvalidIndirect ? '1' : '0') . "' data-invalid-indirect-path='$invalidIndirectPathHtml'>";
$o .= "<tr class='compose-sortable' id='stack-row-$id' data-project='$projectHtml' data-projectname='$projectNameHtml' data-path='$pathHtml' data-isup='$isup' data-profiles='$profilesJson' data-webui='$webuiUrlHtml' data-containers='$containerNamesAttr' data-ctids='$containerIdsAttr' data-hasbuild='$hasBuild' data-invalid-indirect='" . ($hasInvalidIndirect ? '1' : '0') . "' data-invalid-indirect-path='$invalidIndirectPathHtml'>";

// Arrow column
$o .= "<td class='col-arrow'>";
Expand Down Expand Up @@ -235,6 +240,18 @@
$uptimeClass = $isrunning ? 'green-text' : 'grey-text';
$o .= "<td class='col-uptime'><span class='$uptimeClass'>$uptimeDisplay</span></td>";

// CPU & Memory column (advanced only) — populated in real-time via dockerload WebSocket
$o .= "<td class='cm-advanced col-load compose-load-cell'>";
if ($isrunning) {
$o .= "<span class='compose-stack-cpu-$id compose-load-cpu'>0%</span>";
$o .= "<div class='usage-disk mm'><span id='compose-stack-cpu-$id' style='width:0'></span><span></span></div>";
$o .= "<span class='compose-stack-mem-$id compose-text-muted compose-load-mem'>0B / 0B</span>";
} else {
$o .= "<span class='compose-stack-cpu-$id compose-text-muted compose-load-cpu'>-</span>";
$o .= "<span class='compose-stack-mem-$id compose-load-mem' style='display:none'></span>";
}
$o .= "</td>";

// Description column (advanced only)
$o .= "<td class='cm-advanced col-description' style='overflow-wrap:break-word;word-wrap:break-word;'>";
if ($hasInvalidIndirect) {
Expand All @@ -254,7 +271,7 @@

// Expandable details row
$o .= "<tr class='stack-details-row' id='details-row-$id' style='display:none;'>";
$o .= "<td colspan='9' class='stack-details-cell' style='padding:0 0 0 60px;background:var(--dynamix-tablesorter-tbody-row-bg-color);'>";
$o .= "<td colspan='10' class='stack-details-cell' style='padding:0 0 0 60px;background:var(--dynamix-tablesorter-tbody-row-bg-color);'>";
$o .= "<div class='stack-details-container' id='details-container-$id' style='padding:8px 16px;'>";
$o .= "<i class='fa fa-spinner fa-spin compose-spinner'></i> Loading containers...";
$o .= "</div>";
Expand All @@ -264,7 +281,7 @@

// If no stacks found, show a message
if ($stackCount === 0) {
$o = "<tr><td colspan='9' style='text-align:center;padding:20px;color:var(--alt-text-color);'>No Docker Compose stacks found. Click 'Add New Stack' to create one.</td></tr>";
$o = "<tr><td colspan='10' style='text-align:center;padding:20px;color:var(--alt-text-color);'>No Docker Compose stacks found. Click 'Add New Stack' to create one.</td></tr>";
}

// Output the HTML
Expand Down
Loading
Loading