diff --git a/backend/src/index.ts b/backend/src/index.ts index 38d4f11b..d6b90882 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -497,6 +497,7 @@ app.post('/api/stacks/:stackName/restart', async (req: Request, res: Response) = }); // 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; @@ -504,7 +505,9 @@ app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => { const containers = await dockerController.getContainersByStack(stackName); if (containers && containers.length > 0) { for (const c of containers) { - await dockerController.stopContainer(c.Id); + if (c.State === 'running') { + await dockerController.stopContainer(c.Id); + } } } res.json({ status: 'Containers stopped' }); @@ -514,6 +517,27 @@ app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => { } }); +// 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' }); + } catch (error) { + console.error('Failed to start containers:', error); + res.status(500).json({ error: 'Failed to start containers' }); + } +}); + // Update stack: pull images and recreate containers app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) => { try { diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index bf439c11..165569a6 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -162,12 +162,16 @@ export class ComposeService { } }); + let errorLog = ''; + upProcess.stdout.on('data', (data: Buffer) => { sendOutput(data.toString()); }); upProcess.stderr.on('data', (data: Buffer) => { - sendOutput(data.toString()); + const text = data.toString(); + errorLog += text; + sendOutput(text); }); upProcess.on('close', (code: number | null) => { @@ -176,7 +180,7 @@ export class ComposeService { resolve(); } else { sendOutput(`=== Update failed with code ${code} ===\n`); - reject(new Error(`Up failed with code ${code}`)); + reject(new Error(errorLog.trim() || `Up failed with code ${code}`)); } }); diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 9fc5119a..9a5685b2 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -39,16 +39,16 @@ class DockerController { public async getContainersByStack(stackName: string) { const stackDir = path.join(COMPOSE_DIR, stackName); - + try { - const { stdout, stderr } = await execAsync('docker compose ps --format json -a', { + const { stdout, stderr } = await execAsync('docker compose ps --format json -a', { cwd: stackDir, - env: { - ...process.env, - PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' + env: { + ...process.env, + PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' } }); - + // Robust JSON parsing - handle both JSON array and newline-separated JSON objects // Docker Compose v2 may return either format depending on version interface ComposeContainer { @@ -57,9 +57,9 @@ class DockerController { State?: string; Status?: string; } - + let containers: ComposeContainer[] = []; - + // Only parse if stdout has content if (stdout && stdout.trim() !== '') { try { @@ -78,7 +78,7 @@ class DockerController { } } } - + // If containers found via docker compose ps, return them if (containers.length > 0) { // Map to frontend's expected interface @@ -91,16 +91,16 @@ class DockerController { Status: c.Status || '' })); } - + // SMART FALLBACK: Trigger when docker compose ps returns empty // This handles legacy containers with incorrect project labels return await this.smartFallback(stackName, stackDir); - + } catch (error) { // If command fails (e.g., stack not deployed, invalid YAML, missing env_file) const execError = error as { stderr?: string; message?: string }; console.error(`Docker Compose Error for ${stackName}:`, execError.stderr || execError.message); - + // Try smart fallback even on error return await this.smartFallback(stackName, stackDir); } @@ -117,7 +117,7 @@ class DockerController { // Try multiple valid compose file names const composeFileNames = ['compose.yaml', 'docker-compose.yml', 'compose.yml', 'docker-compose.yaml']; let yamlContent: string | null = null; - + for (const fileName of composeFileNames) { try { yamlContent = await fs.readFile(path.join(stackDir, fileName), 'utf-8'); @@ -127,14 +127,14 @@ class DockerController { continue; } } - + if (!yamlContent) { // No compose file found return []; } - + const parsedYaml = yaml.parse(yamlContent); - + if (!parsedYaml || !parsedYaml.services) return []; // 2. Extract expected container names with legacy prefix support @@ -155,7 +155,7 @@ class DockerController { // 3. Query the raw Docker daemon const allContainers = await this.docker.listContainers({ all: true }); - + // 4. Match containers by name const fallbackContainers = allContainers.filter(container => { // container.Names usually looks like ['/plex'] @@ -213,7 +213,7 @@ class DockerController { public async execContainer(containerId: string, ws: WebSocket) { try { const container = this.docker.getContainer(containerId); - + // Try bash first, fall back to sh let exec: Docker.Exec; try { @@ -240,25 +240,24 @@ class DockerController { this.execStream = stream; - // Handle output from container + // Handle output from container - send raw text directly stream.on('data', (chunk: Buffer) => { - ws.send(JSON.stringify({ type: 'output', data: chunk.toString() })); + if (ws.readyState === WebSocket.OPEN) { + ws.send(chunk.toString()); + } }); stream.on('error', (err: Error) => { - ws.send(JSON.stringify({ type: 'error', message: err.message })); + console.error('Exec stream error:', err.message); }); stream.on('end', () => { - ws.send(JSON.stringify({ type: 'exit' })); this.execStream = null; this.currentExec = null; }); - - ws.send(JSON.stringify({ type: 'connected' })); } catch (error) { const err = error as Error; - ws.send(JSON.stringify({ type: 'error', message: err.message })); + console.error('Failed to exec container:', err.message); } } diff --git a/frontend/src/components/BashExecModal.tsx b/frontend/src/components/BashExecModal.tsx index 29d629ae..16d56e85 100644 --- a/frontend/src/components/BashExecModal.tsx +++ b/frontend/src/components/BashExecModal.tsx @@ -39,7 +39,7 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN const fitAddon = new FitAddon(); term.loadAddon(fitAddon); term.open(terminalRef.current); - + setTimeout(() => { fitAddon.fit(); }, 100); @@ -48,7 +48,8 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN fitAddonRef.current = fitAddon; // Connect to WebSocket for bash exec - const ws = new WebSocket('ws://localhost:3000'); + const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const ws = new WebSocket(`${wsProtocol}//${window.location.host}`); wsRef.current = ws; ws.onopen = () => { @@ -60,20 +61,8 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN }; ws.onmessage = (event) => { - try { - const data = JSON.parse(event.data); - if (data.type === 'output') { - term.write(data.data); - } else if (data.type === 'error') { - term.write(`\r\n\x1b[31mError: ${data.message}\x1b[0m\r\n`); - } else if (data.type === 'exit') { - term.write('\r\n\x1b[33mSession ended\x1b[0m\r\n'); - setIsConnected(false); - } - } catch { - // Raw output - term.write(event.data); - } + // Write raw text directly to terminal + term.write(event.data); }; ws.onerror = () => { @@ -82,6 +71,7 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN }; ws.onclose = () => { + term.write('\r\n\x1b[33mSession ended\x1b[0m\r\n'); setIsConnected(false); }; @@ -110,7 +100,7 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN }; window.addEventListener('resize', handleResize); - + return () => { window.removeEventListener('resize', handleResize); }; @@ -165,8 +155,8 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN -
diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 11cc5551..93c6f070 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -10,7 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogD 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, RefreshCw, Terminal, Sun, Moon, RotateCw, CloudDownload, Pencil, X, Search, Home, LogOut } from 'lucide-react'; +import { Plus, Trash2, Play, Square, Save, Terminal, Sun, Moon, RotateCw, CloudDownload, Pencil, X, Search, Home, LogOut } from 'lucide-react'; import { useAuth } from '@/context/AuthContext'; import { apiFetch } from '@/lib/api'; @@ -41,6 +41,7 @@ export default function EditorLayout() { const [newStackName, setNewStackName] = useState(''); const [stackToDelete, setStackToDelete] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [isActionLoading, setIsActionLoading] = useState(false); const [isFileLoading, setIsFileLoading] = useState(false); const [isDarkMode, setIsDarkMode] = useState(true); const [showConsole, setShowConsole] = useState(true); @@ -62,8 +63,8 @@ export default function EditorLayout() { } }, [isDarkMode]); - const refreshStacks = async () => { - setIsLoading(true); + const refreshStacks = async (background = false) => { + if (!background) setIsLoading(true); try { const res = await apiFetch('/stacks'); const stacks = await res.json(); @@ -226,8 +227,10 @@ export default function EditorLayout() { const deployStack = async (e: React.MouseEvent) => { e.preventDefault(); - if (!selectedFile) return; + e.stopPropagation(); + if (!selectedFile || isActionLoading) return; const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); + setIsActionLoading(true); try { await apiFetch(`/stacks/${stackName}/up`, { method: 'POST', @@ -236,16 +239,20 @@ export default function EditorLayout() { const containersRes = await apiFetch(`/stacks/${stackName}/containers`); const conts = await containersRes.json(); setContainers(Array.isArray(conts) ? conts : []); - refreshStacks(); + await refreshStacks(true); } catch (error) { console.error('Failed to deploy:', error); + } finally { + setIsActionLoading(false); } }; const stopStack = async (e: React.MouseEvent) => { e.preventDefault(); - if (!selectedFile) return; + e.stopPropagation(); + if (!selectedFile || isActionLoading) return; const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); + setIsActionLoading(true); try { await apiFetch(`/stacks/${stackName}/stop`, { method: 'POST', @@ -254,16 +261,42 @@ export default function EditorLayout() { const containersRes = await apiFetch(`/stacks/${stackName}/containers`); const conts = await containersRes.json(); setContainers(Array.isArray(conts) ? conts : []); - refreshStacks(); + await refreshStacks(true); } catch (error) { console.error('Failed to stop:', error); + } finally { + setIsActionLoading(false); + } + }; + + const startStack = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!selectedFile || isActionLoading) return; + const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); + setIsActionLoading(true); + try { + await apiFetch(`/stacks/${stackName}/start`, { + method: 'POST', + }); + // Refresh containers after start + const containersRes = await apiFetch(`/stacks/${stackName}/containers`); + const conts = await containersRes.json(); + setContainers(Array.isArray(conts) ? conts : []); + await refreshStacks(true); + } catch (error) { + console.error('Failed to start:', error); + } finally { + setIsActionLoading(false); } }; const restartStack = async (e: React.MouseEvent) => { e.preventDefault(); - if (!selectedFile) return; + e.stopPropagation(); + if (!selectedFile || isActionLoading) return; const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); + setIsActionLoading(true); try { await apiFetch(`/stacks/${stackName}/restart`, { method: 'POST', @@ -272,16 +305,20 @@ export default function EditorLayout() { const containersRes = await apiFetch(`/stacks/${stackName}/containers`); const conts = await containersRes.json(); setContainers(Array.isArray(conts) ? conts : []); - refreshStacks(); + await refreshStacks(true); } catch (error) { console.error('Failed to restart:', error); + } finally { + setIsActionLoading(false); } }; const updateStack = async (e: React.MouseEvent) => { e.preventDefault(); - if (!selectedFile) return; + e.stopPropagation(); + if (!selectedFile || isActionLoading) return; const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); + setIsActionLoading(true); try { await apiFetch(`/stacks/${stackName}/update`, { method: 'POST', @@ -290,9 +327,11 @@ export default function EditorLayout() { const containersRes = await apiFetch(`/stacks/${stackName}/containers`); const conts = await containersRes.json(); setContainers(Array.isArray(conts) ? conts : []); - refreshStacks(); + await refreshStacks(true); } catch (error) { console.error('Failed to update:', error); + } finally { + setIsActionLoading(false); } }; @@ -322,45 +361,6 @@ export default function EditorLayout() { } }; - const startContainer = async (id: string) => { - if (!id || !selectedFile) return; - try { - await apiFetch(`/containers/${id}/start`, { method: 'POST' }); - const containersRes = await apiFetch(`/stacks/${selectedFile}/containers`); - const conts = await containersRes.json(); - setContainers(Array.isArray(conts) ? conts : []); - refreshStacks(); - } catch (error) { - console.error('Failed to start container:', error); - } - }; - - const stopContainer = async (id: string) => { - if (!id || !selectedFile) return; - try { - await apiFetch(`/containers/${id}/stop`, { method: 'POST' }); - const containersRes = await apiFetch(`/stacks/${selectedFile}/containers`); - const conts = await containersRes.json(); - setContainers(Array.isArray(conts) ? conts : []); - refreshStacks(); - } catch (error) { - console.error('Failed to stop container:', error); - } - }; - - const restartContainer = async (id: string) => { - if (!id || !selectedFile) return; - try { - await apiFetch(`/containers/${id}/restart`, { method: 'POST' }); - const containersRes = await apiFetch(`/stacks/${selectedFile}/containers`); - const conts = await containersRes.json(); - setContainers(Array.isArray(conts) ? conts : []); - refreshStacks(); - } catch (error) { - console.error('Failed to restart container:', error); - } - }; - const handleCreateStack = async () => { if (!newStackName.trim()) return; // Send stackName directly (no .yml extension - backend creates directory) @@ -569,25 +569,32 @@ export default function EditorLayout() { {/* Action Bar */}
{!isDeployed && ( - )} {isDeployed && ( <> - + ) : ( + + )} + - - )}
- - -