diff --git a/backend/src/index.ts b/backend/src/index.ts index 57a6cd26..a3051700 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -478,13 +478,14 @@ app.post('/api/containers/:id/restart', async (req: Request, res: Response) => { }); // End of legacy container routes -app.post('/api/stacks/:stackName/up', async (req: Request, res: Response) => { +app.post('/api/stacks/:stackName/deploy', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; - composeService.runCommand(stackName, 'up', terminalWs || undefined); - res.json({ status: 'Command started' }); - } catch (error) { - res.status(500).json({ error: 'Failed to start command' }); + await composeService.deployStack(stackName, terminalWs || undefined); + res.json({ message: 'Deployed successfully' }); + } catch (error: any) { + console.error('Failed to deploy stack:', error); + res.status(500).json({ error: error.message || 'Failed to deploy stack' }); } }); @@ -498,60 +499,33 @@ app.post('/api/stacks/:stackName/down', async (req: Request, res: Response) => { } }); -// Direct container restart - bypasses docker compose for legacy container support app.post('/api/stacks/:stackName/restart', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; - const dockerController = DockerController.getInstance(); - const containers = await dockerController.getContainersByStack(stackName); - if (containers && containers.length > 0) { - for (const c of containers) { - await dockerController.restartContainer(c.Id); - } - } - res.json({ status: 'Containers restarted' }); + composeService.runCommand(stackName, 'restart', terminalWs || undefined); + res.json({ status: 'Command started' }); } catch (error) { console.error('Failed to restart containers:', error); res.status(500).json({ error: 'Failed to restart containers' }); } }); -// Direct container stop - bypasses docker compose for legacy container support -// Only stops containers that are currently running to avoid 304 errors app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; - const dockerController = DockerController.getInstance(); - const containers = await dockerController.getContainersByStack(stackName); - if (containers && containers.length > 0) { - for (const c of containers) { - if (c.State === 'running') { - await dockerController.stopContainer(c.Id); - } - } - } - res.json({ status: 'Containers stopped' }); + composeService.runCommand(stackName, 'stop', terminalWs || undefined); + res.json({ status: 'Command started' }); } catch (error) { console.error('Failed to stop containers:', error); res.status(500).json({ error: 'Failed to stop containers' }); } }); -// Direct container start - bypasses docker compose for legacy container support -// Only starts containers that are not currently running app.post('/api/stacks/:stackName/start', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; - const dockerController = DockerController.getInstance(); - const containers = await dockerController.getContainersByStack(stackName); - if (containers && containers.length > 0) { - for (const c of containers) { - if (c.State !== 'running') { - await dockerController.startContainer(c.Id); - } - } - } - res.json({ status: 'Containers started' }); + composeService.runCommand(stackName, 'start', terminalWs || undefined); + res.json({ status: 'Command started' }); } catch (error) { console.error('Failed to start containers:', error); res.status(500).json({ error: 'Failed to start containers' }); diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index 3a22bf4e..a42d5de0 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -16,14 +16,12 @@ export class ComposeService { * CRITICAL: cwd is set to the stack directory so relative paths in compose files * resolve correctly inside the isolated stack folder */ - runCommand(stackName: string, action: 'up' | 'down', ws?: WebSocket) { + runCommand(stackName: string, action: 'down' | 'start' | 'stop' | 'restart', ws?: WebSocket) { const stackDir = path.join(this.baseDir, stackName); // Run docker compose from within the stack directory // This ensures relative paths (e.g., ./data:/config) resolve correctly - const args = action === 'up' - ? ['compose', 'up', '-d'] - : ['compose', 'down']; + const args = ['compose', '-p', stackName, action]; const child = spawn('docker', args, { cwd: stackDir, // CRITICAL: Set working directory to stack folder @@ -53,6 +51,54 @@ export class ComposeService { } } + /** + * Deploy stack: executes up -d --remove-orphans and awaits completion. + */ + async deployStack(stackName: string, ws?: WebSocket): Promise { + const stackDir = path.join(this.baseDir, stackName); + + return new Promise((resolve, reject) => { + const args = ['compose', '-p', stackName, 'up', '-d', '--remove-orphans']; + const child = spawn('docker', args, { + cwd: stackDir, + env: { + ...process.env, + PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' + } + }); + + let errorLog = ''; + + if (ws) { + child.stdout.on('data', (data: Buffer) => ws.send(data.toString())); + child.stderr.on('data', (data: Buffer) => { + const text = data.toString(); + errorLog += text; + ws.send(text); + }); + child.on('close', (code: number | null) => { + ws.send(`Command exited with code ${code}\n`); + if (code === 0) resolve(); + else reject(new Error(errorLog.trim() || `Command failed with code ${code}`)); + }); + } else { + child.stderr.on('data', (data: Buffer) => { + errorLog += data.toString(); + }); + child.on('close', (code: number | null) => { + if (code === 0) resolve(); + else reject(new Error(errorLog.trim() || `Command failed with code ${code}`)); + }); + } + + child.on('error', (error: Error) => { + console.error(`Docker Compose Deploy Error for ${stackName}:`, error.message); + if (ws) ws.send(`Error: ${error.message}\n`); + reject(error); + }); + }); + } + /** * Stream docker logs for a stack via WebSocket. * Supervisors: Fetches containers via DockerController and spawns `docker logs -f` for each. @@ -199,7 +245,7 @@ export class ComposeService { // Step 1: Pull images sendOutput('=== Pulling latest images ===\n'); await new Promise((resolve, reject) => { - const pullProcess = spawn('docker', ['compose', 'pull'], { + const pullProcess = spawn('docker', ['compose', '-p', stackName, 'pull'], { cwd: stackDir, env: { ...process.env, @@ -235,7 +281,7 @@ export class ComposeService { // Step 2: Recreate containers with new images sendOutput('=== Recreating containers ===\n'); await new Promise((resolve, reject) => { - const upProcess = spawn('docker', ['compose', 'up', '-d'], { + const upProcess = spawn('docker', ['compose', '-p', stackName, 'up', '-d', '--remove-orphans'], { cwd: stackDir, env: { ...process.env, diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 83106c03..1a185121 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -13,7 +13,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, import { Tabs, TabsList, TabsTrigger } from './ui/tabs'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { Badge } from './ui/badge'; -import { Plus, Trash2, Play, Square, Save, Terminal, Sun, Moon, RotateCw, CloudDownload, Pencil, X, Home, LogOut, Brush, ExternalLink, Bell, Settings, MoreVertical, BellRing } from 'lucide-react'; +import { Plus, Trash2, Play, Square, Save, Terminal, Sun, Moon, RotateCw, CloudDownload, Pencil, X, Home, LogOut, Brush, ExternalLink, Bell, Settings, MoreVertical, BellRing, Rocket } from 'lucide-react'; import { useAuth } from '@/context/AuthContext'; import { apiFetch } from '@/lib/api'; import { toast } from 'sonner'; @@ -346,16 +346,18 @@ export default function EditorLayout() { const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); setIsActionLoading(true); try { - await apiFetch(`/stacks/${stackName}/up`, { + await apiFetch(`/stacks/${stackName}/deploy`, { method: 'POST', }); + 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); - } catch (error) { + } catch (error: any) { console.error('Failed to deploy:', error); + toast.error(error.message || "Failed to deploy stack"); } finally { setIsActionLoading(false); } @@ -831,12 +833,10 @@ export default function EditorLayout() { {stackName} {/* Action Bar */}
- {!isDeployed && ( - - )} + {isDeployed && ( <> {isRunning ? ( @@ -845,7 +845,7 @@ export default function EditorLayout() { {isActionLoading ? 'Working...' : 'Stop'} ) : ( -