feat(stacks): per-stack action tracking, optimistic status, and bulk status endpoint (#362)

* feat(stacks): per-stack action tracking, optimistic status, and bulk status endpoint

Replace global loadingAction mutex with per-stack tracking so users can
fire actions on multiple stacks concurrently. Add optimistic status
updates to fix sidebar showing "--" after stop/start. Add bulk
GET /api/stacks/statuses endpoint using a single docker.listContainers
call instead of N docker compose ps invocations (~21s → ~110ms for 3
stacks). Falls back to per-stack queries for remote nodes on older
versions.

* fix(stacks): remove stale 'start' action check from deploy button label
This commit is contained in:
Anso
2026-04-03 17:19:10 -04:00
committed by GitHub
parent fc58158efc
commit dfd4d2858a
4 changed files with 201 additions and 72 deletions
+19
View File
@@ -2917,6 +2917,25 @@ app.get('/api/stacks', async (req: Request, res: Response) => {
}
});
app.get('/api/stacks/statuses', async (req: Request, res: Response) => {
try {
const stacks = await FileSystemService.getInstance(req.nodeId).getStacks();
const stackNames = stacks.map((s: string) => s.replace(/\.(yml|yaml)$/, ''));
const dockerController = DockerController.getInstance(req.nodeId);
const statuses = await dockerController.getBulkStackStatuses(stackNames);
// Map back to filenames to match frontend expectations
const result: Record<string, 'running' | 'exited' | 'unknown'> = {};
for (const stack of stacks) {
const name = stack.replace(/\.(yml|yaml)$/, '');
result[stack] = statuses[name] ?? 'unknown';
}
res.json(result);
} catch (error) {
console.error('Failed to fetch stack statuses:', error);
res.status(500).json({ error: 'Failed to fetch stack statuses' });
}
});
app.get('/api/stacks/:stackName', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
+23
View File
@@ -364,6 +364,29 @@ class DockerController {
return this.validateApiData<any[]>(containers);
}
public async getBulkStackStatuses(stackNames: string[]): Promise<Record<string, 'running' | 'exited' | 'unknown'>> {
const allContainers = await this.docker.listContainers({ all: true });
const knownSet = new Set(stackNames);
const statuses: Record<string, 'running' | 'exited' | 'unknown'> = {};
for (const name of stackNames) {
statuses[name] = 'unknown';
}
for (const container of allContainers as any[]) {
const project: string | undefined = container.Labels?.['com.docker.compose.project'];
if (project && knownSet.has(project)) {
if (container.State === 'running') {
statuses[project] = 'running';
} else if (statuses[project] !== 'running') {
statuses[project] = 'exited';
}
}
}
return statuses;
}
public async getContainersByStack(stackName: string) {
const stackDir = path.join(COMPOSE_DIR, stackName);