mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-14 20:53:14 +00:00
feat: Implement web-based Docker Compose stack management with file editing, deployment, container monitoring, and terminal access.
This commit is contained in:
+38
-24
@@ -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}`);
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function EditorLayout() {
|
||||
const [originalEnvContent, setOriginalEnvContent] = useState<string>('');
|
||||
const [envExists, setEnvExists] = useState<boolean>(false);
|
||||
const [containers, setContainers] = useState<ContainerInfo[]>([]);
|
||||
const [containerStats, setContainerStats] = useState<Record<string, {cpu: string, ram: string}>>({});
|
||||
const [containerStats, setContainerStats] = useState<Record<string, { cpu: string, ram: string }>>({});
|
||||
const [activeTab, setActiveTab] = useState<'compose' | 'env'>('compose');
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
@@ -47,7 +47,7 @@ export default function EditorLayout() {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [stackStatuses, setStackStatuses] = useState<StackStatus>({});
|
||||
|
||||
|
||||
// Bash exec modal state
|
||||
const [bashModalOpen, setBashModalOpen] = useState(false);
|
||||
const [selectedContainer, setSelectedContainer] = useState<{ id: string; name: string } | null>(null);
|
||||
@@ -68,7 +68,7 @@ export default function EditorLayout() {
|
||||
const res = await apiFetch('/stacks');
|
||||
const stacks = await res.json();
|
||||
setFiles(Array.isArray(stacks) ? stacks : []);
|
||||
|
||||
|
||||
// Fetch status for each stack
|
||||
const statuses: StackStatus = {};
|
||||
for (const file of stacks) {
|
||||
@@ -99,19 +99,21 @@ export default function EditorLayout() {
|
||||
(containers || []).forEach(container => {
|
||||
if (!container?.Id) return;
|
||||
try {
|
||||
const ws = new WebSocket('ws://localhost:3000');
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${wsProtocol}//${window.location.host}`);
|
||||
wsMap[container.Id] = ws;
|
||||
ws.onopen = () => ws.send(JSON.stringify({ action: 'streamStats', containerId: container.Id }));
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.cpu_stats && data.precpu_stats && data.memory_stats) {
|
||||
const cpuDelta = data.cpu_stats.cpu_usage.total_usage - data.precpu_stats.cpu_usage.total_usage;
|
||||
const systemDelta = data.cpu_stats.system_cpu_usage - data.precpu_stats.system_cpu_usage;
|
||||
const cpuPercent = systemDelta > 0 ? ((cpuDelta / systemDelta) * data.cpu_stats.online_cpus * 100).toFixed(2) : '0.00';
|
||||
const ramUsage = (data.memory_stats.usage / (1024 * 1024)).toFixed(2) + ' MB';
|
||||
setContainerStats(prev => ({ ...prev, [container.Id]: { cpu: cpuPercent + '%', ram: ramUsage } }));
|
||||
}
|
||||
// Skip initial empty chunks where stats fields are missing
|
||||
if (!data.cpu_stats?.cpu_usage || !data.precpu_stats?.cpu_usage || !data.memory_stats?.usage) return;
|
||||
const cpuDelta = data.cpu_stats.cpu_usage.total_usage - data.precpu_stats.cpu_usage.total_usage;
|
||||
const systemDelta = (data.cpu_stats.system_cpu_usage || 0) - (data.precpu_stats.system_cpu_usage || 0);
|
||||
const onlineCpus = data.cpu_stats.online_cpus || 1;
|
||||
const cpuPercent = systemDelta > 0 ? ((cpuDelta / systemDelta) * onlineCpus * 100).toFixed(2) : '0.00';
|
||||
const ramUsage = (data.memory_stats.usage / (1024 * 1024)).toFixed(2) + ' MB';
|
||||
setContainerStats(prev => ({ ...prev, [container.Id]: { cpu: cpuPercent + '%', ram: ramUsage } }));
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
@@ -222,14 +224,16 @@ export default function EditorLayout() {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const deployStack = async () => {
|
||||
const deployStack = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedFile) return;
|
||||
const stackName = selectedFile.replace(/\.(yml|yaml)$/, '');
|
||||
try {
|
||||
await apiFetch(`/stacks/${selectedFile}/up`, {
|
||||
await apiFetch(`/stacks/${stackName}/up`, {
|
||||
method: 'POST',
|
||||
});
|
||||
// Refresh containers after deploy
|
||||
const containersRes = await apiFetch(`/stacks/${selectedFile}/containers`);
|
||||
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
|
||||
const conts = await containersRes.json();
|
||||
setContainers(Array.isArray(conts) ? conts : []);
|
||||
refreshStacks();
|
||||
@@ -238,14 +242,16 @@ export default function EditorLayout() {
|
||||
}
|
||||
};
|
||||
|
||||
const stopStack = async () => {
|
||||
const stopStack = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedFile) return;
|
||||
const stackName = selectedFile.replace(/\.(yml|yaml)$/, '');
|
||||
try {
|
||||
await apiFetch(`/stacks/${selectedFile}/down`, {
|
||||
await apiFetch(`/stacks/${stackName}/down`, {
|
||||
method: 'POST',
|
||||
});
|
||||
// Refresh containers after stop
|
||||
const containersRes = await apiFetch(`/stacks/${selectedFile}/containers`);
|
||||
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
|
||||
const conts = await containersRes.json();
|
||||
setContainers(Array.isArray(conts) ? conts : []);
|
||||
refreshStacks();
|
||||
@@ -254,17 +260,19 @@ export default function EditorLayout() {
|
||||
}
|
||||
};
|
||||
|
||||
const restartStack = async () => {
|
||||
const restartStack = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedFile) return;
|
||||
const stackName = selectedFile.replace(/\.(yml|yaml)$/, '');
|
||||
try {
|
||||
await apiFetch(`/stacks/${selectedFile}/down`, {
|
||||
await apiFetch(`/stacks/${stackName}/down`, {
|
||||
method: 'POST',
|
||||
});
|
||||
await apiFetch(`/stacks/${selectedFile}/up`, {
|
||||
await apiFetch(`/stacks/${stackName}/up`, {
|
||||
method: 'POST',
|
||||
});
|
||||
// Refresh containers after restart
|
||||
const containersRes = await apiFetch(`/stacks/${selectedFile}/containers`);
|
||||
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
|
||||
const conts = await containersRes.json();
|
||||
setContainers(Array.isArray(conts) ? conts : []);
|
||||
refreshStacks();
|
||||
@@ -273,14 +281,16 @@ export default function EditorLayout() {
|
||||
}
|
||||
};
|
||||
|
||||
const updateStack = async () => {
|
||||
const updateStack = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedFile) return;
|
||||
const stackName = selectedFile.replace(/\.(yml|yaml)$/, '');
|
||||
try {
|
||||
await apiFetch(`/stacks/${selectedFile}/update`, {
|
||||
await apiFetch(`/stacks/${stackName}/update`, {
|
||||
method: 'POST',
|
||||
});
|
||||
// Refresh containers after update
|
||||
const containersRes = await apiFetch(`/stacks/${selectedFile}/containers`);
|
||||
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
|
||||
const conts = await containersRes.json();
|
||||
setContainers(Array.isArray(conts) ? conts : []);
|
||||
refreshStacks();
|
||||
@@ -388,7 +398,7 @@ export default function EditorLayout() {
|
||||
// Safe content strings with fallback
|
||||
const safeContent = content || '';
|
||||
const safeEnvContent = envContent || '';
|
||||
|
||||
|
||||
// Stack state booleans for dynamic button rendering
|
||||
const isDeployed = safeContainers && safeContainers.length > 0;
|
||||
const isRunning = safeContainers?.some(c => c.State === 'running');
|
||||
@@ -478,11 +488,10 @@ export default function EditorLayout() {
|
||||
onClick={() => loadFile(file)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
stackStatuses[file] === 'running' ? 'bg-green-500' :
|
||||
stackStatuses[file] === 'exited' ? 'bg-red-500' : 'bg-gray-400'
|
||||
}`}
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${stackStatuses[file] === 'running' ? 'bg-green-500' :
|
||||
stackStatuses[file] === 'exited' ? 'bg-red-500' : 'bg-gray-400'
|
||||
}`}
|
||||
/>
|
||||
{getDisplayName(file)}
|
||||
</span>
|
||||
@@ -514,7 +523,7 @@ export default function EditorLayout() {
|
||||
title="Go to Home Dashboard"
|
||||
>
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
Home
|
||||
Home
|
||||
</Button>
|
||||
{/* Console Toggle */}
|
||||
<Button
|
||||
@@ -563,28 +572,29 @@ export default function EditorLayout() {
|
||||
{/* Action Bar */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{!isDeployed && (
|
||||
<Button size="sm" className="rounded-lg" onClick={deployStack}>
|
||||
<Button type="button" size="sm" className="rounded-lg" onClick={deployStack}>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Deploy
|
||||
</Button>
|
||||
)}
|
||||
{isDeployed && (
|
||||
<>
|
||||
<Button size="sm" variant="outline" className="rounded-lg" onClick={restartStack}>
|
||||
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={restartStack}>
|
||||
<RotateCw className="w-4 h-4 mr-2" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="rounded-lg" onClick={updateStack}>
|
||||
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={updateStack}>
|
||||
<CloudDownload className="w-4 h-4 mr-2" />
|
||||
Update
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="rounded-lg" onClick={stopStack}>
|
||||
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={stopStack}>
|
||||
<Square className="w-4 h-4 mr-2" />
|
||||
{isRunning ? 'Stop' : 'Down'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="rounded-lg"
|
||||
@@ -610,7 +620,6 @@ export default function EditorLayout() {
|
||||
{safeContainers.map(container => (
|
||||
<div key={container?.Id || Math.random()} className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium text-sm">{container?.Names?.[0]?.replace('/', '') || 'Unknown'}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={container?.State === 'running' ? 'default' : 'destructive'} className="text-xs">
|
||||
{container?.State || 'unknown'}
|
||||
@@ -621,37 +630,37 @@ export default function EditorLayout() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="rounded-lg h-8 w-8 p-0"
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="rounded-lg h-8 w-8 p-0"
|
||||
onClick={() => startContainer(container?.Id)}
|
||||
title="Start"
|
||||
>
|
||||
<Play className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="rounded-lg h-8 w-8 p-0"
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="rounded-lg h-8 w-8 p-0"
|
||||
onClick={() => stopContainer(container?.Id)}
|
||||
title="Stop"
|
||||
>
|
||||
<Square className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="rounded-lg h-8 w-8 p-0"
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="rounded-lg h-8 w-8 p-0"
|
||||
onClick={() => restartContainer(container?.Id)}
|
||||
title="Restart"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-lg h-8 px-2"
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-lg h-8 px-2"
|
||||
onClick={() => openBashModal(container?.Id, container?.Names?.[0]?.replace('/', '') || 'container')}
|
||||
disabled={container?.State !== 'running'}
|
||||
title="Open Bash"
|
||||
@@ -674,7 +683,7 @@ export default function EditorLayout() {
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Terminal</h3>
|
||||
<div className="h-[calc(100%-24px)]">
|
||||
<ErrorBoundary>
|
||||
<TerminalComponent />
|
||||
<TerminalComponent stackName={stackName} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,11 @@ import { FitAddon } from '@xterm/addon-fit';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
export default function TerminalComponent() {
|
||||
interface TerminalComponentProps {
|
||||
stackName?: string;
|
||||
}
|
||||
|
||||
export default function TerminalComponent({ stackName }: TerminalComponentProps) {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const terminalInstance = useRef<Terminal | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
@@ -66,12 +70,25 @@ export default function TerminalComponent() {
|
||||
}
|
||||
});
|
||||
|
||||
const ws = new WebSocket('ws://localhost:3000');
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const cleanStackName = stackName?.replace(/\.(yml|yaml)$/, '');
|
||||
|
||||
// If a stackName is provided, connect to the dedicated logs WebSocket
|
||||
// Otherwise, fall back to the generic terminal WebSocket
|
||||
const wsUrl = cleanStackName
|
||||
? `${wsProtocol}//${window.location.host}/api/stacks/${cleanStackName}/logs`
|
||||
: `${wsProtocol}//${window.location.host}`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (mounted) {
|
||||
ws.send(JSON.stringify({ action: 'connectTerminal' }));
|
||||
if (!cleanStackName) {
|
||||
// Generic terminal mode - send connect action
|
||||
ws.send(JSON.stringify({ action: 'connectTerminal' }));
|
||||
}
|
||||
// For stack logs mode, the server starts streaming automatically on connection
|
||||
}
|
||||
};
|
||||
|
||||
@@ -130,7 +147,7 @@ export default function TerminalComponent() {
|
||||
}
|
||||
fitAddonRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
}, [stackName]);
|
||||
|
||||
return <div ref={terminalRef} className="h-full w-full" />;
|
||||
}
|
||||
Reference in New Issue
Block a user