8 Commits

Author SHA1 Message Date
5daee2d445 Merge pull request #12 from johndoe6345789/claude/setup-docker-nodejs-rmRNK
Fix SSR compatibility for xterm terminal component
2026-01-30 21:55:48 +00:00
Claude
a59b5ad527 Fix SSR build error by dynamically importing xterm modules
The Next.js build was failing with "ReferenceError: self is not defined"
during static page generation for the /dashboard page. This occurred because
the @xterm/xterm library uses browser-specific APIs like 'self' which are
not available during server-side rendering.

Fixed by:
- Converting xterm Terminal and FitAddon imports to dynamic imports
- Using type-only imports for TypeScript types
- Loading modules asynchronously only when the component is mounted in the browser
- Keeping CSS import at top level (safe for SSR as it's handled by bundler)

This ensures xterm code only executes in the browser environment while
maintaining full functionality of the interactive terminal feature.

https://claude.ai/code/session_01FwrvrFYEkL58b9HxjGDNUM
2026-01-30 21:54:00 +00:00
995b7442d7 Merge pull request #11 from johndoe6345789/claude/improve-responsiveness-add-buttons-VTdzq
Add container lifecycle management and improve mobile responsiveness
2026-01-30 21:40:52 +00:00
Claude
ce997ebdda Add automatic fallback to Simple mode when Interactive terminal fails
Frontend changes (TerminalModal.tsx):
- Added fallback state tracking (interactiveFailed, fallbackReason)
- Implemented fallbackToSimpleMode() function for automatic mode switching
- Added connection attempt tracking to prevent infinite retry loops
- Enhanced WebSocket error handling:
  * Detects connection errors (connect_error event)
  * Falls back after 2 failed connection attempts
  * Identifies critical errors (auth, Docker connection failures)
  * Handles unexpected disconnections (transport errors)
- Added prominent notification system:
  * Snackbar alert appears at top-center when fallback occurs
  * Displays reason for fallback to user
  * Includes "Retry" button for easy reconnection attempt
  * Auto-dismisses after 10 seconds
- Visual feedback in mode toggle:
  * Interactive button shows warning icon when failed
  * Orange color indicates failure state
  * Tooltip updates to show failure and retry option
- Smart retry functionality:
  * Resets failure state and connection attempts
  * Clears error messages on retry
  * Can be triggered via notification button or toggle switch

User experience improvements:
- No silent failures - users always know why Interactive mode didn't work
- One-click retry makes recovering from transient errors easy
- Automatic fallback ensures terminal always works (degrades gracefully)
- Clear visual indicators prevent confusion about current mode state

https://claude.ai/code/session_01UFVy14uUD5Q7DjkUSgUFXC
2026-01-30 21:38:56 +00:00
Claude
d9c790c560 Add interactive terminal mode with sudo, nano, and vim support
Backend changes:
- Added flask-socketio and python-socketio for WebSocket support
- Implemented WebSocket endpoint /terminal for interactive terminal sessions
- Added bidirectional communication between client and container PTY
- Enabled full bash shell with stdin support for interactive commands
- Updated server startup to use socketio.run

Frontend changes:
- Added xterm.js (@xterm/xterm) and socket.io-client dependencies
- Added FitAddon for responsive terminal sizing
- Implemented mode toggle between "Simple" and "Interactive" modes
- Created interactive terminal with full PTY support using xterm.js
- Connected WebSocket to backend for real-time command execution
- Added empty directory detection for ls commands in simple mode
- Terminal now defaults to interactive mode for better UX

Features:
- Interactive mode supports sudo with password prompts
- Full support for interactive editors (nano, vim, emacs)
- Proper terminal emulation with color support and control sequences
- Responsive terminal sizing and window resize handling
- Empty folder detection shows "(empty directory)" message
- Mode toggle allows switching between simple and interactive modes

https://claude.ai/code/session_01UFVy14uUD5Q7DjkUSgUFXC
2026-01-30 21:33:54 +00:00
Claude
237ebcede1 Improve responsiveness and add container control buttons
- Added backend API endpoints for start/stop/restart/remove container operations
- Updated frontend API client with new container control methods
- Added start/stop/restart/remove buttons to ContainerCard with status-based visibility
- Added confirmation dialog for container removal
- Improved AppBar responsiveness with icon-only buttons on mobile screens
- Enhanced TerminalModal responsiveness:
  * Fullscreen mode on mobile devices
  * Stacked input layout on small screens
  * Icon-only send button on mobile
  * Responsive font sizes and spacing
- Added responsive typography using clamp() for fluid scaling
- Improved spacing and layout for mobile devices:
  * Reduced padding on small screens
  * Responsive grid layout for container metadata
  * Adaptive title sizes
- Added real-time notifications with Snackbar for operation feedback

https://claude.ai/code/session_01UFVy14uUD5Q7DjkUSgUFXC
2026-01-30 21:27:49 +00:00
2e176f3048 Merge pull request #10 from johndoe6345789/claude/fix-terminal-modal-typescript-X6MVx
Fix terminal input text color styling in TerminalModal
2026-01-30 20:54:42 +00:00
Claude
938cb5a0ba Fix duplicate '& input' property in TerminalModal TextField sx prop
Merged two duplicate '& input' style properties into a single object to resolve TypeScript error during build. The color property is now combined with fontFamily, fontSize, and padding in one declaration.

https://claude.ai/code/session_X6MVx
2026-01-30 20:52:32 +00:00
9 changed files with 1257 additions and 174 deletions

View File

@@ -1,9 +1,12 @@
from flask import Flask, jsonify, request
from flask_cors import CORS
from flask_socketio import SocketIO, emit, disconnect
import docker
import os
import sys
import logging
import threading
import select
from datetime import datetime, timedelta
# Configure logging
@@ -17,7 +20,8 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
app = Flask(__name__)
CORS(app)
CORS(app, resources={r"/*": {"origins": "*"}})
socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading')
# Simple in-memory session storage (in production, use proper session management)
sessions = {}
@@ -340,11 +344,262 @@ def exec_container(container_id):
logger.error(f"Error executing command: {e}", exc_info=True)
return jsonify({'error': str(e)}), 500
@app.route('/api/containers/<container_id>/start', methods=['POST'])
def start_container(container_id):
"""Start a stopped container"""
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return jsonify({'error': 'Unauthorized'}), 401
token = auth_header.split(' ')[1]
if token not in sessions:
return jsonify({'error': 'Invalid session'}), 401
client = get_docker_client()
if not client:
return jsonify({'error': 'Cannot connect to Docker'}), 500
try:
container = client.containers.get(container_id)
container.start()
logger.info(f"Started container {container_id}")
return jsonify({'success': True, 'message': f'Container {container_id} started'})
except Exception as e:
logger.error(f"Error starting container: {e}", exc_info=True)
return jsonify({'error': str(e)}), 500
@app.route('/api/containers/<container_id>/stop', methods=['POST'])
def stop_container(container_id):
"""Stop a running container"""
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return jsonify({'error': 'Unauthorized'}), 401
token = auth_header.split(' ')[1]
if token not in sessions:
return jsonify({'error': 'Invalid session'}), 401
client = get_docker_client()
if not client:
return jsonify({'error': 'Cannot connect to Docker'}), 500
try:
container = client.containers.get(container_id)
container.stop()
logger.info(f"Stopped container {container_id}")
return jsonify({'success': True, 'message': f'Container {container_id} stopped'})
except Exception as e:
logger.error(f"Error stopping container: {e}", exc_info=True)
return jsonify({'error': str(e)}), 500
@app.route('/api/containers/<container_id>/restart', methods=['POST'])
def restart_container(container_id):
"""Restart a container"""
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return jsonify({'error': 'Unauthorized'}), 401
token = auth_header.split(' ')[1]
if token not in sessions:
return jsonify({'error': 'Invalid session'}), 401
client = get_docker_client()
if not client:
return jsonify({'error': 'Cannot connect to Docker'}), 500
try:
container = client.containers.get(container_id)
container.restart()
logger.info(f"Restarted container {container_id}")
return jsonify({'success': True, 'message': f'Container {container_id} restarted'})
except Exception as e:
logger.error(f"Error restarting container: {e}", exc_info=True)
return jsonify({'error': str(e)}), 500
@app.route('/api/containers/<container_id>', methods=['DELETE'])
def remove_container(container_id):
"""Remove a container"""
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return jsonify({'error': 'Unauthorized'}), 401
token = auth_header.split(' ')[1]
if token not in sessions:
return jsonify({'error': 'Invalid session'}), 401
client = get_docker_client()
if not client:
return jsonify({'error': 'Cannot connect to Docker'}), 500
try:
container = client.containers.get(container_id)
# Force remove (including if running)
container.remove(force=True)
logger.info(f"Removed container {container_id}")
return jsonify({'success': True, 'message': f'Container {container_id} removed'})
except Exception as e:
logger.error(f"Error removing container: {e}", exc_info=True)
return jsonify({'error': str(e)}), 500
@app.route('/api/health', methods=['GET'])
def health():
"""Health check endpoint"""
return jsonify({'status': 'healthy'})
# WebSocket handlers for interactive terminal
active_terminals = {}
@socketio.on('connect', namespace='/terminal')
def handle_connect():
"""Handle WebSocket connection"""
logger.info(f"Client connected to terminal WebSocket: {request.sid}")
@socketio.on('disconnect', namespace='/terminal')
def handle_disconnect():
"""Handle WebSocket disconnection"""
logger.info(f"Client disconnected from terminal WebSocket: {request.sid}")
# Clean up any active terminal sessions
if request.sid in active_terminals:
try:
exec_instance = active_terminals[request.sid]['exec']
# Try to stop the exec instance
if hasattr(exec_instance, 'kill'):
exec_instance.kill()
except:
pass
del active_terminals[request.sid]
@socketio.on('start_terminal', namespace='/terminal')
def handle_start_terminal(data):
"""Start an interactive terminal session"""
try:
container_id = data.get('container_id')
token = data.get('token')
cols = data.get('cols', 80)
rows = data.get('rows', 24)
# Validate token
if not token or token not in sessions:
emit('error', {'error': 'Unauthorized'})
disconnect()
return
# Get Docker client and container
client = get_docker_client()
if not client:
emit('error', {'error': 'Cannot connect to Docker'})
return
container = client.containers.get(container_id)
# Create an interactive bash session with PTY
exec_instance = container.exec_run(
['/bin/bash'],
stdin=True,
stdout=True,
stderr=True,
tty=True,
socket=True,
environment={
'TERM': 'xterm-256color',
'COLUMNS': str(cols),
'LINES': str(rows),
'LANG': 'C.UTF-8'
}
)
# Store the exec instance
active_terminals[request.sid] = {
'exec': exec_instance,
'container_id': container_id
}
# Start a thread to read from the container and send to client
def read_output():
sock = exec_instance.output
try:
while True:
# Check if socket is still connected
if request.sid not in active_terminals:
break
try:
# Read data from container
data = sock.recv(4096)
if not data:
break
# Send to client
try:
decoded_data = data.decode('utf-8')
except UnicodeDecodeError:
decoded_data = data.decode('latin-1', errors='replace')
socketio.emit('output', {'data': decoded_data},
namespace='/terminal', room=request.sid)
except Exception as e:
logger.error(f"Error reading from container: {e}")
break
finally:
# Clean up
if request.sid in active_terminals:
del active_terminals[request.sid]
try:
sock.close()
except:
pass
socketio.emit('exit', {'code': 0},
namespace='/terminal', room=request.sid)
# Start the output reader thread
output_thread = threading.Thread(target=read_output, daemon=True)
output_thread.start()
emit('started', {'message': 'Terminal started'})
except Exception as e:
logger.error(f"Error starting terminal: {e}", exc_info=True)
emit('error', {'error': str(e)})
@socketio.on('input', namespace='/terminal')
def handle_input(data):
"""Handle input from the client"""
try:
if request.sid not in active_terminals:
emit('error', {'error': 'No active terminal session'})
return
terminal_data = active_terminals[request.sid]
exec_instance = terminal_data['exec']
input_data = data.get('data', '')
# Send input to the container
sock = exec_instance.output
sock.send(input_data.encode('utf-8'))
except Exception as e:
logger.error(f"Error sending input: {e}", exc_info=True)
emit('error', {'error': str(e)})
@socketio.on('resize', namespace='/terminal')
def handle_resize(data):
"""Handle terminal resize"""
try:
cols = data.get('cols', 80)
rows = data.get('rows', 24)
if request.sid in active_terminals:
terminal_data = active_terminals[request.sid]
exec_instance = terminal_data['exec']
# Note: Docker exec_run doesn't support resizing after creation
# This is a limitation of the Docker API
# We acknowledge the resize but can't actually resize the PTY
logger.info(f"Terminal resize requested: {cols}x{rows}")
except Exception as e:
logger.error(f"Error resizing terminal: {e}", exc_info=True)
if __name__ == '__main__':
# Run diagnostics on startup
logger.info("Backend server starting...")
@@ -357,4 +612,4 @@ if __name__ == '__main__':
else:
logger.error("✗ Docker connection FAILED on startup - check logs above for details")
app.run(host='0.0.0.0', port=5000, debug=True)
socketio.run(app, host='0.0.0.0', port=5000, debug=True, allow_unsafe_werkzeug=True)

View File

@@ -2,3 +2,5 @@ Flask==3.0.0
Flask-CORS==6.0.0
python-dotenv==1.0.0
docker==7.1.0
flask-socketio==5.3.6
python-socketio==5.11.0

View File

@@ -12,6 +12,8 @@ import {
Toolbar,
IconButton,
CircularProgress,
useMediaQuery,
useTheme,
} from '@mui/material';
import { Logout, Refresh, Inventory2 } from '@mui/icons-material';
import { useAuth } from '@/lib/auth';
@@ -22,6 +24,8 @@ import TerminalModal from '@/components/TerminalModal';
export default function Dashboard() {
const { isAuthenticated, loading: authLoading, logout } = useAuth();
const router = useRouter();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const [containers, setContainers] = useState<ContainerType[]>([]);
const [selectedContainer, setSelectedContainer] = useState<ContainerType | null>(null);
const [isTerminalOpen, setIsTerminalOpen] = useState(false);
@@ -123,39 +127,66 @@ export default function Dashboard() {
<Box>
<Typography
variant="h1"
sx={{ fontFamily: '"JetBrains Mono", monospace', fontSize: '1.5rem' }}
sx={{
fontFamily: '"JetBrains Mono", monospace',
fontSize: { xs: '1.1rem', sm: '1.5rem' }
}}
>
Container Shell
</Typography>
<Typography variant="caption" color="text.secondary">
{containers.length} active {containers.length === 1 ? 'container' : 'containers'}
</Typography>
{!isMobile && (
<Typography variant="caption" color="text.secondary">
{containers.length} active {containers.length === 1 ? 'container' : 'containers'}
</Typography>
)}
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="outlined"
size="small"
onClick={handleRefresh}
disabled={isRefreshing}
startIcon={isRefreshing ? <CircularProgress size={16} /> : <Refresh />}
>
Refresh
</Button>
<Button
variant="outlined"
size="small"
onClick={handleLogout}
startIcon={<Logout />}
>
Logout
</Button>
{isMobile ? (
<>
<IconButton
color="inherit"
onClick={handleRefresh}
disabled={isRefreshing}
size="small"
>
{isRefreshing ? <CircularProgress size={20} /> : <Refresh />}
</IconButton>
<IconButton
color="inherit"
onClick={handleLogout}
size="small"
>
<Logout />
</IconButton>
</>
) : (
<>
<Button
variant="outlined"
size="small"
onClick={handleRefresh}
disabled={isRefreshing}
startIcon={isRefreshing ? <CircularProgress size={16} /> : <Refresh />}
>
Refresh
</Button>
<Button
variant="outlined"
size="small"
onClick={handleLogout}
startIcon={<Logout />}
>
Logout
</Button>
</>
)}
</Box>
</Toolbar>
</AppBar>
<Container maxWidth="xl" sx={{ py: 4 }}>
<Container maxWidth="xl" sx={{ py: { xs: 2, sm: 3, md: 4 } }}>
{error && (
<Box sx={{ mb: 2, p: 2, bgcolor: 'error.dark', borderRadius: 1 }}>
<Typography color="error.contrastText">{error}</Typography>
@@ -202,6 +233,7 @@ export default function Dashboard() {
<ContainerCard
container={container}
onOpenShell={() => handleOpenShell(container)}
onContainerUpdate={fetchContainers}
/>
</Grid>
))}

View File

@@ -1,6 +1,6 @@
'use client';
import React from 'react';
import React, { useState } from 'react';
import {
Card,
CardContent,
@@ -9,26 +9,104 @@ import {
Box,
Chip,
Divider,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
CircularProgress,
Alert,
Snackbar,
} from '@mui/material';
import { Terminal, PlayArrow, Inventory2 } from '@mui/icons-material';
import { Container } from '@/lib/api';
import { Terminal, PlayArrow, Stop, Refresh, Delete, Inventory2 } from '@mui/icons-material';
import { Container, apiClient } from '@/lib/api';
interface ContainerCardProps {
container: Container;
onOpenShell: () => void;
onContainerUpdate?: () => void;
}
export default function ContainerCard({ container, onOpenShell }: ContainerCardProps) {
export default function ContainerCard({ container, onOpenShell, onContainerUpdate }: ContainerCardProps) {
const [isLoading, setIsLoading] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
open: false,
message: '',
severity: 'success',
});
const statusColors = {
running: 'success',
stopped: 'default',
paused: 'warning',
exited: 'default',
created: 'info',
} as const;
const borderColors = {
running: '#38b2ac',
stopped: '#718096',
paused: '#ecc94b',
exited: '#718096',
created: '#4299e1',
};
const handleStart = async () => {
setIsLoading(true);
try {
await apiClient.startContainer(container.id);
setSnackbar({ open: true, message: 'Container started successfully', severity: 'success' });
onContainerUpdate?.();
} catch (error) {
setSnackbar({ open: true, message: `Failed to start: ${error instanceof Error ? error.message : 'Unknown error'}`, severity: 'error' });
} finally {
setIsLoading(false);
}
};
const handleStop = async () => {
setIsLoading(true);
try {
await apiClient.stopContainer(container.id);
setSnackbar({ open: true, message: 'Container stopped successfully', severity: 'success' });
onContainerUpdate?.();
} catch (error) {
setSnackbar({ open: true, message: `Failed to stop: ${error instanceof Error ? error.message : 'Unknown error'}`, severity: 'error' });
} finally {
setIsLoading(false);
}
};
const handleRestart = async () => {
setIsLoading(true);
try {
await apiClient.restartContainer(container.id);
setSnackbar({ open: true, message: 'Container restarted successfully', severity: 'success' });
onContainerUpdate?.();
} catch (error) {
setSnackbar({ open: true, message: `Failed to restart: ${error instanceof Error ? error.message : 'Unknown error'}`, severity: 'error' });
} finally {
setIsLoading(false);
}
};
const handleRemove = async () => {
setShowDeleteDialog(false);
setIsLoading(true);
try {
await apiClient.removeContainer(container.id);
setSnackbar({ open: true, message: 'Container removed successfully', severity: 'success' });
onContainerUpdate?.();
} catch (error) {
setSnackbar({ open: true, message: `Failed to remove: ${error instanceof Error ? error.message : 'Unknown error'}`, severity: 'error' });
} finally {
setIsLoading(false);
}
};
const handleCloseSnackbar = () => {
setSnackbar({ ...snackbar, open: false });
};
return (
@@ -97,7 +175,7 @@ export default function ContainerCard({ container, onOpenShell }: ContainerCardP
<Divider sx={{ my: 2 }} />
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2, mb: 3 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' }, gap: 2, mb: 3 }}>
<Box>
<Typography
variant="caption"
@@ -140,23 +218,137 @@ export default function ContainerCard({ container, onOpenShell }: ContainerCardP
</Box>
</Box>
<Button
fullWidth
variant="contained"
color="primary"
onClick={onOpenShell}
disabled={container.status !== 'running'}
startIcon={<Terminal />}
sx={{
fontWeight: 500,
'&:hover': {
backgroundColor: 'secondary.main',
},
}}
>
Open Shell
</Button>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{/* Action buttons based on status */}
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{(container.status === 'stopped' || container.status === 'exited' || container.status === 'created') && (
<Button
variant="contained"
size="small"
onClick={handleStart}
disabled={isLoading}
startIcon={isLoading ? <CircularProgress size={16} /> : <PlayArrow />}
sx={{
flex: 1,
minWidth: '100px',
backgroundColor: '#38b2ac',
'&:hover': { backgroundColor: '#2c8a84' },
}}
>
Start
</Button>
)}
{container.status === 'running' && (
<>
<Button
variant="contained"
size="small"
onClick={handleStop}
disabled={isLoading}
startIcon={isLoading ? <CircularProgress size={16} /> : <Stop />}
sx={{
flex: 1,
minWidth: '100px',
backgroundColor: '#f56565',
'&:hover': { backgroundColor: '#e53e3e' },
}}
>
Stop
</Button>
<Button
variant="outlined"
size="small"
onClick={handleRestart}
disabled={isLoading}
startIcon={isLoading ? <CircularProgress size={16} /> : <Refresh />}
sx={{
flex: 1,
minWidth: '100px',
borderColor: '#ecc94b',
color: '#ecc94b',
'&:hover': {
borderColor: '#d69e2e',
backgroundColor: 'rgba(236, 201, 75, 0.1)',
},
}}
>
Restart
</Button>
</>
)}
<Button
variant="outlined"
size="small"
onClick={() => setShowDeleteDialog(true)}
disabled={isLoading}
startIcon={<Delete />}
sx={{
minWidth: '100px',
borderColor: '#fc8181',
color: '#fc8181',
'&:hover': {
borderColor: '#f56565',
backgroundColor: 'rgba(252, 129, 129, 0.1)',
},
}}
>
Remove
</Button>
</Box>
{/* Terminal button */}
<Button
fullWidth
variant="contained"
color="primary"
onClick={onOpenShell}
disabled={container.status !== 'running' || isLoading}
startIcon={<Terminal />}
sx={{
fontWeight: 500,
'&:hover': {
backgroundColor: 'secondary.main',
},
}}
>
Open Shell
</Button>
</Box>
</CardContent>
{/* Delete confirmation dialog */}
<Dialog
open={showDeleteDialog}
onClose={() => setShowDeleteDialog(false)}
>
<DialogTitle>Confirm Container Removal</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to remove container <strong>{container.name}</strong>?
This action cannot be undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setShowDeleteDialog(false)}>Cancel</Button>
<Button onClick={handleRemove} color="error" variant="contained">
Remove
</Button>
</DialogActions>
</Dialog>
{/* Snackbar for notifications */}
<Snackbar
open={snackbar.open}
autoHideDuration={4000}
onClose={handleCloseSnackbar}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<Alert onClose={handleCloseSnackbar} severity={snackbar.severity} sx={{ width: '100%' }}>
{snackbar.message}
</Alert>
</Snackbar>
</Card>
);
}

View File

@@ -12,9 +12,24 @@ import {
Typography,
IconButton,
Paper,
useMediaQuery,
useTheme,
ToggleButtonGroup,
ToggleButton,
Tooltip,
Alert,
Snackbar,
} from '@mui/material';
import { Close, Send } from '@mui/icons-material';
import { apiClient } from '@/lib/api';
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 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 {
open: boolean;
@@ -35,19 +50,239 @@ export default function TerminalModal({
containerName,
containerId,
}: TerminalModalProps) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
// Mode selection: 'simple' or 'interactive'
const [mode, setMode] = useState<'simple' | 'interactive'>('interactive');
// Fallback tracking
const [interactiveFailed, setInteractiveFailed] = useState(false);
const [fallbackReason, setFallbackReason] = useState('');
const [showFallbackNotification, setShowFallbackNotification] = useState(false);
// Simple mode state
const [command, setCommand] = useState('');
const [output, setOutput] = useState<OutputLine[]>([]);
const [isExecuting, setIsExecuting] = useState(false);
const [workdir, setWorkdir] = useState('/');
const outputRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when output changes
// Interactive mode state
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<Terminal | null>(null);
const socketRef = useRef<Socket | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const connectionAttempts = useRef(0);
// Auto-scroll to bottom when output changes (simple mode)
useEffect(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
}, [output]);
// Function to fallback to simple mode
const fallbackToSimpleMode = (reason: string) => {
console.warn('Falling back to simple mode:', reason);
setInteractiveFailed(true);
setFallbackReason(reason);
setMode('simple');
setShowFallbackNotification(true);
// Cleanup interactive terminal if it exists
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
}
if (xtermRef.current) {
xtermRef.current.dispose();
xtermRef.current = null;
}
};
// Initialize interactive terminal
useEffect(() => {
if (!open || mode !== 'interactive' || !terminalRef.current) return;
let term: Terminal | null = null;
let fitAddon: FitAddon | null = null;
let socket: Socket | null = null;
// Dynamically import xterm modules (browser-only)
const initTerminal = async () => {
try {
// Dynamic imports to avoid SSR issues
const [{ Terminal }, { FitAddon }] = await Promise.all([
import('@xterm/xterm'),
import('@xterm/addon-fit'),
]);
if (!terminalRef.current) return; // Component might have unmounted
// 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',
},
});
fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(terminalRef.current);
// Fit terminal to container
setTimeout(() => {
try {
if (fitAddon) fitAddon.fit();
} catch (e) {
console.error('Error fitting terminal:', e);
}
}, 0);
xtermRef.current = term;
fitAddonRef.current = fitAddon;
// Connect to WebSocket
const wsUrl = API_BASE_URL.replace(/^http/, 'ws');
socket = io(`${wsUrl}/terminal`, {
transports: ['websocket', 'polling'],
});
socketRef.current = socket;
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))) {
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.');
}
};
// Start terminal initialization
const cleanup = initTerminal();
// Cleanup
return () => {
cleanup.then((cleanupFn) => {
if (cleanupFn) cleanupFn();
});
xtermRef.current = null;
socketRef.current = null;
fitAddonRef.current = null;
};
}, [open, mode, containerId, isMobile]);
const handleExecute = async () => {
if (!command.trim()) return;
@@ -74,6 +309,12 @@ export default function TerminalModal({
type: result.exit_code === 0 ? 'output' : 'error',
content: result.output
}]);
} else if (command.trim().startsWith('ls')) {
// If ls command returns empty output, indicate empty directory
setOutput((prev) => [...prev, {
type: 'output',
content: '(empty directory)'
}]);
}
} catch (error) {
setOutput((prev) => [...prev, {
@@ -94,12 +335,45 @@ export default function TerminalModal({
};
const handleClose = () => {
// Cleanup interactive terminal
if (socketRef.current) {
socketRef.current.disconnect();
}
if (xtermRef.current) {
xtermRef.current.dispose();
}
// Reset simple mode state
setOutput([]);
setCommand('');
setWorkdir('/');
onClose();
};
const handleModeChange = (
event: React.MouseEvent<HTMLElement>,
newMode: 'simple' | 'interactive' | null,
) => {
if (newMode !== null) {
// If switching to interactive mode after a failure, reset the failure state
if (newMode === 'interactive' && interactiveFailed) {
setInteractiveFailed(false);
setFallbackReason('');
connectionAttempts.current = 0;
}
setMode(newMode);
}
};
const handleRetryInteractive = () => {
setInteractiveFailed(false);
setFallbackReason('');
setShowFallbackNotification(false);
connectionAttempts.current = 0;
setMode('interactive');
};
const formatPrompt = (workdir: string) => {
// Shorten workdir if it's too long (show ~ for home, or just basename)
let displayDir = workdir;
@@ -146,10 +420,11 @@ export default function TerminalModal({
onClose={handleClose}
maxWidth="md"
fullWidth
fullScreen={isMobile}
PaperProps={{
sx: {
minHeight: '500px',
maxHeight: '80vh',
minHeight: isMobile ? '100vh' : '500px',
maxHeight: isMobile ? '100vh' : '80vh',
},
}}
>
@@ -159,137 +434,233 @@ export default function TerminalModal({
justifyContent: 'space-between',
alignItems: 'center',
pb: 2,
pt: { xs: 1, sm: 2 },
px: { xs: 2, sm: 3 },
flexWrap: 'wrap',
gap: 2,
}}
>
<Typography variant="h2" component="div">
Terminal - {containerName}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, flex: 1 }}>
<Typography
variant="h2"
component="div"
sx={{ fontSize: { xs: '1.1rem', sm: '1.5rem' } }}
>
Terminal - {containerName}
</Typography>
<ToggleButtonGroup
value={mode}
exclusive
onChange={handleModeChange}
size="small"
sx={{ display: 'flex' }}
>
<Tooltip title={interactiveFailed ? "Interactive mode failed - click to retry" : "Interactive mode with full terminal support (sudo, nano, vim)"}>
<ToggleButton
value="interactive"
sx={{
flex: 1,
fontSize: { xs: '0.75rem', sm: '0.875rem' },
...(interactiveFailed && {
borderColor: '#f59e0b',
color: '#f59e0b',
'&:hover': {
borderColor: '#d97706',
backgroundColor: 'rgba(245, 158, 11, 0.1)',
},
}),
}}
>
{interactiveFailed ? (
<Warning sx={{ mr: 0.5, fontSize: '1rem' }} />
) : (
<TerminalIcon sx={{ mr: 0.5, fontSize: '1rem' }} />
)}
Interactive
</ToggleButton>
</Tooltip>
<Tooltip title="Simple command execution mode">
<ToggleButton value="simple" sx={{ flex: 1, fontSize: { xs: '0.75rem', sm: '0.875rem' } }}>
<Code sx={{ mr: 0.5, fontSize: '1rem' }} />
Simple
</ToggleButton>
</Tooltip>
</ToggleButtonGroup>
</Box>
<IconButton onClick={handleClose} size="small">
<Close />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<Paper
ref={outputRef}
elevation={0}
sx={{
backgroundColor: '#300A24',
color: '#F8F8F2',
fontFamily: '"Ubuntu Mono", "Courier New", monospace',
fontSize: '14px',
padding: 2,
minHeight: '400px',
maxHeight: '500px',
overflowY: 'auto',
mb: 2,
border: '1px solid #5E2750',
borderRadius: '4px',
'&::-webkit-scrollbar': {
width: '10px',
},
'&::-webkit-scrollbar-track': {
background: '#2C0922',
},
'&::-webkit-scrollbar-thumb': {
background: '#5E2750',
borderRadius: '5px',
'&:hover': {
background: '#772953',
}
},
}}
>
{output.length === 0 ? (
<Box>
<Typography sx={{
color: '#8BE9FD',
fontFamily: 'inherit',
fontSize: '13px',
mb: 1
}}>
Ubuntu-style Terminal - Connected to <span style={{ color: '#50FA7B', fontWeight: 'bold' }}>{containerName}</span>
</Typography>
<Typography sx={{
color: '#6272A4',
fontFamily: 'inherit',
fontSize: '12px'
}}>
Type a command and press Enter or click Execute...
</Typography>
</Box>
) : (
<Box>
{output.map((line, index) => (
<React.Fragment key={index}>
{highlightCommand(line)}
</React.Fragment>
))}
</Box>
)}
</Paper>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Typography sx={{
fontFamily: '"Ubuntu Mono", monospace',
fontSize: '14px',
color: '#8BE9FD',
fontWeight: 'bold',
whiteSpace: 'nowrap'
}}>
{formatPrompt(workdir)}
</Typography>
<TextField
fullWidth
value={command}
onChange={(e) => setCommand(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="ls -la"
disabled={isExecuting}
variant="outlined"
size="small"
autoFocus
{mode === 'interactive' ? (
/* Interactive terminal with xterm.js */
<Box
ref={terminalRef}
sx={{
fontFamily: '"Ubuntu Mono", monospace',
'& input': {
fontFamily: '"Ubuntu Mono", monospace',
fontSize: '14px',
padding: '8px 12px',
height: { xs: '400px', sm: '500px' },
backgroundColor: '#300A24',
borderRadius: '4px',
border: '1px solid #5E2750',
overflow: 'hidden',
'& .xterm': {
padding: '8px',
},
'& .MuiOutlinedInput-root': {
backgroundColor: '#1E1E1E',
'& fieldset': {
borderColor: '#5E2750',
},
'&:hover fieldset': {
borderColor: '#772953',
},
'&.Mui-focused fieldset': {
borderColor: '#8BE9FD',
},
},
'& input': {
color: '#F8F8F2',
'& .xterm-viewport': {
backgroundColor: '#300A24 !important',
},
}}
/>
<Button
variant="contained"
onClick={handleExecute}
disabled={isExecuting || !command.trim()}
startIcon={<Send />}
sx={{
backgroundColor: '#5E2750',
'&:hover': {
backgroundColor: '#772953',
},
textTransform: 'none',
fontWeight: 'bold',
}}
>
Run
</Button>
</Box>
) : (
/* Simple command execution mode */
<>
<Paper
ref={outputRef}
elevation={0}
sx={{
backgroundColor: '#300A24',
color: '#F8F8F2',
fontFamily: '"Ubuntu Mono", "Courier New", monospace',
fontSize: { xs: '12px', sm: '14px' },
padding: { xs: 1.5, sm: 2 },
minHeight: { xs: '300px', sm: '400px' },
maxHeight: { xs: '400px', sm: '500px' },
overflowY: 'auto',
mb: 2,
border: '1px solid #5E2750',
borderRadius: '4px',
'&::-webkit-scrollbar': {
width: { xs: '6px', sm: '10px' },
},
'&::-webkit-scrollbar-track': {
background: '#2C0922',
},
'&::-webkit-scrollbar-thumb': {
background: '#5E2750',
borderRadius: '5px',
'&:hover': {
background: '#772953',
}
},
}}
>
{output.length === 0 ? (
<Box>
<Typography sx={{
color: '#8BE9FD',
fontFamily: 'inherit',
fontSize: '13px',
mb: 1
}}>
Ubuntu-style Terminal - Connected to <span style={{ color: '#50FA7B', fontWeight: 'bold' }}>{containerName}</span>
</Typography>
<Typography sx={{
color: '#6272A4',
fontFamily: 'inherit',
fontSize: '12px'
}}>
Type a command and press Enter or click Execute...
</Typography>
</Box>
) : (
<Box>
{output.map((line, index) => (
<React.Fragment key={index}>
{highlightCommand(line)}
</React.Fragment>
))}
</Box>
)}
</Paper>
<Box sx={{
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
gap: 1,
alignItems: isMobile ? 'stretch' : 'center'
}}>
<Typography sx={{
fontFamily: '"Ubuntu Mono", monospace',
fontSize: { xs: '12px', sm: '14px' },
color: '#8BE9FD',
fontWeight: 'bold',
whiteSpace: 'nowrap',
alignSelf: isMobile ? 'flex-start' : 'center'
}}>
{formatPrompt(workdir)}
</Typography>
<Box sx={{ display: 'flex', gap: 1, flex: 1 }}>
<TextField
fullWidth
value={command}
onChange={(e) => setCommand(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="ls -la"
disabled={isExecuting}
variant="outlined"
size="small"
autoFocus
sx={{
fontFamily: '"Ubuntu Mono", monospace',
'& input': {
fontFamily: '"Ubuntu Mono", monospace',
fontSize: { xs: '12px', sm: '14px' },
padding: { xs: '6px 10px', sm: '8px 12px' },
color: '#F8F8F2',
},
'& .MuiOutlinedInput-root': {
backgroundColor: '#1E1E1E',
'& fieldset': {
borderColor: '#5E2750',
},
'&:hover fieldset': {
borderColor: '#772953',
},
'&.Mui-focused fieldset': {
borderColor: '#8BE9FD',
},
},
}}
/>
{isMobile ? (
<IconButton
onClick={handleExecute}
disabled={isExecuting || !command.trim()}
sx={{
backgroundColor: '#5E2750',
color: 'white',
'&:hover': {
backgroundColor: '#772953',
},
'&:disabled': {
backgroundColor: '#3a1a2f',
},
}}
>
<Send />
</IconButton>
) : (
<Button
variant="contained"
onClick={handleExecute}
disabled={isExecuting || !command.trim()}
startIcon={<Send />}
sx={{
backgroundColor: '#5E2750',
'&:hover': {
backgroundColor: '#772953',
},
textTransform: 'none',
fontWeight: 'bold',
}}
>
Run
</Button>
)}
</Box>
</Box>
</>
)}
</DialogContent>
<DialogActions>
@@ -297,6 +668,33 @@ export default function TerminalModal({
Close
</Button>
</DialogActions>
{/* Fallback notification */}
<Snackbar
open={showFallbackNotification}
autoHideDuration={10000}
onClose={() => setShowFallbackNotification(false)}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
>
<Alert
severity="warning"
icon={<Warning />}
action={
<Button color="inherit" size="small" onClick={handleRetryInteractive}>
Retry
</Button>
}
onClose={() => setShowFallbackNotification(false)}
sx={{ width: '100%', maxWidth: '600px' }}
>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Switched to Simple Mode
</Typography>
<Typography variant="body2" sx={{ fontSize: '0.875rem' }}>
{fallbackReason}
</Typography>
</Alert>
</Snackbar>
</Dialog>
);
}

View File

@@ -115,6 +115,90 @@ class ApiClient {
return response.json();
}
async startContainer(containerId: string): Promise<any> {
const token = this.getToken();
if (!token) {
throw new Error('Not authenticated');
}
const response = await fetch(`${API_BASE_URL}/api/containers/${containerId}/start`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to start container');
}
return response.json();
}
async stopContainer(containerId: string): Promise<any> {
const token = this.getToken();
if (!token) {
throw new Error('Not authenticated');
}
const response = await fetch(`${API_BASE_URL}/api/containers/${containerId}/stop`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to stop container');
}
return response.json();
}
async restartContainer(containerId: string): Promise<any> {
const token = this.getToken();
if (!token) {
throw new Error('Not authenticated');
}
const response = await fetch(`${API_BASE_URL}/api/containers/${containerId}/restart`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to restart container');
}
return response.json();
}
async removeContainer(containerId: string): Promise<any> {
const token = this.getToken();
if (!token) {
throw new Error('Not authenticated');
}
const response = await fetch(`${API_BASE_URL}/api/containers/${containerId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to remove container');
}
return response.json();
}
}
export const apiClient = new ApiClient();

View File

@@ -28,28 +28,42 @@ const theme = createTheme({
main: '#38b2ac',
},
},
breakpoints: {
values: {
xs: 0,
sm: 600,
md: 900,
lg: 1200,
xl: 1536,
},
},
typography: {
fontFamily: '"JetBrains Mono", "Courier New", monospace',
h1: {
fontWeight: 700,
fontSize: '2rem',
fontSize: 'clamp(1.5rem, 4vw, 2rem)',
letterSpacing: '-0.02em',
},
h2: {
fontWeight: 600,
fontSize: '1.5rem',
fontSize: 'clamp(1.125rem, 3vw, 1.5rem)',
},
h3: {
fontWeight: 500,
fontSize: '1.125rem',
fontSize: 'clamp(1rem, 2.5vw, 1.125rem)',
},
body1: {
fontSize: '0.875rem',
fontSize: 'clamp(0.8rem, 1.5vw, 0.875rem)',
lineHeight: 1.6,
},
body2: {
fontSize: 'clamp(0.75rem, 1.3vw, 0.8125rem)',
lineHeight: 1.5,
},
button: {
fontWeight: 500,
textTransform: 'none',
fontSize: 'clamp(0.8rem, 1.5vw, 0.875rem)',
},
},
components: {

View File

@@ -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",

View File

@@ -13,9 +13,12 @@
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.7",
"@mui/material": "^7.3.7",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-fit": "^0.10.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",