From dfd4d2858a023ed013afbe93c077a3152a0773c5 Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 3 Apr 2026 17:19:10 -0400 Subject: [PATCH] feat(stacks): per-stack action tracking, optimistic status, and bulk status endpoint (#362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- CHANGELOG.md | 14 ++ backend/src/index.ts | 19 ++ backend/src/services/DockerController.ts | 23 +++ frontend/src/components/EditorLayout.tsx | 217 +++++++++++++++-------- 4 files changed, 201 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60690c18..821c8728 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/backend/src/index.ts b/backend/src/index.ts index bdbe3b6d..a335c198 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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 = {}; + 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; diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 7ec42bb0..ac8eed2a 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -364,6 +364,29 @@ class DockerController { return this.validateApiData(containers); } + public async getBulkStackStatuses(stackNames: string[]): Promise> { + const allContainers = await this.docker.listContainers({ all: true }); + const knownSet = new Set(stackNames); + + const statuses: Record = {}; + 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); diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index a6425320..16a8ba09 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -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(null); const [isLoading, setIsLoading] = useState(false); - const [loadingAction, setLoadingAction] = useState(null); + const [stackActions, setStackActions] = useState>({}); + const stackActionsRef = useRef>({}); + 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 | 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() { >
- {stackStatuses[file] === 'running' ? 'UP' : stackStatuses[file] === 'exited' ? 'DN' : '--'} + {isStackBusy(file) + ? + : stackStatuses[file] === 'running' ? 'UP' + : stackStatuses[file] === 'exited' ? 'DN' + : '--'} {getDisplayName(file)} {isPro && stackLabelMap[file]?.length > 0 && ( @@ -1421,19 +1494,19 @@ export default function EditorLayout() { Check for updates - executeStackActionByFile(file, 'deploy', 'deploy')}> + executeStackActionByFile(file, 'deploy', 'deploy')}> Deploy - executeStackActionByFile(file, 'stop', 'stop')}> + executeStackActionByFile(file, 'stop', 'stop')}> Stop - executeStackActionByFile(file, 'restart', 'restart')}> + executeStackActionByFile(file, 'restart', 'restart')}> Restart - executeStackActionByFile(file, 'update', 'update')}> + executeStackActionByFile(file, 'update', 'update')}> Update @@ -1510,19 +1583,19 @@ export default function EditorLayout() { Check for updates - executeStackActionByFile(file, 'deploy', 'deploy')}> + executeStackActionByFile(file, 'deploy', 'deploy')}> Deploy - executeStackActionByFile(file, 'stop', 'stop')}> + executeStackActionByFile(file, 'stop', 'stop')}> Stop - executeStackActionByFile(file, 'restart', 'restart')}> + executeStackActionByFile(file, 'restart', 'restart')}> Restart - executeStackActionByFile(file, 'update', 'update')}> + executeStackActionByFile(file, 'update', 'update')}> Update @@ -1756,7 +1829,7 @@ export default function EditorLayout() { ) : ( )}