mirror of
https://github.com/johndoe6345789/docker-swarm-termina.git
synced 2026-04-25 06:05:00 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5daee2d445 | |||
|
|
a59b5ad527 | ||
| 995b7442d7 | |||
|
|
ce997ebdda | ||
|
|
d9c790c560 | ||
|
|
237ebcede1 | ||
| 2e176f3048 | |||
|
|
938cb5a0ba |
259
backend/app.py
259
backend/app.py
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user