import express, { Request, Response, NextFunction } from 'express'; import cors from 'cors'; import cookieParser from 'cookie-parser'; import WebSocket from 'ws'; import jwt from 'jsonwebtoken'; import DockerController, { globalDockerNetwork } from './services/DockerController'; import { FileSystemService } from './services/FileSystemService'; import { ComposeService } from './services/ComposeService'; import bcrypt from 'bcrypt'; import crypto from 'crypto'; // @ts-ignore - composerize lacks proper type definitions import composerize from 'composerize'; import si from 'systeminformation'; import http from 'http'; import httpProxy from 'http-proxy'; import { createProxyMiddleware } from 'http-proxy-middleware'; import { spawn, exec } from 'child_process'; import { promisify } from 'util'; import path from 'path'; import { HostTerminalService } from './services/HostTerminalService'; import { DatabaseService } from './services/DatabaseService'; import { NotificationService } from './services/NotificationService'; import { MonitorService } from './services/MonitorService'; import { templateService } from './services/TemplateService'; import { ErrorParser } from './utils/ErrorParser'; import { NodeRegistry } from './services/NodeRegistry'; import YAML from 'yaml'; import fs, { promises as fsPromises } from 'fs'; const execAsync = promisify(exec); const app = express(); const PORT = 3000; // FileSystemService and ComposeService are instantiated per-request via .getInstance(nodeId) // Cookie settings const COOKIE_NAME = 'sencho_token'; // Helper to determine if request is secure (HTTPS or behind a proxy that terminates SSL) const isSecureRequest = (req: Request): boolean => { return req.secure || req.headers['x-forwarded-proto'] === 'https'; }; // Helper to get cookie options dynamically per-request const getCookieOptions = (req: Request) => ({ httpOnly: true, secure: isSecureRequest(req), sameSite: 'strict' as const, maxAge: 24 * 60 * 60 * 1000, // 24 hours }); // Middleware app.use(cors({ origin: true, credentials: true, })); app.use(express.json()); app.use(cookieParser()); // Node Context Middleware const nodeContextMiddleware = (req: Request, res: Response, next: NextFunction) => { const nodeIdHeader = req.headers['x-node-id'] as string; const nodeIdQuery = req.query.nodeId as string; if (nodeIdHeader) { req.nodeId = parseInt(nodeIdHeader, 10); } else if (nodeIdQuery) { req.nodeId = parseInt(nodeIdQuery, 10); } else { req.nodeId = NodeRegistry.getInstance().getDefaultNodeId(); } // Intercept requests to deleted nodes to prevent downstream errors if (req.path.startsWith('/api/') && !req.path.startsWith('/api/auth/')) { const node = DatabaseService.getInstance().getNode(req.nodeId); if (!node) { res.status(404).json({ error: `Node with id ${req.nodeId} not found or was deleted.` }); return; } } next(); }; app.use(nodeContextMiddleware); // Extend Express Request type for user and node declare global { namespace Express { interface Request { user?: { username: string }; nodeId: number; } } } // WebSocket proxy server for forwarding remote node WS connections const wsProxyServer = httpProxy.createProxyServer({ changeOrigin: true }); wsProxyServer.on('error', (err, _req, socket: any) => { console.error('[WS Proxy] Error:', err.message); try { socket?.destroy(); } catch {} }); // Authentication Middleware // Accepts both cookie auth (browser sessions) and Bearer token auth (Sencho-to-Sencho proxy) const authMiddleware = async (req: Request, res: Response, next: NextFunction): Promise => { const cookieToken = req.cookies[COOKIE_NAME]; const bearerToken = req.headers.authorization?.startsWith('Bearer ') ? req.headers.authorization.slice(7) : null; const token = cookieToken || bearerToken; if (!token) { res.status(401).json({ error: 'Authentication required' }); return; } try { const settings = DatabaseService.getInstance().getGlobalSettings(); const jwtSecret = settings.auth_jwt_secret; if (!jwtSecret) throw new Error('No JWT secret'); const decoded = jwt.verify(token, jwtSecret) as { username?: string; scope?: string }; // Accept both user sessions and node proxy tokens req.user = { username: decoded.username || 'node-proxy' }; next(); } catch (err) { console.error('[Auth] Token validation failed:', (err as Error).message); res.status(401).json({ error: 'Invalid or expired token' }); return; } }; // Auth Routes (no authentication required) // Check if setup is needed app.get('/api/auth/status', async (req: Request, res: Response): Promise => { try { const settings = DatabaseService.getInstance().getGlobalSettings(); const needsSetup = !settings.auth_username || !settings.auth_password_hash || !settings.auth_jwt_secret; res.json({ needsSetup }); } catch (error) { console.error('Error checking setup status:', error); res.json({ needsSetup: true }); } }); // Initial setup endpoint app.post('/api/auth/setup', async (req: Request, res: Response): Promise => { try { const dbSvc = DatabaseService.getInstance(); const settings = dbSvc.getGlobalSettings(); const needsSetup = !settings.auth_username || !settings.auth_password_hash || !settings.auth_jwt_secret; if (!needsSetup) { res.status(400).json({ error: 'Setup has already been completed' }); return; } 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; } // Save credentials (this also generates the JWT secret) const passwordHash = await bcrypt.hash(password, 10); const jwtSecret = crypto.randomBytes(64).toString('hex'); dbSvc.updateGlobalSetting('auth_username', username); dbSvc.updateGlobalSetting('auth_password_hash', passwordHash); dbSvc.updateGlobalSetting('auth_jwt_secret', jwtSecret); // Issue JWT and log user in const token = jwt.sign({ username }, jwtSecret, { expiresIn: '24h' }); res.cookie(COOKIE_NAME, token, getCookieOptions(req)); res.json({ success: true, message: 'Setup completed successfully' }); } catch (error) { console.error('Setup error:', error); res.status(500).json({ error: 'Failed to complete setup' }); } }); // 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 settings = DatabaseService.getInstance().getGlobalSettings(); const storedUsername = settings.auth_username; const storedHash = settings.auth_password_hash; if (storedUsername && storedHash && username === storedUsername) { const isValid = await bcrypt.compare(password, storedHash); if (isValid) { const jwtSecret = settings.auth_jwt_secret; if (!jwtSecret) throw new Error('JWT secret missing from DB'); const token = jwt.sign({ username }, jwtSecret, { expiresIn: '24h' }); res.cookie(COOKIE_NAME, token, getCookieOptions(req)); res.json({ success: true, message: 'Login successful' }); return; } } res.status(401).json({ error: 'Invalid credentials' }); } catch (error) { console.error('Login error:', error); res.status(500).json({ error: 'Login failed' }); } }); // Update password endpoint app.put('/api/auth/password', authMiddleware, async (req: Request, res: Response): Promise => { try { const { oldPassword, newPassword } = req.body; if (!oldPassword || !newPassword) { res.status(400).json({ error: 'Old password and new password are required' }); return; } if (newPassword.length < 6) { res.status(400).json({ error: 'New password must be at least 6 characters' }); return; } const dbSvc = DatabaseService.getInstance(); const settings = dbSvc.getGlobalSettings(); const storedHash = settings.auth_password_hash; if (!storedHash) { res.status(400).json({ error: 'Auth not configured properly' }); return; } const isValid = await bcrypt.compare(oldPassword, storedHash); if (!isValid) { res.status(401).json({ error: 'Invalid old password' }); return; } const newHash = await bcrypt.hash(newPassword, 10); dbSvc.updateGlobalSetting('auth_password_hash', newHash); res.json({ success: true, message: 'Password updated successfully' }); } catch (error) { console.error('Password update error:', error); res.status(500).json({ error: 'Failed to update password' }); } }); app.post('/api/auth/logout', (req: Request, res: Response): void => { res.clearCookie(COOKIE_NAME, { httpOnly: true, secure: isSecureRequest(req), sameSite: 'strict', }); res.json({ success: true, message: 'Logged out successfully' }); }); // Check authentication status app.get('/api/auth/check', authMiddleware, (req: Request, res: Response): void => { res.json({ authenticated: true, user: req.user }); }); // Generate a long-lived node proxy token for Sencho-to-Sencho authentication app.post('/api/auth/generate-node-token', authMiddleware, async (req: Request, res: Response): Promise => { try { const settings = DatabaseService.getInstance().getGlobalSettings(); const jwtSecret = settings.auth_jwt_secret; if (!jwtSecret) { res.status(500).json({ error: 'No JWT secret configured on this instance.' }); return; } // No expiry — this token is managed by the admin who pastes it into the main dashboard const token = jwt.sign({ scope: 'node_proxy' }, jwtSecret); res.json({ token }); } catch (error: any) { res.status(500).json({ error: error.message || 'Failed to generate node token' }); } }); // Apply authentication middleware to all /api/* routes except /api/auth/* app.use('/api', (req: Request, res: Response, next: NextFunction): void => { if (req.path.startsWith('/auth/')) { next(); return; } authMiddleware(req, res, next); }); // Remote Node HTTP Proxy // Intercepts all /api/ requests for remote Distributed API nodes and forwards them // to the target Sencho instance. Node management and auth routes always execute locally. app.use('/api/', (req: Request, res: Response, next: NextFunction): void => { if (req.path.startsWith('/auth/') || req.path.startsWith('/nodes')) { next(); return; } const node = NodeRegistry.getInstance().getNode(req.nodeId); if (!node || node.type !== 'remote') { next(); return; } if (!node.api_url || !node.api_token) { res.status(503).json({ error: `Remote node "${node.name}" has no API URL or token configured. Update it in Settings → Nodes.` }); return; } createProxyMiddleware({ target: node.api_url.replace(/\/$/, ''), changeOrigin: true, on: { proxyReq: (proxyReq) => { // Remote Sencho is always "local" to itself — strip node context proxyReq.removeHeader('x-node-id'); proxyReq.setHeader('Authorization', `Bearer ${node.api_token!}`); }, error: (_err, _req, proxyRes) => { if (!(proxyRes as Response).headersSent) { (proxyRes as Response).status(502).json({ error: `Remote node "${node.name}" is unreachable. Check the API URL and ensure Sencho is running on that host.` }); } }, }, })(req, res, next); }); // Create HTTP server for WebSocket upgrade handling const server = http.createServer(app); // WebSocket server with authentication const wss = new WebSocket.Server({ noServer: true }); let terminalWs: WebSocket | null = null; // Handle WebSocket upgrade with JWT authentication server.on('upgrade', async (req, socket, head) => { // Parse cookies from the upgrade request const cookieHeader = req.headers.cookie || ''; const cookies = Object.fromEntries( cookieHeader.split(';').map(c => c.trim().split('=')).filter(([k, v]) => k && v) ); // Accept either cookie auth (browser sessions) or Bearer token auth (node-to-node WS proxy) const cookieToken = cookies[COOKIE_NAME]; const authHeader = req.headers['authorization'] as string | undefined; const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null; const token = cookieToken || bearerToken; if (!token) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return; } try { const settings = DatabaseService.getInstance().getGlobalSettings(); const jwtSecret = settings.auth_jwt_secret; if (!jwtSecret) throw new Error('No JWT secret'); jwt.verify(token, jwtSecret); const url = req.url || ''; const parsedUrl = new URL(url, `http://${req.headers.host || 'localhost'}`); const pathname = parsedUrl.pathname; // Resolve node context from query param const nodeIdParam = parsedUrl.searchParams.get('nodeId'); const nodeId = nodeIdParam ? parseInt(nodeIdParam, 10) : NodeRegistry.getInstance().getDefaultNodeId(); const node = NodeRegistry.getInstance().getNode(nodeId); // Remote Node WebSocket Proxy — forward the entire WS connection to the remote Sencho instance if (node && node.type === 'remote' && node.api_url && node.api_token) { const wsTarget = node.api_url.replace(/\/$/, '').replace(/^https?/, (m) => m === 'https' ? 'wss' : 'ws'); req.headers['authorization'] = `Bearer ${node.api_token}`; delete req.headers['x-node-id']; wsProxyServer.ws(req, socket, head, { target: wsTarget }); return; } // Local node handling const logsMatch = pathname.match(/^\/api\/stacks\/([^/]+)\/logs$/); const hostConsoleMatch = pathname.match(/^\/api\/system\/host-console/); if (logsMatch) { // Dedicated stack logs WebSocket - uses Supervisor loop for persistent logs const logsWss = new WebSocket.Server({ noServer: true }); logsWss.handleUpgrade(req, socket, head, (ws) => { const stackName = decodeURIComponent(logsMatch[1]); try { ComposeService.getInstance(nodeId).streamLogs(stackName, ws); } catch (error) { console.error('Failed to stream logs:', error); if (ws.readyState === WebSocket.OPEN) { ws.send(`Error streaming logs: ${(error as Error).message}\n`); } } }); } else if (hostConsoleMatch) { const hostConsoleWss = new WebSocket.Server({ noServer: true }); hostConsoleWss.handleUpgrade(req, socket, head, (ws) => { let targetDirectory = ''; try { targetDirectory = FileSystemService.getInstance(nodeId).getBaseDir(); const stackParam = parsedUrl.searchParams.get('stack'); if (stackParam) { targetDirectory = path.join(targetDirectory, stackParam); } } catch (e) { targetDirectory = FileSystemService.getInstance(NodeRegistry.getInstance().getDefaultNodeId()).getBaseDir(); } try { HostTerminalService.spawnTerminal(ws, targetDirectory); } catch (error) { console.error('Failed to spawn host terminal:', error); if (ws.readyState === WebSocket.OPEN) { ws.send(`Error spawning terminal: ${(error as Error).message}\r\n`); ws.close(); } } }); } 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(); return; } }); wss.on('connection', (ws) => { console.log('WebSocket connected'); ws.on('message', (message) => { try { const data = JSON.parse(message.toString()); // Only handle 'action'-based messages at the global level. // 'type'-based messages (input, resize, ping) are handled by the // per-session listener registered inside execContainer's closure. if (!data.action) return; if (data.action === 'connectTerminal') { terminalWs = ws; } else if (data.action === 'streamStats') { const nodeId = data.nodeId ? parseInt(data.nodeId, 10) : NodeRegistry.getInstance().getDefaultNodeId(); const dockerController = DockerController.getInstance(nodeId); dockerController.streamStats(data.containerId, ws); } else if (data.action === 'execContainer') { // Handle container exec for bash access // Input, resize, and cleanup are handled inside execContainer's closure const nodeId = data.nodeId ? parseInt(data.nodeId, 10) : NodeRegistry.getInstance().getDefaultNodeId(); const dockerController = DockerController.getInstance(nodeId); dockerController.execContainer(data.containerId, ws); } } catch (error) { // Malformed JSON — ignore silently } }); }); // API Routes (all protected by authMiddleware) app.get('/api/containers', async (req: Request, res: Response) => { try { const dockerController = DockerController.getInstance(req.nodeId); const containers = await dockerController.getRunningContainers(); res.json(containers); } catch (error) { res.status(500).json({ error: 'Failed to fetch containers' }); } }); // Stack Routes - Updated to use stackName (directory name) instead of filename app.get('/api/stacks', async (req: Request, res: Response) => { try { const stacks = await FileSystemService.getInstance(req.nodeId).getStacks(); res.json(stacks); } catch (error) { res.status(500).json({ error: 'Failed to fetch stacks' }); } }); app.get('/api/stacks/:stackName', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; const content = await FileSystemService.getInstance(req.nodeId).getStackContent(stackName); res.send(content); } catch (error) { res.status(500).json({ error: 'Failed to read stack' }); } }); app.put('/api/stacks/:stackName', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; if (stackName.includes('..') || stackName.includes('/') || stackName.includes('\\')) { return res.status(400).json({ error: 'Invalid stack name' }); } const { content } = req.body; console.log('PUT /api/stacks/:stackName', { stackName, contentType: typeof content, contentLength: content?.length }); if (typeof content !== 'string') { console.error('Content is not a string:', content); return res.status(400).json({ error: 'Content must be a string' }); } await FileSystemService.getInstance(req.nodeId).saveStackContent(stackName, content); console.log('Stack saved successfully:', stackName); res.json({ message: 'Stack saved successfully' }); } catch (error) { console.error('Failed to save stack:', error); res.status(500).json({ error: 'Failed to save stack' }); } }); // Helper: resolve all env file paths dynamically from compose.yaml's env_file field async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promise { const fsService = FileSystemService.getInstance(nodeId); const stackDir = path.join(fsService.getBaseDir(), stackName); const defaultEnvPath = path.join(stackDir, '.env'); try { // Try to read and parse the compose file const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml']; let composeContent: string | null = null; for (const file of composeFiles) { try { composeContent = await fsService.readFile(path.join(stackDir, file), 'utf-8'); break; } catch { // Try next file } } if (!composeContent) return [defaultEnvPath]; const parsed = YAML.parse(composeContent); if (!parsed?.services) return [defaultEnvPath]; const envFiles = new Set(); // Iterate through all services and collect every env_file declaration for (const serviceName of Object.keys(parsed.services)) { const service = parsed.services[serviceName]; if (!service?.env_file) continue; if (typeof service.env_file === 'string') { const resolvedPath = path.isAbsolute(service.env_file) ? service.env_file : path.resolve(stackDir, service.env_file); envFiles.add(resolvedPath); } else if (Array.isArray(service.env_file)) { for (const entry of service.env_file) { const entryPath = typeof entry === 'string' ? entry : (entry?.path || ''); if (entryPath) { const resolvedPath = path.isAbsolute(entryPath) ? entryPath : path.resolve(stackDir, entryPath); envFiles.add(resolvedPath); } } } } if (envFiles.size === 0) { return [defaultEnvPath]; } return Array.from(envFiles); } catch (error) { console.warn(`Could not parse compose.yaml for env_file resolution in stack "${stackName}":`, error); } return [defaultEnvPath]; } app.get('/api/stacks/:stackName/envs', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName); res.json({ envFiles: envPaths }); } catch (error) { res.status(500).json({ error: 'Failed to resolve env files' }); } }); app.get('/api/stacks/:stackName/env', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; const requestedFile = req.query.file as string | undefined; const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName); let envPath = envPaths[0]; // Fallback to the first if (requestedFile) { // Validate that the requested file exists in the allowed resolved list if (envPaths.includes(requestedFile)) { envPath = requestedFile; } else { return res.status(400).json({ error: 'Requested env file not allowed' }); } } const fsService = FileSystemService.getInstance(req.nodeId); try { await fsService.access(envPath); } catch { return res.status(404).json({ error: 'Env file not found' }); } const content = await fsService.readFile(envPath, 'utf-8'); res.send(content); } catch (error) { console.error('Failed to read env file:', error); res.status(500).json({ error: 'Failed to read env file' }); } }); app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; if (stackName.includes('..') || stackName.includes('/') || stackName.includes('\\')) { return res.status(400).json({ error: 'Invalid stack name' }); } const { content } = req.body; if (typeof content !== 'string') { return res.status(400).json({ error: 'Content must be a string' }); } const requestedFile = req.query.file as string | undefined; const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName); let envPath = envPaths[0]; // Fallback if (requestedFile) { if (envPaths.includes(requestedFile)) { envPath = requestedFile; } else { return res.status(400).json({ error: 'Requested env file not allowed' }); } } const fsService = FileSystemService.getInstance(req.nodeId); await fsService.writeFile(envPath, content, 'utf-8'); res.json({ message: 'Env file saved successfully' }); } catch (error) { console.error('Failed to save env file:', error); res.status(500).json({ error: 'Failed to save env file' }); } }); app.post('/api/stacks', async (req: Request, res: Response) => { try { const { stackName } = req.body; if (!stackName || typeof stackName !== 'string') { return res.status(400).json({ error: 'Stack name is required and must be a string' }); } if (!/^[a-zA-Z0-9-]+$/.test(stackName)) { return res.status(400).json({ error: 'Stack name can only contain alphanumeric characters and hyphens' }); } await FileSystemService.getInstance(req.nodeId).createStack(stackName); res.json({ message: 'Stack created successfully', name: stackName }); } catch (error: any) { if (error.message && error.message.includes('already exists')) { return res.status(409).json({ error: 'Stack already exists' }); } console.error('Failed to create stack:', error); res.status(500).json({ error: 'Failed to create stack' }); } }); app.delete('/api/stacks/:name', async (req: Request, res: Response) => { const stackName = req.params.name as string; try { // Stage 1: Tell Docker to clean up ghost networks/containers try { await ComposeService.getInstance(req.nodeId).downStack(stackName); } catch (downErr) { console.warn(`[Teardown] Docker down failed or nothing to clean up for ${stackName}`); } // Stage 2: Obliterate the files await FileSystemService.getInstance(req.nodeId).deleteStack(stackName); res.json({ success: true }); } catch (error: any) { res.status(500).json({ error: error.message || 'Failed to delete stack' }); } }); app.get('/api/stacks/:stackName/containers', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; const dockerController = DockerController.getInstance(req.nodeId); const containers = await dockerController.getContainersByStack(stackName); res.json(containers); } catch (error) { res.status(500).json({ error: 'Failed to fetch containers' }); } }); app.get('/api/containers/:id/logs', async (req: Request, res: Response) => { try { const id = req.params.id as string; const dockerController = DockerController.getInstance(req.nodeId); // Pass both req and res so we can listen for the client disconnect await dockerController.streamContainerLogs(id, req, res); } catch (error) { res.status(500).json({ error: 'Failed to initialize log stream' }); } }); app.post('/api/containers/:id/start', async (req: Request, res: Response) => { try { const id = req.params.id as string; const dockerController = DockerController.getInstance(req.nodeId); await dockerController.startContainer(id); res.json({ message: 'Container started' }); } catch (error) { res.status(500).json({ error: 'Failed to start container' }); } }); app.post('/api/containers/:id/stop', async (req: Request, res: Response) => { try { const id = req.params.id as string; const dockerController = DockerController.getInstance(req.nodeId); await dockerController.stopContainer(id); res.json({ message: 'Container stopped' }); } catch (error) { res.status(500).json({ error: 'Failed to stop container' }); } }); app.post('/api/containers/:id/restart', async (req: Request, res: Response) => { try { const id = req.params.id as string; const dockerController = DockerController.getInstance(req.nodeId); await dockerController.restartContainer(id); res.json({ message: 'Container restarted' }); } catch (error) { res.status(500).json({ error: 'Failed to restart container' }); } }); // End of legacy container routes app.post('/api/stacks/:stackName/deploy', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; await ComposeService.getInstance(req.nodeId).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' }); } }); app.post('/api/stacks/:stackName/down', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; await ComposeService.getInstance(req.nodeId).runCommand(stackName, 'down', terminalWs || undefined); res.json({ status: 'Command started' }); } catch (error) { res.status(500).json({ error: 'Failed to start command' }); } }); app.post('/api/stacks/:stackName/restart', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; const dockerController = DockerController.getInstance(req.nodeId); const containers = await dockerController.getContainersByStack(stackName); if (!containers || containers.length === 0) { return res.status(404).json({ error: 'No containers found for this stack.' }); } await Promise.all(containers.map(c => dockerController.restartContainer(c.Id))); res.json({ success: true, message: 'Restart completed via Engine API.' }); } catch (error: any) { console.error('Failed to restart containers:', error); res.status(500).json({ error: error.message || 'Failed to restart containers' }); } }); app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; const dockerController = DockerController.getInstance(req.nodeId); const containers = await dockerController.getContainersByStack(stackName); if (!containers || containers.length === 0) { return res.status(404).json({ error: 'No containers found for this stack.' }); } await Promise.all(containers.map(c => dockerController.stopContainer(c.Id))); res.json({ success: true, message: 'Stop completed via Engine API.' }); } catch (error: any) { console.error('Failed to stop containers:', error); res.status(500).json({ error: error.message || 'Failed to stop containers' }); } }); app.post('/api/stacks/:stackName/start', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; const dockerController = DockerController.getInstance(req.nodeId); const containers = await dockerController.getContainersByStack(stackName); if (!containers || containers.length === 0) { return res.status(404).json({ error: 'No containers found for this stack.' }); } await Promise.all(containers.map(c => dockerController.startContainer(c.Id))); res.json({ success: true, message: 'Start completed via Engine API.' }); } catch (error: any) { console.error('Failed to start containers:', error); res.status(500).json({ error: error.message || 'Failed to start containers' }); } }); // Update stack: pull images and recreate containers app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; // Await update completion await ComposeService.getInstance(req.nodeId).updateStack(stackName, terminalWs || undefined); res.json({ status: 'Update completed' }); } catch (error) { res.status(500).json({ error: 'Failed to update' }); } }); // Docker Run to Compose converter endpoint app.post('/api/convert', async (req: Request, res: Response) => { try { const { dockerRun } = req.body; if (!dockerRun || typeof dockerRun !== 'string') { return res.status(400).json({ error: 'dockerRun command is required' }); } const yaml = composerize(dockerRun); res.json({ yaml }); } catch (error) { console.error('Conversion error:', error); res.status(500).json({ error: 'Failed to convert docker run command' }); } }); // Get all containers stats for dashboard app.get('/api/stats', async (req: Request, res: Response) => { try { const dockerController = DockerController.getInstance(req.nodeId); 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' }); } }); app.get('/api/metrics/historical', async (req: Request, res: Response) => { try { const metrics = DatabaseService.getInstance().getContainerMetrics(24); res.json(metrics); } catch (error) { res.status(500).json({ error: 'Failed to fetch metrics' }); } }); app.get('/api/logs/global', async (req: Request, res: Response) => { try { const dockerController = DockerController.getInstance(req.nodeId); const containers = await dockerController.getRunningContainers(); const allLogs: any[] = []; await Promise.all(containers.map(async (c) => { const stackName = c.Labels?.['com.docker.compose.project'] || 'system'; let rawName = c.Names?.[0]?.replace(/^\//, '') || c.Id.substring(0, 12); // Standardize naming: Strip stack name prefix if it exists let containerName = rawName; if (rawName.startsWith(`${stackName}-`)) { containerName = rawName.replace(`${stackName}-`, '').replace(/-1$/, ''); } else if (rawName.startsWith(`${stackName}_`)) { containerName = rawName.replace(`${stackName}_`, '').replace(/_1$/, ''); } try { const container = dockerController.getDocker().getContainer(c.Id); const inspect = await container.inspect(); const isTty = inspect.Config.Tty; const logsBuffer = await container.logs({ stdout: true, stderr: true, tail: 100, timestamps: true }) as Buffer; const parseAndPushLog = (line: string, source: string) => { if (!line.trim()) return; const timeMatch = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.*)/); let cleanMessage = line; let timestampMs = Date.now(); if (timeMatch) { timestampMs = new Date(timeMatch[1]).getTime(); cleanMessage = timeMatch[2]; } // Default to INFO, or ERROR if coming from STDERR. let level = source === 'STDERR' ? 'ERROR' : 'INFO'; // 1. Explicitly check for INFO/DEBUG indicators (Overrides STDERR defaults) if (/level=["']?(info|debug|trace)["']?/i.test(cleanMessage) || /\[\s*(info|inf|debug|dbg|trace)\s*\]/i.test(cleanMessage) || /(?:\s|^)(info|inf|debug|trace)(?:\s|:|\(|\[|$)/i.test(cleanMessage)) { level = 'INFO'; } // 2. Check for WARN indicators else if (/level=["']?(warn|warning)["']?/i.test(cleanMessage) || /\[\s*(warn|warning)\s*\]/i.test(cleanMessage) || /(?:\s|^)(warn|warning)(?:\s|:|\(|\[|$)/i.test(cleanMessage)) { level = 'WARN'; } // 3. Check for ERROR indicators else if (/level=["']?(error|err|fatal|crit|critical|panic)["']?/i.test(cleanMessage) || /\[\s*(error|err|fatal|crit|critical|panic)\s*\]/i.test(cleanMessage) || /(?:\s|^)(error|err|fatal|crit|critical|panic)(?:\s|:|\(|\[|$)/i.test(cleanMessage) || /Exception:/i.test(cleanMessage)) { level = 'ERROR'; } allLogs.push({ stackName, containerName, source, level, message: cleanMessage, timestampMs }); }; if (isTty) { // No multiplex headers. Just split by newline. const payload = logsBuffer.toString('utf-8').replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g, ""); payload.split('\n').forEach(line => parseAndPushLog(line, 'STDOUT')); } else { // Parse 8-byte Docker multiplex header let offset = 0; while (offset < logsBuffer.length) { const streamType = logsBuffer[offset]; const length = logsBuffer.readUInt32BE(offset + 4); offset += 8; if (offset + length > logsBuffer.length) break; const payload = logsBuffer.slice(offset, offset + length).toString('utf-8'); offset += length; payload.split('\n').forEach(line => parseAndPushLog(line, streamType === 2 ? 'STDERR' : 'STDOUT')); } } } catch (err) { console.warn(`[GlobalLogs] Failed to fetch/parse logs for container ${containerName} (${c.Id.substring(0, 12)}):`, (err as Error).message); } })); // Sort globally by timestamp ascending (newest bottom) and limit to 2000 lines allLogs.sort((a, b) => a.timestampMs - b.timestampMs); res.json(allLogs.slice(-2000)); } catch (error) { res.status(500).json({ error: 'Failed to fetch global logs' }); } }); app.get('/api/logs/global/stream', async (req: Request, res: Response) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders(); const dockerController = DockerController.getInstance(req.nodeId); const streams: NodeJS.ReadableStream[] = []; try { const containers = await dockerController.getRunningContainers(); await Promise.all(containers.map(async (c) => { const stackName = c.Labels?.['com.docker.compose.project'] || 'system'; let rawName = c.Names?.[0]?.replace(/^\//, '') || c.Id.substring(0, 12); let containerName = rawName; if (rawName.startsWith(`${stackName}-`)) containerName = rawName.replace(`${stackName}-`, '').replace(/-1$/, ''); else if (rawName.startsWith(`${stackName}_`)) containerName = rawName.replace(`${stackName}_`, '').replace(/_1$/, ''); try { const container = dockerController.getDocker().getContainer(c.Id); const inspect = await container.inspect(); const isTty = inspect.Config.Tty; // Dev mode gets a larger tail const stream = await container.logs({ follow: true, stdout: true, stderr: true, tail: 500, timestamps: true }); streams.push(stream); const processLine = (line: string, source: string) => { if (!line.trim()) return; const timeMatch = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.*)/); let cleanMessage = line; let timestampMs = Date.now(); if (timeMatch) { timestampMs = new Date(timeMatch[1]).getTime(); cleanMessage = timeMatch[2]; } // Default to INFO, or ERROR if coming from STDERR. let level = source === 'STDERR' ? 'ERROR' : 'INFO'; // 1. Explicitly check for INFO/DEBUG indicators (Overrides STDERR defaults) if (/level=["']?(info|debug|trace)["']?/i.test(cleanMessage) || /\[\s*(info|inf|debug|dbg|trace)\s*\]/i.test(cleanMessage) || /(?:\s|^)(info|inf|debug|trace)(?:\s|:|\(|\[|$)/i.test(cleanMessage)) { level = 'INFO'; } // 2. Check for WARN indicators else if (/level=["']?(warn|warning)["']?/i.test(cleanMessage) || /\[\s*(warn|warning)\s*\]/i.test(cleanMessage) || /(?:\s|^)(warn|warning)(?:\s|:|\(|\[|$)/i.test(cleanMessage)) { level = 'WARN'; } // 3. Check for ERROR indicators else if (/level=["']?(error|err|fatal|crit|critical|panic)["']?/i.test(cleanMessage) || /\[\s*(error|err|fatal|crit|critical|panic)\s*\]/i.test(cleanMessage) || /(?:\s|^)(error|err|fatal|crit|critical|panic)(?:\s|:|\(|\[|$)/i.test(cleanMessage) || /Exception:/i.test(cleanMessage)) { level = 'ERROR'; } res.write(`data: ${JSON.stringify({ stackName, containerName, source, level, message: cleanMessage, timestampMs })}\n\n`); }; stream.on('data', (chunk: Buffer) => { if (isTty) { const payload = chunk.toString('utf-8').replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g, ""); payload.split('\n').forEach(line => processLine(line, 'STDOUT')); } else { let offset = 0; while (offset < chunk.length) { if (offset + 8 > chunk.length) break; const streamType = chunk[offset]; const length = chunk.readUInt32BE(offset + 4); offset += 8; if (offset + length > chunk.length) break; const payload = chunk.slice(offset, offset + length).toString('utf-8'); offset += length; payload.split('\n').forEach(line => processLine(line, streamType === 2 ? 'STDERR' : 'STDOUT')); } } }); } catch (err) { /* ignore */ } })); // Cleanup when client closes the tab or switches views req.on('close', () => { streams.forEach(s => { try { (s as any).destroy(); } catch (e) { } }); }); } catch (error) { res.write(`data: ${JSON.stringify({ level: 'ERROR', message: '[Sencho] Failed to attach global log stream.', timestampMs: Date.now(), stackName: 'system', containerName: 'backend', source: 'STDERR' })}\n\n`); res.end(); } }); // Get host system stats app.get('/api/system/stats', async (req: Request, res: Response) => { try { const nodeId = req.nodeId ?? NodeRegistry.getInstance().getDefaultNodeId(); const node = NodeRegistry.getInstance().getNode(nodeId); const rxSec = Math.max(0, globalDockerNetwork.rxSec); const txSec = Math.max(0, globalDockerNetwork.txSec); if (node && node.type === 'remote') { // Remote node: use Docker daemon info for CPU/RAM — disk is not available via Docker API const docker = NodeRegistry.getInstance().getDocker(nodeId); const info = await docker.info(); res.json({ cpu: { usage: '0', cores: info.NCPU ?? 0, }, memory: { total: info.MemTotal ?? 0, used: 0, free: info.MemTotal ?? 0, usagePercent: '0', }, disk: null, network: { rxBytes: 0, txBytes: 0, rxSec, txSec }, }); return; } // Local node: use systeminformation for accurate host metrics const [currentLoad, mem, fsSize] = await Promise.all([ si.currentLoad(), si.mem(), si.fsSize() ]); const mainDisk = fsSize.find(fs => fs.mount === '/' || fs.mount === 'C:') || fsSize[0]; res.json({ cpu: { usage: currentLoad.currentLoad.toFixed(1), cores: currentLoad.cpus.length, }, memory: { total: mem.total, used: mem.used, free: mem.free, usagePercent: ((mem.used / mem.total) * 100).toFixed(1), }, disk: mainDisk ? { fs: mainDisk.fs, mount: mainDisk.mount, total: mainDisk.size, used: mainDisk.used, free: mainDisk.available, usagePercent: mainDisk.use ? mainDisk.use.toFixed(1) : '0', } : null, network: { rxBytes: 0, txBytes: 0, rxSec, txSec }, }); } catch (error) { console.error('Failed to fetch system stats:', error); res.status(500).json({ error: 'Failed to fetch system stats' }); } }); // --- Notification & Alerting Routes --- app.get('/api/agents', async (req: Request, res: Response) => { try { const agents = DatabaseService.getInstance().getAgents(); res.json(agents); } catch (error) { res.status(500).json({ error: 'Failed to fetch agents' }); } }); app.post('/api/agents', async (req: Request, res: Response) => { try { const agent = req.body; DatabaseService.getInstance().upsertAgent(agent); res.json({ success: true }); } catch (error) { res.status(500).json({ error: 'Failed to update agent' }); } }); app.get('/api/settings', async (req: Request, res: Response) => { try { const settings = DatabaseService.getInstance().getGlobalSettings(); res.json(settings); } catch (error) { res.status(500).json({ error: 'Failed to fetch settings' }); } }); app.post('/api/settings', async (req: Request, res: Response) => { try { const { key, value } = req.body; DatabaseService.getInstance().updateGlobalSetting(key, value); res.json({ success: true }); } catch (error) { res.status(500).json({ error: 'Failed to update setting' }); } }); app.get('/api/alerts', async (req: Request, res: Response) => { try { let stackName = req.query.stackName as string | undefined; if (Array.isArray(stackName)) stackName = stackName[0] as string; const alerts = DatabaseService.getInstance().getStackAlerts(stackName); res.json(alerts); } catch (error) { res.status(500).json({ error: 'Failed to fetch alerts' }); } }); app.post('/api/alerts', async (req: Request, res: Response) => { try { const alert = req.body; DatabaseService.getInstance().addStackAlert(alert); res.json({ success: true }); } catch (error) { res.status(500).json({ error: 'Failed to add alert' }); } }); app.delete('/api/alerts/:id', async (req: Request, res: Response) => { try { const id = parseInt(req.params.id as string, 10); DatabaseService.getInstance().deleteStackAlert(id); res.json({ success: true }); } catch (error) { res.status(500).json({ error: 'Failed to delete alert' }); } }); app.get('/api/notifications', async (req: Request, res: Response) => { try { const history = DatabaseService.getInstance().getNotificationHistory(); res.json(history); } catch (error) { res.status(500).json({ error: 'Failed to fetch notifications' }); } }); app.post('/api/notifications/read', async (req: Request, res: Response) => { try { DatabaseService.getInstance().markAllNotificationsRead(); res.json({ success: true }); } catch (error) { res.status(500).json({ error: 'Failed to mark notifications read' }); } }); app.delete('/api/notifications/:id', async (req: Request, res: Response) => { try { const id = parseInt(req.params.id as string, 10); DatabaseService.getInstance().deleteNotification(id); res.json({ success: true }); } catch (error) { res.status(500).json({ error: 'Failed to delete notification' }); } }); app.delete('/api/notifications', async (req: Request, res: Response) => { try { DatabaseService.getInstance().deleteAllNotifications(); res.json({ success: true }); } catch (error) { res.status(500).json({ error: 'Failed to clear notifications' }); } }); app.post('/api/notifications/test', async (req: Request, res: Response) => { try { const { type, url } = req.body; await NotificationService.getInstance().testDispatch(type, url); res.json({ success: true }); } catch (error: any) { res.status(500).json({ error: 'Test failed', details: error.message }); } }); // --- System Maintenance Routes (The System Janitor) --- app.get('/api/system/orphans', async (req: Request, res: Response) => { try { const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); const dockerController = DockerController.getInstance(req.nodeId); const orphans = await dockerController.getOrphanContainers(knownStacks); res.json(orphans); } catch (error) { console.error('Failed to fetch orphan containers:', error); res.status(500).json({ error: 'Failed to fetch orphan containers' }); } }); app.post('/api/system/prune/orphans', async (req: Request, res: Response) => { try { const { containerIds } = req.body; if (!Array.isArray(containerIds)) { return res.status(400).json({ error: 'containerIds must be an array' }); } const dockerController = DockerController.getInstance(req.nodeId); const results = await dockerController.removeContainers(containerIds); res.json({ results }); } catch (error) { console.error('Failed to prune orphan containers:', error); res.status(500).json({ error: 'Failed to prune orphan containers' }); } }); app.post('/api/system/prune/system', async (req: Request, res: Response) => { try { const { target } = req.body; // 'containers', 'images', 'networks', 'volumes' if (!['containers', 'images', 'networks', 'volumes'].includes(target)) { return res.status(400).json({ error: 'Invalid prune target' }); } const dockerController = DockerController.getInstance(req.nodeId); const result = await dockerController.pruneSystem(target); res.json({ message: 'Prune completed', ...result }); } catch (error: any) { console.error('System prune error:', error); res.status(500).json({ error: 'System prune failed', details: error.message }); } }); app.get('/api/system/docker-df', async (req: Request, res: Response) => { try { const dockerController = DockerController.getInstance(req.nodeId); const df = await dockerController.getDiskUsage(); res.json(df); } catch (error) { console.error('Failed to fetch docker disk usage:', error); res.status(500).json({ error: 'Failed to fetch docker disk usage' }); } }); app.get('/api/system/images', async (req: Request, res: Response) => { try { const dockerController = DockerController.getInstance(req.nodeId); const images = await dockerController.getImages(); res.json(images); } catch (error) { console.error('Failed to fetch images:', error); res.status(500).json({ error: 'Failed to fetch images' }); } }); app.get('/api/system/volumes', async (req: Request, res: Response) => { try { const dockerController = DockerController.getInstance(req.nodeId); const volumes = await dockerController.getVolumes(); res.json(volumes); } catch (error) { console.error('Failed to fetch volumes:', error); res.status(500).json({ error: 'Failed to fetch volumes' }); } }); app.get('/api/system/networks', async (req: Request, res: Response) => { try { const dockerController = DockerController.getInstance(req.nodeId); const networks = await dockerController.getNetworks(); res.json(networks); } catch (error) { console.error('Failed to fetch networks:', error); res.status(500).json({ error: 'Failed to fetch networks' }); } }); app.post('/api/system/images/delete', async (req: Request, res: Response) => { try { const { id } = req.body; if (!id) return res.status(400).json({ error: 'ID is required' }); const dockerController = DockerController.getInstance(req.nodeId); await dockerController.removeImage(id); res.json({ success: true, message: 'Image deleted' }); } catch (error: any) { console.error('Failed to delete image:', error); res.status(500).json({ error: error.message || 'Failed to delete image' }); } }); app.post('/api/system/volumes/delete', async (req: Request, res: Response) => { try { const { id } = req.body; if (!id) return res.status(400).json({ error: 'ID is required' }); const dockerController = DockerController.getInstance(req.nodeId); await dockerController.removeVolume(id); res.json({ success: true, message: 'Volume deleted' }); } catch (error: any) { console.error('Failed to delete volume:', error); res.status(500).json({ error: error.message || 'Failed to delete volume' }); } }); app.post('/api/system/networks/delete', async (req: Request, res: Response) => { try { const { id } = req.body; if (!id) return res.status(400).json({ error: 'ID is required' }); const dockerController = DockerController.getInstance(req.nodeId); await dockerController.removeNetwork(id); res.json({ success: true, message: 'Network deleted' }); } catch (error: any) { console.error('Failed to delete network:', error); res.status(500).json({ error: error.message || 'Failed to delete network' }); } }); // --- App Templates Routes --- app.get('/api/templates', async (req: Request, res: Response) => { try { const templates = await templateService.getTemplates(); res.json(templates); } catch (error) { res.status(500).json({ error: 'Failed to fetch templates' }); } }); app.post('/api/templates/deploy', async (req: Request, res: Response) => { try { const { stackName, template, envVars } = req.body; if (!stackName || !template) { return res.status(400).json({ error: 'stackName and template are required' }); } const stackPath = path.join(FileSystemService.getInstance(req.nodeId).getBaseDir(), stackName); if (fs.existsSync(stackPath)) { return res.status(409).json({ error: `A stack directory named '${stackName}' already exists. Please choose a different Stack Name.`, rolledBack: false }); } // 1. Create stack directory await FileSystemService.getInstance(req.nodeId).createStack(stackName); // 2. Generate compose YAML and save const composeYaml = templateService.generateComposeFromTemplate(template); await FileSystemService.getInstance(req.nodeId).saveStackContent(stackName, composeYaml); // 3. Generate env string and save to default .env if (envVars) { const envString = templateService.generateEnvString(envVars); const stackDir = path.join(FileSystemService.getInstance(req.nodeId).getBaseDir(), stackName); const defaultEnvPath = path.join(stackDir, '.env'); await fsPromises.writeFile(defaultEnvPath, envString, 'utf-8'); } // 4. Deploy the stack with atomic rollback try { await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined); res.json({ success: true, message: 'Template deployed successfully' }); } catch (deployError: any) { const rawError = deployError.message || String(deployError); const parsed = ErrorParser.parse(rawError); const shouldRollback = parsed.rule ? parsed.rule.canSilentlyRollback : true; if (shouldRollback) { try { // Stage 1: Tell Docker to clean up ghost networks/containers await ComposeService.getInstance(req.nodeId).downStack(stackName); } catch (downErr) { console.error("Rollback Stage 1 (Docker down) failed:", downErr); } try { // Stage 2: Obliterate the files await FileSystemService.getInstance(req.nodeId).deleteStack(stackName); } catch (fsErr) { console.error("Rollback Stage 2 (File deletion) failed:", fsErr); } } res.status(500).json({ error: parsed.message, rolledBack: shouldRollback, ruleId: parsed.rule?.id || 'UNKNOWN' }); } } catch (error: any) { console.error('Failed to deploy template:', error); res.status(500).json({ error: error.message || 'Failed to deploy template' }); } }); // ========================= // Node Management API // ========================= // List all nodes app.get('/api/nodes', async (req: Request, res: Response) => { try { const nodes = DatabaseService.getInstance().getNodes(); res.json(nodes); } catch (error) { res.status(500).json({ error: 'Failed to fetch nodes' }); } }); // Get a specific node app.get('/api/nodes/:id', async (req: Request, res: Response) => { try { const id = parseInt(req.params.id as string); const node = DatabaseService.getInstance().getNode(id); if (!node) { return res.status(404).json({ error: 'Node not found' }); } res.json(node); } catch (error) { res.status(500).json({ error: 'Failed to fetch node' }); } }); // Create a new node app.post('/api/nodes', async (req: Request, res: Response) => { try { const { name, type, compose_dir, is_default, api_url, api_token } = req.body; if (!name || typeof name !== 'string') { return res.status(400).json({ error: 'Node name is required' }); } if (!type || !['local', 'remote'].includes(type)) { return res.status(400).json({ error: 'Node type must be "local" or "remote"' }); } if (type === 'remote' && (!api_url || typeof api_url !== 'string')) { return res.status(400).json({ error: 'API URL is required for remote nodes' }); } const id = DatabaseService.getInstance().addNode({ name, type, compose_dir: compose_dir || '/app/compose', is_default: is_default || false, api_url: api_url || '', api_token: api_token || '', }); res.json({ success: true, id }); } catch (error: any) { if (error.message?.includes('UNIQUE constraint')) { return res.status(409).json({ error: 'A node with that name already exists' }); } console.error('Failed to create node:', error); res.status(500).json({ error: error.message || 'Failed to create node' }); } }); // Update a node app.put('/api/nodes/:id', async (req: Request, res: Response) => { try { const id = parseInt(req.params.id as string); const updates = req.body; DatabaseService.getInstance().updateNode(id, updates); // Evict cached Docker connection so it reconnects with new config NodeRegistry.getInstance().evictConnection(id); res.json({ success: true }); } catch (error: any) { console.error('Failed to update node:', error); res.status(500).json({ error: error.message || 'Failed to update node' }); } }); // Delete a node app.delete('/api/nodes/:id', async (req: Request, res: Response) => { try { const id = parseInt(req.params.id as string); DatabaseService.getInstance().deleteNode(id); NodeRegistry.getInstance().evictConnection(id); res.json({ success: true }); } catch (error: any) { console.error('Failed to delete node:', error); res.status(500).json({ error: error.message || 'Failed to delete node' }); } }); // Test connection to a node app.post('/api/nodes/:id/test', async (req: Request, res: Response) => { try { const id = parseInt(req.params.id as string); const result = await NodeRegistry.getInstance().testConnection(id); res.json(result); } catch (error: any) { res.status(500).json({ success: false, error: error.message || 'Connection test failed' }); } }); // 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) => { if (!req.path.startsWith('/api')) { res.sendFile('index.html', { root: 'public' }); } else { res.status(404).json({ error: 'API endpoint not found' }); } }); } else { // In development, still need to catch 404s for API to prevent hangs app.use((req: Request, res: Response) => { if (req.path.startsWith('/api')) { res.status(404).json({ error: 'API endpoint not found' }); } }); } // Start server with migration async function startServer() { try { // Run migration before starting server console.log('Running stack migration check...'); const defaultFsService = FileSystemService.getInstance(NodeRegistry.getInstance().getDefaultNodeId()); await defaultFsService.migrateFlatToDirectory(); console.log('Migration check completed'); } catch (error) { console.error('Migration failed:', error); // Continue starting server even if migration fails } // Start Background Watchdog MonitorService.getInstance().start(); server.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); } startServer();