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
+14
View File
@@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
* **stacks:** bulk `/api/stacks/statuses` endpoint — fetches all stack statuses in a single Docker API call instead of N individual `docker compose ps` invocations. Falls back to per-stack queries for remote nodes on older versions.
### Fixed
* **stacks:** sidebar status indicators showing "--" (unknown) after stopping a stack instead of "DN". Root cause was a race condition where `refreshStacks` queried container state before Docker had fully transitioned containers.
### Changed
* **stacks:** stack actions (deploy, stop, restart, update) are now tracked per-stack instead of globally. Users can fire actions on multiple stacks concurrently without the UI blocking. Sidebar shows a spinner per stack during in-flight actions.
## [0.34.0](https://github.com/AnsoCode/Sencho/compare/v0.33.1...v0.34.0) (2026-04-03)
+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);
+145 -72
View File
@@ -66,6 +66,8 @@ interface StackStatus {
[key: string]: 'running' | 'exited' | 'unknown';
}
type StackAction = 'deploy' | 'stop' | 'restart' | 'update' | 'delete' | 'rollback';
interface Notification {
id: number;
level: 'info' | 'warning' | 'error';
@@ -120,7 +122,24 @@ export default function EditorLayout() {
const [newStackName, setNewStackName] = useState('');
const [stackToDelete, setStackToDelete] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [loadingAction, setLoadingAction] = useState<string | null>(null);
const [stackActions, setStackActions] = useState<Record<string, StackAction>>({});
const stackActionsRef = useRef<Record<string, StackAction>>({});
stackActionsRef.current = stackActions;
const setStackAction = (stackFile: string, action: StackAction) => {
setStackActions(prev => ({ ...prev, [stackFile]: action }));
};
const clearStackAction = (stackFile: string) => {
setStackActions(prev => {
const next = { ...prev };
delete next[stackFile];
return next;
});
};
const isStackBusy = (stackFile: string) => stackFile in stackActions;
const loadingAction = selectedFile ? (stackActions[selectedFile] ?? null) : null;
const [isScanning, setIsScanning] = useState(false);
const [isFileLoading, setIsFileLoading] = useState(false);
const [backupInfo, setBackupInfo] = useState<{ exists: boolean; timestamp: number | null }>({ exists: false, timestamp: null });
@@ -280,22 +299,36 @@ export default function EditorLayout() {
const fileList: string[] = Array.isArray(data) ? data : [];
setFiles(fileList);
// Fetch status for all stacks in parallel
const statusResults = await Promise.allSettled(
fileList.map(async (file) => {
const containersRes = await apiFetch(`/stacks/${file}/containers`);
const containers = await containersRes.json();
const hasRunning = Array.isArray(containers) && containers.some((c: ContainerInfo) => c.State === 'running');
return { file, status: hasRunning ? 'running' as const : (Array.isArray(containers) && containers.length > 0 ? 'exited' as const : 'unknown' as const) };
})
);
const statuses: StackStatus = {};
for (const result of statusResults) {
if (result.status === 'fulfilled') {
statuses[result.value.file] = result.value.status;
// Fetch all stack statuses in a single bulk call (falls back to per-stack queries for older remote nodes)
const statusRes = await apiFetch('/stacks/statuses');
let bulkStatuses: Record<string, 'running' | 'exited' | 'unknown'> | null = null;
if (statusRes.ok) {
bulkStatuses = await statusRes.json();
} else {
// Fallback: query each stack individually (remote node may not have bulk endpoint)
const statusResults = await Promise.allSettled(
fileList.map(async (file) => {
const containersRes = await apiFetch(`/stacks/${file}/containers`);
const containers = await containersRes.json();
const hasRunning = Array.isArray(containers) && containers.some((c: ContainerInfo) => c.State === 'running');
return { file, status: hasRunning ? 'running' as const : (Array.isArray(containers) && containers.length > 0 ? 'exited' as const : 'unknown' as const) };
})
);
bulkStatuses = {};
for (const result of statusResults) {
if (result.status === 'fulfilled') {
bulkStatuses[result.value.file] = result.value.status;
}
}
}
setStackStatuses(statuses);
setStackStatuses(prev => {
const next: StackStatus = {};
for (const file of fileList) {
const status = bulkStatuses?.[file] ?? 'unknown';
next[file] = (file in stackActionsRef.current) ? (prev[file] ?? status) : status;
}
return next;
});
refreshLabels();
return fileList;
} catch (error) {
@@ -307,6 +340,10 @@ export default function EditorLayout() {
}
};
const setOptimisticStatus = (stackFile: string, status: 'running' | 'exited') => {
setStackStatuses(prev => ({ ...prev, [stackFile]: status }));
};
const refreshLabels = async () => {
if (!isPro) return;
try {
@@ -846,28 +883,31 @@ export default function EditorLayout() {
};
const rollbackStack = async () => {
if (!selectedFile || loadingAction !== null) return;
setLoadingAction('rollback');
if (!selectedFile || isStackBusy(selectedFile)) return;
const stackFile = selectedFile;
setStackAction(stackFile, 'rollback');
setOptimisticStatus(stackFile, 'running');
try {
const res = await apiFetch(`/stacks/${selectedFile}/rollback`, { method: 'POST' });
const res = await apiFetch(`/stacks/${stackFile}/rollback`, { method: 'POST' });
if (!res.ok) {
const err = await res.json();
throw new Error(err?.error || 'Rollback failed');
}
toast.success('Stack rolled back successfully.');
// Reload the editor content
const contentRes = await apiFetch(`/stacks/${selectedFile}`);
const contentRes = await apiFetch(`/stacks/${stackFile}`);
const text = await contentRes.text();
setContent(text || '');
setOriginalContent(text || '');
// Refresh backup info
const backupRes = await apiFetch(`/stacks/${selectedFile}/backup`);
const backupRes = await apiFetch(`/stacks/${stackFile}/backup`);
if (backupRes.ok) setBackupInfo(await backupRes.json());
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'Rollback failed';
toast.error(msg);
} finally {
setLoadingAction(null);
clearStackAction(stackFile);
refreshStacks(true);
}
};
@@ -892,9 +932,11 @@ export default function EditorLayout() {
const deployStack = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!selectedFile || loadingAction !== null) return;
const stackName = selectedFile.replace(/\.(yml|yaml)$/, '');
setLoadingAction('deploy');
if (!selectedFile || isStackBusy(selectedFile)) return;
const stackFile = selectedFile;
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
setStackAction(stackFile, 'deploy');
setOptimisticStatus(stackFile, 'running');
try {
const response = await apiFetch(`/stacks/${stackName}/deploy`, {
method: 'POST',
@@ -905,10 +947,11 @@ export default function EditorLayout() {
}
toast.success("Stack deployed successfully!");
// Refresh containers after deploy
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
const conts = await containersRes.json();
setContainers(Array.isArray(conts) ? conts : []);
await refreshStacks(true);
if (selectedFile === stackFile) {
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
const conts = await containersRes.json();
setContainers(Array.isArray(conts) ? conts : []);
}
// Refresh backup info
if (isPro) {
try {
@@ -921,16 +964,19 @@ export default function EditorLayout() {
const msg = (error as Error).message || 'Failed to deploy stack';
toast.error(isPro ? `${msg} - automatically rolled back to previous version.` : msg);
} finally {
setLoadingAction(null);
clearStackAction(stackFile);
refreshStacks(true);
}
};
const stopStack = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!selectedFile || loadingAction !== null) return;
const stackName = selectedFile.replace(/\.(yml|yaml)$/, '');
setLoadingAction('stop');
if (!selectedFile || isStackBusy(selectedFile)) return;
const stackFile = selectedFile;
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
setStackAction(stackFile, 'stop');
setOptimisticStatus(stackFile, 'exited');
try {
const response = await apiFetch(`/stacks/${stackName}/stop`, {
method: 'POST',
@@ -941,24 +987,28 @@ export default function EditorLayout() {
}
toast.success('Stack stopped successfully!');
// Refresh containers after stop
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
const conts = await containersRes.json();
setContainers(Array.isArray(conts) ? conts : []);
await refreshStacks(true);
if (selectedFile === stackFile) {
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
const conts = await containersRes.json();
setContainers(Array.isArray(conts) ? conts : []);
}
} catch (error) {
console.error('Failed to stop:', error);
toast.error((error as Error).message || 'Failed to stop stack');
} finally {
setLoadingAction(null);
clearStackAction(stackFile);
refreshStacks(true);
}
};
const restartStack = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!selectedFile || loadingAction !== null) return;
const stackName = selectedFile.replace(/\.(yml|yaml)$/, '');
setLoadingAction('restart');
if (!selectedFile || isStackBusy(selectedFile)) return;
const stackFile = selectedFile;
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
setStackAction(stackFile, 'restart');
setOptimisticStatus(stackFile, 'running');
try {
const response = await apiFetch(`/stacks/${stackName}/restart`, {
method: 'POST',
@@ -969,24 +1019,28 @@ export default function EditorLayout() {
}
toast.success('Stack restarted successfully!');
// Refresh containers after restart
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
const conts = await containersRes.json();
setContainers(Array.isArray(conts) ? conts : []);
await refreshStacks(true);
if (selectedFile === stackFile) {
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
const conts = await containersRes.json();
setContainers(Array.isArray(conts) ? conts : []);
}
} catch (error) {
console.error('Failed to restart:', error);
toast.error((error as Error).message || 'Failed to restart stack');
} finally {
setLoadingAction(null);
clearStackAction(stackFile);
refreshStacks(true);
}
};
const updateStack = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!selectedFile || loadingAction !== null) return;
const stackName = selectedFile.replace(/\.(yml|yaml)$/, '');
setLoadingAction('update');
if (!selectedFile || isStackBusy(selectedFile)) return;
const stackFile = selectedFile;
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
setStackAction(stackFile, 'update');
setOptimisticStatus(stackFile, 'running');
try {
const response = await apiFetch(`/stacks/${stackName}/update`, {
method: 'POST',
@@ -997,21 +1051,26 @@ export default function EditorLayout() {
}
toast.success('Stack updated successfully!');
// Refresh containers after update
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
const conts = await containersRes.json();
setContainers(Array.isArray(conts) ? conts : []);
await refreshStacks(true);
if (selectedFile === stackFile) {
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
const conts = await containersRes.json();
setContainers(Array.isArray(conts) ? conts : []);
}
} catch (error) {
console.error('Failed to update:', error);
toast.error((error as Error).message || 'Failed to update stack');
} finally {
setLoadingAction(null);
clearStackAction(stackFile);
refreshStacks(true);
}
};
const deleteStack = async () => {
if (!stackToDelete) return;
setLoadingAction('delete');
// Find matching file entry for per-stack tracking
const deleteKey = files.find(f => f === stackToDelete || f.replace(/\.(yml|yaml)$/, '') === stackToDelete) ?? stackToDelete;
if (isStackBusy(deleteKey)) return;
setStackAction(deleteKey, 'delete');
try {
const response = await apiFetch(`/stacks/${stackToDelete}`, {
method: 'DELETE',
@@ -1038,15 +1097,23 @@ export default function EditorLayout() {
console.error('Failed to delete stack:', error);
toast.error((error as Error).message || 'Failed to delete stack');
} finally {
setLoadingAction(null);
clearStackAction(deleteKey);
}
};
// Context-menu-friendly stack actions (accept file name directly)
const executeStackActionByFile = async (stackFile: string, action: string, endpoint: string) => {
if (loadingAction !== null) return;
const executeStackActionByFile = async (stackFile: string, action: StackAction, endpoint: string) => {
if (isStackBusy(stackFile)) return;
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
setLoadingAction(action);
setStackAction(stackFile, action);
// Optimistic status update
if (action === 'stop') {
setOptimisticStatus(stackFile, 'exited');
} else if (action === 'deploy' || action === 'restart' || action === 'update') {
setOptimisticStatus(stackFile, 'running');
}
try {
const response = await apiFetch(`/stacks/${stackName}/${endpoint}`, { method: 'POST' });
if (!response.ok) {
@@ -1059,7 +1126,6 @@ export default function EditorLayout() {
const conts = await containersRes.json();
setContainers(Array.isArray(conts) ? conts : []);
}
await refreshStacks(true);
if (action === 'update') fetchImageUpdates();
if (action === 'deploy' && isPro) {
try {
@@ -1072,7 +1138,8 @@ export default function EditorLayout() {
const msg = (error as Error).message || `Failed to ${action} stack`;
toast.error(action === 'deploy' && isPro ? `${msg} - automatically rolled back to previous version.` : msg);
} finally {
setLoadingAction(null);
clearStackAction(stackFile);
refreshStacks(true);
}
};
@@ -1369,11 +1436,17 @@ export default function EditorLayout() {
>
<div className="flex items-center gap-2 w-full">
<span
className={`font-mono text-[10px] shrink-0 w-[18px] ${stackStatuses[file] === 'running' ? 'text-success' :
className={`font-mono text-[10px] shrink-0 w-[18px] flex items-center ${
isStackBusy(file) ? 'text-muted-foreground' :
stackStatuses[file] === 'running' ? 'text-success' :
stackStatuses[file] === 'exited' ? 'text-destructive' : 'text-stat-icon'
}`}
}`}
>
{stackStatuses[file] === 'running' ? 'UP' : stackStatuses[file] === 'exited' ? 'DN' : '--'}
{isStackBusy(file)
? <Loader2 className="w-3 h-3 animate-spin" strokeWidth={2} />
: stackStatuses[file] === 'running' ? 'UP'
: stackStatuses[file] === 'exited' ? 'DN'
: '--'}
</span>
<span className="flex-1 truncate font-mono text-[13px]">{getDisplayName(file)}</span>
{isPro && stackLabelMap[file]?.length > 0 && (
@@ -1421,19 +1494,19 @@ export default function EditorLayout() {
Check for updates
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => executeStackActionByFile(file, 'deploy', 'deploy')}>
<DropdownMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'deploy', 'deploy')}>
<Play className="h-4 w-4 mr-2" />
Deploy
</DropdownMenuItem>
<DropdownMenuItem onClick={() => executeStackActionByFile(file, 'stop', 'stop')}>
<DropdownMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'stop', 'stop')}>
<Square className="h-4 w-4 mr-2" />
Stop
</DropdownMenuItem>
<DropdownMenuItem onClick={() => executeStackActionByFile(file, 'restart', 'restart')}>
<DropdownMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'restart', 'restart')}>
<RotateCw className="h-4 w-4 mr-2" />
Restart
</DropdownMenuItem>
<DropdownMenuItem onClick={() => executeStackActionByFile(file, 'update', 'update')}>
<DropdownMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'update', 'update')}>
<Download className="h-4 w-4 mr-2" />
Update
</DropdownMenuItem>
@@ -1510,19 +1583,19 @@ export default function EditorLayout() {
Check for updates
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => executeStackActionByFile(file, 'deploy', 'deploy')}>
<ContextMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'deploy', 'deploy')}>
<Play className="h-4 w-4 mr-2" />
Deploy
</ContextMenuItem>
<ContextMenuItem onClick={() => executeStackActionByFile(file, 'stop', 'stop')}>
<ContextMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'stop', 'stop')}>
<Square className="h-4 w-4 mr-2" />
Stop
</ContextMenuItem>
<ContextMenuItem onClick={() => executeStackActionByFile(file, 'restart', 'restart')}>
<ContextMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'restart', 'restart')}>
<RotateCw className="h-4 w-4 mr-2" />
Restart
</ContextMenuItem>
<ContextMenuItem onClick={() => executeStackActionByFile(file, 'update', 'update')}>
<ContextMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'update', 'update')}>
<Download className="h-4 w-4 mr-2" />
Update
</ContextMenuItem>
@@ -1756,7 +1829,7 @@ export default function EditorLayout() {
) : (
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={deployStack} disabled={loadingAction !== null}>
<Play className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'deploy' || loadingAction === 'start' ? 'Starting...' : 'Start'}
{loadingAction === 'deploy' ? 'Starting...' : 'Start'}
</Button>
)}
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={updateStack} disabled={loadingAction !== null}>