mirror of
https://github.com/johndoe6345789/docker-swarm-termina.git
synced 2026-04-24 21:55:13 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5daee2d445 | |||
|
|
a59b5ad527 |
@@ -23,8 +23,12 @@ import {
|
||||
import { Close, Send, Terminal as TerminalIcon, Code, Warning } from '@mui/icons-material';
|
||||
import { apiClient, API_BASE_URL } from '@/lib/api';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
|
||||
// Import types only (no runtime code)
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
import type { FitAddon } from '@xterm/addon-fit';
|
||||
|
||||
// Import CSS at top level (safe for SSR)
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
interface TerminalModalProps {
|
||||
@@ -101,146 +105,178 @@ export default function TerminalModal({
|
||||
useEffect(() => {
|
||||
if (!open || mode !== 'interactive' || !terminalRef.current) return;
|
||||
|
||||
// Create terminal instance
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: isMobile ? 12 : 14,
|
||||
fontFamily: '"Ubuntu Mono", "Courier New", monospace',
|
||||
theme: {
|
||||
background: '#300A24',
|
||||
foreground: '#F8F8F2',
|
||||
cursor: '#F8F8F2',
|
||||
black: '#2C0922',
|
||||
red: '#FF5555',
|
||||
green: '#50FA7B',
|
||||
yellow: '#F1FA8C',
|
||||
blue: '#8BE9FD',
|
||||
magenta: '#FF79C6',
|
||||
cyan: '#8BE9FD',
|
||||
white: '#F8F8F2',
|
||||
brightBlack: '#6272A4',
|
||||
brightRed: '#FF6E6E',
|
||||
brightGreen: '#69FF94',
|
||||
brightYellow: '#FFFFA5',
|
||||
brightBlue: '#D6ACFF',
|
||||
brightMagenta: '#FF92DF',
|
||||
brightCyan: '#A4FFFF',
|
||||
brightWhite: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
let term: Terminal | null = null;
|
||||
let fitAddon: FitAddon | null = null;
|
||||
let socket: Socket | null = null;
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(terminalRef.current);
|
||||
|
||||
// Fit terminal to container
|
||||
setTimeout(() => {
|
||||
// Dynamically import xterm modules (browser-only)
|
||||
const initTerminal = async () => {
|
||||
try {
|
||||
fitAddon.fit();
|
||||
} catch (e) {
|
||||
console.error('Error fitting terminal:', e);
|
||||
}
|
||||
}, 0);
|
||||
// Dynamic imports to avoid SSR issues
|
||||
const [{ Terminal }, { FitAddon }] = await Promise.all([
|
||||
import('@xterm/xterm'),
|
||||
import('@xterm/addon-fit'),
|
||||
]);
|
||||
|
||||
xtermRef.current = term;
|
||||
fitAddonRef.current = fitAddon;
|
||||
if (!terminalRef.current) return; // Component might have unmounted
|
||||
|
||||
// Connect to WebSocket
|
||||
const wsUrl = API_BASE_URL.replace(/^http/, 'ws');
|
||||
const socket = io(`${wsUrl}/terminal`, {
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
// Create terminal instance
|
||||
term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: isMobile ? 12 : 14,
|
||||
fontFamily: '"Ubuntu Mono", "Courier New", monospace',
|
||||
theme: {
|
||||
background: '#300A24',
|
||||
foreground: '#F8F8F2',
|
||||
cursor: '#F8F8F2',
|
||||
black: '#2C0922',
|
||||
red: '#FF5555',
|
||||
green: '#50FA7B',
|
||||
yellow: '#F1FA8C',
|
||||
blue: '#8BE9FD',
|
||||
magenta: '#FF79C6',
|
||||
cyan: '#8BE9FD',
|
||||
white: '#F8F8F2',
|
||||
brightBlack: '#6272A4',
|
||||
brightRed: '#FF6E6E',
|
||||
brightGreen: '#69FF94',
|
||||
brightYellow: '#FFFFA5',
|
||||
brightBlue: '#D6ACFF',
|
||||
brightMagenta: '#FF92DF',
|
||||
brightCyan: '#A4FFFF',
|
||||
brightWhite: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(terminalRef.current);
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('WebSocket connected');
|
||||
connectionAttempts.current = 0; // Reset on successful connection
|
||||
|
||||
// Start terminal session
|
||||
const token = apiClient.getToken();
|
||||
const termSize = fitAddon.proposeDimensions();
|
||||
socket.emit('start_terminal', {
|
||||
container_id: containerId,
|
||||
token: token,
|
||||
cols: termSize?.cols || 80,
|
||||
rows: termSize?.rows || 24,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('WebSocket connection error:', error);
|
||||
connectionAttempts.current++;
|
||||
|
||||
// After 2 failed attempts, fallback to simple mode
|
||||
if (connectionAttempts.current >= 2) {
|
||||
fallbackToSimpleMode('Failed to establish WebSocket connection. Network or server may be unavailable.');
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('started', () => {
|
||||
term.write('\r\n*** Interactive Terminal Started ***\r\n');
|
||||
term.write('You can now use sudo, nano, vim, and other interactive commands.\r\n\r\n');
|
||||
});
|
||||
|
||||
socket.on('output', (data: { data: string }) => {
|
||||
term.write(data.data);
|
||||
});
|
||||
|
||||
socket.on('error', (data: { error: string }) => {
|
||||
console.error('Terminal error:', data.error);
|
||||
term.write(`\r\n\x1b[31mError: ${data.error}\x1b[0m\r\n`);
|
||||
|
||||
// Check for critical errors that should trigger fallback
|
||||
const criticalErrors = ['Unauthorized', 'Cannot connect to Docker', 'Invalid session'];
|
||||
if (criticalErrors.some(err => data.error.includes(err))) {
|
||||
// Fit terminal to container
|
||||
setTimeout(() => {
|
||||
fallbackToSimpleMode(`Interactive terminal failed: ${data.error}`);
|
||||
}, 2000); // Give user time to see the error
|
||||
}
|
||||
});
|
||||
try {
|
||||
if (fitAddon) fitAddon.fit();
|
||||
} catch (e) {
|
||||
console.error('Error fitting terminal:', e);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
socket.on('exit', () => {
|
||||
term.write('\r\n\r\n*** Terminal Session Ended ***\r\n');
|
||||
});
|
||||
xtermRef.current = term;
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.log('WebSocket disconnected:', reason);
|
||||
// Connect to WebSocket
|
||||
const wsUrl = API_BASE_URL.replace(/^http/, 'ws');
|
||||
socket = io(`${wsUrl}/terminal`, {
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
|
||||
// If disconnect was unexpected and not user-initiated
|
||||
if (reason === 'transport error' || reason === 'transport close') {
|
||||
fallbackToSimpleMode('WebSocket connection lost unexpectedly.');
|
||||
}
|
||||
});
|
||||
socketRef.current = socket;
|
||||
|
||||
// Handle terminal input
|
||||
term.onData((data) => {
|
||||
socket.emit('input', { data });
|
||||
});
|
||||
socket.on('connect', () => {
|
||||
console.log('WebSocket connected');
|
||||
connectionAttempts.current = 0; // Reset on successful connection
|
||||
|
||||
// Handle terminal resize
|
||||
const handleResize = () => {
|
||||
try {
|
||||
fitAddon.fit();
|
||||
const termSize = fitAddon.proposeDimensions();
|
||||
if (termSize) {
|
||||
socket.emit('resize', {
|
||||
cols: termSize.cols,
|
||||
rows: termSize.rows,
|
||||
// Start terminal session
|
||||
const token = apiClient.getToken();
|
||||
const termSize = fitAddon?.proposeDimensions();
|
||||
socket?.emit('start_terminal', {
|
||||
container_id: containerId,
|
||||
token: token,
|
||||
cols: termSize?.cols || 80,
|
||||
rows: termSize?.rows || 24,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error resizing terminal:', e);
|
||||
});
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('WebSocket connection error:', error);
|
||||
connectionAttempts.current++;
|
||||
|
||||
// After 2 failed attempts, fallback to simple mode
|
||||
if (connectionAttempts.current >= 2) {
|
||||
fallbackToSimpleMode('Failed to establish WebSocket connection. Network or server may be unavailable.');
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('started', () => {
|
||||
term?.write('\r\n*** Interactive Terminal Started ***\r\n');
|
||||
term?.write('You can now use sudo, nano, vim, and other interactive commands.\r\n\r\n');
|
||||
});
|
||||
|
||||
socket.on('output', (data: { data: string }) => {
|
||||
term?.write(data.data);
|
||||
});
|
||||
|
||||
socket.on('error', (data: { error: string }) => {
|
||||
console.error('Terminal error:', data.error);
|
||||
term?.write(`\r\n\x1b[31mError: ${data.error}\x1b[0m\r\n`);
|
||||
|
||||
// Check for critical errors that should trigger fallback
|
||||
const criticalErrors = ['Unauthorized', 'Cannot connect to Docker', 'Invalid session'];
|
||||
if (criticalErrors.some(err => data.error.includes(err))) {
|
||||
setTimeout(() => {
|
||||
fallbackToSimpleMode(`Interactive terminal failed: ${data.error}`);
|
||||
}, 2000); // Give user time to see the error
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('exit', () => {
|
||||
term?.write('\r\n\r\n*** Terminal Session Ended ***\r\n');
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.log('WebSocket disconnected:', reason);
|
||||
|
||||
// If disconnect was unexpected and not user-initiated
|
||||
if (reason === 'transport error' || reason === 'transport close') {
|
||||
fallbackToSimpleMode('WebSocket connection lost unexpectedly.');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle terminal input
|
||||
term.onData((data) => {
|
||||
socket?.emit('input', { data });
|
||||
});
|
||||
|
||||
// Handle terminal resize
|
||||
const handleResize = () => {
|
||||
try {
|
||||
if (fitAddon) {
|
||||
fitAddon.fit();
|
||||
const termSize = fitAddon.proposeDimensions();
|
||||
if (termSize) {
|
||||
socket?.emit('resize', {
|
||||
cols: termSize.cols,
|
||||
rows: termSize.rows,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error resizing terminal:', e);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Return cleanup function for this terminal instance
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (term) term.dispose();
|
||||
if (socket) socket.disconnect();
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize terminal:', error);
|
||||
fallbackToSimpleMode('Failed to load terminal. Switching to simple mode.');
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
// Start terminal initialization
|
||||
const cleanup = initTerminal();
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
term.dispose();
|
||||
socket.disconnect();
|
||||
cleanup.then((cleanupFn) => {
|
||||
if (cleanupFn) cleanupFn();
|
||||
});
|
||||
xtermRef.current = null;
|
||||
socketRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
|
||||
105
frontend/package-lock.json
generated
105
frontend/package-lock.json
generated
@@ -12,9 +12,12 @@
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.7",
|
||||
"@mui/material": "^7.3.7",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"next": "16.1.5",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"socket.io-client": "^4.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -1639,6 +1642,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
@@ -2599,6 +2608,21 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
@@ -3339,6 +3363,28 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
"version": "6.6.4",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
|
||||
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.18.3",
|
||||
"xmlhttprequest-ssl": "~2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.18.4",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
|
||||
@@ -6405,6 +6451,34 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-client": "~6.6.1",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
|
||||
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||
@@ -7107,6 +7181,35 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xmlhttprequest-ssl": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
Reference in New Issue
Block a user