Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
55613da
feat(stack): centralize container counts and stack state management
mstrhakr Apr 10, 2026
940ffee
fix(stack): derive running state from DOM instead of stale server res…
mstrhakr Apr 10, 2026
57c8aea
fix(stack): derive running state from DOM and remove stale isRunning …
mstrhakr Apr 10, 2026
3e0df46
feat(stack): centralize profile management by introducing effective p…
mstrhakr Apr 10, 2026
562d25d
fix(stack): simplify stack status display logic and update prompt for…
mstrhakr Apr 10, 2026
68a1f31
fix(stack): update container ID collection to use getContainerList me…
mstrhakr Apr 10, 2026
9e75c8f
fix(tests): correct string escaping in checkStackUpdates onclick handler
mstrhakr Apr 10, 2026
4ff41e0
fix(submodule): update tests/framework submodule branch to main
mstrhakr Apr 10, 2026
57e001f
fix(auto-update): centralize shell command resolution for auto-update…
mstrhakr Apr 10, 2026
b3756ae
fix(auto-update): revert some changes and improve test environment setup
mstrhakr Apr 10, 2026
2a9027b
fix(tests): centralize shell wrapper for autoupdate tests
mstrhakr Apr 10, 2026
c0097e1
fix(profiles): prefer running profiles for multi-stack up/update
mstrhakr Apr 10, 2026
80857dd
fix(state): count non-running unknown container states as stopped
mstrhakr Apr 10, 2026
608a0de
fix(dashboard): map paused stacks into partial summary bucket
mstrhakr Apr 10, 2026
a391081
fix(profiles): fix profile selection logic for compose actions
mstrhakr Apr 11, 2026
082d8a9
fix(stack): centralize container counts and optimize state retrieval
mstrhakr Apr 11, 2026
e5825bd
chore(submodule): pin tests/framework to v0.2
mstrhakr Apr 11, 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
63 changes: 18 additions & 45 deletions source/compose.manager/include/ComposeList.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,38 +24,23 @@
$composeFile = $stackInfo->composeFilePath ?? ($stackInfo->composeSource . '/' . COMPOSE_FILE_NAMES[0]);
$overridePath = $stackInfo->getOverridePath();

// Use StackInfo's getDefinedServices for accurate service count
$definedServicesList = $stackInfo->getDefinedServices();
$definedServices = count($definedServicesList);

$projectContainers = $stackInfo->getContainerList();
$runningCount = 0;
$stoppedCount = 0;
$pausedCount = 0;
$restartingCount = 0;

foreach ($projectContainers as $ct) {
$ctState = $ct['State'] ?? '';
if ($ctState === 'running') {
$runningCount++;
} elseif ($ctState === 'exited') {
$stoppedCount++;
} elseif ($ctState === 'paused') {
$pausedCount++;
} elseif ($ctState === 'restarting') {
$restartingCount++;
}
}

// Container counts
$actualContainerCount = count($projectContainers);
$containerCount = $definedServices > 0 ? $definedServices : $actualContainerCount;
// getStackState() internally calls getContainerCounts(), which caches
// the result. Calling getContainerCounts() afterwards is free.
$stackState = $stackInfo->getStackState();
$counts = $stackInfo->getContainerCounts();

$runningCount = $counts['running'];
$stoppedCount = $counts['stopped'];
$pausedCount = $counts['paused'];
$restartingCount = $counts['restarting'];
$actualContainerCount = $counts['total'];
$containerCount = $counts['total'];

// 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) {
foreach ($stackInfo->getContainerList() as $ct) {
$n = $ct['Names'] ?? '';
if ($n) $containerNamesList[] = $n;
$ctId = $ct['ID'] ?? '';
Expand Down Expand Up @@ -124,24 +109,12 @@
$hasInvalidIndirect = ($invalidIndirectPath !== null && trim($invalidIndirectPath) !== '');
$invalidIndirectPathHtml = htmlspecialchars($invalidIndirectPath ?? '', ENT_QUOTES, 'UTF-8');

// Status like Docker tab (started/stopped with icon)
$status = $isrunning ? ($runningCount == $containerCount ? 'started' : 'partial') : 'stopped';
// Use exclamation icon for partial state so it looks like a warning
if ($status === 'partial') {
$shape = 'exclamation-circle';
} elseif ($isrunning) {
$shape = 'play';
} else {
$shape = 'square';
}
$color = $status == 'started' ? 'green-text' : ($status == 'partial' ? 'orange-text' : 'grey-text');
// Use 'partial' outer class for partial state to allow correct styling
$outerClass = $isrunning ? ($runningCount == $containerCount ? 'started' : 'partial') : 'stopped';

$statusLabel = $status;
if ($status == 'partial') {
$statusLabel = "partial ($runningCount/$containerCount)";
}
// Status icon, label, color — derived from centralized getStackState()
$status = $stackState['state'];
$shape = $stackState['shape'];
$color = $stackState['color'];
$outerClass = $status;
$statusLabel = $stackState['label'];

// Get stack started_at timestamp via StackInfo
$stackStartedAt = $stackInfo->getStartedAt();
Expand Down
115 changes: 48 additions & 67 deletions source/compose.manager/include/ComposeManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ function compose_manager_cpu_spec_count($cpuSpec)
<script src="<?php autov('/plugins/compose.manager/javascript/js-yaml/js-yaml.min.js'); ?>" type="text/javascript"></script>
<script src="<?php autov('/plugins/compose.manager/javascript/common.js'); ?>" type="text/javascript"></script>
<script src="<?php autov('/plugins/compose.manager/javascript/composeSortable.js'); ?>" type="text/javascript"></script>
<script src="<?php autov('/plugins/compose.manager/javascript/composeStackUtils.js'); ?>" type="text/javascript"></script>
<script>
var compose_root = <?php echo json_encode($compose_root); ?>;
var caURL = "/plugins/compose.manager/include/Exec.php";
Expand Down Expand Up @@ -419,7 +420,7 @@ function createStackInfo(project, containers, opts) {
return {
projectName: opts.projectName || project,
containers: normalized,
isRunning: (opts.isRunning !== undefined) ? opts.isRunning : isRunning,
isRunning: isRunning,
hasUpdate: (opts.hasUpdate !== undefined) ? opts.hasUpdate : hasUpdate,
totalServices: opts.totalServices || normalized.length,
lastChecked: opts.lastChecked || null
Expand Down Expand Up @@ -1179,9 +1180,13 @@ function updateUpdateAllButton() {
var stacksWithUpdates = 0;
for (var stackName in stackUpdateStatus) {
var stackInfo = stackUpdateStatus[stackName];
if (stackInfo.hasUpdate && stackInfo.isRunning) {
stacksWithUpdates++;
}
if (!stackInfo.hasUpdate) continue;
// Derive running state from DOM — saved status may be stale
var $row = $('#compose_stacks tr.compose-sortable[data-project="' + stackName + '"]');
if ($row.length === 0) continue;
var stateText = $row.find('.state').text();
var isRunning = stateText.indexOf('started') !== -1 || stateText.indexOf('partial') !== -1;
if (isRunning) stacksWithUpdates++;
}
$('#updateAllBtn').prop('disabled', stacksWithUpdates === 0);
}
Expand All @@ -1194,24 +1199,26 @@ function updateAllStacks() {
// Collect all stacks with updates
for (var stackName in stackUpdateStatus) {
var stackInfo = stackUpdateStatus[stackName];
if (stackInfo.hasUpdate && stackInfo.isRunning) {
var $stackRow = $('#compose_stacks tr.compose-sortable[data-project="' + stackName + '"]');
if ($stackRow.length === 0) continue;
if (!stackInfo.hasUpdate) continue;
var $stackRow = $('#compose_stacks tr.compose-sortable[data-project="' + stackName + '"]');
if ($stackRow.length === 0) continue;
// Derive running state from DOM — saved status may be stale
var rowStateText = $stackRow.find('.state').text();
if (rowStateText.indexOf('started') === -1 && rowStateText.indexOf('partial') === -1) continue;

var autostart = $stackRow.find('.auto_start').is(':checked');
var autostart = $stackRow.find('.auto_start').is(':checked');

// Skip if autostart only mode and autostart is not enabled
if (autostartOnly && !autostart) continue;
// Skip if autostart only mode and autostart is not enabled
if (autostartOnly && !autostart) continue;

var path = $stackRow.data('path');
var projectName = $stackRow.data('projectname');
var path = $stackRow.data('path');
var projectName = $stackRow.data('projectname');

stacks.push({
project: stackName,
projectName: projectName,
path: path
});
}
stacks.push({
project: stackName,
projectName: projectName,
path: path
});
}

if (stacks.length === 0) {
Expand Down Expand Up @@ -1322,25 +1329,15 @@ function updateStackUpdateUI(stackName, stackInfo) {
var stackId = $stackRow.attr('id').replace('stack-row-', '');
var $updateCell = $stackRow.find('.compose-updatecolumn');

// Check if the stack is running - use server response or DOM state
var isRunning = stackInfo.isRunning;
if (isRunning === undefined) {
// Fallback to DOM state check
var stateText = $stackRow.find('.state').text();
isRunning = stateText.indexOf('started') !== -1 || stateText.indexOf('partial') !== -1;
}

// If the stack is stopped and we have no previously-checked update
// data, show "stopped". But if a prior update check produced valid
// container info (images are still on disk), display it — the SHA
// comparison is still accurate even when the stack isn't running.
var hasCheckedData = stackInfo.containers && stackInfo.containers.length > 0 &&
stackInfo.containers.some(function(ct) {
return ct.hasUpdate !== undefined || ct.localSha || ct.updateStatus;
});
// Always derive running state from the current DOM rather than the
// stackInfo payload. The saved update-status file may contain a stale
// isRunning value from when the check originally ran (e.g. the stack
// was stopped then but has since been started).
var stateText = $stackRow.find('.state').text();
var isRunning = stateText.indexOf('started') !== -1 || stateText.indexOf('partial') !== -1;

if (!isRunning && !hasCheckedData) {
// Stack is not running and no prior update info - show stopped
if (!isRunning) {
// Stack is not running - show stopped
$updateCell.html('<span class="grey-text" style="white-space:nowrap;"><i class="fa fa-stop fa-fw"></i> stopped</span>');
return;
}
Expand Down Expand Up @@ -1409,8 +1406,10 @@ function updateStackUpdateUI(stackName, stackInfo) {
$updateCell.html(html);
}
} else {
// No containers found - show pull updates as clickable (for stacks that aren't running)
$updateCell.html('<a class="exec" style="cursor:pointer;" onclick="showUpdateWarning(\'' + composeEscapeAttr(stackName) + '\', \'' + composeEscapeAttr(stackId) + '\');"><i class="fa fa-cloud-download fa-fw"></i> pull updates</a>');
// No containers found in update data — stack is running but
// hasn't been checked yet. Prompt a check rather than an
// update so the SHA metadata gets populated first.
$updateCell.html('<a class="exec" style="cursor:pointer;" onclick="checkStackUpdates(\'' + composeEscapeAttr(stackName) + '\');"><i class="fa fa-cloud-download fa-fw"></i> check for updates</a>');
}

// Apply current view mode — cm-advanced elements are controlled by
Expand Down Expand Up @@ -1472,8 +1471,7 @@ function checkStackUpdates(stackName) {
var response = JSON.parse(data);
if (response.result === 'success') {
var stackInfo = createStackInfo(stackName, response.updates, {
projectName: response.projectName,
isRunning: true
projectName: response.projectName
});
stackUpdateStatus[stackName] = stackInfo;
updateStackUpdateUI(stackName, stackInfo);
Expand Down Expand Up @@ -2992,7 +2990,6 @@ function refreshStackRow(stackId, project) {
mergeUpdateStatus(containers, project);
// Update cache with fresh data
stackContainersCache[stackId] = containers;
stackDefinedServicesCache[stackId] = response.definedServices || containers.length;
if (response.startedAt) stackStartedAtCache[stackId] = response.startedAt;
// Now update the row using the fresh cache
updateParentStackFromContainers(stackId, project);
Expand Down Expand Up @@ -3734,7 +3731,6 @@ function ComposeLogs(pathOrProject, profile = "") {
var currentStackId = null;
var expandedStacks = {};
var stackContainersCache = {};
var stackDefinedServicesCache = {}; // Cache for defined service counts
var stackStartedAtCache = {}; // Cache for stack-level started_at timestamps
// Track stacks currently loading details to prevent concurrent reloads
var stackDetailsLoading = {};
Expand Down Expand Up @@ -4915,7 +4911,6 @@ function loadStackContainerDetails(stackId, project) {
mergeUpdateStatus(containers, project);

stackContainersCache[stackId] = containers;
stackDefinedServicesCache[stackId] = response.definedServices || containers.length;
if (response.startedAt) stackStartedAtCache[stackId] = response.startedAt;
composeClientDebug('[loadStackContainerDetails] success', {
stackId: stackId,
Expand Down Expand Up @@ -5227,8 +5222,7 @@ function renderContainerDetails(stackId, containers, project) {
// Build a condensed stackInfo object from the stackContainersCache for a stack
function buildStackInfoFromCache(stackId, project) {
var containers = stackContainersCache[stackId] || [];
var definedServices = stackDefinedServicesCache[stackId] || containers.length;
return createStackInfo(project, containers, { totalServices: definedServices });
return createStackInfo(project, containers);
}

// Update only the parent stack row using cached container details
Expand Down Expand Up @@ -5269,39 +5263,26 @@ function updateParentStackFromContainers(stackId, project) {
// Update the stack row status icon and state text based on container states
var $stateEl = $stackRow.find('.state');
var origText = $stateEl.data('orig-text') || $stateEl.text();
// Determine aggregated state
var runningCount = stackInfo.containers.filter(function(c) {
return c.isRunning;
}).length;
// Use totalServices (defined services) when available, but never
// show a denominator smaller than the currently discovered containers.
var totalCount = Math.max(stackInfo.totalServices || 0, stackInfo.containers.length);
// Derive state from containers using centralized helper
var stateInfo = deriveStackState(stackInfo.containers);
var runningCount = stateInfo.runningCount;
var totalCount = stateInfo.totalCount;
var anyRunning = runningCount > 0;
var anyPaused = stackInfo.containers.some(function(c) {
return !c.isRunning && (c.updateStatus === 'paused' || c.updateStatus === 'paused');
});
var newState;
// If some containers are running but not all, show 'partial' and include counts
if (anyRunning && runningCount < totalCount) {
newState = 'partial';
$stateEl.text('partial (' + runningCount + '/' + totalCount + ')');
} else {
newState = anyRunning ? 'started' : (anyPaused ? 'paused' : 'stopped');
$stateEl.text(newState);
}
var newState = stateInfo.state;
$stateEl.text(stateInfo.label);

// Update the containers count cell to reflect cached values
try {
var $containersCell = $stackRow.find('td.col-containers');
var containersClass = (runningCount == totalCount && runningCount > 0) ? 'green-text' : (runningCount > 0 ? 'orange-text' : 'grey-text');
var containersClass = stateInfo.colorClass;
$containersCell.html('<span class="' + containersClass + '">' + runningCount + ' / ' + totalCount + '</span>');
} catch (e) {}

// Update the status icon to match the new state and color
var $icon = $stackRow.find('.compose-status-icon');
if ($icon.length) {
var shape = newState === 'started' ? 'play' : (newState === 'paused' ? 'pause' : (newState === 'partial' ? 'exclamation-circle' : 'square'));
var colorClass = newState === 'started' ? 'green-text' : (newState === 'paused' || newState === 'partial' ? 'orange-text' : 'grey-text');
var shape = stateInfo.shape;
var colorClass = stateInfo.colorClass;

// Remove spinner / temporary classes and any previous fa-<name> classes
$icon.removeClass('fa-refresh fa-spin compose-status-spinner');
Expand Down
30 changes: 11 additions & 19 deletions source/compose.manager/include/DashboardStacks.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,35 +34,27 @@
foreach (StackInfo::allFromRoot($compose_root) as $stackInfo) {
$summary['total']++;

$projectContainers = $stackInfo->getContainerList();
$runningCount = 0;
$totalContainers = count($projectContainers);
// Centralized stack state
$stackState = $stackInfo->getStackState();
$state = $stackState['state'];
$runningCount = $stackState['running'];
$totalContainers = $stackState['total'];

// Read stack started_at timestamp via StackInfo
$startedAt = $stackInfo->getStartedAt();

foreach ($projectContainers as $ct) {
if (($ct['State'] ?? '') === 'running') {
$runningCount++;
}
// Collect container names for hiding from Docker tile
// Collect container names for hiding from Docker tile
foreach ($stackInfo->getContainerList() as $ct) {
$name = ltrim(trim($ct['Names'] ?? ''), '/');
if ($name) {
$summary['composeContainerNames'][] = $name;
}
}

$state = 'stopped';
if ($totalContainers > 0) {
if ($runningCount === $totalContainers) {
$state = 'started';
$summary['started']++;
} elseif ($runningCount > 0) {
$state = 'partial';
$summary['partial']++;
} else {
$summary['stopped']++;
}
if ($state === 'started') {
$summary['started']++;
} elseif ($state === 'partial' || $state === 'paused') {
$summary['partial']++;
} else {
$summary['stopped']++;
}
Expand Down
10 changes: 3 additions & 7 deletions source/compose.manager/include/Exec.php
Original file line number Diff line number Diff line change
Expand Up @@ -582,9 +582,8 @@ function getPostScript(): string
$updateStatusData = json_decode(file_get_contents($updateStatusFile), true) ?: [];
}

// Get defined service count via StackInfo
$definedServicesList = $stackInfo->getDefinedServices();
$definedServices = count($definedServicesList);
// Get stack state via centralized StackInfo method
$stackState = $stackInfo->getStackState();

foreach ($rows as $rawContainer) {
// Get additional details using docker inspect
Expand Down Expand Up @@ -731,7 +730,7 @@ function getPostScript(): string
$containers[] = ContainerInfo::fromDockerInspect($rawContainer)->toArray();
}

echo json_encode(['result' => 'success', 'containers' => $containers, 'definedServices' => $definedServices, 'projectName' => $stackInfo->projectFolder, 'startedAt' => $stackInfo->getStartedAt()]);
echo json_encode(['result' => 'success', 'containers' => $containers, 'stackState' => $stackState, 'projectName' => $stackInfo->projectFolder, 'startedAt' => $stackInfo->getStartedAt()]);
break;
case 'getProfileServices':
// Returns the list of services that docker compose would act on for the
Expand Down Expand Up @@ -917,7 +916,6 @@ function getPostScript(): string

$stackUpdates = [];
$hasStackUpdate = false;
$isRunning = false;

if ($rows) {
// Load once, batch-clear local SHAs, save once (avoid per-container I/O)
Expand All @@ -928,7 +926,6 @@ function getPostScript(): string
foreach ($rows as $container) {
$state = $container['State'] ?? '';
if ($state === 'running') {
$isRunning = true;
$image = $container['Image'] ?? '';
if ($image) {
$image = normalizeImageForUpdateCheck($image);
Expand Down Expand Up @@ -995,7 +992,6 @@ function getPostScript(): string
$allUpdates[$stackName] = [
'projectName' => $projectName,
'hasUpdate' => $hasStackUpdate,
'isRunning' => $isRunning,
'containers' => $stackUpdates
];
}
Expand Down
Loading
Loading