mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 17:34:23 +00:00
1492 lines
52 KiB
TypeScript
1492 lines
52 KiB
TypeScript
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 { 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 for stack management
|
|
const fileSystemService = new FileSystemService();
|
|
|
|
// ComposeService for stack operations
|
|
const composeService = new ComposeService();
|
|
|
|
// 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());
|
|
|
|
// Extend Express Request type for user
|
|
declare module 'express' {
|
|
interface Request {
|
|
user?: { username: string };
|
|
}
|
|
}
|
|
|
|
// Authentication Middleware
|
|
const authMiddleware = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
const token = req.cookies[COOKIE_NAME];
|
|
|
|
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 };
|
|
req.user = { username: decoded.username };
|
|
next();
|
|
} catch {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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 });
|
|
});
|
|
|
|
// 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);
|
|
});
|
|
|
|
// 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)
|
|
);
|
|
|
|
const token = cookies[COOKIE_NAME];
|
|
|
|
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);
|
|
|
|
// Check if this is a stack logs WebSocket request
|
|
const url = req.url || '';
|
|
const logsMatch = url.match(/^\/api\/stacks\/([^/]+)\/logs$/);
|
|
const hostConsoleMatch = url.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.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 = fileSystemService.getBaseDir();
|
|
try {
|
|
const reqUrl = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`);
|
|
const stackParam = reqUrl.searchParams.get('stack');
|
|
if (stackParam) {
|
|
targetDirectory = path.join(targetDirectory, stackParam);
|
|
}
|
|
} catch (e) {
|
|
// ignore parsing error, fallback to base dir
|
|
}
|
|
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 dockerController = DockerController.getInstance();
|
|
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 dockerController = DockerController.getInstance();
|
|
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();
|
|
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.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.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.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(stackName: string): Promise<string[]> {
|
|
const stackDir = path.join(fileSystemService.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 fsPromises.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<string>();
|
|
|
|
// 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(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(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' });
|
|
}
|
|
}
|
|
|
|
try {
|
|
await fsPromises.access(envPath);
|
|
} catch {
|
|
return res.status(404).json({ error: 'Env file not found' });
|
|
}
|
|
|
|
const content = await fsPromises.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(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' });
|
|
}
|
|
}
|
|
|
|
await fsPromises.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.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.downStack(stackName);
|
|
} catch (downErr) {
|
|
console.warn(`[Teardown] Docker down failed or nothing to clean up for ${stackName}`);
|
|
}
|
|
|
|
// Stage 2: Obliterate the files
|
|
await fileSystemService.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();
|
|
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();
|
|
// 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();
|
|
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();
|
|
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();
|
|
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.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.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();
|
|
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();
|
|
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();
|
|
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.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();
|
|
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();
|
|
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();
|
|
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 [currentLoad, mem, fsSize] = await Promise.all([
|
|
si.currentLoad(),
|
|
si.mem(),
|
|
si.fsSize()
|
|
]);
|
|
|
|
let rxSec = Math.max(0, globalDockerNetwork.rxSec);
|
|
let txSec = Math.max(0, globalDockerNetwork.txSec);
|
|
let rxBytes = 0;
|
|
let txBytes = 0;
|
|
|
|
// 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),
|
|
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,
|
|
txBytes,
|
|
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.getStacks();
|
|
const dockerController = DockerController.getInstance();
|
|
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();
|
|
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();
|
|
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();
|
|
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();
|
|
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();
|
|
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();
|
|
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();
|
|
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();
|
|
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();
|
|
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.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.createStack(stackName);
|
|
|
|
// 2. Generate compose YAML and save
|
|
const composeYaml = templateService.generateComposeFromTemplate(template);
|
|
await fileSystemService.saveStackContent(stackName, composeYaml);
|
|
|
|
// 3. Generate env string and save to default .env
|
|
if (envVars) {
|
|
const envString = templateService.generateEnvString(envVars);
|
|
const stackDir = path.join(fileSystemService.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.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.downStack(stackName);
|
|
} catch (downErr) {
|
|
console.error("Rollback Stage 1 (Docker down) failed:", downErr);
|
|
}
|
|
|
|
try {
|
|
// Stage 2: Obliterate the files
|
|
await fileSystemService.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);
|
|
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, host, port, compose_dir, is_default } = 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' && (!host || typeof host !== 'string')) {
|
|
return res.status(400).json({ error: 'Host is required for remote nodes' });
|
|
}
|
|
|
|
const id = DatabaseService.getInstance().addNode({
|
|
name,
|
|
type,
|
|
host: host || '',
|
|
port: port || 2375,
|
|
compose_dir: compose_dir || '/opt/docker',
|
|
is_default: is_default || false,
|
|
});
|
|
|
|
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);
|
|
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);
|
|
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);
|
|
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...');
|
|
await fileSystemService.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();
|