diff --git a/backend/src/index.ts b/backend/src/index.ts index d3b21b0b..8592e500 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -55,12 +55,12 @@ declare module 'express' { // Authentication Middleware const authMiddleware = async (req: Request, res: Response, next: NextFunction): Promise => { const token = req.cookies[COOKIE_NAME]; - + if (!token) { res.status(401).json({ error: 'Authentication required' }); return; } - + try { const jwtSecret = await configService.getJwtSecret(); const decoded = jwt.verify(token, jwtSecret) as { username: string }; @@ -96,23 +96,23 @@ app.post('/api/auth/setup', async (req: Request, res: Response): Promise = } const { username, password, confirmPassword } = req.body; - + // Validation if (!username || !password || !confirmPassword) { res.status(400).json({ error: 'All fields are required' }); return; } - + if (username.length < 3) { res.status(400).json({ error: 'Username must be at least 3 characters' }); return; } - + if (password.length < 6) { res.status(400).json({ error: 'Password must be at least 6 characters' }); return; } - + if (password !== confirmPassword) { res.status(400).json({ error: 'Passwords do not match' }); return; @@ -120,7 +120,7 @@ app.post('/api/auth/setup', async (req: Request, res: Response): Promise = // Save credentials (this also generates the JWT secret) await configService.saveConfig(username, password); - + // Issue JWT and log user in const jwtSecret = await configService.getJwtSecret(); const token = jwt.sign({ username }, jwtSecret, { expiresIn: '24h' }); @@ -135,15 +135,15 @@ app.post('/api/auth/setup', async (req: Request, res: Response): Promise = // Login endpoint app.post('/api/auth/login', async (req: Request, res: Response): Promise => { const { username, password } = req.body; - + if (!username || !password) { res.status(400).json({ error: 'Username and password are required' }); return; } - + try { const isValid = await configService.validateCredentials(username, password); - + if (isValid) { const jwtSecret = await configService.getJwtSecret(); const token = jwt.sign({ username }, jwtSecret, { expiresIn: '24h' }); @@ -151,7 +151,7 @@ app.post('/api/auth/login', async (req: Request, res: Response): Promise = res.json({ success: true, message: 'Login successful' }); return; } - + res.status(401).json({ error: 'Invalid credentials' }); } catch (error) { console.error('Login error:', error); @@ -197,22 +197,36 @@ server.on('upgrade', async (req, socket, head) => { const cookies = Object.fromEntries( cookieHeader.split(';').map(c => c.trim().split('=')).filter(([k, v]) => k && v) ); - + const token = cookies[COOKIE_NAME]; - + if (!token) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return; } - + try { const jwtSecret = await configService.getJwtSecret(); jwt.verify(token, jwtSecret); - // Authentication successful, proceed with WebSocket connection - wss.handleUpgrade(req, socket, head, (ws) => { - wss.emit('connection', ws, req); - }); + + // Check if this is a stack logs WebSocket request + const url = req.url || ''; + const logsMatch = url.match(/^\/api\/stacks\/([^/]+)\/logs$/); + + if (logsMatch) { + // Dedicated stack logs WebSocket + const logsWss = new WebSocket.Server({ noServer: true }); + logsWss.handleUpgrade(req, socket, head, (ws) => { + const stackName = decodeURIComponent(logsMatch[1]); + composeService.streamLogs(stackName, ws); + }); + } else { + // Generic terminal WebSocket + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit('connection', ws, req); + }); + } } catch (error) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); @@ -455,11 +469,11 @@ app.get('/api/stats', async (req: Request, res: Response) => { const dockerController = DockerController.getInstance(); const containers = await dockerController.getRunningContainers(); const allContainers = await dockerController.getAllContainers(); - + const active = containers.length; const exited = allContainers.filter((c: { State: string }) => c.State === 'exited').length; const total = allContainers.length; - + res.json({ active, exited, total, inactive: total - active - exited }); } catch (error) { res.status(500).json({ error: 'Failed to fetch stats' }); @@ -474,10 +488,10 @@ app.get('/api/system/stats', async (req: Request, res: Response) => { si.mem(), si.fsSize(), ]); - + // Find the main mount (usually the largest or root mount) const mainDisk = fsSize.find(fs => fs.mount === '/' || fs.mount === 'C:') || fsSize[0]; - + res.json({ cpu: { usage: currentLoad.currentLoad.toFixed(1), @@ -507,7 +521,7 @@ app.get('/api/system/stats', async (req: Request, res: Response) => { // Serve static files in production (for Docker deployment) if (process.env.NODE_ENV === 'production') { app.use(express.static('public')); - + // Handle SPA routing - serve index.html for non-API routes // Using app.use middleware instead of app.get('*') for path-to-regexp compatibility app.use((req: Request, res: Response) => { @@ -528,7 +542,7 @@ async function startServer() { console.error('Migration failed:', error); // Continue starting server even if migration fails } - + server.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index 89f98dcc..bf439c11 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -16,18 +16,18 @@ export class ComposeService { */ runCommand(stackName: string, action: 'up' | 'down', 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'] + const args = action === 'up' + ? ['compose', 'up', '-d'] : ['compose', 'down']; - const child = spawn('docker', args, { + const child = spawn('docker', args, { cwd: stackDir, // CRITICAL: Set working directory to stack folder - 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' } }); @@ -51,6 +51,57 @@ export class ComposeService { } } + /** + * Stream docker compose logs for a stack via WebSocket. + * Spawns `docker compose logs -f --tail 100` and pipes stdout/stderr to ws.send(). + * Kills the child process when the WebSocket closes. + */ + streamLogs(stackName: string, ws: WebSocket) { + const stackDir = path.join(this.baseDir, stackName); + + const child = spawn('docker', ['compose', 'logs', '-f', '--tail', '100'], { + cwd: stackDir, + env: { + ...process.env, + PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' + } + }); + + child.stdout.on('data', (data: Buffer) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(data.toString()); + } + }); + + child.stderr.on('data', (data: Buffer) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(data.toString()); + } + }); + + child.on('error', (error: Error) => { + console.error(`Docker Compose Logs Error for ${stackName}:`, error.message); + if (ws.readyState === WebSocket.OPEN) { + ws.send(`Error: ${error.message}\n`); + } + }); + + child.on('close', (code: number | null) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(`\r\nLog stream ended (code ${code})\r\n`); + } + }); + + // Kill the logs process when the WebSocket is closed + ws.on('close', () => { + try { + child.kill(); + } catch { + // Ignore kill errors + } + }); + } + /** * Update stack: pull images first, then recreate containers * CRITICAL: cwd is set to the stack directory so relative paths resolve correctly @@ -67,11 +118,11 @@ 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', 'pull'], { 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' } }); @@ -103,11 +154,11 @@ 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', 'up', '-d'], { 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' } }); diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 44d384bf..0d84d80b 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -34,7 +34,7 @@ export default function EditorLayout() { const [originalEnvContent, setOriginalEnvContent] = useState(''); const [envExists, setEnvExists] = useState(false); const [containers, setContainers] = useState([]); - const [containerStats, setContainerStats] = useState>({}); + const [containerStats, setContainerStats] = useState>({}); const [activeTab, setActiveTab] = useState<'compose' | 'env'>('compose'); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -47,7 +47,7 @@ export default function EditorLayout() { const [isEditing, setIsEditing] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [stackStatuses, setStackStatuses] = useState({}); - + // Bash exec modal state const [bashModalOpen, setBashModalOpen] = useState(false); const [selectedContainer, setSelectedContainer] = useState<{ id: string; name: string } | null>(null); @@ -68,7 +68,7 @@ export default function EditorLayout() { const res = await apiFetch('/stacks'); const stacks = await res.json(); setFiles(Array.isArray(stacks) ? stacks : []); - + // Fetch status for each stack const statuses: StackStatus = {}; for (const file of stacks) { @@ -99,19 +99,21 @@ export default function EditorLayout() { (containers || []).forEach(container => { if (!container?.Id) return; try { - const ws = new WebSocket('ws://localhost:3000'); + const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const ws = new WebSocket(`${wsProtocol}//${window.location.host}`); wsMap[container.Id] = ws; ws.onopen = () => ws.send(JSON.stringify({ action: 'streamStats', containerId: container.Id })); ws.onmessage = (event) => { try { const data = JSON.parse(event.data); - if (data.cpu_stats && data.precpu_stats && data.memory_stats) { - const cpuDelta = data.cpu_stats.cpu_usage.total_usage - data.precpu_stats.cpu_usage.total_usage; - const systemDelta = data.cpu_stats.system_cpu_usage - data.precpu_stats.system_cpu_usage; - const cpuPercent = systemDelta > 0 ? ((cpuDelta / systemDelta) * data.cpu_stats.online_cpus * 100).toFixed(2) : '0.00'; - const ramUsage = (data.memory_stats.usage / (1024 * 1024)).toFixed(2) + ' MB'; - setContainerStats(prev => ({ ...prev, [container.Id]: { cpu: cpuPercent + '%', ram: ramUsage } })); - } + // Skip initial empty chunks where stats fields are missing + if (!data.cpu_stats?.cpu_usage || !data.precpu_stats?.cpu_usage || !data.memory_stats?.usage) return; + const cpuDelta = data.cpu_stats.cpu_usage.total_usage - data.precpu_stats.cpu_usage.total_usage; + const systemDelta = (data.cpu_stats.system_cpu_usage || 0) - (data.precpu_stats.system_cpu_usage || 0); + const onlineCpus = data.cpu_stats.online_cpus || 1; + const cpuPercent = systemDelta > 0 ? ((cpuDelta / systemDelta) * onlineCpus * 100).toFixed(2) : '0.00'; + const ramUsage = (data.memory_stats.usage / (1024 * 1024)).toFixed(2) + ' MB'; + setContainerStats(prev => ({ ...prev, [container.Id]: { cpu: cpuPercent + '%', ram: ramUsage } })); } catch { // Ignore parse errors } @@ -222,14 +224,16 @@ export default function EditorLayout() { setIsEditing(true); }; - const deployStack = async () => { + const deployStack = async (e: React.MouseEvent) => { + e.preventDefault(); if (!selectedFile) return; + const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); try { - await apiFetch(`/stacks/${selectedFile}/up`, { + await apiFetch(`/stacks/${stackName}/up`, { method: 'POST', }); // Refresh containers after deploy - const containersRes = await apiFetch(`/stacks/${selectedFile}/containers`); + const containersRes = await apiFetch(`/stacks/${stackName}/containers`); const conts = await containersRes.json(); setContainers(Array.isArray(conts) ? conts : []); refreshStacks(); @@ -238,14 +242,16 @@ export default function EditorLayout() { } }; - const stopStack = async () => { + const stopStack = async (e: React.MouseEvent) => { + e.preventDefault(); if (!selectedFile) return; + const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); try { - await apiFetch(`/stacks/${selectedFile}/down`, { + await apiFetch(`/stacks/${stackName}/down`, { method: 'POST', }); // Refresh containers after stop - const containersRes = await apiFetch(`/stacks/${selectedFile}/containers`); + const containersRes = await apiFetch(`/stacks/${stackName}/containers`); const conts = await containersRes.json(); setContainers(Array.isArray(conts) ? conts : []); refreshStacks(); @@ -254,17 +260,19 @@ export default function EditorLayout() { } }; - const restartStack = async () => { + const restartStack = async (e: React.MouseEvent) => { + e.preventDefault(); if (!selectedFile) return; + const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); try { - await apiFetch(`/stacks/${selectedFile}/down`, { + await apiFetch(`/stacks/${stackName}/down`, { method: 'POST', }); - await apiFetch(`/stacks/${selectedFile}/up`, { + await apiFetch(`/stacks/${stackName}/up`, { method: 'POST', }); // Refresh containers after restart - const containersRes = await apiFetch(`/stacks/${selectedFile}/containers`); + const containersRes = await apiFetch(`/stacks/${stackName}/containers`); const conts = await containersRes.json(); setContainers(Array.isArray(conts) ? conts : []); refreshStacks(); @@ -273,14 +281,16 @@ export default function EditorLayout() { } }; - const updateStack = async () => { + const updateStack = async (e: React.MouseEvent) => { + e.preventDefault(); if (!selectedFile) return; + const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); try { - await apiFetch(`/stacks/${selectedFile}/update`, { + await apiFetch(`/stacks/${stackName}/update`, { method: 'POST', }); // Refresh containers after update - const containersRes = await apiFetch(`/stacks/${selectedFile}/containers`); + const containersRes = await apiFetch(`/stacks/${stackName}/containers`); const conts = await containersRes.json(); setContainers(Array.isArray(conts) ? conts : []); refreshStacks(); @@ -388,7 +398,7 @@ export default function EditorLayout() { // Safe content strings with fallback const safeContent = content || ''; const safeEnvContent = envContent || ''; - + // Stack state booleans for dynamic button rendering const isDeployed = safeContainers && safeContainers.length > 0; const isRunning = safeContainers?.some(c => c.State === 'running'); @@ -478,11 +488,10 @@ export default function EditorLayout() { onClick={() => loadFile(file)} > - {getDisplayName(file)} @@ -514,7 +523,7 @@ export default function EditorLayout() { title="Go to Home Dashboard" > - Home + Home {/* Console Toggle */} )} {isDeployed && ( <> - - - )} - - -