26 Commits

Author SHA1 Message Date
e94b1f0053 Merge pull request #13 from johndoe6345789/dependabot/pip/backend/pip-5b6eb6b015
Bump python-socketio from 5.11.0 to 5.14.0 in /backend in the pip group across 1 directory
2026-01-30 23:32:33 +00:00
0902d480ed Merge pull request #14 from johndoe6345789/claude/fix-logout-auth-redirects-G4kZm
Migrate auth from context to Redux state management
2026-01-30 23:30:14 +00:00
Claude
42c1d4737f Add comprehensive unit testing infrastructure
Install testing dependencies:
- Jest and jest-environment-jsdom for test runner
- React Testing Library for component testing
- @testing-library/user-event for user interaction simulation
- @types/jest for TypeScript support

Configure Jest:
- Next.js Jest configuration with jsdom environment
- Mock window.matchMedia, localStorage, and fetch
- Setup test paths and coverage collection

Add test coverage:
- Utility functions (terminal formatPrompt and highlightCommand)
- Redux store (authSlice async thunks and reducers)
- Custom hooks (useLoginForm, useAuthRedirect, useTerminalModal)
- React components (LoginForm, TerminalHeader, ContainerHeader, ContainerInfo, EmptyState)

Test results: 59 tests passing across 10 test suites

https://claude.ai/code/session_G4kZm
2026-01-30 23:28:09 +00:00
Claude
e97b50a916 Organize interfaces and utilities into centralized folders
Move all TypeScript interfaces from component files to /lib/interfaces folder
Move terminal utility functions to /lib/utils folder
Update all component imports to use centralized interfaces and utilities
Fix JSX.Element type to React.ReactElement in terminal utils

This improves code organization and reduces duplication across components

https://claude.ai/code/session_G4kZm
2026-01-30 23:16:45 +00:00
Claude
748bf87699 Refactor TerminalModal under 150 LOC
TerminalModal: 700 -> 140 LOC

Created custom hooks:
- useSimpleTerminal (79 LOC) - Simple command execution logic
- useInteractiveTerminal (208 LOC) - xterm.js terminal logic

Created sub-components:
- TerminalHeader (93 LOC) - Mode switching header
- InteractiveTerminal (28 LOC) - xterm.js view
- SimpleTerminal (50 LOC) - Simple mode wrapper
- TerminalOutput (111 LOC) - Terminal output display
- CommandInput (123 LOC) - Command input field
- FallbackNotification (45 LOC) - Error notification

All React components now under 150 LOC
Build verified successful

https://claude.ai/code/session_01U3wVqokhrL3dTeq2dTq73n
2026-01-30 23:07:50 +00:00
Claude
70d32f13b2 Break down large components under 150 LOC
ContainerCard: 354 -> 91 LOC
- Extract useContainerActions custom hook (88 LOC)
- Split into sub-components:
  - ContainerHeader (77 LOC)
  - ContainerInfo (54 LOC)
  - ContainerActions (125 LOC)
  - DeleteConfirmDialog (41 LOC)

Dashboard: 249 -> 89 LOC
- Extract useContainerList hook (39 LOC)
- Extract useTerminalModal hook (24 LOC)
- Split into sub-components:
  - DashboardHeader (118 LOC)
  - EmptyState (44 LOC)

All React components now under 150 LOC for better maintainability

https://claude.ai/code/session_01U3wVqokhrL3dTeq2dTq73n
2026-01-30 23:00:37 +00:00
Claude
87daa3add3 Refactor components to use custom hooks
- Extract login form logic into useLoginForm hook
- Create useAuthRedirect hook for auth-based navigation
- Refactor LoginForm component to use useLoginForm (147 -> 135 LOC)
- Refactor login page to use useAuthRedirect (23 -> 14 LOC)
- Update dashboard to use useAuthRedirect for cleaner code
- Improve code reusability and testability

https://claude.ai/code/session_01U3wVqokhrL3dTeq2dTq73n
2026-01-30 22:54:14 +00:00
Claude
2c34509d0f Address PR review feedback
- Memoize auth error callback with useCallback to prevent recreation
- Use useAppDispatch hook instead of direct store.dispatch
- Update initAuth to retrieve and persist username from localStorage
- Add getUsername/setUsername methods to API client
- Remove unused auth.tsx context file to avoid confusion
- Fix router dependency issue with proper memoization

Fixes all issues raised in PR #14 code review

https://claude.ai/code/session_01U3wVqokhrL3dTeq2dTq73n
2026-01-30 22:50:26 +00:00
32253724b0 Update frontend/app/providers.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-30 22:43:00 +00:00
f9d781271f Update frontend/lib/store/authErrorHandler.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-30 22:42:47 +00:00
69dee82d89 Update frontend/lib/api.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-30 22:42:23 +00:00
5343fd9f51 Update frontend/lib/store/authSlice.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-30 22:42:10 +00:00
Claude
b580744f32 Fix logout and auth redirect issues with Redux implementation
- Install Redux Toolkit and React Redux
- Create Redux store with auth slice for centralized state management
- Implement global auth error handler to redirect to login on auth failures
- Update API client to trigger auth errors on 401 responses
- Replace React Context auth with Redux throughout the app
- Fix logout button to properly clear auth state and redirect
- Add automatic redirect to dashboard when already logged in on login page
- Add automatic redirect to login when not authenticated on dashboard
- Improve error handling for all container API methods

https://claude.ai/code/session_01U3wVqokhrL3dTeq2dTq73n
2026-01-30 22:22:12 +00:00
dependabot[bot]
cc2915e82d Bump python-socketio in /backend in the pip group across 1 directory
Bumps the pip group with 1 update in the /backend directory: [python-socketio](https://github.com/miguelgrinberg/python-socketio).


Updates `python-socketio` from 5.11.0 to 5.14.0
- [Release notes](https://github.com/miguelgrinberg/python-socketio/releases)
- [Changelog](https://github.com/miguelgrinberg/python-socketio/blob/main/CHANGES.md)
- [Commits](https://github.com/miguelgrinberg/python-socketio/compare/v5.11.0...v5.14.0)

---
updated-dependencies:
- dependency-name: python-socketio
  dependency-version: 5.14.0
  dependency-type: direct:production
  dependency-group: pip
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-30 22:03:12 +00:00
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
57 changed files with 8139 additions and 579 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.14.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

@@ -1,84 +1,32 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
Box,
Container,
Typography,
Button,
Grid,
AppBar,
Toolbar,
IconButton,
CircularProgress,
} from '@mui/material';
import { Logout, Refresh, Inventory2 } from '@mui/icons-material';
import { useAuth } from '@/lib/auth';
import { apiClient, Container as ContainerType } from '@/lib/api';
import { Box, Container, Typography, Grid, CircularProgress, useMediaQuery, useTheme } from '@mui/material';
import { useAppDispatch } from '@/lib/store/hooks';
import { logout as logoutAction } from '@/lib/store/authSlice';
import { useAuthRedirect } from '@/lib/hooks/useAuthRedirect';
import { useContainerList } from '@/lib/hooks/useContainerList';
import { useTerminalModal } from '@/lib/hooks/useTerminalModal';
import DashboardHeader from '@/components/Dashboard/DashboardHeader';
import EmptyState from '@/components/Dashboard/EmptyState';
import ContainerCard from '@/components/ContainerCard';
import TerminalModal from '@/components/TerminalModal';
export default function Dashboard() {
const { isAuthenticated, loading: authLoading, logout } = useAuth();
const { isAuthenticated, loading: authLoading } = useAuthRedirect('/');
const dispatch = useAppDispatch();
const router = useRouter();
const [containers, setContainers] = useState<ContainerType[]>([]);
const [selectedContainer, setSelectedContainer] = useState<ContainerType | null>(null);
const [isTerminalOpen, setIsTerminalOpen] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
useEffect(() => {
if (!authLoading && !isAuthenticated) {
router.push('/');
}
}, [isAuthenticated, authLoading, router]);
const fetchContainers = async () => {
setIsRefreshing(true);
setError('');
try {
const data = await apiClient.getContainers();
setContainers(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch containers');
if (err instanceof Error && err.message === 'Session expired') {
router.push('/');
}
} finally {
setIsRefreshing(false);
setIsLoading(false);
}
};
useEffect(() => {
if (isAuthenticated) {
fetchContainers();
const interval = setInterval(fetchContainers, 10000);
return () => clearInterval(interval);
}
}, [isAuthenticated]);
const handleOpenShell = (container: ContainerType) => {
setSelectedContainer(container);
setIsTerminalOpen(true);
};
const handleCloseTerminal = () => {
setIsTerminalOpen(false);
setTimeout(() => setSelectedContainer(null), 300);
};
const { containers, isRefreshing, isLoading, error, refreshContainers } = useContainerList(isAuthenticated);
const { selectedContainer, isTerminalOpen, openTerminal, closeTerminal } = useTerminalModal();
const handleLogout = async () => {
await logout();
await dispatch(logoutAction());
router.push('/');
};
const handleRefresh = () => {
fetchContainers();
};
if (authLoading || isLoading) {
return (
<Box
@@ -96,66 +44,15 @@ export default function Dashboard() {
return (
<Box sx={{ minHeight: '100vh', backgroundColor: 'background.default' }}>
<AppBar
position="sticky"
sx={{
backgroundColor: 'rgba(45, 55, 72, 0.5)',
backdropFilter: 'blur(8px)',
borderBottom: 1,
borderColor: 'divider',
}}
>
<Toolbar>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexGrow: 1 }}>
<Box
sx={{
width: 40,
height: 40,
background: 'rgba(56, 178, 172, 0.1)',
borderRadius: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Inventory2 sx={{ color: 'secondary.main' }} />
</Box>
<Box>
<Typography
variant="h1"
sx={{ fontFamily: '"JetBrains Mono", monospace', fontSize: '1.5rem' }}
>
Container Shell
</Typography>
<Typography variant="caption" color="text.secondary">
{containers.length} active {containers.length === 1 ? 'container' : 'containers'}
</Typography>
</Box>
</Box>
<DashboardHeader
containerCount={containers.length}
isMobile={isMobile}
isRefreshing={isRefreshing}
onRefresh={refreshContainers}
onLogout={handleLogout}
/>
<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>
</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>
@@ -163,45 +60,15 @@ export default function Dashboard() {
)}
{containers.length === 0 && !isLoading ? (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: 400,
textAlign: 'center',
}}
>
<Box
sx={{
width: 80,
height: 80,
backgroundColor: 'action.hover',
borderRadius: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mb: 2,
}}
>
<Inventory2 sx={{ fontSize: 40, color: 'text.secondary' }} />
</Box>
<Typography variant="h2" gutterBottom>
No Active Containers
</Typography>
<Typography color="text.secondary" sx={{ maxWidth: 500 }}>
There are currently no running containers to display. Start a container to see it
appear here.
</Typography>
</Box>
<EmptyState />
) : (
<Grid container spacing={3}>
{containers.map((container) => (
<Grid size={{ xs: 12, sm: 6, lg: 4 }} key={container.id}>
<ContainerCard
container={container}
onOpenShell={() => handleOpenShell(container)}
onOpenShell={() => openTerminal(container)}
onContainerUpdate={refreshContainers}
/>
</Grid>
))}
@@ -212,7 +79,7 @@ export default function Dashboard() {
{selectedContainer && (
<TerminalModal
open={isTerminalOpen}
onClose={handleCloseTerminal}
onClose={closeTerminal}
containerName={selectedContainer.name}
containerId={selectedContainer.id}
/>

View File

@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import "./globals.css";
import { ThemeProvider } from "@/lib/theme";
import { AuthProvider } from "@/lib/auth";
import { Providers } from "./providers";
export const metadata: Metadata = {
title: "Container Shell - Docker Swarm Terminal",
@@ -26,9 +26,9 @@ export default function RootLayout({
</head>
<body>
<ThemeProvider>
<AuthProvider>
<Providers>
{children}
</AuthProvider>
</Providers>
</ThemeProvider>
</body>
</html>

View File

@@ -1,19 +1,10 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth';
import { useAuthRedirect } from '@/lib/hooks/useAuthRedirect';
import LoginForm from '@/components/LoginForm';
export default function Home() {
const { isAuthenticated, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading && isAuthenticated) {
router.push('/dashboard');
}
}, [isAuthenticated, loading, router]);
const { loading } = useAuthRedirect('/dashboard');
if (loading) {
return null;

View File

@@ -0,0 +1,39 @@
'use client';
import React, { useEffect, useCallback } from 'react';
import { Provider } from 'react-redux';
import { useRouter } from 'next/navigation';
import { store } from '@/lib/store/store';
import { initAuth, setUnauthenticated } from '@/lib/store/authSlice';
import { setAuthErrorCallback } from '@/lib/store/authErrorHandler';
import { useAppDispatch } from '@/lib/store/hooks';
function AuthInitializer({ children }: { children: React.ReactNode }) {
const router = useRouter();
const dispatch = useAppDispatch();
// Memoize the auth error callback to prevent recreating on every render
const handleAuthError = useCallback(() => {
// Clear auth state and redirect to login
dispatch(setUnauthenticated());
router.push('/');
}, [dispatch, router]);
useEffect(() => {
// Initialize auth state
dispatch(initAuth());
// Set up global auth error handler
setAuthErrorCallback(handleAuthError);
}, [dispatch, handleAuthError]);
return <>{children}</>;
}
export function Providers({ children }: { children: React.ReactNode }) {
return (
<Provider store={store}>
<AuthInitializer>{children}</AuthInitializer>
</Provider>
);
}

View File

@@ -1,34 +1,38 @@
'use client';
import React from 'react';
import {
Card,
CardContent,
Typography,
Button,
Box,
Chip,
Divider,
} from '@mui/material';
import { Terminal, PlayArrow, Inventory2 } from '@mui/icons-material';
import React, { useState } from 'react';
import { Card, CardContent, Divider, Snackbar, Alert } from '@mui/material';
import { Container } from '@/lib/api';
import { ContainerCardProps } from '@/lib/interfaces/container';
import { useContainerActions } from '@/lib/hooks/useContainerActions';
import ContainerHeader from './ContainerCard/ContainerHeader';
import ContainerInfo from './ContainerCard/ContainerInfo';
import ContainerActions from './ContainerCard/ContainerActions';
import DeleteConfirmDialog from './ContainerCard/DeleteConfirmDialog';
interface ContainerCardProps {
container: Container;
onOpenShell: () => void;
}
const borderColors = {
running: '#38b2ac',
stopped: '#718096',
paused: '#ecc94b',
exited: '#718096',
created: '#4299e1',
};
export default function ContainerCard({ container, onOpenShell }: ContainerCardProps) {
const statusColors = {
running: 'success',
stopped: 'default',
paused: 'warning',
} as const;
export default function ContainerCard({ container, onOpenShell, onContainerUpdate }: ContainerCardProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const {
isLoading,
snackbar,
handleStart,
handleStop,
handleRestart,
handleRemove,
closeSnackbar,
} = useContainerActions(container.id, onContainerUpdate);
const borderColors = {
running: '#38b2ac',
stopped: '#718096',
paused: '#ecc94b',
const confirmRemove = () => {
setShowDeleteDialog(false);
handleRemove();
};
return (
@@ -39,124 +43,44 @@ export default function ContainerCard({ container, onOpenShell }: ContainerCardP
}}
>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start', flex: 1 }}>
<Box
sx={{
width: 40,
height: 40,
background: 'rgba(56, 178, 172, 0.1)',
borderRadius: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Inventory2 sx={{ color: 'secondary.main', fontSize: 20 }} />
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography
variant="h3"
component="h3"
sx={{
fontFamily: '"JetBrains Mono", monospace',
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{container.name}
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{container.image}
</Typography>
</Box>
</Box>
<Chip
label={container.status}
color={statusColors[container.status as keyof typeof statusColors] || 'default'}
size="small"
icon={container.status === 'running' ? <PlayArrow sx={{ fontSize: 12 }} /> : undefined}
sx={{
fontFamily: '"JetBrains Mono", monospace',
textTransform: 'capitalize',
}}
/>
</Box>
<ContainerHeader
name={container.name}
image={container.image}
status={container.status}
/>
<Divider sx={{ my: 2 }} />
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2, mb: 3 }}>
<Box>
<Typography
variant="caption"
color="text.secondary"
sx={{
textTransform: 'uppercase',
letterSpacing: '0.05em',
display: 'block',
mb: 0.5,
}}
>
Container ID
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: '"JetBrains Mono", monospace' }}
>
{container.id}
</Typography>
</Box>
<Box>
<Typography
variant="caption"
color="text.secondary"
sx={{
textTransform: 'uppercase',
letterSpacing: '0.05em',
display: 'block',
mb: 0.5,
}}
>
Uptime
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: '"JetBrains Mono", monospace' }}
>
{container.uptime}
</Typography>
</Box>
</Box>
<ContainerInfo id={container.id} uptime={container.uptime} />
<Button
fullWidth
variant="contained"
color="primary"
onClick={onOpenShell}
disabled={container.status !== 'running'}
startIcon={<Terminal />}
sx={{
fontWeight: 500,
'&:hover': {
backgroundColor: 'secondary.main',
},
}}
>
Open Shell
</Button>
<ContainerActions
status={container.status}
isLoading={isLoading}
onStart={handleStart}
onStop={handleStop}
onRestart={handleRestart}
onRemove={() => setShowDeleteDialog(true)}
onOpenShell={onOpenShell}
/>
</CardContent>
<DeleteConfirmDialog
open={showDeleteDialog}
containerName={container.name}
onClose={() => setShowDeleteDialog(false)}
onConfirm={confirmRemove}
/>
<Snackbar
open={snackbar.open}
autoHideDuration={4000}
onClose={closeSnackbar}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<Alert onClose={closeSnackbar} severity={snackbar.severity} sx={{ width: '100%' }}>
{snackbar.message}
</Alert>
</Snackbar>
</Card>
);
}

View File

@@ -0,0 +1,116 @@
import React from 'react';
import { Box, Button, CircularProgress } from '@mui/material';
import { PlayArrow, Stop, Refresh, Delete, Terminal } from '@mui/icons-material';
import { ContainerActionsProps } from '@/lib/interfaces/container';
export default function ContainerActions({
status,
isLoading,
onStart,
onStop,
onRestart,
onRemove,
onOpenShell,
}: ContainerActionsProps) {
const isRunning = status === 'running';
const isStopped = status === 'stopped' || status === 'exited' || status === 'created';
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{isStopped && (
<Button
variant="contained"
size="small"
onClick={onStart}
disabled={isLoading}
startIcon={isLoading ? <CircularProgress size={16} /> : <PlayArrow />}
sx={{
flex: 1,
minWidth: '100px',
backgroundColor: '#38b2ac',
'&:hover': { backgroundColor: '#2c8a84' },
}}
>
Start
</Button>
)}
{isRunning && (
<>
<Button
variant="contained"
size="small"
onClick={onStop}
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={onRestart}
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={onRemove}
disabled={isLoading}
startIcon={<Delete />}
sx={{
minWidth: '100px',
borderColor: '#fc8181',
color: '#fc8181',
'&:hover': {
borderColor: '#f56565',
backgroundColor: 'rgba(252, 129, 129, 0.1)',
},
}}
>
Remove
</Button>
</Box>
<Button
fullWidth
variant="contained"
color="primary"
onClick={onOpenShell}
disabled={!isRunning || isLoading}
startIcon={<Terminal />}
sx={{
fontWeight: 500,
'&:hover': {
backgroundColor: 'secondary.main',
},
}}
>
Open Shell
</Button>
</Box>
);
}

View File

@@ -0,0 +1,72 @@
import React from 'react';
import { Box, Typography, Chip } from '@mui/material';
import { PlayArrow, Inventory2 } from '@mui/icons-material';
import { ContainerHeaderProps } from '@/lib/interfaces/container';
const statusColors = {
running: 'success',
stopped: 'default',
paused: 'warning',
exited: 'default',
created: 'info',
} as const;
export default function ContainerHeader({ name, image, status }: ContainerHeaderProps) {
return (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start', flex: 1 }}>
<Box
sx={{
width: 40,
height: 40,
background: 'rgba(56, 178, 172, 0.1)',
borderRadius: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Inventory2 sx={{ color: 'secondary.main', fontSize: 20 }} />
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography
variant="h3"
component="h3"
sx={{
fontFamily: '"JetBrains Mono", monospace',
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{name}
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{image}
</Typography>
</Box>
</Box>
<Chip
label={status}
color={statusColors[status as keyof typeof statusColors] || 'default'}
size="small"
icon={status === 'running' ? <PlayArrow sx={{ fontSize: 12 }} /> : undefined}
sx={{
fontFamily: '"JetBrains Mono", monospace',
textTransform: 'capitalize',
}}
/>
</Box>
);
}

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { Box, Typography } from '@mui/material';
import { ContainerInfoProps } from '@/lib/interfaces/container';
export default function ContainerInfo({ id, uptime }: ContainerInfoProps) {
return (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' }, gap: 2, mb: 3 }}>
<Box>
<Typography
variant="caption"
color="text.secondary"
sx={{
textTransform: 'uppercase',
letterSpacing: '0.05em',
display: 'block',
mb: 0.5,
}}
>
Container ID
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: '"JetBrains Mono", monospace' }}
>
{id}
</Typography>
</Box>
<Box>
<Typography
variant="caption"
color="text.secondary"
sx={{
textTransform: 'uppercase',
letterSpacing: '0.05em',
display: 'block',
mb: 0.5,
}}
>
Uptime
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: '"JetBrains Mono", monospace' }}
>
{uptime}
</Typography>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,35 @@
import React from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
Button,
} from '@mui/material';
import { DeleteConfirmDialogProps } from '@/lib/interfaces/container';
export default function DeleteConfirmDialog({
open,
containerName,
onClose,
onConfirm,
}: DeleteConfirmDialogProps) {
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>Confirm Container Removal</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to remove container <strong>{containerName}</strong>?
This action cannot be undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button onClick={onConfirm} color="error" variant="contained">
Remove
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import ContainerHeader from '../ContainerHeader';
describe('ContainerHeader', () => {
it('renders container name', () => {
render(
<ContainerHeader name="test-container" image="nginx:latest" status="running" />
);
expect(screen.getByText('test-container')).toBeInTheDocument();
});
it('renders container image', () => {
render(
<ContainerHeader name="test-container" image="nginx:latest" status="running" />
);
expect(screen.getByText('nginx:latest')).toBeInTheDocument();
});
it('renders status chip with correct label', () => {
render(
<ContainerHeader name="test-container" image="nginx:latest" status="running" />
);
expect(screen.getByText('running')).toBeInTheDocument();
});
it('applies success color for running status', () => {
const { container } = render(
<ContainerHeader name="test-container" image="nginx:latest" status="running" />
);
const statusChip = screen.getByText('running').closest('.MuiChip-root');
expect(statusChip).toHaveClass('MuiChip-colorSuccess');
});
it('applies default color for stopped status', () => {
const { container } = render(
<ContainerHeader name="test-container" image="nginx:latest" status="stopped" />
);
const statusChip = screen.getByText('stopped').closest('.MuiChip-root');
expect(statusChip).toHaveClass('MuiChip-colorDefault');
});
it('applies warning color for paused status', () => {
const { container } = render(
<ContainerHeader name="test-container" image="nginx:latest" status="paused" />
);
const statusChip = screen.getByText('paused').closest('.MuiChip-root');
expect(statusChip).toHaveClass('MuiChip-colorWarning');
});
it('renders play icon for running containers', () => {
const { container } = render(
<ContainerHeader name="test-container" image="nginx:latest" status="running" />
);
const playIcon = container.querySelector('[data-testid="PlayArrowIcon"]');
expect(playIcon).toBeInTheDocument();
});
it('does not render play icon for stopped containers', () => {
const { container } = render(
<ContainerHeader name="test-container" image="nginx:latest" status="stopped" />
);
const playIcon = container.querySelector('[data-testid="PlayArrowIcon"]');
expect(playIcon).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import ContainerInfo from '../ContainerInfo';
describe('ContainerInfo', () => {
it('renders container ID label', () => {
render(<ContainerInfo id="abc123def456" uptime="2 hours" />);
expect(screen.getByText(/container id/i)).toBeInTheDocument();
});
it('renders container ID value', () => {
render(<ContainerInfo id="abc123def456" uptime="2 hours" />);
expect(screen.getByText('abc123def456')).toBeInTheDocument();
});
it('renders uptime label', () => {
render(<ContainerInfo id="abc123def456" uptime="2 hours" />);
expect(screen.getByText(/uptime/i)).toBeInTheDocument();
});
it('renders uptime value', () => {
render(<ContainerInfo id="abc123def456" uptime="2 hours" />);
expect(screen.getByText('2 hours')).toBeInTheDocument();
});
it('renders different uptime formats correctly', () => {
const { rerender } = render(<ContainerInfo id="abc123" uptime="5 minutes" />);
expect(screen.getByText('5 minutes')).toBeInTheDocument();
rerender(<ContainerInfo id="abc123" uptime="3 days" />);
expect(screen.getByText('3 days')).toBeInTheDocument();
rerender(<ContainerInfo id="abc123" uptime="1 month" />);
expect(screen.getByText('1 month')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,108 @@
import React from 'react';
import {
AppBar,
Toolbar,
Box,
Typography,
Button,
IconButton,
CircularProgress,
} from '@mui/material';
import { Logout, Refresh, Inventory2 } from '@mui/icons-material';
import { DashboardHeaderProps } from '@/lib/interfaces/dashboard';
export default function DashboardHeader({
containerCount,
isMobile,
isRefreshing,
onRefresh,
onLogout,
}: DashboardHeaderProps) {
return (
<AppBar
position="sticky"
sx={{
backgroundColor: 'rgba(45, 55, 72, 0.5)',
backdropFilter: 'blur(8px)',
borderBottom: 1,
borderColor: 'divider',
}}
>
<Toolbar>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexGrow: 1 }}>
<Box
sx={{
width: 40,
height: 40,
background: 'rgba(56, 178, 172, 0.1)',
borderRadius: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Inventory2 sx={{ color: 'secondary.main' }} />
</Box>
<Box>
<Typography
variant="h1"
sx={{
fontFamily: '"JetBrains Mono", monospace',
fontSize: { xs: '1.1rem', sm: '1.5rem' }
}}
>
Container Shell
</Typography>
{!isMobile && (
<Typography variant="caption" color="text.secondary">
{containerCount} active {containerCount === 1 ? 'container' : 'containers'}
</Typography>
)}
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
{isMobile ? (
<>
<IconButton
color="inherit"
onClick={onRefresh}
disabled={isRefreshing}
size="small"
>
{isRefreshing ? <CircularProgress size={20} /> : <Refresh />}
</IconButton>
<IconButton
color="inherit"
onClick={onLogout}
size="small"
>
<Logout />
</IconButton>
</>
) : (
<>
<Button
variant="outlined"
size="small"
onClick={onRefresh}
disabled={isRefreshing}
startIcon={isRefreshing ? <CircularProgress size={16} /> : <Refresh />}
>
Refresh
</Button>
<Button
variant="outlined"
size="small"
onClick={onLogout}
startIcon={<Logout />}
>
Logout
</Button>
</>
)}
</Box>
</Toolbar>
</AppBar>
);
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { Box, Typography } from '@mui/material';
import { Inventory2 } from '@mui/icons-material';
export default function EmptyState() {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: 400,
textAlign: 'center',
}}
>
<Box
sx={{
width: 80,
height: 80,
backgroundColor: 'action.hover',
borderRadius: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mb: 2,
}}
>
<Inventory2 sx={{ fontSize: 40, color: 'text.secondary' }} />
</Box>
<Typography variant="h2" gutterBottom>
No Active Containers
</Typography>
<Typography color="text.secondary" sx={{ maxWidth: 500 }}>
There are currently no running containers to display. Start a container to see it appear here.
</Typography>
</Box>
);
}

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import EmptyState from '../EmptyState';
describe('EmptyState', () => {
it('renders no containers message', () => {
render(<EmptyState />);
expect(screen.getByText(/no active containers/i)).toBeInTheDocument();
});
it('renders descriptive message', () => {
render(<EmptyState />);
expect(
screen.getByText(/there are currently no running containers to display/i)
).toBeInTheDocument();
});
it('renders inventory icon', () => {
const { container } = render(<EmptyState />);
const icon = container.querySelector('[data-testid="Inventory2Icon"]');
expect(icon).toBeInTheDocument();
});
});

View File

@@ -1,6 +1,6 @@
'use client';
import React, { useState } from 'react';
import React from 'react';
import {
Card,
CardContent,
@@ -11,31 +11,19 @@ import {
Alert,
} from '@mui/material';
import { LockOpen } from '@mui/icons-material';
import { useAuth } from '@/lib/auth';
import { useRouter } from 'next/navigation';
import { useLoginForm } from '@/lib/hooks/useLoginForm';
export default function LoginForm() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isShaking, setIsShaking] = useState(false);
const { login } = useAuth();
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
const success = await login(username, password);
if (success) {
router.push('/dashboard');
} else {
setError('Invalid credentials');
setIsShaking(true);
setTimeout(() => setIsShaking(false), 500);
}
};
const {
username,
setUsername,
password,
setPassword,
isShaking,
error,
loading,
handleSubmit,
} = useLoginForm();
return (
<Box
@@ -121,8 +109,9 @@ export default function LoginForm() {
color="secondary"
size="large"
sx={{ mb: 2 }}
disabled={loading}
>
Access Dashboard
{loading ? 'Logging in...' : 'Access Dashboard'}
</Button>
<Typography

View File

@@ -1,27 +1,14 @@
'use client';
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Box,
Typography,
IconButton,
Paper,
} from '@mui/material';
import { Close, Send } from '@mui/icons-material';
import { apiClient } from '@/lib/api';
interface TerminalModalProps {
open: boolean;
onClose: () => void;
containerName: string;
containerId: string;
}
import { Dialog, DialogContent, DialogActions, Button, useMediaQuery, useTheme } from '@mui/material';
import { useSimpleTerminal } from '@/lib/hooks/useSimpleTerminal';
import { useInteractiveTerminal } from '@/lib/hooks/useInteractiveTerminal';
import { TerminalModalProps } from '@/lib/interfaces/terminal';
import TerminalHeader from './TerminalModal/TerminalHeader';
import SimpleTerminal from './TerminalModal/SimpleTerminal';
import InteractiveTerminal from './TerminalModal/InteractiveTerminal';
import FallbackNotification from './TerminalModal/FallbackNotification';
export default function TerminalModal({
open,
@@ -29,132 +16,105 @@ export default function TerminalModal({
containerName,
containerId,
}: TerminalModalProps) {
const [command, setCommand] = useState('');
const [output, setOutput] = useState<string[]>([]);
const [isExecuting, setIsExecuting] = useState(false);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const handleExecute = async () => {
if (!command.trim()) return;
const [mode, setMode] = useState<'simple' | 'interactive'>('interactive');
const [interactiveFailed, setInteractiveFailed] = useState(false);
const [fallbackReason, setFallbackReason] = useState('');
const [showFallbackNotification, setShowFallbackNotification] = useState(false);
setIsExecuting(true);
setOutput((prev) => [...prev, `$ ${command}`]);
const simpleTerminal = useSimpleTerminal(containerId);
try {
const result = await apiClient.executeCommand(containerId, command);
setOutput((prev) => [...prev, result.output || '(no output)']);
} catch (error) {
setOutput((prev) => [...prev, `Error: ${error instanceof Error ? error.message : 'Unknown error'}`]);
} finally {
setIsExecuting(false);
setCommand('');
const handleFallback = (reason: string) => {
console.warn('Falling back to simple mode:', reason);
setInteractiveFailed(true);
setFallbackReason(reason);
setMode('simple');
setShowFallbackNotification(true);
interactiveTerminal.cleanup();
};
const interactiveTerminal = useInteractiveTerminal({
open: open && mode === 'interactive',
containerId,
containerName,
isMobile,
onFallback: handleFallback,
});
const handleClose = () => {
interactiveTerminal.cleanup();
simpleTerminal.reset();
onClose();
};
const handleModeChange = (
event: React.MouseEvent<HTMLElement>,
newMode: 'simple' | 'interactive' | null,
) => {
if (newMode !== null) {
if (newMode === 'interactive' && interactiveFailed) {
setInteractiveFailed(false);
setFallbackReason('');
}
setMode(newMode);
}
};
const handleRetryInteractive = () => {
setInteractiveFailed(false);
setFallbackReason('');
setShowFallbackNotification(false);
setMode('interactive');
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleExecute();
simpleTerminal.executeCommand();
}
};
const handleClose = () => {
setOutput([]);
setCommand('');
onClose();
};
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',
},
}}
>
<DialogTitle
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
pb: 2,
}}
>
<Typography variant="h2" component="div">
Terminal - {containerName}
</Typography>
<IconButton onClick={handleClose} size="small">
<Close />
</IconButton>
</DialogTitle>
<TerminalHeader
containerName={containerName}
mode={mode}
interactiveFailed={interactiveFailed}
onModeChange={handleModeChange}
onClose={handleClose}
/>
<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)}
{mode === 'interactive' ? (
<InteractiveTerminal terminalRef={interactiveTerminal.terminalRef} />
) : (
<SimpleTerminal
output={simpleTerminal.output}
command={simpleTerminal.command}
workdir={simpleTerminal.workdir}
isExecuting={simpleTerminal.isExecuting}
isMobile={isMobile}
containerName={containerName}
outputRef={simpleTerminal.outputRef}
onCommandChange={simpleTerminal.setCommand}
onExecute={simpleTerminal.executeCommand}
onKeyPress={handleKeyPress}
placeholder="Enter command (e.g., ls, pwd, echo 'hello')"
disabled={isExecuting}
variant="outlined"
size="small"
sx={{
fontFamily: '"JetBrains Mono", monospace',
'& input': {
fontFamily: '"JetBrains Mono", monospace',
},
}}
/>
<Button
variant="contained"
color="secondary"
onClick={handleExecute}
disabled={isExecuting || !command.trim()}
startIcon={<Send />}
>
Execute
</Button>
</Box>
)}
</DialogContent>
<DialogActions>
@@ -162,6 +122,13 @@ export default function TerminalModal({
Close
</Button>
</DialogActions>
<FallbackNotification
show={showFallbackNotification}
reason={fallbackReason}
onClose={() => setShowFallbackNotification(false)}
onRetry={handleRetryInteractive}
/>
</Dialog>
);
}

View File

@@ -0,0 +1,105 @@
import React from 'react';
import { Box, Typography, TextField, Button, IconButton } from '@mui/material';
import { Send } from '@mui/icons-material';
import { CommandInputProps } from '@/lib/interfaces/terminal';
import { formatPrompt } from '@/lib/utils/terminal';
export default function CommandInput({
command,
workdir,
isExecuting,
isMobile,
containerName,
onCommandChange,
onExecute,
onKeyPress,
}: CommandInputProps) {
return (
<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(containerName, workdir)}
</Typography>
<Box sx={{ display: 'flex', gap: 1, flex: 1 }}>
<TextField
fullWidth
value={command}
onChange={(e) => onCommandChange(e.target.value)}
onKeyPress={onKeyPress}
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={onExecute}
disabled={isExecuting || !command.trim()}
sx={{
backgroundColor: '#5E2750',
color: 'white',
'&:hover': {
backgroundColor: '#772953',
},
'&:disabled': {
backgroundColor: '#3a1a2f',
},
}}
>
<Send />
</IconButton>
) : (
<Button
variant="contained"
onClick={onExecute}
disabled={isExecuting || !command.trim()}
startIcon={<Send />}
sx={{
backgroundColor: '#5E2750',
'&:hover': {
backgroundColor: '#772953',
},
textTransform: 'none',
fontWeight: 'bold',
}}
>
Run
</Button>
)}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { Snackbar, Alert, Typography, Button } from '@mui/material';
import { Warning } from '@mui/icons-material';
import { FallbackNotificationProps } from '@/lib/interfaces/terminal';
export default function FallbackNotification({
show,
reason,
onClose,
onRetry,
}: FallbackNotificationProps) {
return (
<Snackbar
open={show}
autoHideDuration={10000}
onClose={onClose}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
>
<Alert
severity="warning"
icon={<Warning />}
action={
<Button color="inherit" size="small" onClick={onRetry}>
Retry
</Button>
}
onClose={onClose}
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' }}>
{reason}
</Typography>
</Alert>
</Snackbar>
);
}

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { Box } from '@mui/material';
import '@xterm/xterm/css/xterm.css';
import { InteractiveTerminalProps } from '@/lib/interfaces/terminal';
export default function InteractiveTerminal({ terminalRef }: InteractiveTerminalProps) {
return (
<Box
ref={terminalRef}
sx={{
height: { xs: '400px', sm: '500px' },
backgroundColor: '#300A24',
borderRadius: '4px',
border: '1px solid #5E2750',
overflow: 'hidden',
'& .xterm': {
padding: '8px',
},
'& .xterm-viewport': {
backgroundColor: '#300A24 !important',
},
}}
/>
);
}

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { SimpleTerminalProps } from '@/lib/interfaces/terminal';
import TerminalOutput from './TerminalOutput';
import CommandInput from './CommandInput';
export default function SimpleTerminal({
output,
command,
workdir,
isExecuting,
isMobile,
containerName,
outputRef,
onCommandChange,
onExecute,
onKeyPress,
}: SimpleTerminalProps) {
return (
<>
<TerminalOutput
output={output}
containerName={containerName}
outputRef={outputRef}
/>
<CommandInput
command={command}
workdir={workdir}
isExecuting={isExecuting}
isMobile={isMobile}
containerName={containerName}
onCommandChange={onCommandChange}
onExecute={onExecute}
onKeyPress={onKeyPress}
/>
</>
);
}

View File

@@ -0,0 +1,86 @@
import React from 'react';
import {
DialogTitle,
Box,
Typography,
IconButton,
ToggleButtonGroup,
ToggleButton,
Tooltip,
} from '@mui/material';
import { Close, Terminal as TerminalIcon, Code, Warning } from '@mui/icons-material';
import { TerminalHeaderProps } from '@/lib/interfaces/terminal';
export default function TerminalHeader({
containerName,
mode,
interactiveFailed,
onModeChange,
onClose,
}: TerminalHeaderProps) {
return (
<DialogTitle
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
pb: 2,
pt: { xs: 1, sm: 2 },
px: { xs: 2, sm: 3 },
flexWrap: 'wrap',
gap: 2,
}}
>
<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={onModeChange}
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={onClose} size="small">
<Close />
</IconButton>
</DialogTitle>
);
}

View File

@@ -0,0 +1,67 @@
import React from 'react';
import { Box, Paper, Typography } from '@mui/material';
import { TerminalOutputProps } from '@/lib/interfaces/terminal';
import { highlightCommand } from '@/lib/utils/terminal';
export default function TerminalOutput({ output, containerName, outputRef }: TerminalOutputProps) {
return (
<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, containerName)}
</React.Fragment>
))}
</Box>
)}
</Paper>
);
}

View File

@@ -0,0 +1,88 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import TerminalHeader from '../TerminalHeader';
describe('TerminalHeader', () => {
const mockOnClose = jest.fn();
const mockOnModeChange = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('renders container name', () => {
render(
<TerminalHeader
containerName="test-container"
mode="interactive"
interactiveFailed={false}
onModeChange={mockOnModeChange}
onClose={mockOnClose}
/>
);
expect(screen.getByText(/Terminal - test-container/i)).toBeInTheDocument();
});
it('renders interactive and simple mode buttons', () => {
render(
<TerminalHeader
containerName="test-container"
mode="interactive"
interactiveFailed={false}
onModeChange={mockOnModeChange}
onClose={mockOnClose}
/>
);
expect(screen.getByText('Interactive')).toBeInTheDocument();
expect(screen.getByText('Simple')).toBeInTheDocument();
});
it('calls onClose when close button is clicked', () => {
render(
<TerminalHeader
containerName="test-container"
mode="interactive"
interactiveFailed={false}
onModeChange={mockOnModeChange}
onClose={mockOnClose}
/>
);
const closeButton = screen.getByRole('button', { name: '' });
fireEvent.click(closeButton);
expect(mockOnClose).toHaveBeenCalled();
});
it('shows warning icon when interactive mode failed', () => {
const { container } = render(
<TerminalHeader
containerName="test-container"
mode="simple"
interactiveFailed={true}
onModeChange={mockOnModeChange}
onClose={mockOnClose}
/>
);
const warningIcon = container.querySelector('[data-testid="WarningIcon"]');
expect(warningIcon).toBeInTheDocument();
});
it('applies correct mode selection', () => {
render(
<TerminalHeader
containerName="test-container"
mode="simple"
interactiveFailed={false}
onModeChange={mockOnModeChange}
onClose={mockOnClose}
/>
);
const simpleButton = screen.getByText('Simple').closest('button');
expect(simpleButton).toHaveClass('Mui-selected');
});
});

View File

@@ -0,0 +1,78 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import authReducer from '@/lib/store/authSlice';
import LoginForm from '../LoginForm';
jest.mock('next/navigation', () => ({
useRouter: jest.fn(() => ({
push: jest.fn(),
})),
}));
const createMockStore = (loading = false) =>
configureStore({
reducer: {
auth: authReducer,
},
preloadedState: {
auth: {
isAuthenticated: false,
loading,
username: null,
error: null,
},
},
});
const renderWithProvider = (component: React.ReactElement, loading = false) => {
return render(<Provider store={createMockStore(loading)}>{component}</Provider>);
};
describe('LoginForm', () => {
it('renders login form elements', () => {
renderWithProvider(<LoginForm />);
expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /access dashboard/i })).toBeInTheDocument();
});
it('updates username input on change', () => {
renderWithProvider(<LoginForm />);
const usernameInput = screen.getByLabelText(/username/i) as HTMLInputElement;
fireEvent.change(usernameInput, { target: { value: 'testuser' } });
expect(usernameInput.value).toBe('testuser');
});
it('updates password input on change', () => {
renderWithProvider(<LoginForm />);
const passwordInput = screen.getByLabelText(/password/i) as HTMLInputElement;
fireEvent.change(passwordInput, { target: { value: 'testpass' } });
expect(passwordInput.value).toBe('testpass');
});
it('shows loading text when loading', () => {
renderWithProvider(<LoginForm />, true);
expect(screen.getByRole('button', { name: /logging in/i })).toBeInTheDocument();
});
it('password input is type password', () => {
renderWithProvider(<LoginForm />);
const passwordInput = screen.getByLabelText(/password/i) as HTMLInputElement;
expect(passwordInput.type).toBe('password');
});
it('shows helper text with default credentials', () => {
renderWithProvider(<LoginForm />);
expect(screen.getByText(/default: admin \/ admin123/i)).toBeInTheDocument();
});
});

27
frontend/jest.config.js Normal file
View File

@@ -0,0 +1,27 @@
const nextJest = require('next/jest')
const createJestConfig = nextJest({
dir: './',
})
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
testMatch: [
'**/__tests__/**/*.[jt]s?(x)',
'**/?(*.)+(spec|test).[jt]s?(x)',
],
collectCoverageFrom: [
'lib/**/*.{js,jsx,ts,tsx}',
'components/**/*.{js,jsx,ts,tsx}',
'app/**/*.{js,jsx,ts,tsx}',
'!**/*.d.ts',
'!**/node_modules/**',
'!**/.next/**',
],
}
module.exports = createJestConfig(customJestConfig)

28
frontend/jest.setup.js Normal file
View File

@@ -0,0 +1,28 @@
import '@testing-library/jest-dom'
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
})
// Mock localStorage
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
}
global.localStorage = localStorageMock
// Mock fetch
global.fetch = jest.fn()

View File

@@ -1,5 +1,7 @@
export const API_BASE_URL =
typeof window !== 'undefined' && (window as any).__ENV__?.NEXT_PUBLIC_API_URL
import { triggerAuthError } from './store/authErrorHandler';
export const API_BASE_URL =
typeof window !== 'undefined' && (window as any).__ENV__?.NEXT_PUBLIC_API_URL
? (window as any).__ENV__.NEXT_PUBLIC_API_URL
: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000';
@@ -31,6 +33,7 @@ class ApiClient {
localStorage.setItem('auth_token', token);
} else {
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_username');
}
}
@@ -41,6 +44,23 @@ class ApiClient {
return this.token;
}
getUsername(): string | null {
if (typeof window !== 'undefined') {
return localStorage.getItem('auth_username');
}
return null;
}
setUsername(username: string | null) {
if (typeof window !== 'undefined') {
if (username) {
localStorage.setItem('auth_username', username);
} else {
localStorage.removeItem('auth_username');
}
}
}
async login(username: string, password: string): Promise<AuthResponse> {
const response = await fetch(`${API_BASE_URL}/api/auth/login`, {
method: 'POST',
@@ -53,6 +73,7 @@ class ApiClient {
const data = await response.json();
if (data.success && data.token) {
this.setToken(data.token);
this.setUsername(data.username || username);
}
return data;
}
@@ -73,6 +94,7 @@ class ApiClient {
async getContainers(): Promise<Container[]> {
const token = this.getToken();
if (!token) {
triggerAuthError();
throw new Error('Not authenticated');
}
@@ -85,6 +107,7 @@ class ApiClient {
if (!response.ok) {
if (response.status === 401) {
this.setToken(null);
triggerAuthError();
throw new Error('Session expired');
}
throw new Error('Failed to fetch containers');
@@ -97,6 +120,7 @@ class ApiClient {
async executeCommand(containerId: string, command: string): Promise<any> {
const token = this.getToken();
if (!token) {
triggerAuthError();
throw new Error('Not authenticated');
}
@@ -110,11 +134,124 @@ class ApiClient {
});
if (!response.ok) {
if (response.status === 401) {
this.setToken(null);
triggerAuthError();
throw new Error('Session expired');
}
throw new Error('Failed to execute command');
}
return response.json();
}
async startContainer(containerId: string): Promise<any> {
const token = this.getToken();
if (!token) {
triggerAuthError();
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) {
if (response.status === 401) {
this.setToken(null);
triggerAuthError();
throw new Error('Session expired');
}
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) {
triggerAuthError();
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) {
if (response.status === 401) {
this.setToken(null);
triggerAuthError();
throw new Error('Session expired');
}
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) {
triggerAuthError();
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) {
if (response.status === 401) {
this.setToken(null);
triggerAuthError();
throw new Error('Session expired');
}
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) {
triggerAuthError();
throw new Error('Not authenticated');
}
const response = await fetch(`${API_BASE_URL}/api/containers/${containerId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
if (response.status === 401) {
this.setToken(null);
triggerAuthError();
throw new Error('Session expired');
}
const error = await response.json();
throw new Error(error.error || 'Failed to remove container');
}
return response.json();
}
}
export const apiClient = new ApiClient();

View File

@@ -1,70 +0,0 @@
'use client';
import React, { createContext, useContext, useState, useEffect } from 'react';
import { apiClient } from './api';
interface AuthContextType {
isAuthenticated: boolean;
username: string | null;
login: (username: string, password: string) => Promise<boolean>;
logout: () => Promise<void>;
loading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [username, setUsername] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check if user has a valid token
const token = apiClient.getToken();
if (token) {
setIsAuthenticated(true);
// In a real app, you'd validate the token with the backend
}
setLoading(false);
}, []);
const login = async (username: string, password: string): Promise<boolean> => {
try {
const response = await apiClient.login(username, password);
if (response.success) {
setIsAuthenticated(true);
setUsername(response.username || username);
return true;
}
return false;
} catch (error) {
console.error('Login error:', error);
return false;
}
};
const logout = async () => {
try {
await apiClient.logout();
} catch (error) {
console.error('Logout error:', error);
} finally {
setIsAuthenticated(false);
setUsername(null);
}
};
return (
<AuthContext.Provider value={{ isAuthenticated, username, login, logout, loading }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -0,0 +1,69 @@
import { renderHook } from '@testing-library/react';
import { useRouter } from 'next/navigation';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import authReducer from '@/lib/store/authSlice';
import { useAuthRedirect } from '../useAuthRedirect';
jest.mock('next/navigation', () => ({
useRouter: jest.fn(),
}));
const createMockStore = (isAuthenticated: boolean) =>
configureStore({
reducer: {
auth: authReducer,
},
preloadedState: {
auth: {
isAuthenticated,
loading: false,
username: isAuthenticated ? 'testuser' : null,
error: null,
},
},
});
describe('useAuthRedirect', () => {
const mockPush = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(useRouter as jest.Mock).mockReturnValue({
push: mockPush,
});
});
it('redirects to dashboard when authenticated and redirectTo is dashboard', () => {
const store = createMockStore(true);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<Provider store={store}>{children}</Provider>
);
renderHook(() => useAuthRedirect('/dashboard'), { wrapper });
expect(mockPush).toHaveBeenCalledWith('/dashboard');
});
it('redirects to login when not authenticated and redirectTo is /', () => {
const store = createMockStore(false);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<Provider store={store}>{children}</Provider>
);
renderHook(() => useAuthRedirect('/'), { wrapper });
expect(mockPush).toHaveBeenCalledWith('/');
});
it('does not redirect when authenticated but redirectTo is /', () => {
const store = createMockStore(true);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<Provider store={store}>{children}</Provider>
);
renderHook(() => useAuthRedirect('/'), { wrapper });
expect(mockPush).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,90 @@
import { renderHook, act } from '@testing-library/react';
import { useRouter } from 'next/navigation';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import authReducer from '@/lib/store/authSlice';
import { useLoginForm } from '../useLoginForm';
jest.mock('next/navigation', () => ({
useRouter: jest.fn(),
}));
const createMockStore = () =>
configureStore({
reducer: {
auth: authReducer,
},
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<Provider store={createMockStore()}>{children}</Provider>
);
describe('useLoginForm', () => {
const mockPush = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(useRouter as jest.Mock).mockReturnValue({
push: mockPush,
});
});
it('initializes with empty username and password', () => {
const { result } = renderHook(() => useLoginForm(), { wrapper });
expect(result.current.username).toBe('');
expect(result.current.password).toBe('');
expect(result.current.error).toBeNull();
});
it('updates username when setUsername is called', () => {
const { result } = renderHook(() => useLoginForm(), { wrapper });
act(() => {
result.current.setUsername('testuser');
});
expect(result.current.username).toBe('testuser');
});
it('updates password when setPassword is called', () => {
const { result } = renderHook(() => useLoginForm(), { wrapper });
act(() => {
result.current.setPassword('testpass');
});
expect(result.current.password).toBe('testpass');
});
it('handles form submission', async () => {
const { result } = renderHook(() => useLoginForm(), { wrapper });
const mockEvent = {
preventDefault: jest.fn(),
} as unknown as React.FormEvent;
act(() => {
result.current.setUsername('testuser');
result.current.setPassword('testpass');
});
await act(async () => {
await result.current.handleSubmit(mockEvent);
});
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
it('returns loading state', () => {
const { result } = renderHook(() => useLoginForm(), { wrapper });
expect(result.current.loading).toBeDefined();
});
it('returns isShaking state', () => {
const { result } = renderHook(() => useLoginForm(), { wrapper });
expect(result.current.isShaking).toBe(false);
});
});

View File

@@ -0,0 +1,61 @@
import { renderHook, act } from '@testing-library/react';
import { useTerminalModal } from '../useTerminalModal';
describe('useTerminalModal', () => {
it('initializes with modal closed and no container selected', () => {
const { result } = renderHook(() => useTerminalModal());
expect(result.current.isTerminalOpen).toBe(false);
expect(result.current.selectedContainer).toBeNull();
});
it('opens modal with selected container', () => {
const { result } = renderHook(() => useTerminalModal());
const mockContainer = { id: '123', name: 'test-container' } as any;
act(() => {
result.current.openTerminal(mockContainer);
});
expect(result.current.isTerminalOpen).toBe(true);
expect(result.current.selectedContainer).toEqual(mockContainer);
});
it('closes modal and eventually clears selected container', async () => {
const { result } = renderHook(() => useTerminalModal());
const mockContainer = { id: '123', name: 'test-container' } as any;
act(() => {
result.current.openTerminal(mockContainer);
});
expect(result.current.isTerminalOpen).toBe(true);
act(() => {
result.current.closeTerminal();
});
expect(result.current.isTerminalOpen).toBe(false);
});
it('handles multiple open and close cycles', () => {
const { result } = renderHook(() => useTerminalModal());
const container1 = { id: '123', name: 'container1' } as any;
const container2 = { id: '456', name: 'container2' } as any;
act(() => {
result.current.openTerminal(container1);
});
expect(result.current.selectedContainer).toEqual(container1);
act(() => {
result.current.closeTerminal();
});
expect(result.current.isTerminalOpen).toBe(false);
act(() => {
result.current.openTerminal(container2);
});
expect(result.current.selectedContainer).toEqual(container2);
});
});

View File

@@ -0,0 +1,20 @@
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAppSelector } from '@/lib/store/hooks';
export function useAuthRedirect(redirectTo: '/dashboard' | '/') {
const { isAuthenticated, loading } = useAppSelector((state) => state.auth);
const router = useRouter();
useEffect(() => {
if (loading) return;
if (redirectTo === '/dashboard' && isAuthenticated) {
router.push('/dashboard');
} else if (redirectTo === '/' && !isAuthenticated) {
router.push('/');
}
}, [isAuthenticated, loading, router, redirectTo]);
return { isAuthenticated, loading };
}

View File

@@ -0,0 +1,87 @@
import { useState } from 'react';
import { apiClient } from '@/lib/api';
export function useContainerActions(containerId: string, onUpdate?: () => void) {
const [isLoading, setIsLoading] = useState(false);
const [snackbar, setSnackbar] = useState<{
open: boolean;
message: string;
severity: 'success' | 'error';
}>({
open: false,
message: '',
severity: 'success',
});
const showSuccess = (message: string) => {
setSnackbar({ open: true, message, severity: 'success' });
onUpdate?.();
};
const showError = (action: string, error: unknown) => {
const message = `Failed to ${action}: ${error instanceof Error ? error.message : 'Unknown error'}`;
setSnackbar({ open: true, message, severity: 'error' });
};
const handleStart = async () => {
setIsLoading(true);
try {
await apiClient.startContainer(containerId);
showSuccess('Container started successfully');
} catch (error) {
showError('start', error);
} finally {
setIsLoading(false);
}
};
const handleStop = async () => {
setIsLoading(true);
try {
await apiClient.stopContainer(containerId);
showSuccess('Container stopped successfully');
} catch (error) {
showError('stop', error);
} finally {
setIsLoading(false);
}
};
const handleRestart = async () => {
setIsLoading(true);
try {
await apiClient.restartContainer(containerId);
showSuccess('Container restarted successfully');
} catch (error) {
showError('restart', error);
} finally {
setIsLoading(false);
}
};
const handleRemove = async () => {
setIsLoading(true);
try {
await apiClient.removeContainer(containerId);
showSuccess('Container removed successfully');
} catch (error) {
showError('remove', error);
} finally {
setIsLoading(false);
}
};
const closeSnackbar = () => {
setSnackbar({ ...snackbar, open: false });
};
return {
isLoading,
snackbar,
handleStart,
handleStop,
handleRestart,
handleRemove,
closeSnackbar,
};
}

View File

@@ -0,0 +1,39 @@
import { useState, useEffect } from 'react';
import { apiClient, Container } from '@/lib/api';
export function useContainerList(isAuthenticated: boolean) {
const [containers, setContainers] = useState<Container[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const fetchContainers = async () => {
setIsRefreshing(true);
setError('');
try {
const data = await apiClient.getContainers();
setContainers(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch containers');
} finally {
setIsRefreshing(false);
setIsLoading(false);
}
};
useEffect(() => {
if (isAuthenticated) {
fetchContainers();
const interval = setInterval(fetchContainers, 10000);
return () => clearInterval(interval);
}
}, [isAuthenticated]);
return {
containers,
isRefreshing,
isLoading,
error,
refreshContainers: fetchContainers,
};
}

View File

@@ -0,0 +1,208 @@
import { useRef, useEffect } from 'react';
import { io, Socket } from 'socket.io-client';
import { apiClient, API_BASE_URL } from '@/lib/api';
import type { Terminal } from '@xterm/xterm';
import type { FitAddon } from '@xterm/addon-fit';
interface UseInteractiveTerminalProps {
open: boolean;
containerId: string;
containerName: string;
isMobile: boolean;
onFallback: (reason: string) => void;
}
export function useInteractiveTerminal({
open,
containerId,
containerName,
isMobile,
onFallback,
}: UseInteractiveTerminalProps) {
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);
useEffect(() => {
if (!open || !terminalRef.current) return;
let term: Terminal | null = null;
let fitAddon: FitAddon | null = null;
let socket: Socket | null = null;
const initTerminal = async () => {
try {
const [{ Terminal }, { FitAddon }] = await Promise.all([
import('@xterm/xterm'),
import('@xterm/addon-fit'),
]);
if (!terminalRef.current) return;
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);
setTimeout(() => {
try {
if (fitAddon) fitAddon.fit();
} catch (e) {
console.error('Error fitting terminal:', e);
}
}, 0);
xtermRef.current = term;
fitAddonRef.current = fitAddon;
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;
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++;
if (connectionAttempts.current >= 2) {
onFallback('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`);
const criticalErrors = ['Unauthorized', 'Cannot connect to Docker', 'Invalid session'];
if (criticalErrors.some(err => data.error.includes(err))) {
setTimeout(() => {
onFallback(`Interactive terminal failed: ${data.error}`);
}, 2000);
}
});
socket.on('exit', () => {
term?.write('\r\n\r\n*** Terminal Session Ended ***\r\n');
});
socket.on('disconnect', (reason) => {
console.log('WebSocket disconnected:', reason);
if (reason === 'transport error' || reason === 'transport close') {
onFallback('WebSocket connection lost unexpectedly.');
}
});
term.onData((data) => {
socket?.emit('input', { data });
});
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 () => {
window.removeEventListener('resize', handleResize);
if (term) term.dispose();
if (socket) socket.disconnect();
};
} catch (error) {
console.error('Failed to initialize terminal:', error);
onFallback('Failed to load terminal. Switching to simple mode.');
}
};
const cleanup = initTerminal();
return () => {
cleanup.then((cleanupFn) => {
if (cleanupFn) cleanupFn();
});
xtermRef.current = null;
socketRef.current = null;
fitAddonRef.current = null;
};
}, [open, containerId, isMobile, onFallback]);
const cleanup = () => {
if (socketRef.current) {
socketRef.current.disconnect();
}
if (xtermRef.current) {
xtermRef.current.dispose();
}
};
return {
terminalRef,
cleanup,
};
}

View File

@@ -0,0 +1,38 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAppDispatch, useAppSelector } from '@/lib/store/hooks';
import { login, clearError } from '@/lib/store/authSlice';
export function useLoginForm() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isShaking, setIsShaking] = useState(false);
const dispatch = useAppDispatch();
const { error, loading } = useAppSelector((state) => state.auth);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
dispatch(clearError());
const result = await dispatch(login({ username, password }));
if (login.fulfilled.match(result)) {
router.push('/dashboard');
} else {
setIsShaking(true);
setTimeout(() => setIsShaking(false), 500);
}
};
return {
username,
setUsername,
password,
setPassword,
isShaking,
error,
loading,
handleSubmit,
};
}

View File

@@ -0,0 +1,74 @@
import { useState, useRef, useEffect } from 'react';
import { apiClient } from '@/lib/api';
import { OutputLine } from '@/lib/interfaces/terminal';
export function useSimpleTerminal(containerId: string) {
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
useEffect(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
}, [output]);
const executeCommand = async () => {
if (!command.trim()) return;
setIsExecuting(true);
setOutput((prev) => [...prev, {
type: 'command',
content: command,
workdir: workdir
}]);
try {
const result = await apiClient.executeCommand(containerId, command);
if (result.workdir) {
setWorkdir(result.workdir);
}
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')) {
setOutput((prev) => [...prev, {
type: 'output',
content: '(empty directory)'
}]);
}
} catch (error) {
setOutput((prev) => [...prev, {
type: 'error',
content: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`
}]);
} finally {
setIsExecuting(false);
setCommand('');
}
};
const reset = () => {
setOutput([]);
setCommand('');
setWorkdir('/');
};
return {
command,
setCommand,
output,
isExecuting,
workdir,
outputRef,
executeCommand,
reset,
};
}

View File

@@ -0,0 +1,24 @@
import { useState } from 'react';
import { Container } from '@/lib/api';
export function useTerminalModal() {
const [selectedContainer, setSelectedContainer] = useState<Container | null>(null);
const [isTerminalOpen, setIsTerminalOpen] = useState(false);
const openTerminal = (container: Container) => {
setSelectedContainer(container);
setIsTerminalOpen(true);
};
const closeTerminal = () => {
setIsTerminalOpen(false);
setTimeout(() => setSelectedContainer(null), 300);
};
return {
selectedContainer,
isTerminalOpen,
openTerminal,
closeTerminal,
};
}

View File

@@ -0,0 +1,35 @@
import { Container } from '@/lib/api';
export interface ContainerCardProps {
container: Container;
onOpenShell: () => void;
onContainerUpdate?: () => void;
}
export interface ContainerHeaderProps {
name: string;
image: string;
status: string;
}
export interface ContainerInfoProps {
id: string;
uptime: string;
}
export interface ContainerActionsProps {
status: string;
isLoading: boolean;
onStart: () => void;
onStop: () => void;
onRestart: () => void;
onRemove: () => void;
onOpenShell: () => void;
}
export interface DeleteConfirmDialogProps {
open: boolean;
containerName: string;
onClose: () => void;
onConfirm: () => void;
}

View File

@@ -0,0 +1,7 @@
export interface DashboardHeaderProps {
containerCount: number;
isMobile: boolean;
isRefreshing: boolean;
onRefresh: () => void;
onLogout: () => void;
}

View File

@@ -0,0 +1,61 @@
export interface OutputLine {
type: 'command' | 'output' | 'error';
content: string;
workdir?: string;
}
export interface TerminalModalProps {
open: boolean;
onClose: () => void;
containerName: string;
containerId: string;
}
export interface TerminalHeaderProps {
containerName: string;
mode: 'simple' | 'interactive';
interactiveFailed: boolean;
onModeChange: (event: React.MouseEvent<HTMLElement>, newMode: 'simple' | 'interactive' | null) => void;
onClose: () => void;
}
export interface InteractiveTerminalProps {
terminalRef: React.RefObject<HTMLDivElement | null>;
}
export interface SimpleTerminalProps {
output: OutputLine[];
command: string;
workdir: string;
isExecuting: boolean;
isMobile: boolean;
containerName: string;
outputRef: React.RefObject<HTMLDivElement | null>;
onCommandChange: (value: string) => void;
onExecute: () => void;
onKeyPress: (e: React.KeyboardEvent) => void;
}
export interface TerminalOutputProps {
output: OutputLine[];
containerName: string;
outputRef: React.RefObject<HTMLDivElement | null>;
}
export interface CommandInputProps {
command: string;
workdir: string;
isExecuting: boolean;
isMobile: boolean;
containerName: string;
onCommandChange: (value: string) => void;
onExecute: () => void;
onKeyPress: (e: React.KeyboardEvent) => void;
}
export interface FallbackNotificationProps {
show: boolean;
reason: string;
onClose: () => void;
onRetry: () => void;
}

View File

@@ -0,0 +1,134 @@
import { configureStore } from '@reduxjs/toolkit';
import authReducer, {
login,
logout,
initAuth,
setUnauthenticated,
} from '../authSlice';
import * as apiClient from '@/lib/api';
jest.mock('@/lib/api');
describe('authSlice', () => {
let store: ReturnType<typeof configureStore>;
beforeEach(() => {
store = configureStore({
reducer: {
auth: authReducer,
},
});
jest.clearAllMocks();
localStorage.clear();
});
describe('initial state', () => {
it('has correct initial state', () => {
const state = store.getState().auth;
expect(state).toEqual({
isAuthenticated: false,
loading: true,
username: null,
error: null,
});
});
});
describe('setUnauthenticated', () => {
it('sets auth state to unauthenticated', () => {
store.dispatch(setUnauthenticated());
const state = store.getState().auth;
expect(state.isAuthenticated).toBe(false);
expect(state.username).toBeNull();
});
});
describe('login async thunk', () => {
it('handles successful login', async () => {
const mockLoginResponse = { success: true, username: 'testuser' };
(apiClient.apiClient.login as jest.Mock).mockResolvedValue(mockLoginResponse);
await store.dispatch(login({ username: 'testuser', password: 'password' }));
const state = store.getState().auth;
expect(state.isAuthenticated).toBe(true);
expect(state.username).toBe('testuser');
expect(state.loading).toBe(false);
});
it('handles login failure', async () => {
(apiClient.apiClient.login as jest.Mock).mockRejectedValue(
new Error('Invalid credentials')
);
await store.dispatch(login({ username: 'testuser', password: 'wrong' }));
const state = store.getState().auth;
expect(state.isAuthenticated).toBe(false);
expect(state.username).toBeNull();
expect(state.loading).toBe(false);
expect(state.error).toBeTruthy();
});
it('sets loading state during login', () => {
(apiClient.apiClient.login as jest.Mock).mockImplementation(
() => new Promise(() => {})
);
store.dispatch(login({ username: 'testuser', password: 'password' }));
const state = store.getState().auth;
expect(state.loading).toBe(true);
});
});
describe('logout async thunk', () => {
it('clears authentication state on logout', async () => {
(apiClient.apiClient.logout as jest.Mock).mockResolvedValue({});
await store.dispatch(logout());
const state = store.getState().auth;
expect(state.isAuthenticated).toBe(false);
expect(state.username).toBeNull();
});
});
describe('initAuth async thunk', () => {
it('restores auth state when token is valid', async () => {
(apiClient.apiClient.getToken as jest.Mock).mockReturnValue('valid-token');
(apiClient.apiClient.getUsername as jest.Mock).mockReturnValue('testuser');
(apiClient.apiClient.getContainers as jest.Mock).mockResolvedValue([]);
await store.dispatch(initAuth());
const state = store.getState().auth;
expect(state.isAuthenticated).toBe(true);
expect(state.username).toBe('testuser');
});
it('clears invalid token', async () => {
(apiClient.apiClient.getToken as jest.Mock).mockReturnValue('invalid-token');
(apiClient.apiClient.getContainers as jest.Mock).mockRejectedValue(
new Error('Unauthorized')
);
await store.dispatch(initAuth());
const state = store.getState().auth;
expect(state.isAuthenticated).toBe(false);
expect(state.username).toBeNull();
expect(apiClient.apiClient.setToken).toHaveBeenCalledWith(null);
});
it('handles no token present', async () => {
(apiClient.apiClient.getToken as jest.Mock).mockReturnValue(null);
await store.dispatch(initAuth());
const state = store.getState().auth;
expect(state.isAuthenticated).toBe(false);
expect(state.username).toBeNull();
});
});
});

View File

@@ -0,0 +1,22 @@
// Global auth error handler
// This can be called from API client when auth errors occur
let authErrorCallback: (() => void) | null = null;
let authErrorHandled = false;
export const setAuthErrorCallback = (callback: () => void) => {
authErrorCallback = callback;
authErrorHandled = false;
};
export const triggerAuthError = () => {
if (!authErrorCallback) {
return;
}
if (authErrorHandled) {
return;
}
authErrorHandled = true;
authErrorCallback();
};

View File

@@ -0,0 +1,119 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { apiClient } from '../api';
interface AuthState {
isAuthenticated: boolean;
username: string | null;
loading: boolean;
error: string | null;
}
const initialState: AuthState = {
isAuthenticated: false,
username: null,
loading: true,
error: null,
};
// Async thunks
export const initAuth = createAsyncThunk('auth/init', async () => {
const token = apiClient.getToken();
if (token) {
// Validate token by fetching containers
try {
await apiClient.getContainers();
const username = apiClient.getUsername();
return { isAuthenticated: true, username };
} catch (error) {
// Token is invalid, clear it
apiClient.setToken(null);
return { isAuthenticated: false, username: null };
}
}
return { isAuthenticated: false, username: null };
});
export const login = createAsyncThunk(
'auth/login',
async ({ username, password }: { username: string; password: string }, { rejectWithValue }) => {
try {
const response = await apiClient.login(username, password);
if (response.success) {
return { username: response.username || username };
}
return rejectWithValue(response.message || 'Login failed');
} catch (error) {
return rejectWithValue('Login failed. Please try again.');
}
}
);
export const logout = createAsyncThunk('auth/logout', async () => {
await apiClient.logout();
});
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
clearError: (state) => {
state.error = null;
},
setUnauthenticated: (state) => {
state.isAuthenticated = false;
state.username = null;
apiClient.setToken(null);
},
},
extraReducers: (builder) => {
builder
// Init auth
.addCase(initAuth.pending, (state) => {
state.loading = true;
})
.addCase(initAuth.fulfilled, (state, action) => {
state.loading = false;
state.isAuthenticated = action.payload.isAuthenticated;
state.username = action.payload.username;
})
.addCase(initAuth.rejected, (state) => {
state.loading = false;
state.isAuthenticated = false;
state.username = null;
})
// Login
.addCase(login.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(login.fulfilled, (state, action) => {
state.loading = false;
state.isAuthenticated = true;
state.username = action.payload.username;
state.error = null;
})
.addCase(login.rejected, (state, action) => {
state.loading = false;
state.isAuthenticated = false;
state.error = action.payload as string;
})
// Logout
.addCase(logout.pending, (state) => {
state.loading = true;
})
.addCase(logout.fulfilled, (state) => {
state.loading = false;
state.isAuthenticated = false;
state.username = null;
})
.addCase(logout.rejected, (state) => {
// Even if logout fails, clear local state
state.loading = false;
state.isAuthenticated = false;
state.username = null;
});
},
});
export const { clearError, setUnauthenticated } = authSlice.actions;
export default authSlice.reducer;

View File

@@ -0,0 +1,5 @@
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

View File

@@ -0,0 +1,11 @@
import { configureStore } from '@reduxjs/toolkit';
import authReducer from './authSlice';
export const store = configureStore({
reducer: {
auth: authReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

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

@@ -0,0 +1,98 @@
import React from 'react';
import { render } from '@testing-library/react';
import { formatPrompt, highlightCommand } from '../terminal';
import { OutputLine } from '@/lib/interfaces/terminal';
describe('formatPrompt', () => {
it('formats a simple prompt correctly', () => {
const result = formatPrompt('test-container', '/home/user');
expect(result).toBe('root@test-container:/home/user#');
});
it('truncates long directory paths', () => {
const longPath = '/very/long/directory/path/that/is/over/thirty/characters';
const result = formatPrompt('test-container', longPath);
expect(result).toContain('...');
expect(result).toContain('characters');
expect(result).toMatch(/^root@test-container:\.\.\.\/characters#$/);
});
it('handles root directory', () => {
const result = formatPrompt('test-container', '/');
expect(result).toBe('root@test-container:/#');
});
it('handles container names with special characters', () => {
const result = formatPrompt('my-container-123', '/app');
expect(result).toBe('root@my-container-123:/app#');
});
});
describe('highlightCommand', () => {
it('renders command output with proper formatting', () => {
const line: OutputLine = {
type: 'command',
content: 'ls -la',
workdir: '/home/user',
};
const { container } = render(highlightCommand(line, 'test-container'));
expect(container.textContent).toContain('root@test-container:/home/user#');
expect(container.textContent).toContain('ls');
expect(container.textContent).toContain('-la');
});
it('renders command with no arguments', () => {
const line: OutputLine = {
type: 'command',
content: 'pwd',
workdir: '/app',
};
const { container } = render(highlightCommand(line, 'test-container'));
expect(container.textContent).toContain('pwd');
});
it('renders error output with red color', () => {
const line: OutputLine = {
type: 'error',
content: 'Error: command not found',
};
const { container } = render(highlightCommand(line, 'test-container'));
const errorDiv = container.querySelector('div');
expect(errorDiv).toHaveStyle({ color: '#FF5555' });
expect(container.textContent).toContain('Error: command not found');
});
it('renders regular output', () => {
const line: OutputLine = {
type: 'output',
content: 'Hello world',
};
const { container } = render(highlightCommand(line, 'test-container'));
expect(container.textContent).toContain('Hello world');
});
it('preserves whitespace in output', () => {
const line: OutputLine = {
type: 'output',
content: 'Line 1\nLine 2',
};
const { container } = render(highlightCommand(line, 'test-container'));
const outputDiv = container.querySelector('div');
expect(outputDiv).toHaveStyle({ whiteSpace: 'pre-wrap' });
});
it('uses default workdir when not provided', () => {
const line: OutputLine = {
type: 'command',
content: 'echo test',
};
const { container } = render(highlightCommand(line, 'test-container'));
expect(container.textContent).toContain('root@test-container:/#');
});
});

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { OutputLine } from '@/lib/interfaces/terminal';
export const formatPrompt = (containerName: string, workdir: string): string => {
let displayDir = workdir;
if (workdir.length > 30) {
const parts = workdir.split('/');
displayDir = '.../' + parts[parts.length - 1];
}
return `root@${containerName}:${displayDir}#`;
};
export const highlightCommand = (line: OutputLine, containerName: string): React.ReactElement => {
if (line.type === 'command') {
const prompt = formatPrompt(containerName, 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>
);
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -6,24 +6,38 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.7",
"@mui/material": "^7.3.7",
"@reduxjs/toolkit": "^2.11.2",
"@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",
"react-redux": "^9.2.0",
"socket.io-client": "^4.8.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.1",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"tailwindcss": "^4",
"typescript": "^5"
}