feat: Improved frontend UI components with shadcn

This commit is contained in:
SaelixCode
2026-02-25 22:33:51 -05:00
parent aeb3d6e812
commit 46e6aedfd0
14 changed files with 713 additions and 159 deletions
+7 -41
View File
@@ -25,6 +25,9 @@ const configService = new ConfigService();
// FileSystemService for stack management
const fileSystemService = new FileSystemService();
// ComposeService for stack operations
const composeService = new ComposeService();
// Cookie settings
const COOKIE_NAME = 'sencho_token';
@@ -219,48 +222,12 @@ server.on('upgrade', async (req, socket, head) => {
const logsMatch = url.match(/^\/api\/stacks\/([^/]+)\/logs$/);
if (logsMatch) {
// Dedicated stack logs WebSocket - uses raw Docker CLI to support legacy containers
// Dedicated stack logs WebSocket - uses Supervisor loop for persistent logs
const logsWss = new WebSocket.Server({ noServer: true });
logsWss.handleUpgrade(req, socket, head, async (ws) => {
logsWss.handleUpgrade(req, socket, head, (ws) => {
const stackName = decodeURIComponent(logsMatch[1]);
try {
const dockerController = DockerController.getInstance();
const containers = await dockerController.getContainersByStack(stackName);
if (!containers || containers.length === 0) {
ws.send('No containers running to log.\n');
return;
}
// Stream logs from all containers using raw docker logs CLI
const childProcesses: ReturnType<typeof spawn>[] = [];
for (const container of containers) {
const containerName = container.Names?.[0]?.replace(/^\//, '') || container.Id;
const logProcess = spawn('docker', ['logs', '-f', '--tail', '100', containerName], {
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
});
childProcesses.push(logProcess);
logProcess.stdout.on('data', (data: Buffer) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data.toString());
}
});
logProcess.stderr.on('data', (data: Buffer) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data.toString());
}
});
logProcess.on('error', (error: Error) => {
console.error(`Docker logs error for ${containerName}:`, error.message);
});
}
// Kill all log processes when WebSocket closes
ws.on('close', () => {
for (const proc of childProcesses) {
try { proc.kill(); } catch { /* ignore */ }
}
});
composeService.streamLogs(stackName, ws);
} catch (error) {
console.error('Failed to stream logs:', error);
if (ws.readyState === WebSocket.OPEN) {
@@ -481,8 +448,7 @@ app.post('/api/containers/:id/restart', async (req: Request, res: Response) => {
}
});
const composeService = new ComposeService();
// End of legacy container routes
app.post('/api/stacks/:stackName/up', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
+105 -43
View File
@@ -1,6 +1,7 @@
import { spawn } from 'child_process';
import path from 'path';
import WebSocket from 'ws';
import DockerController from './DockerController';
export class ComposeService {
private baseDir: string;
@@ -52,54 +53,115 @@ 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.
* Stream docker logs for a stack via WebSocket.
* Supervisors: Fetches containers via DockerController and spawns `docker logs -f` for each.
* Automatically re-spawns on process exit if WebSocket is still OPEN, creating a persistent stream.
* Kills the child processes when the WebSocket closes.
*/
streamLogs(stackName: string, ws: WebSocket) {
const stackDir = path.join(this.baseDir, stackName);
let isClosed = false;
let isFirstRun = true;
let isWaitingForActivity = false;
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
}
isClosed = true;
});
const startStream = async () => {
if (isClosed || ws.readyState !== WebSocket.OPEN) return;
try {
const dockerController = DockerController.getInstance();
const containers = await dockerController.getContainersByStack(stackName);
if (!containers || containers.length === 0) {
if (!isWaitingForActivity) {
ws.send(`\r\n\x1b[33m[Sencho] No containers found. Waiting for activity...\x1b[0m\r\n`);
isWaitingForActivity = true;
}
setTimeout(startStream, 2000);
return;
}
const runningContainers = containers.filter(c => c.State === 'running');
// If not first run and no containers are running, we poll to wait for activity
if (!isFirstRun && runningContainers.length === 0) {
if (!isWaitingForActivity) {
ws.send(`\r\n\x1b[33m[Sencho] Log stream ended. Waiting for container activity...\x1b[0m\r\n`);
isWaitingForActivity = true;
}
setTimeout(startStream, 2000);
return;
}
// On first run, we stream all containers to dump history.
// On subsequent runs, we only attach to running containers to avoid immediate exit loop.
const containersToLog = isFirstRun ? containers : runningContainers;
isFirstRun = false;
isWaitingForActivity = false; // Reset since we are tracking active elements
let activeProcesses = 0;
let streamEndedHandled = false;
const childProcesses: ReturnType<typeof spawn>[] = [];
const onWsClose = () => {
childProcesses.forEach(cp => {
try { cp.kill(); } catch { /* ignore */ }
});
};
ws.on('close', onWsClose);
const handleProcessEnd = () => {
activeProcesses--;
if (activeProcesses <= 0 && !streamEndedHandled) {
streamEndedHandled = true;
ws.removeListener('close', onWsClose);
if (!isClosed && ws.readyState === WebSocket.OPEN) {
setTimeout(startStream, 1000);
}
}
};
for (const container of containersToLog) {
const containerName = container.Names?.[0]?.replace(/^\//, '') || container.Id;
const child = spawn('docker', ['logs', '-f', '--tail', '100', containerName], {
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
});
activeProcesses++;
childProcesses.push(child);
const sendOutput = (data: Buffer) => {
if (ws.readyState === WebSocket.OPEN) {
const text = data.toString().replace(/\r?\n/g, '\r\n');
ws.send(text);
}
};
child.stdout.on('data', sendOutput);
child.stderr.on('data', sendOutput);
child.on('error', handleProcessEnd);
child.on('close', handleProcessEnd);
}
} catch (err) {
if (!isClosed && ws.readyState === WebSocket.OPEN) {
if (!isWaitingForActivity) {
ws.send(`\r\n\x1b[31m[Sencho] Error tracking containers. Retrying...\x1b[0m\r\n`);
isWaitingForActivity = true;
}
setTimeout(startStream, 2000);
}
}
};
startStream();
}
/**