feat: Implement web-based Docker Compose stack management with file editing, deployment, container monitoring, and terminal access.

This commit is contained in:
SaelixCode
2026-02-21 15:05:05 -05:00
parent e94b706a9f
commit 18c51f083e
4 changed files with 187 additions and 96 deletions
+38 -24
View File
@@ -55,12 +55,12 @@ declare module 'express' {
// 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 jwtSecret = await configService.getJwtSecret();
const decoded = jwt.verify(token, jwtSecret) as { username: string };
@@ -96,23 +96,23 @@ app.post('/api/auth/setup', async (req: Request, res: Response): Promise<void> =
}
const { username, password, confirmPassword } = req.body;
// Validation
if (!username || !password || !confirmPassword) {
res.status(400).json({ error: 'All fields are required' });
return;
}
if (username.length < 3) {
res.status(400).json({ error: 'Username must be at least 3 characters' });
return;
}
if (password.length < 6) {
res.status(400).json({ error: 'Password must be at least 6 characters' });
return;
}
if (password !== confirmPassword) {
res.status(400).json({ error: 'Passwords do not match' });
return;
@@ -120,7 +120,7 @@ app.post('/api/auth/setup', async (req: Request, res: Response): Promise<void> =
// Save credentials (this also generates the JWT secret)
await configService.saveConfig(username, password);
// Issue JWT and log user in
const jwtSecret = await configService.getJwtSecret();
const token = jwt.sign({ username }, jwtSecret, { expiresIn: '24h' });
@@ -135,15 +135,15 @@ app.post('/api/auth/setup', async (req: Request, res: Response): Promise<void> =
// 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 isValid = await configService.validateCredentials(username, password);
if (isValid) {
const jwtSecret = await configService.getJwtSecret();
const token = jwt.sign({ username }, jwtSecret, { expiresIn: '24h' });
@@ -151,7 +151,7 @@ app.post('/api/auth/login', async (req: Request, res: Response): Promise<void> =
res.json({ success: true, message: 'Login successful' });
return;
}
res.status(401).json({ error: 'Invalid credentials' });
} catch (error) {
console.error('Login error:', error);
@@ -197,22 +197,36 @@ server.on('upgrade', async (req, socket, head) => {
const cookies = Object.fromEntries(
cookieHeader.split(';').map(c => c.trim().split('=')).filter(([k, v]) => k && v)
);
const token = cookies[COOKIE_NAME];
if (!token) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
try {
const jwtSecret = await configService.getJwtSecret();
jwt.verify(token, jwtSecret);
// Authentication successful, proceed with WebSocket connection
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
// Check if this is a stack logs WebSocket request
const url = req.url || '';
const logsMatch = url.match(/^\/api\/stacks\/([^/]+)\/logs$/);
if (logsMatch) {
// Dedicated stack logs WebSocket
const logsWss = new WebSocket.Server({ noServer: true });
logsWss.handleUpgrade(req, socket, head, (ws) => {
const stackName = decodeURIComponent(logsMatch[1]);
composeService.streamLogs(stackName, ws);
});
} else {
// Generic terminal WebSocket
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
}
} catch (error) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
@@ -455,11 +469,11 @@ app.get('/api/stats', async (req: Request, res: Response) => {
const dockerController = DockerController.getInstance();
const containers = await dockerController.getRunningContainers();
const allContainers = await dockerController.getAllContainers();
const active = containers.length;
const exited = allContainers.filter((c: { State: string }) => c.State === 'exited').length;
const total = allContainers.length;
res.json({ active, exited, total, inactive: total - active - exited });
} catch (error) {
res.status(500).json({ error: 'Failed to fetch stats' });
@@ -474,10 +488,10 @@ app.get('/api/system/stats', async (req: Request, res: Response) => {
si.mem(),
si.fsSize(),
]);
// Find the main mount (usually the largest or root mount)
const mainDisk = fsSize.find(fs => fs.mount === '/' || fs.mount === 'C:') || fsSize[0];
res.json({
cpu: {
usage: currentLoad.currentLoad.toFixed(1),
@@ -507,7 +521,7 @@ app.get('/api/system/stats', async (req: Request, res: Response) => {
// Serve static files in production (for Docker deployment)
if (process.env.NODE_ENV === 'production') {
app.use(express.static('public'));
// Handle SPA routing - serve index.html for non-API routes
// Using app.use middleware instead of app.get('*') for path-to-regexp compatibility
app.use((req: Request, res: Response) => {
@@ -528,7 +542,7 @@ async function startServer() {
console.error('Migration failed:', error);
// Continue starting server even if migration fails
}
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
+66 -15
View File
@@ -16,18 +16,18 @@ export class ComposeService {
*/
runCommand(stackName: string, action: 'up' | 'down', ws?: WebSocket) {
const stackDir = path.join(this.baseDir, stackName);
// Run docker compose from within the stack directory
// This ensures relative paths (e.g., ./data:/config) resolve correctly
const args = action === 'up'
? ['compose', 'up', '-d']
const args = action === 'up'
? ['compose', 'up', '-d']
: ['compose', 'down'];
const child = spawn('docker', args, {
const child = spawn('docker', args, {
cwd: stackDir, // CRITICAL: Set working directory to stack folder
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
});
@@ -51,6 +51,57 @@ export class ComposeService {
}
}
/**
* Stream docker compose logs for a stack via WebSocket.
* Spawns `docker compose logs -f --tail 100` and pipes stdout/stderr to ws.send().
* Kills the child process when the WebSocket closes.
*/
streamLogs(stackName: string, ws: WebSocket) {
const stackDir = path.join(this.baseDir, stackName);
const child = spawn('docker', ['compose', 'logs', '-f', '--tail', '100'], {
cwd: stackDir,
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
});
child.stdout.on('data', (data: Buffer) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data.toString());
}
});
child.stderr.on('data', (data: Buffer) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data.toString());
}
});
child.on('error', (error: Error) => {
console.error(`Docker Compose Logs Error for ${stackName}:`, error.message);
if (ws.readyState === WebSocket.OPEN) {
ws.send(`Error: ${error.message}\n`);
}
});
child.on('close', (code: number | null) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(`\r\nLog stream ended (code ${code})\r\n`);
}
});
// Kill the logs process when the WebSocket is closed
ws.on('close', () => {
try {
child.kill();
} catch {
// Ignore kill errors
}
});
}
/**
* Update stack: pull images first, then recreate containers
* CRITICAL: cwd is set to the stack directory so relative paths resolve correctly
@@ -67,11 +118,11 @@ export class ComposeService {
// Step 1: Pull images
sendOutput('=== Pulling latest images ===\n');
await new Promise<void>((resolve, reject) => {
const pullProcess = spawn('docker', ['compose', 'pull'], {
const pullProcess = spawn('docker', ['compose', 'pull'], {
cwd: stackDir,
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
});
@@ -103,11 +154,11 @@ export class ComposeService {
// Step 2: Recreate containers with new images
sendOutput('=== Recreating containers ===\n');
await new Promise<void>((resolve, reject) => {
const upProcess = spawn('docker', ['compose', 'up', '-d'], {
const upProcess = spawn('docker', ['compose', 'up', '-d'], {
cwd: stackDir,
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
});