feat: Implement Docker Compose stack management, container monitoring, and interactive bash terminal with new frontend and backend services.

This commit is contained in:
SaelixCode
2026-02-22 14:40:54 -05:00
parent 280090bd2b
commit d5fac02da3
6 changed files with 132 additions and 133 deletions
+9 -19
View File
@@ -39,7 +39,7 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(terminalRef.current);
setTimeout(() => {
fitAddon.fit();
}, 100);
@@ -48,7 +48,8 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN
fitAddonRef.current = fitAddon;
// Connect to WebSocket for bash exec
const ws = new WebSocket('ws://localhost:3000');
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${wsProtocol}//${window.location.host}`);
wsRef.current = ws;
ws.onopen = () => {
@@ -60,20 +61,8 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'output') {
term.write(data.data);
} else if (data.type === 'error') {
term.write(`\r\n\x1b[31mError: ${data.message}\x1b[0m\r\n`);
} else if (data.type === 'exit') {
term.write('\r\n\x1b[33mSession ended\x1b[0m\r\n');
setIsConnected(false);
}
} catch {
// Raw output
term.write(event.data);
}
// Write raw text directly to terminal
term.write(event.data);
};
ws.onerror = () => {
@@ -82,6 +71,7 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN
};
ws.onclose = () => {
term.write('\r\n\x1b[33mSession ended\x1b[0m\r\n');
setIsConnected(false);
};
@@ -110,7 +100,7 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
@@ -165,8 +155,8 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN
<X className="w-4 h-4" />
</Button>
</DialogHeader>
<div
ref={terminalRef}
<div
ref={terminalRef}
className="flex-1 rounded-lg overflow-hidden bg-[#1e1e1e] p-2"
style={{ minHeight: '500px' }}
/>
+66 -85
View File
@@ -10,7 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogD
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Badge } from './ui/badge';
import { Plus, Trash2, Play, Square, Save, RefreshCw, Terminal, Sun, Moon, RotateCw, CloudDownload, Pencil, X, Search, Home, LogOut } from 'lucide-react';
import { Plus, Trash2, Play, Square, Save, Terminal, Sun, Moon, RotateCw, CloudDownload, Pencil, X, Search, Home, LogOut } from 'lucide-react';
import { useAuth } from '@/context/AuthContext';
import { apiFetch } from '@/lib/api';
@@ -41,6 +41,7 @@ export default function EditorLayout() {
const [newStackName, setNewStackName] = useState('');
const [stackToDelete, setStackToDelete] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isActionLoading, setIsActionLoading] = useState(false);
const [isFileLoading, setIsFileLoading] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(true);
const [showConsole, setShowConsole] = useState(true);
@@ -62,8 +63,8 @@ export default function EditorLayout() {
}
}, [isDarkMode]);
const refreshStacks = async () => {
setIsLoading(true);
const refreshStacks = async (background = false) => {
if (!background) setIsLoading(true);
try {
const res = await apiFetch('/stacks');
const stacks = await res.json();
@@ -226,8 +227,10 @@ export default function EditorLayout() {
const deployStack = async (e: React.MouseEvent) => {
e.preventDefault();
if (!selectedFile) return;
e.stopPropagation();
if (!selectedFile || isActionLoading) return;
const stackName = selectedFile.replace(/\.(yml|yaml)$/, '');
setIsActionLoading(true);
try {
await apiFetch(`/stacks/${stackName}/up`, {
method: 'POST',
@@ -236,16 +239,20 @@ export default function EditorLayout() {
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
const conts = await containersRes.json();
setContainers(Array.isArray(conts) ? conts : []);
refreshStacks();
await refreshStacks(true);
} catch (error) {
console.error('Failed to deploy:', error);
} finally {
setIsActionLoading(false);
}
};
const stopStack = async (e: React.MouseEvent) => {
e.preventDefault();
if (!selectedFile) return;
e.stopPropagation();
if (!selectedFile || isActionLoading) return;
const stackName = selectedFile.replace(/\.(yml|yaml)$/, '');
setIsActionLoading(true);
try {
await apiFetch(`/stacks/${stackName}/stop`, {
method: 'POST',
@@ -254,16 +261,42 @@ export default function EditorLayout() {
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
const conts = await containersRes.json();
setContainers(Array.isArray(conts) ? conts : []);
refreshStacks();
await refreshStacks(true);
} catch (error) {
console.error('Failed to stop:', error);
} finally {
setIsActionLoading(false);
}
};
const startStack = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!selectedFile || isActionLoading) return;
const stackName = selectedFile.replace(/\.(yml|yaml)$/, '');
setIsActionLoading(true);
try {
await apiFetch(`/stacks/${stackName}/start`, {
method: 'POST',
});
// Refresh containers after start
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
const conts = await containersRes.json();
setContainers(Array.isArray(conts) ? conts : []);
await refreshStacks(true);
} catch (error) {
console.error('Failed to start:', error);
} finally {
setIsActionLoading(false);
}
};
const restartStack = async (e: React.MouseEvent) => {
e.preventDefault();
if (!selectedFile) return;
e.stopPropagation();
if (!selectedFile || isActionLoading) return;
const stackName = selectedFile.replace(/\.(yml|yaml)$/, '');
setIsActionLoading(true);
try {
await apiFetch(`/stacks/${stackName}/restart`, {
method: 'POST',
@@ -272,16 +305,20 @@ export default function EditorLayout() {
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
const conts = await containersRes.json();
setContainers(Array.isArray(conts) ? conts : []);
refreshStacks();
await refreshStacks(true);
} catch (error) {
console.error('Failed to restart:', error);
} finally {
setIsActionLoading(false);
}
};
const updateStack = async (e: React.MouseEvent) => {
e.preventDefault();
if (!selectedFile) return;
e.stopPropagation();
if (!selectedFile || isActionLoading) return;
const stackName = selectedFile.replace(/\.(yml|yaml)$/, '');
setIsActionLoading(true);
try {
await apiFetch(`/stacks/${stackName}/update`, {
method: 'POST',
@@ -290,9 +327,11 @@ export default function EditorLayout() {
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
const conts = await containersRes.json();
setContainers(Array.isArray(conts) ? conts : []);
refreshStacks();
await refreshStacks(true);
} catch (error) {
console.error('Failed to update:', error);
} finally {
setIsActionLoading(false);
}
};
@@ -322,45 +361,6 @@ export default function EditorLayout() {
}
};
const startContainer = async (id: string) => {
if (!id || !selectedFile) return;
try {
await apiFetch(`/containers/${id}/start`, { method: 'POST' });
const containersRes = await apiFetch(`/stacks/${selectedFile}/containers`);
const conts = await containersRes.json();
setContainers(Array.isArray(conts) ? conts : []);
refreshStacks();
} catch (error) {
console.error('Failed to start container:', error);
}
};
const stopContainer = async (id: string) => {
if (!id || !selectedFile) return;
try {
await apiFetch(`/containers/${id}/stop`, { method: 'POST' });
const containersRes = await apiFetch(`/stacks/${selectedFile}/containers`);
const conts = await containersRes.json();
setContainers(Array.isArray(conts) ? conts : []);
refreshStacks();
} catch (error) {
console.error('Failed to stop container:', error);
}
};
const restartContainer = async (id: string) => {
if (!id || !selectedFile) return;
try {
await apiFetch(`/containers/${id}/restart`, { method: 'POST' });
const containersRes = await apiFetch(`/stacks/${selectedFile}/containers`);
const conts = await containersRes.json();
setContainers(Array.isArray(conts) ? conts : []);
refreshStacks();
} catch (error) {
console.error('Failed to restart container:', error);
}
};
const handleCreateStack = async () => {
if (!newStackName.trim()) return;
// Send stackName directly (no .yml extension - backend creates directory)
@@ -569,25 +569,32 @@ export default function EditorLayout() {
{/* Action Bar */}
<div className="flex items-center gap-2 flex-wrap">
{!isDeployed && (
<Button type="button" size="sm" className="rounded-lg" onClick={deployStack}>
<Button type="button" size="sm" className="rounded-lg" onClick={deployStack} disabled={isActionLoading}>
<Play className="w-4 h-4 mr-2" />
Deploy
{isActionLoading ? 'Working...' : 'Deploy'}
</Button>
)}
{isDeployed && (
<>
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={restartStack}>
{isRunning ? (
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={stopStack} disabled={isActionLoading}>
<Square className="w-4 h-4 mr-2" />
{isActionLoading ? 'Working...' : 'Stop'}
</Button>
) : (
<Button type="button" size="sm" className="rounded-lg" onClick={startStack} disabled={isActionLoading}>
<Play className="w-4 h-4 mr-2" />
{isActionLoading ? 'Working...' : 'Start'}
</Button>
)}
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={restartStack} disabled={isActionLoading}>
<RotateCw className="w-4 h-4 mr-2" />
Restart
</Button>
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={updateStack}>
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={updateStack} disabled={isActionLoading}>
<CloudDownload className="w-4 h-4 mr-2" />
Update
</Button>
<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
@@ -595,6 +602,7 @@ export default function EditorLayout() {
size="sm"
variant="destructive"
className="rounded-lg"
disabled={isActionLoading}
onClick={() => {
setStackToDelete(selectedFile);
setDeleteDialogOpen(true);
@@ -627,33 +635,6 @@ 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"
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"
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"
onClick={() => restartContainer(container?.Id)}
title="Restart"
>
<RefreshCw className="w-3 h-3" />
</Button>
<Button
size="sm"
variant="outline"
+2 -1
View File
@@ -94,7 +94,8 @@ export default function TerminalComponent({ stackName }: TerminalComponentProps)
ws.onmessage = (event) => {
if (mounted && terminalInstance.current) {
terminalInstance.current.write(event.data);
const text = typeof event.data === 'string' ? event.data : event.data.toString();
terminalInstance.current.write(text.replace(/\r?\n/g, '\r\n'));
}
};