mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat: Implement Docker Compose stack management, container monitoring, and interactive bash terminal with new frontend and backend services.
This commit is contained in:
+25
-1
@@ -497,6 +497,7 @@ app.post('/api/stacks/:stackName/restart', async (req: Request, res: Response) =
|
||||
});
|
||||
|
||||
// Direct container stop - bypasses docker compose for legacy container support
|
||||
// Only stops containers that are currently running to avoid 304 errors
|
||||
app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
@@ -504,7 +505,9 @@ app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => {
|
||||
const containers = await dockerController.getContainersByStack(stackName);
|
||||
if (containers && containers.length > 0) {
|
||||
for (const c of containers) {
|
||||
await dockerController.stopContainer(c.Id);
|
||||
if (c.State === 'running') {
|
||||
await dockerController.stopContainer(c.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
res.json({ status: 'Containers stopped' });
|
||||
@@ -514,6 +517,27 @@ app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Direct container start - bypasses docker compose for legacy container support
|
||||
// Only starts containers that are not currently running
|
||||
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) {
|
||||
for (const c of containers) {
|
||||
if (c.State !== 'running') {
|
||||
await dockerController.startContainer(c.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
res.json({ status: 'Containers started' });
|
||||
} catch (error) {
|
||||
console.error('Failed to start containers:', error);
|
||||
res.status(500).json({ error: 'Failed to start containers' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update stack: pull images and recreate containers
|
||||
app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
@@ -162,12 +162,16 @@ export class ComposeService {
|
||||
}
|
||||
});
|
||||
|
||||
let errorLog = '';
|
||||
|
||||
upProcess.stdout.on('data', (data: Buffer) => {
|
||||
sendOutput(data.toString());
|
||||
});
|
||||
|
||||
upProcess.stderr.on('data', (data: Buffer) => {
|
||||
sendOutput(data.toString());
|
||||
const text = data.toString();
|
||||
errorLog += text;
|
||||
sendOutput(text);
|
||||
});
|
||||
|
||||
upProcess.on('close', (code: number | null) => {
|
||||
@@ -176,7 +180,7 @@ export class ComposeService {
|
||||
resolve();
|
||||
} else {
|
||||
sendOutput(`=== Update failed with code ${code} ===\n`);
|
||||
reject(new Error(`Up failed with code ${code}`));
|
||||
reject(new Error(errorLog.trim() || `Up failed with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -39,16 +39,16 @@ class DockerController {
|
||||
|
||||
public async getContainersByStack(stackName: string) {
|
||||
const stackDir = path.join(COMPOSE_DIR, stackName);
|
||||
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync('docker compose ps --format json -a', {
|
||||
const { stdout, stderr } = await execAsync('docker compose ps --format json -a', {
|
||||
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'
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Robust JSON parsing - handle both JSON array and newline-separated JSON objects
|
||||
// Docker Compose v2 may return either format depending on version
|
||||
interface ComposeContainer {
|
||||
@@ -57,9 +57,9 @@ class DockerController {
|
||||
State?: string;
|
||||
Status?: string;
|
||||
}
|
||||
|
||||
|
||||
let containers: ComposeContainer[] = [];
|
||||
|
||||
|
||||
// Only parse if stdout has content
|
||||
if (stdout && stdout.trim() !== '') {
|
||||
try {
|
||||
@@ -78,7 +78,7 @@ class DockerController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If containers found via docker compose ps, return them
|
||||
if (containers.length > 0) {
|
||||
// Map to frontend's expected interface
|
||||
@@ -91,16 +91,16 @@ class DockerController {
|
||||
Status: c.Status || ''
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
// SMART FALLBACK: Trigger when docker compose ps returns empty
|
||||
// This handles legacy containers with incorrect project labels
|
||||
return await this.smartFallback(stackName, stackDir);
|
||||
|
||||
|
||||
} catch (error) {
|
||||
// If command fails (e.g., stack not deployed, invalid YAML, missing env_file)
|
||||
const execError = error as { stderr?: string; message?: string };
|
||||
console.error(`Docker Compose Error for ${stackName}:`, execError.stderr || execError.message);
|
||||
|
||||
|
||||
// Try smart fallback even on error
|
||||
return await this.smartFallback(stackName, stackDir);
|
||||
}
|
||||
@@ -117,7 +117,7 @@ class DockerController {
|
||||
// Try multiple valid compose file names
|
||||
const composeFileNames = ['compose.yaml', 'docker-compose.yml', 'compose.yml', 'docker-compose.yaml'];
|
||||
let yamlContent: string | null = null;
|
||||
|
||||
|
||||
for (const fileName of composeFileNames) {
|
||||
try {
|
||||
yamlContent = await fs.readFile(path.join(stackDir, fileName), 'utf-8');
|
||||
@@ -127,14 +127,14 @@ class DockerController {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!yamlContent) {
|
||||
// No compose file found
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
const parsedYaml = yaml.parse(yamlContent);
|
||||
|
||||
|
||||
if (!parsedYaml || !parsedYaml.services) return [];
|
||||
|
||||
// 2. Extract expected container names with legacy prefix support
|
||||
@@ -155,7 +155,7 @@ class DockerController {
|
||||
|
||||
// 3. Query the raw Docker daemon
|
||||
const allContainers = await this.docker.listContainers({ all: true });
|
||||
|
||||
|
||||
// 4. Match containers by name
|
||||
const fallbackContainers = allContainers.filter(container => {
|
||||
// container.Names usually looks like ['/plex']
|
||||
@@ -213,7 +213,7 @@ class DockerController {
|
||||
public async execContainer(containerId: string, ws: WebSocket) {
|
||||
try {
|
||||
const container = this.docker.getContainer(containerId);
|
||||
|
||||
|
||||
// Try bash first, fall back to sh
|
||||
let exec: Docker.Exec;
|
||||
try {
|
||||
@@ -240,25 +240,24 @@ class DockerController {
|
||||
|
||||
this.execStream = stream;
|
||||
|
||||
// Handle output from container
|
||||
// Handle output from container - send raw text directly
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
ws.send(JSON.stringify({ type: 'output', data: chunk.toString() }));
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(chunk.toString());
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', (err: Error) => {
|
||||
ws.send(JSON.stringify({ type: 'error', message: err.message }));
|
||||
console.error('Exec stream error:', err.message);
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
ws.send(JSON.stringify({ type: 'exit' }));
|
||||
this.execStream = null;
|
||||
this.currentExec = null;
|
||||
});
|
||||
|
||||
ws.send(JSON.stringify({ type: 'connected' }));
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
ws.send(JSON.stringify({ type: 'error', message: err.message }));
|
||||
console.error('Failed to exec container:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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' }}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user