diff --git a/backend/app.py b/backend/app.py index 68e4266..8178fc2 100644 --- a/backend/app.py +++ b/backend/app.py @@ -340,6 +340,103 @@ def exec_container(container_id): logger.error(f"Error executing command: {e}", exc_info=True) return jsonify({'error': str(e)}), 500 +@app.route('/api/containers//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//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//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/', methods=['DELETE']) +def remove_container(container_id): + """Remove a container""" + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return jsonify({'error': 'Unauthorized'}), 401 + + token = auth_header.split(' ')[1] + if token not in sessions: + return jsonify({'error': 'Invalid session'}), 401 + + client = get_docker_client() + if not client: + return jsonify({'error': 'Cannot connect to Docker'}), 500 + + try: + container = client.containers.get(container_id) + # Force remove (including if running) + container.remove(force=True) + logger.info(f"Removed container {container_id}") + return jsonify({'success': True, 'message': f'Container {container_id} removed'}) + except Exception as e: + logger.error(f"Error removing container: {e}", exc_info=True) + return jsonify({'error': str(e)}), 500 + @app.route('/api/health', methods=['GET']) def health(): """Health check endpoint""" diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index 1195e53..8ee2f20 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -12,6 +12,8 @@ import { Toolbar, IconButton, CircularProgress, + useMediaQuery, + useTheme, } from '@mui/material'; import { Logout, Refresh, Inventory2 } from '@mui/icons-material'; import { useAuth } from '@/lib/auth'; @@ -22,6 +24,8 @@ import TerminalModal from '@/components/TerminalModal'; export default function Dashboard() { const { isAuthenticated, loading: authLoading, logout } = useAuth(); const router = useRouter(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const [containers, setContainers] = useState([]); const [selectedContainer, setSelectedContainer] = useState(null); const [isTerminalOpen, setIsTerminalOpen] = useState(false); @@ -123,39 +127,66 @@ export default function Dashboard() { Container Shell - - {containers.length} active {containers.length === 1 ? 'container' : 'containers'} - + {!isMobile && ( + + {containers.length} active {containers.length === 1 ? 'container' : 'containers'} + + )} - - + {isMobile ? ( + <> + + {isRefreshing ? : } + + + + + + ) : ( + <> + + + + )} - + {error && ( {error} @@ -202,6 +233,7 @@ export default function Dashboard() { handleOpenShell(container)} + onContainerUpdate={fetchContainers} /> ))} diff --git a/frontend/components/ContainerCard.tsx b/frontend/components/ContainerCard.tsx index b487245..f1227f8 100644 --- a/frontend/components/ContainerCard.tsx +++ b/frontend/components/ContainerCard.tsx @@ -1,6 +1,6 @@ 'use client'; -import React from 'react'; +import React, { useState } from 'react'; import { Card, CardContent, @@ -9,26 +9,104 @@ import { Box, Chip, Divider, + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, + CircularProgress, + Alert, + Snackbar, } from '@mui/material'; -import { Terminal, PlayArrow, Inventory2 } from '@mui/icons-material'; -import { Container } from '@/lib/api'; +import { Terminal, PlayArrow, Stop, Refresh, Delete, Inventory2 } from '@mui/icons-material'; +import { Container, apiClient } from '@/lib/api'; interface ContainerCardProps { container: Container; onOpenShell: () => void; + onContainerUpdate?: () => void; } -export default function ContainerCard({ container, onOpenShell }: ContainerCardProps) { +export default function ContainerCard({ container, onOpenShell, onContainerUpdate }: ContainerCardProps) { + const [isLoading, setIsLoading] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({ + open: false, + message: '', + severity: 'success', + }); + const statusColors = { running: 'success', stopped: 'default', paused: 'warning', + exited: 'default', + created: 'info', } as const; const borderColors = { running: '#38b2ac', stopped: '#718096', paused: '#ecc94b', + exited: '#718096', + created: '#4299e1', + }; + + const handleStart = async () => { + setIsLoading(true); + try { + await apiClient.startContainer(container.id); + setSnackbar({ open: true, message: 'Container started successfully', severity: 'success' }); + onContainerUpdate?.(); + } catch (error) { + setSnackbar({ open: true, message: `Failed to start: ${error instanceof Error ? error.message : 'Unknown error'}`, severity: 'error' }); + } finally { + setIsLoading(false); + } + }; + + const handleStop = async () => { + setIsLoading(true); + try { + await apiClient.stopContainer(container.id); + setSnackbar({ open: true, message: 'Container stopped successfully', severity: 'success' }); + onContainerUpdate?.(); + } catch (error) { + setSnackbar({ open: true, message: `Failed to stop: ${error instanceof Error ? error.message : 'Unknown error'}`, severity: 'error' }); + } finally { + setIsLoading(false); + } + }; + + const handleRestart = async () => { + setIsLoading(true); + try { + await apiClient.restartContainer(container.id); + setSnackbar({ open: true, message: 'Container restarted successfully', severity: 'success' }); + onContainerUpdate?.(); + } catch (error) { + setSnackbar({ open: true, message: `Failed to restart: ${error instanceof Error ? error.message : 'Unknown error'}`, severity: 'error' }); + } finally { + setIsLoading(false); + } + }; + + const handleRemove = async () => { + setShowDeleteDialog(false); + setIsLoading(true); + try { + await apiClient.removeContainer(container.id); + setSnackbar({ open: true, message: 'Container removed successfully', severity: 'success' }); + onContainerUpdate?.(); + } catch (error) { + setSnackbar({ open: true, message: `Failed to remove: ${error instanceof Error ? error.message : 'Unknown error'}`, severity: 'error' }); + } finally { + setIsLoading(false); + } + }; + + const handleCloseSnackbar = () => { + setSnackbar({ ...snackbar, open: false }); }; return ( @@ -97,7 +175,7 @@ export default function ContainerCard({ container, onOpenShell }: ContainerCardP - + - + + {/* Action buttons based on status */} + + {(container.status === 'stopped' || container.status === 'exited' || container.status === 'created') && ( + + )} + + {container.status === 'running' && ( + <> + + + + )} + + + + + {/* Terminal button */} + + + + {/* Delete confirmation dialog */} + setShowDeleteDialog(false)} + > + Confirm Container Removal + + + Are you sure you want to remove container {container.name}? + This action cannot be undone. + + + + + + + + + {/* Snackbar for notifications */} + + + {snackbar.message} + + ); } diff --git a/frontend/components/TerminalModal.tsx b/frontend/components/TerminalModal.tsx index 790969b..e02aced 100644 --- a/frontend/components/TerminalModal.tsx +++ b/frontend/components/TerminalModal.tsx @@ -12,6 +12,8 @@ import { Typography, IconButton, Paper, + useMediaQuery, + useTheme, } from '@mui/material'; import { Close, Send } from '@mui/icons-material'; import { apiClient } from '@/lib/api'; @@ -35,6 +37,8 @@ export default function TerminalModal({ containerName, containerId, }: TerminalModalProps) { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const [command, setCommand] = useState(''); const [output, setOutput] = useState([]); const [isExecuting, setIsExecuting] = useState(false); @@ -146,10 +150,11 @@ export default function TerminalModal({ onClose={handleClose} maxWidth="md" fullWidth + fullScreen={isMobile} PaperProps={{ sx: { - minHeight: '500px', - maxHeight: '80vh', + minHeight: isMobile ? '100vh' : '500px', + maxHeight: isMobile ? '100vh' : '80vh', }, }} > @@ -159,9 +164,15 @@ export default function TerminalModal({ justifyContent: 'space-between', alignItems: 'center', pb: 2, + pt: { xs: 1, sm: 2 }, + px: { xs: 2, sm: 3 }, }} > - + Terminal - {containerName} @@ -177,16 +188,16 @@ export default function TerminalModal({ backgroundColor: '#300A24', color: '#F8F8F2', fontFamily: '"Ubuntu Mono", "Courier New", monospace', - fontSize: '14px', - padding: 2, - minHeight: '400px', - maxHeight: '500px', + 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: '10px', + width: { xs: '6px', sm: '10px' }, }, '&::-webkit-scrollbar-track': { background: '#2C0922', @@ -229,64 +240,91 @@ export default function TerminalModal({ )} - + {formatPrompt(workdir)} - setCommand(e.target.value)} - onKeyPress={handleKeyPress} - placeholder="ls -la" - disabled={isExecuting} - variant="outlined" - size="small" - autoFocus - sx={{ - fontFamily: '"Ubuntu Mono", monospace', - '& input': { + + setCommand(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="ls -la" + disabled={isExecuting} + variant="outlined" + size="small" + autoFocus + sx={{ fontFamily: '"Ubuntu Mono", monospace', - fontSize: '14px', - padding: '8px 12px', - color: '#F8F8F2', - }, - '& .MuiOutlinedInput-root': { - backgroundColor: '#1E1E1E', - '& fieldset': { - borderColor: '#5E2750', + '& input': { + fontFamily: '"Ubuntu Mono", monospace', + fontSize: { xs: '12px', sm: '14px' }, + padding: { xs: '6px 10px', sm: '8px 12px' }, + color: '#F8F8F2', }, - '&:hover fieldset': { - borderColor: '#772953', + '& .MuiOutlinedInput-root': { + backgroundColor: '#1E1E1E', + '& fieldset': { + borderColor: '#5E2750', + }, + '&:hover fieldset': { + borderColor: '#772953', + }, + '&.Mui-focused fieldset': { + borderColor: '#8BE9FD', + }, }, - '&.Mui-focused fieldset': { - borderColor: '#8BE9FD', - }, - }, - }} - /> - + }} + /> + {isMobile ? ( + + + + ) : ( + + )} + diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 56439cb..cfa2928 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -115,6 +115,90 @@ class ApiClient { return response.json(); } + + async startContainer(containerId: string): Promise { + const token = this.getToken(); + if (!token) { + throw new Error('Not authenticated'); + } + + const response = await fetch(`${API_BASE_URL}/api/containers/${containerId}/start`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to start container'); + } + + return response.json(); + } + + async stopContainer(containerId: string): Promise { + const token = this.getToken(); + if (!token) { + throw new Error('Not authenticated'); + } + + const response = await fetch(`${API_BASE_URL}/api/containers/${containerId}/stop`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to stop container'); + } + + return response.json(); + } + + async restartContainer(containerId: string): Promise { + const token = this.getToken(); + if (!token) { + throw new Error('Not authenticated'); + } + + const response = await fetch(`${API_BASE_URL}/api/containers/${containerId}/restart`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to restart container'); + } + + return response.json(); + } + + async removeContainer(containerId: string): Promise { + const token = this.getToken(); + if (!token) { + throw new Error('Not authenticated'); + } + + const response = await fetch(`${API_BASE_URL}/api/containers/${containerId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to remove container'); + } + + return response.json(); + } } export const apiClient = new ApiClient(); diff --git a/frontend/lib/theme.tsx b/frontend/lib/theme.tsx index c1a9c33..26a9e63 100644 --- a/frontend/lib/theme.tsx +++ b/frontend/lib/theme.tsx @@ -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: {