12 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
85819a2f84 Merge pull request #9 from johndoe6345789/claude/cleanup-filesystem-xwToR
Add persistent working directory tracking to terminal sessions
2026-01-30 20:34:22 +00:00
Claude
613c2dc55c Enhance terminal with Ubuntu-style UI and fix shell command issues
Backend improvements:
- Fix execline shadowing standard commands by setting proper PATH
- Add session-based working directory tracking for persistent cd
- Wrap all commands in bash/sh to avoid execline interpreter
- Handle cd commands specially to update session state
- Add robust error handling and fallback to sh

Frontend enhancements:
- Implement Ubuntu aubergine color scheme (#300A24 background)
- Add syntax highlighting for commands, arguments, and errors
- Display working directory in prompt (root@container:path#)
- Auto-scroll terminal output to bottom
- Improve terminal UX with Ubuntu Mono font
- Show current directory in command input prompt

https://claude.ai/code/session_01EvWjmaqmfnKwUTdBUj5JFY
2026-01-30 20:32:13 +00:00
1398b3a82f Merge pull request #8 from johndoe6345789/claude/fix-caprover-docker-U6NbO
Add manual Docker socket mount fix and comprehensive troubleshooting
2026-01-30 19:53:28 +00:00
Claude
f8d2320236 Add manual Docker socket mount fix and comprehensive troubleshooting
Problem: CapRover's serviceUpdateOverride in captain-definition doesn't
always apply automatically, causing "Docker socket NOT found" errors.

Solution: Manual docker service update command to apply the mount.

Changes:
- Added CAPROVER_TROUBLESHOOTING.md with complete step-by-step fix
  - Manual docker service update command
  - Verification steps
  - Common issues and solutions
  - SELinux/AppArmor troubleshooting

- Created fix-caprover-docker-mount.sh automated script
  - Finds service automatically
  - Applies mount
  - Verifies configuration
  - Shows service status

- Enhanced backend/app.py diagnostics
  - Lists /var/run directory contents
  - Shows Docker-related files
  - Better error messages explaining the issue
  - Explicit note when mount is missing

- Updated backend/requirements.txt
  - Docker SDK 7.0.0 -> 7.1.0 (fixes URL scheme error)

- Updated CAPROVER_DEPLOYMENT.md
  - Prominent warning about serviceUpdateOverride limitation
  - New Step 4: Verify and Apply Docker Socket Mount
  - Quick fix command prominently displayed
  - Links to troubleshooting guide
  - Updated troubleshooting section with manual fix

- Updated QUICKSTART.md
  - Warning after backend deployment instructions
  - Quick fix command for both deployment options
  - Links to troubleshooting guide

This provides users with immediate solutions when encountering the
"Cannot connect to Docker" error, which is now properly diagnosed
and can be fixed with a single command.

https://claude.ai/code/session_01NfGGGQ9Zn6ue7PRZpAoB2N
2026-01-30 19:48:53 +00:00
13 changed files with 1937 additions and 162 deletions

View File

@@ -71,6 +71,25 @@ The `backend/captain-definition` file contains critical configuration:
5. **Replicas**: Set to 1 (multiple replicas can't share the same socket)
### ⚠️ IMPORTANT: serviceUpdateOverride Limitation
**The `serviceUpdateOverride` in `captain-definition` may not be applied automatically by CapRover.** This is a known limitation with some CapRover versions.
**If you see "Docker socket NOT found" in your logs**, you MUST manually apply the Docker socket mount after deployment.
**Quick Fix** (run on your CapRover server):
```bash
# SSH into your CapRover server
ssh root@your-server.com
# Apply the mount (replace with your service name)
docker service update \
--mount-add type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \
srv-captain--terminalbackend
```
**See [CAPROVER_TROUBLESHOOTING.md](CAPROVER_TROUBLESHOOTING.md) for detailed instructions.**
### Security Considerations
**IMPORTANT**: Granting Docker socket access to a container is a security-sensitive operation. The container effectively has root access to the host system.
@@ -116,27 +135,79 @@ caprover deploy
Or manually:
1. Create a tarball: `tar -czf backend.tar.gz .`
1. Create a tarball: `tar -cf backend.tar .`
2. Upload via CapRover dashboard
3. Wait for deployment to complete
#### 4. Verify Deployment
#### 4. **CRITICAL: Verify and Apply Docker Socket Mount**
Check the application logs in CapRover dashboard. You should see:
After deployment, check if the Docker socket is mounted:
**a) Check Application Logs** (in CapRover dashboard):
Look for:
```
=== Docker Environment Diagnosis ===
DOCKER_HOST: unix:///var/run/docker.sock
✓ Docker socket exists at /var/run/docker.sock
Socket permissions: 0o140777
Readable: True
Writable: True
Current user: root (UID: 0, GID: 0)
✓ Successfully connected to Docker using Unix socket
✓ Docker connection verified on startup
```
If you see errors, check the "Troubleshooting" section below.
If you see:
```
✗ Docker socket NOT found at /var/run/docker.sock
```
Then the `serviceUpdateOverride` wasn't applied. **You must manually apply it.**
**b) Manually Apply the Mount** (run on your CapRover server):
```bash
# SSH into your CapRover server
ssh root@your-server.com
# Find your service name
docker service ls | grep terminalbackend
# Should show something like: srv-captain--terminalbackend
# Apply the Docker socket mount
docker service update \
--mount-add type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \
srv-captain--terminalbackend
```
**c) Verify the Mount Was Applied**:
```bash
docker service inspect srv-captain--terminalbackend \
--format '{{json .Spec.TaskTemplate.ContainerSpec.Mounts}}' | python3 -m json.tool
```
Should show:
```json
[
{
"Type": "bind",
"Source": "/var/run/docker.sock",
"Target": "/var/run/docker.sock"
}
]
```
**d) Wait for Service Restart**:
The service will automatically restart with the new configuration. Monitor:
```bash
docker service ps srv-captain--terminalbackend
```
**e) Check Logs Again**:
In CapRover dashboard, refresh the logs. You should now see:
```
✓ Docker socket exists at /var/run/docker.sock
✓ Docker connection verified on startup
```
**See [CAPROVER_TROUBLESHOOTING.md](CAPROVER_TROUBLESHOOTING.md) for detailed troubleshooting.**
#### 5. Test the API
@@ -184,33 +255,72 @@ caprover deploy
### "Cannot connect to Docker" Error
If you see this error, check the following:
**This is the most common issue!** CapRover's `serviceUpdateOverride` often doesn't apply automatically.
1. **Verify captain-definition**: Ensure `serviceUpdateOverride` is present and correct
#### Quick Fix (Run on CapRover Server)
2. **Check logs for diagnostics**:
```bash
# SSH into your CapRover server
ssh root@your-server.com
# Apply the Docker socket mount manually
docker service update \
--mount-add type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \
srv-captain--terminalbackend
# Verify it worked
docker service inspect srv-captain--terminalbackend \
--format '{{json .Spec.TaskTemplate.ContainerSpec.Mounts}}' | python3 -m json.tool
```
**📖 See [CAPROVER_TROUBLESHOOTING.md](CAPROVER_TROUBLESHOOTING.md) for complete step-by-step instructions.**
#### Diagnostic Checklist
If the quick fix doesn't work, check:
1. **Check logs in CapRover dashboard** for:
```
=== Docker Environment Diagnosis ===
Docker socket NOT found at /var/run/docker.sock
```
Look for:
- Socket existence
- Permissions (should be readable and writable)
- User info (should be root)
3. **Common issues**:
2. **Verify socket exists on host**:
```bash
ls -la /var/run/docker.sock
```
**Socket not found**:
- The mount configuration isn't being applied
- Redeploy the app after updating `captain-definition`
3. **Check service is running as root**:
```bash
docker service inspect srv-captain--terminalbackend \
--format '{{.Spec.TaskTemplate.ContainerSpec.User}}'
```
Should return: `root`
**Permission denied**:
- User isn't root
- Socket permissions are wrong
- Check that `"User": "root"` is in captain-definition
4. **Check Docker version compatibility**:
```bash
docker version
```
**Connection refused**:
- Docker daemon isn't running on the host
- Check CapRover host: `docker info`
5. **Review SELinux/AppArmor** if on RHEL/Ubuntu:
```bash
getenforce # Should be Permissive or Disabled for testing
```
#### Common Issues
**Socket not found**:
- ✅ **Solution**: Manually apply mount (see Quick Fix above)
- The `serviceUpdateOverride` wasn't applied by CapRover
**Permission denied**:
- ✅ **Solution**: Ensure service runs as root:
```bash
docker service update --user root srv-captain--terminalbackend
```
**Connection refused / "Not supported URL scheme http+docker"**:
- ✅ **Solution**: Update docker library version in `requirements.txt` to `docker==7.1.0`
- Redeploy the application
### Viewing Logs

259
CAPROVER_TROUBLESHOOTING.md Normal file
View File

@@ -0,0 +1,259 @@
# CapRover Docker Socket Troubleshooting Guide
This guide helps resolve the "Cannot connect to Docker" error in CapRover deployments.
## Problem: Docker Socket Not Mounted
### Symptoms
In your CapRover application logs, you see:
```
✗ Docker socket NOT found at /var/run/docker.sock
This means the Docker socket mount is NOT configured in CapRover
The serviceUpdateOverride in captain-definition may not be applied
```
### Root Cause
CapRover's `serviceUpdateOverride` in `captain-definition` **may not always be applied automatically**. This is a known limitation with some CapRover versions or configurations.
## Solution: Manual Docker Service Update
You need to manually update the Docker Swarm service to mount the Docker socket.
### Step 1: SSH into Your CapRover Server
```bash
ssh root@your-caprover-server.com
```
### Step 2: Find Your Service Name
List all services to find your backend service:
```bash
docker service ls
```
Look for your service, typically named: `srv-captain--terminalbackend` (or whatever you named your app)
### Step 3: Check Current Mounts
```bash
docker service inspect srv-captain--terminalbackend \
--format '{{json .Spec.TaskTemplate.ContainerSpec.Mounts}}' | python3 -m json.tool
```
If this returns `null` or an empty array, the mount isn't configured.
### Step 4: Apply the Docker Socket Mount
Run this command to mount the Docker socket:
```bash
docker service update \
--mount-add type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \
srv-captain--terminalbackend
```
**Important**: Replace `srv-captain--terminalbackend` with your actual service name.
### Step 5: Verify the Mount
```bash
docker service inspect srv-captain--terminalbackend \
--format '{{json .Spec.TaskTemplate.ContainerSpec.Mounts}}' | python3 -m json.tool
```
You should see:
```json
[
{
"Type": "bind",
"Source": "/var/run/docker.sock",
"Target": "/var/run/docker.sock"
}
]
```
### Step 6: Wait for Service Restart
Docker Swarm will automatically restart your service with the new configuration. Monitor the status:
```bash
docker service ps srv-captain--terminalbackend --no-trunc
```
### Step 7: Check Logs
In CapRover dashboard, go to your app and check logs. You should now see:
```
✓ Docker socket exists at /var/run/docker.sock
Socket permissions: 0o140777
Readable: True
Writable: True
✓ Successfully connected to Docker using Unix socket
✓ Docker connection verified on startup
```
### Step 8: Test the API
```bash
curl https://terminalbackend.wardcrew.com/api/health
```
Should return:
```json
{"status":"healthy"}
```
## Automated Script
We've provided a script to automate this process. Download it from the repository:
```bash
# On your CapRover server
wget https://raw.githubusercontent.com/johndoe6345789/docker-swarm-termina/main/fix-caprover-docker-mount.sh
chmod +x fix-caprover-docker-mount.sh
# Run it
./fix-caprover-docker-mount.sh srv-captain--terminalbackend
```
## Alternative Solution: Use CapRover's Service Update Feature
Some CapRover versions support manual service configuration through the UI:
1. Go to CapRover dashboard
2. Navigate to your app
3. Click on "⚙️ Edit Default Nginx Configurations" (or similar settings)
4. Look for advanced Docker/Swarm settings
5. Add the mount configuration
However, this feature availability varies by CapRover version.
## Why serviceUpdateOverride Doesn't Always Work
The `captain-definition` file's `serviceUpdateOverride` field is designed to apply custom Docker Swarm configurations. However:
1. **Timing Issue**: It may only apply on initial deployment, not on updates
2. **CapRover Version**: Older versions may not fully support this feature
3. **Validation**: CapRover may skip configurations it deems risky
4. **Security**: Some CapRover installations restrict privileged configurations
## Persistence
Once you've manually applied the mount using `docker service update`, it will **persist across app updates** as long as you don't:
- Delete and recreate the app in CapRover
- Manually remove the mount
- Use a CapRover feature that resets service configuration
## Additional Troubleshooting
### Issue: "Permission denied" errors
**Solution**: Ensure the service runs as root:
```bash
docker service update \
--user root \
srv-captain--terminalbackend
```
### Issue: Socket exists but connection still fails
**Diagnosis**: Check socket permissions on the host:
```bash
ls -la /var/run/docker.sock
```
Should be:
```
srw-rw---- 1 root docker /var/run/docker.sock
```
**Solution**: Fix permissions:
```bash
chmod 666 /var/run/docker.sock # Temporary - not recommended for production
# OR
chmod 660 /var/run/docker.sock
chown root:docker /var/run/docker.sock
```
### Issue: "Not supported URL scheme http+docker"
This error indicates a docker-py library issue.
**Solution**: Update the docker library version in `requirements.txt`:
```
docker==7.1.0
```
Then redeploy the app.
### Issue: Can't find service name
**Solution**: List all services with details:
```bash
docker service ls --format "table {{.Name}}\t{{.Mode}}\t{{.Replicas}}"
```
Look for services starting with `srv-captain--`
### Issue: Mount applied but service won't start
**Diagnosis**: Check service logs:
```bash
docker service logs srv-captain--terminalbackend --tail 100 --follow
```
**Common causes**:
- SELinux blocking socket access
- AppArmor policies
- Container runtime restrictions
**Solution**: Temporarily disable SELinux/AppArmor to test:
```bash
# SELinux
setenforce 0
# After testing, re-enable
setenforce 1
```
## Production Recommendations
For production deployments:
1. **Use Docker Socket Proxy**: Instead of mounting the raw socket, use a proxy like [tecnativa/docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy)
2. **Limit API Access**: Configure proxy to only allow specific Docker API endpoints
3. **Network Isolation**: Deploy backend on a dedicated private network
4. **Audit Logging**: Enable Docker audit logging for socket access
5. **Regular Updates**: Keep Docker, CapRover, and your application updated
## Support
If you're still experiencing issues:
1. **Check CapRover version**: `caprover --version`
2. **Check Docker version**: `docker version`
3. **Review CapRover logs**: `docker service logs captain-captain --tail 100`
4. **Test Docker socket on host**: `docker ps` (should work without errors)
Open an issue with:
- CapRover version
- Docker version
- Complete application logs
- Output of `docker service inspect srv-captain--terminalbackend`

View File

@@ -19,7 +19,21 @@ Get up and running with Docker Swarm Terminal in minutes.
- Go to "Deployment" tab
- Upload the `.tar` file (uncompressed - required by CapRover)
3. Wait for deployment to complete
4. Check logs for: `✓ Docker connection verified on startup`
4. **Check logs for: `✓ Docker connection verified on startup`**
**⚠️ IMPORTANT**: If you see `✗ Docker socket NOT found`, you must manually apply the mount:
```bash
# SSH into your CapRover server
ssh root@your-server.com
# Apply the Docker socket mount
docker service update \
--mount-add type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \
srv-captain--terminalbackend
```
See [CAPROVER_TROUBLESHOOTING.md](CAPROVER_TROUBLESHOOTING.md) for details.
### Frontend
@@ -50,6 +64,14 @@ caprover deploy
caprover logs terminalbackend --follow
```
**⚠️ IMPORTANT**: If logs show `✗ Docker socket NOT found`, manually apply the mount on your CapRover server:
```bash
docker service update \
--mount-add type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \
srv-captain--terminalbackend
```
### Frontend
```bash

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,10 +20,13 @@ 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 = {}
# Track working directory per session
session_workdirs = {}
# Default credentials (should be environment variables in production)
ADMIN_USERNAME = os.getenv('ADMIN_USERNAME', 'admin')
@@ -39,6 +45,22 @@ def diagnose_docker_environment():
logger.info(f"DOCKER_CERT_PATH: {docker_cert_path}")
logger.info(f"DOCKER_TLS_VERIFY: {docker_tls_verify}")
# Check what's in /var/run
logger.info("Checking /var/run directory contents:")
try:
if os.path.exists('/var/run'):
var_run_contents = os.listdir('/var/run')
logger.info(f" /var/run contains: {var_run_contents}")
# Check for any Docker-related files
docker_related = [f for f in var_run_contents if 'docker' in f.lower()]
if docker_related:
logger.info(f" Docker-related files/dirs found: {docker_related}")
else:
logger.warning(" /var/run directory doesn't exist")
except Exception as e:
logger.error(f" Error reading /var/run: {e}")
# Check Docker socket
socket_path = '/var/run/docker.sock'
logger.info(f"Checking Docker socket at {socket_path}")
@@ -63,6 +85,8 @@ def diagnose_docker_environment():
logger.warning(f"⚠ Socket exists but lacks proper permissions!")
else:
logger.error(f"✗ Docker socket NOT found at {socket_path}")
logger.error(f" This means the Docker socket mount is NOT configured in CapRover")
logger.error(f" The serviceUpdateOverride in captain-definition may not be applied")
# Check current user
import pwd
@@ -213,27 +237,208 @@ def exec_container(container_id):
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
data = request.get_json()
command = data.get('command', '/bin/sh')
user_command = data.get('command', 'echo "No command provided"')
client = get_docker_client()
if not client:
return jsonify({'error': 'Cannot connect to Docker'}), 500
try:
container = client.containers.get(container_id)
exec_instance = container.exec_run(command, stdout=True, stderr=True, stdin=True, tty=True)
# Get or initialize session working directory
session_key = f"{token}_{container_id}"
if session_key not in session_workdirs:
# Get container's default working directory or use root
session_workdirs[session_key] = '/'
current_workdir = session_workdirs[session_key]
# Check if this is a cd command
cd_match = user_command.strip()
is_cd_command = cd_match.startswith('cd ')
# If it's a cd command, handle it specially
if is_cd_command:
target_dir = cd_match[3:].strip() or '~'
# Resolve the new directory and update session
resolve_command = f'cd "{current_workdir}" && cd {target_dir} && pwd'
bash_command = [
'/bin/bash',
'-c',
f'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; {resolve_command}'
]
else:
# Regular command - execute in current working directory
bash_command = [
'/bin/bash',
'-c',
f'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; cd "{current_workdir}" && {user_command}; echo "::WORKDIR::$(pwd)"'
]
# Try bash first, fallback to sh if bash doesn't exist
try:
exec_instance = container.exec_run(
bash_command,
stdout=True,
stderr=True,
stdin=False,
tty=True,
environment={'TERM': 'xterm-256color', 'LANG': 'C.UTF-8'}
)
except Exception as bash_error:
logger.warning(f"Bash execution failed, trying sh: {bash_error}")
# Fallback to sh
if is_cd_command:
target_dir = cd_match[3:].strip() or '~'
resolve_command = f'cd "{current_workdir}" && cd {target_dir} && pwd'
sh_command = ['/bin/sh', '-c', f'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; {resolve_command}']
else:
sh_command = ['/bin/sh', '-c', f'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; cd "{current_workdir}" && {user_command}; echo "::WORKDIR::$(pwd)"']
exec_instance = container.exec_run(
sh_command,
stdout=True,
stderr=True,
stdin=False,
tty=True,
environment={'TERM': 'xterm-256color', 'LANG': 'C.UTF-8'}
)
# Decode output with error handling
output = ''
if exec_instance.output:
try:
output = exec_instance.output.decode('utf-8')
except UnicodeDecodeError:
# Try latin-1 as fallback
output = exec_instance.output.decode('latin-1', errors='replace')
# Extract and update working directory from output
new_workdir = current_workdir
if is_cd_command:
# For cd commands, the output is the new pwd
new_workdir = output.strip()
session_workdirs[session_key] = new_workdir
output = '' # Don't show the pwd output for cd
else:
# Extract workdir marker from output
if '::WORKDIR::' in output:
parts = output.rsplit('::WORKDIR::', 1)
output = parts[0]
new_workdir = parts[1].strip()
session_workdirs[session_key] = new_workdir
return jsonify({
'output': exec_instance.output.decode('utf-8') if exec_instance.output else '',
'exit_code': exec_instance.exit_code
'output': output,
'exit_code': exec_instance.exit_code,
'workdir': new_workdir
})
except Exception as e:
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'])
@@ -241,6 +446,160 @@ 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...")
@@ -253,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

@@ -1,4 +1,6 @@
Flask==3.0.0
Flask-CORS==6.0.0
python-dotenv==1.0.0
docker==7.0.0
docker==7.1.0
flask-socketio==5.3.6
python-socketio==5.11.0

View File

@@ -0,0 +1,62 @@
#!/bin/bash
# Script to manually apply Docker socket mount to CapRover service
# Run this on your CapRover server if serviceUpdateOverride doesn't work
set -e
# Service name - update this to match your CapRover app name
SERVICE_NAME="${1:-srv-captain--terminalbackend}"
echo "=== CapRover Docker Socket Mount Fix ==="
echo "Service name: $SERVICE_NAME"
echo ""
# Check if service exists
if ! docker service ls | grep -q "$SERVICE_NAME"; then
echo "❌ Error: Service '$SERVICE_NAME' not found"
echo ""
echo "Available services:"
docker service ls --format "{{.Name}}"
echo ""
echo "Usage: $0 <service-name>"
echo "Example: $0 srv-captain--terminalbackend"
exit 1
fi
echo "✓ Service found"
echo ""
# Show current service configuration
echo "Current service mounts:"
docker service inspect "$SERVICE_NAME" --format '{{json .Spec.TaskTemplate.ContainerSpec.Mounts}}' | python3 -m json.tool || echo "No mounts configured"
echo ""
# Update service with Docker socket mount
echo "Applying Docker socket mount..."
docker service update \
--mount-add type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \
"$SERVICE_NAME"
echo ""
echo "✓ Mount applied successfully"
echo ""
# Verify the update
echo "Verifying updated configuration:"
docker service inspect "$SERVICE_NAME" --format '{{json .Spec.TaskTemplate.ContainerSpec.Mounts}}' | python3 -m json.tool
echo ""
# Check service status
echo "Service status:"
docker service ps "$SERVICE_NAME" --no-trunc
echo ""
echo "=== Next Steps ==="
echo "1. Wait for the service to restart (check logs in CapRover dashboard)"
echo "2. Look for this in logs: '✓ Docker socket exists at /var/run/docker.sock'"
echo "3. Test the API: curl https://your-backend-domain.com/api/health"
echo ""
echo "If you still see errors, check:"
echo " - The service is running as root (not restricted by CapRover)"
echo " - SELinux/AppArmor isn't blocking socket access"
echo " - Docker socket exists on host: ls -la /var/run/docker.sock"

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

@@ -1,6 +1,6 @@
'use client';
import React, { useState } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import {
Dialog,
DialogTitle,
@@ -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;
@@ -23,27 +38,289 @@ interface TerminalModalProps {
containerId: string;
}
interface OutputLine {
type: 'command' | 'output' | 'error';
content: string;
workdir?: string;
}
export default function TerminalModal({
open,
onClose,
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<string[]>([]);
const [output, setOutput] = useState<OutputLine[]>([]);
const [isExecuting, setIsExecuting] = useState(false);
const [workdir, setWorkdir] = useState('/');
const outputRef = useRef<HTMLDivElement>(null);
// 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;
setIsExecuting(true);
setOutput((prev) => [...prev, `$ ${command}`]);
// Add command to output with current working directory
setOutput((prev) => [...prev, {
type: 'command',
content: command,
workdir: workdir
}]);
try {
const result = await apiClient.executeCommand(containerId, command);
setOutput((prev) => [...prev, result.output || '(no output)']);
// Update working directory if provided
if (result.workdir) {
setWorkdir(result.workdir);
}
// Add command output
if (result.output && result.output.trim()) {
setOutput((prev) => [...prev, {
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, `Error: ${error instanceof Error ? error.message : 'Unknown error'}`]);
setOutput((prev) => [...prev, {
type: 'error',
content: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`
}]);
} finally {
setIsExecuting(false);
setCommand('');
@@ -58,21 +335,96 @@ 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;
if (workdir.length > 30) {
const parts = workdir.split('/');
displayDir = '.../' + parts[parts.length - 1];
}
return `root@${containerName}:${displayDir}#`;
};
const highlightCommand = (line: OutputLine) => {
if (line.type === 'command') {
const prompt = formatPrompt(line.workdir || '/');
const parts = line.content.split(' ');
const cmd = parts[0];
const args = parts.slice(1).join(' ');
return (
<div style={{ marginBottom: '4px' }}>
<span style={{ color: '#8BE9FD', fontWeight: 'bold' }}>{prompt}</span>
{' '}
<span style={{ color: '#50FA7B', fontWeight: 'bold' }}>{cmd}</span>
{args && <span style={{ color: '#F8F8F2' }}> {args}</span>}
</div>
);
} else if (line.type === 'error') {
return (
<div style={{ color: '#FF5555', marginBottom: '2px' }}>
{line.content}
</div>
);
} else {
return (
<div style={{ color: '#F8F8F2', marginBottom: '2px', whiteSpace: 'pre-wrap' }}>
{line.content}
</div>
);
}
};
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="md"
fullWidth
fullScreen={isMobile}
PaperProps={{
sx: {
minHeight: '500px',
maxHeight: '80vh',
minHeight: isMobile ? '100vh' : '500px',
maxHeight: isMobile ? '100vh' : '80vh',
},
}}
>
@@ -82,79 +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
elevation={0}
sx={{
backgroundColor: '#0d1117',
color: '#c9d1d9',
fontFamily: '"JetBrains Mono", monospace',
fontSize: '0.875rem',
padding: 2,
minHeight: '300px',
maxHeight: '400px',
overflowY: 'auto',
mb: 2,
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
background: '#161b22',
},
'&::-webkit-scrollbar-thumb': {
background: '#30363d',
borderRadius: '4px',
},
}}
>
{output.length === 0 ? (
<Typography color="text.secondary" sx={{ fontFamily: 'inherit' }}>
Connected to {containerName}. Enter a command to start...
</Typography>
) : (
<Box component="pre" sx={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{output.join('\n')}
</Box>
)}
</Paper>
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
fullWidth
value={command}
onChange={(e) => setCommand(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Enter command (e.g., ls, pwd, echo 'hello')"
disabled={isExecuting}
variant="outlined"
size="small"
{mode === 'interactive' ? (
/* Interactive terminal with xterm.js */
<Box
ref={terminalRef}
sx={{
fontFamily: '"JetBrains Mono", monospace',
'& input': {
fontFamily: '"JetBrains Mono", monospace',
height: { xs: '400px', sm: '500px' },
backgroundColor: '#300A24',
borderRadius: '4px',
border: '1px solid #5E2750',
overflow: 'hidden',
'& .xterm': {
padding: '8px',
},
'& .xterm-viewport': {
backgroundColor: '#300A24 !important',
},
}}
/>
<Button
variant="contained"
color="secondary"
onClick={handleExecute}
disabled={isExecuting || !command.trim()}
startIcon={<Send />}
>
Execute
</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>
@@ -162,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",