mirror of
https://github.com/johndoe6345789/docker-swarm-termina.git
synced 2026-04-24 13:45:01 +00:00
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
This commit is contained in:
@@ -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/<container_id>/start', methods=['POST'])
|
||||
def start_container(container_id):
|
||||
"""Start a stopped container"""
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if not auth_header or not auth_header.startswith('Bearer '):
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
|
||||
token = auth_header.split(' ')[1]
|
||||
if token not in sessions:
|
||||
return jsonify({'error': 'Invalid session'}), 401
|
||||
|
||||
client = get_docker_client()
|
||||
if not client:
|
||||
return jsonify({'error': 'Cannot connect to Docker'}), 500
|
||||
|
||||
try:
|
||||
container = client.containers.get(container_id)
|
||||
container.start()
|
||||
logger.info(f"Started container {container_id}")
|
||||
return jsonify({'success': True, 'message': f'Container {container_id} started'})
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting container: {e}", exc_info=True)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/containers/<container_id>/stop', methods=['POST'])
|
||||
def stop_container(container_id):
|
||||
"""Stop a running container"""
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if not auth_header or not auth_header.startswith('Bearer '):
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
|
||||
token = auth_header.split(' ')[1]
|
||||
if token not in sessions:
|
||||
return jsonify({'error': 'Invalid session'}), 401
|
||||
|
||||
client = get_docker_client()
|
||||
if not client:
|
||||
return jsonify({'error': 'Cannot connect to Docker'}), 500
|
||||
|
||||
try:
|
||||
container = client.containers.get(container_id)
|
||||
container.stop()
|
||||
logger.info(f"Stopped container {container_id}")
|
||||
return jsonify({'success': True, 'message': f'Container {container_id} stopped'})
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping container: {e}", exc_info=True)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/containers/<container_id>/restart', methods=['POST'])
|
||||
def restart_container(container_id):
|
||||
"""Restart a container"""
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if not auth_header or not auth_header.startswith('Bearer '):
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
|
||||
token = auth_header.split(' ')[1]
|
||||
if token not in sessions:
|
||||
return jsonify({'error': 'Invalid session'}), 401
|
||||
|
||||
client = get_docker_client()
|
||||
if not client:
|
||||
return jsonify({'error': 'Cannot connect to Docker'}), 500
|
||||
|
||||
try:
|
||||
container = client.containers.get(container_id)
|
||||
container.restart()
|
||||
logger.info(f"Restarted container {container_id}")
|
||||
return jsonify({'success': True, 'message': f'Container {container_id} restarted'})
|
||||
except Exception as e:
|
||||
logger.error(f"Error restarting container: {e}", exc_info=True)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/containers/<container_id>', methods=['DELETE'])
|
||||
def remove_container(container_id):
|
||||
"""Remove a container"""
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if not auth_header or not auth_header.startswith('Bearer '):
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
|
||||
token = auth_header.split(' ')[1]
|
||||
if token not in sessions:
|
||||
return jsonify({'error': 'Invalid session'}), 401
|
||||
|
||||
client = get_docker_client()
|
||||
if not client:
|
||||
return jsonify({'error': 'Cannot connect to Docker'}), 500
|
||||
|
||||
try:
|
||||
container = client.containers.get(container_id)
|
||||
# Force remove (including if running)
|
||||
container.remove(force=True)
|
||||
logger.info(f"Removed container {container_id}")
|
||||
return jsonify({'success': True, 'message': f'Container {container_id} removed'})
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing container: {e}", exc_info=True)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/health', methods=['GET'])
|
||||
def health():
|
||||
"""Health check endpoint"""
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
Toolbar,
|
||||
IconButton,
|
||||
CircularProgress,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import { Logout, Refresh, Inventory2 } from '@mui/icons-material';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
@@ -22,6 +24,8 @@ import TerminalModal from '@/components/TerminalModal';
|
||||
export default function Dashboard() {
|
||||
const { isAuthenticated, loading: authLoading, logout } = useAuth();
|
||||
const router = useRouter();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const [containers, setContainers] = useState<ContainerType[]>([]);
|
||||
const [selectedContainer, setSelectedContainer] = useState<ContainerType | null>(null);
|
||||
const [isTerminalOpen, setIsTerminalOpen] = useState(false);
|
||||
@@ -123,39 +127,66 @@ export default function Dashboard() {
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h1"
|
||||
sx={{ fontFamily: '"JetBrains Mono", monospace', fontSize: '1.5rem' }}
|
||||
sx={{
|
||||
fontFamily: '"JetBrains Mono", monospace',
|
||||
fontSize: { xs: '1.1rem', sm: '1.5rem' }
|
||||
}}
|
||||
>
|
||||
Container Shell
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{containers.length} active {containers.length === 1 ? 'container' : 'containers'}
|
||||
</Typography>
|
||||
{!isMobile && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{containers.length} active {containers.length === 1 ? 'container' : 'containers'}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
startIcon={isRefreshing ? <CircularProgress size={16} /> : <Refresh />}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={handleLogout}
|
||||
startIcon={<Logout />}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
{isMobile ? (
|
||||
<>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
size="small"
|
||||
>
|
||||
{isRefreshing ? <CircularProgress size={20} /> : <Refresh />}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={handleLogout}
|
||||
size="small"
|
||||
>
|
||||
<Logout />
|
||||
</IconButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
startIcon={isRefreshing ? <CircularProgress size={16} /> : <Refresh />}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={handleLogout}
|
||||
startIcon={<Logout />}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
<Container maxWidth="xl" sx={{ py: { xs: 2, sm: 3, md: 4 } }}>
|
||||
{error && (
|
||||
<Box sx={{ mb: 2, p: 2, bgcolor: 'error.dark', borderRadius: 1 }}>
|
||||
<Typography color="error.contrastText">{error}</Typography>
|
||||
@@ -202,6 +233,7 @@ export default function Dashboard() {
|
||||
<ContainerCard
|
||||
container={container}
|
||||
onOpenShell={() => handleOpenShell(container)}
|
||||
onContainerUpdate={fetchContainers}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -9,26 +9,104 @@ import {
|
||||
Box,
|
||||
Chip,
|
||||
Divider,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Snackbar,
|
||||
} from '@mui/material';
|
||||
import { Terminal, PlayArrow, Inventory2 } from '@mui/icons-material';
|
||||
import { Container } from '@/lib/api';
|
||||
import { Terminal, PlayArrow, Stop, Refresh, Delete, Inventory2 } from '@mui/icons-material';
|
||||
import { Container, apiClient } from '@/lib/api';
|
||||
|
||||
interface ContainerCardProps {
|
||||
container: Container;
|
||||
onOpenShell: () => void;
|
||||
onContainerUpdate?: () => void;
|
||||
}
|
||||
|
||||
export default function ContainerCard({ container, onOpenShell }: ContainerCardProps) {
|
||||
export default function ContainerCard({ container, onOpenShell, onContainerUpdate }: ContainerCardProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
|
||||
open: false,
|
||||
message: '',
|
||||
severity: 'success',
|
||||
});
|
||||
|
||||
const statusColors = {
|
||||
running: 'success',
|
||||
stopped: 'default',
|
||||
paused: 'warning',
|
||||
exited: 'default',
|
||||
created: 'info',
|
||||
} as const;
|
||||
|
||||
const borderColors = {
|
||||
running: '#38b2ac',
|
||||
stopped: '#718096',
|
||||
paused: '#ecc94b',
|
||||
exited: '#718096',
|
||||
created: '#4299e1',
|
||||
};
|
||||
|
||||
const handleStart = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await apiClient.startContainer(container.id);
|
||||
setSnackbar({ open: true, message: 'Container started successfully', severity: 'success' });
|
||||
onContainerUpdate?.();
|
||||
} catch (error) {
|
||||
setSnackbar({ open: true, message: `Failed to start: ${error instanceof Error ? error.message : 'Unknown error'}`, severity: 'error' });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await apiClient.stopContainer(container.id);
|
||||
setSnackbar({ open: true, message: 'Container stopped successfully', severity: 'success' });
|
||||
onContainerUpdate?.();
|
||||
} catch (error) {
|
||||
setSnackbar({ open: true, message: `Failed to stop: ${error instanceof Error ? error.message : 'Unknown error'}`, severity: 'error' });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestart = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await apiClient.restartContainer(container.id);
|
||||
setSnackbar({ open: true, message: 'Container restarted successfully', severity: 'success' });
|
||||
onContainerUpdate?.();
|
||||
} catch (error) {
|
||||
setSnackbar({ open: true, message: `Failed to restart: ${error instanceof Error ? error.message : 'Unknown error'}`, severity: 'error' });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
setShowDeleteDialog(false);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await apiClient.removeContainer(container.id);
|
||||
setSnackbar({ open: true, message: 'Container removed successfully', severity: 'success' });
|
||||
onContainerUpdate?.();
|
||||
} catch (error) {
|
||||
setSnackbar({ open: true, message: `Failed to remove: ${error instanceof Error ? error.message : 'Unknown error'}`, severity: 'error' });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseSnackbar = () => {
|
||||
setSnackbar({ ...snackbar, open: false });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -97,7 +175,7 @@ export default function ContainerCard({ container, onOpenShell }: ContainerCardP
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2, mb: 3 }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' }, gap: 2, mb: 3 }}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
@@ -140,23 +218,137 @@ export default function ContainerCard({ container, onOpenShell }: ContainerCardP
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={onOpenShell}
|
||||
disabled={container.status !== 'running'}
|
||||
startIcon={<Terminal />}
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
'&:hover': {
|
||||
backgroundColor: 'secondary.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Open Shell
|
||||
</Button>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{/* Action buttons based on status */}
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{(container.status === 'stopped' || container.status === 'exited' || container.status === 'created') && (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleStart}
|
||||
disabled={isLoading}
|
||||
startIcon={isLoading ? <CircularProgress size={16} /> : <PlayArrow />}
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: '100px',
|
||||
backgroundColor: '#38b2ac',
|
||||
'&:hover': { backgroundColor: '#2c8a84' },
|
||||
}}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{container.status === 'running' && (
|
||||
<>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleStop}
|
||||
disabled={isLoading}
|
||||
startIcon={isLoading ? <CircularProgress size={16} /> : <Stop />}
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: '100px',
|
||||
backgroundColor: '#f56565',
|
||||
'&:hover': { backgroundColor: '#e53e3e' },
|
||||
}}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={handleRestart}
|
||||
disabled={isLoading}
|
||||
startIcon={isLoading ? <CircularProgress size={16} /> : <Refresh />}
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: '100px',
|
||||
borderColor: '#ecc94b',
|
||||
color: '#ecc94b',
|
||||
'&:hover': {
|
||||
borderColor: '#d69e2e',
|
||||
backgroundColor: 'rgba(236, 201, 75, 0.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
disabled={isLoading}
|
||||
startIcon={<Delete />}
|
||||
sx={{
|
||||
minWidth: '100px',
|
||||
borderColor: '#fc8181',
|
||||
color: '#fc8181',
|
||||
'&:hover': {
|
||||
borderColor: '#f56565',
|
||||
backgroundColor: 'rgba(252, 129, 129, 0.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Terminal button */}
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={onOpenShell}
|
||||
disabled={container.status !== 'running' || isLoading}
|
||||
startIcon={<Terminal />}
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
'&:hover': {
|
||||
backgroundColor: 'secondary.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Open Shell
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog
|
||||
open={showDeleteDialog}
|
||||
onClose={() => setShowDeleteDialog(false)}
|
||||
>
|
||||
<DialogTitle>Confirm Container Removal</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to remove container <strong>{container.name}</strong>?
|
||||
This action cannot be undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setShowDeleteDialog(false)}>Cancel</Button>
|
||||
<Button onClick={handleRemove} color="error" variant="contained">
|
||||
Remove
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Snackbar for notifications */}
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
autoHideDuration={4000}
|
||||
onClose={handleCloseSnackbar}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<Alert onClose={handleCloseSnackbar} severity={snackbar.severity} sx={{ width: '100%' }}>
|
||||
{snackbar.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,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<OutputLine[]>([]);
|
||||
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 },
|
||||
}}
|
||||
>
|
||||
<Typography variant="h2" component="div">
|
||||
<Typography
|
||||
variant="h2"
|
||||
component="div"
|
||||
sx={{ fontSize: { xs: '1.1rem', sm: '1.5rem' } }}
|
||||
>
|
||||
Terminal - {containerName}
|
||||
</Typography>
|
||||
<IconButton onClick={handleClose} size="small">
|
||||
@@ -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({
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
gap: 1,
|
||||
alignItems: isMobile ? 'stretch' : 'center'
|
||||
}}>
|
||||
<Typography sx={{
|
||||
fontFamily: '"Ubuntu Mono", monospace',
|
||||
fontSize: '14px',
|
||||
fontSize: { xs: '12px', sm: '14px' },
|
||||
color: '#8BE9FD',
|
||||
fontWeight: 'bold',
|
||||
whiteSpace: 'nowrap'
|
||||
whiteSpace: 'nowrap',
|
||||
alignSelf: isMobile ? 'flex-start' : 'center'
|
||||
}}>
|
||||
{formatPrompt(workdir)}
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="ls -la"
|
||||
disabled={isExecuting}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
autoFocus
|
||||
sx={{
|
||||
fontFamily: '"Ubuntu Mono", monospace',
|
||||
'& input': {
|
||||
<Box sx={{ display: 'flex', gap: 1, flex: 1 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="ls -la"
|
||||
disabled={isExecuting}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
autoFocus
|
||||
sx={{
|
||||
fontFamily: '"Ubuntu Mono", monospace',
|
||||
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',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleExecute}
|
||||
disabled={isExecuting || !command.trim()}
|
||||
startIcon={<Send />}
|
||||
sx={{
|
||||
backgroundColor: '#5E2750',
|
||||
'&:hover': {
|
||||
backgroundColor: '#772953',
|
||||
},
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
}}
|
||||
/>
|
||||
{isMobile ? (
|
||||
<IconButton
|
||||
onClick={handleExecute}
|
||||
disabled={isExecuting || !command.trim()}
|
||||
sx={{
|
||||
backgroundColor: '#5E2750',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
backgroundColor: '#772953',
|
||||
},
|
||||
'&:disabled': {
|
||||
backgroundColor: '#3a1a2f',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Send />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleExecute}
|
||||
disabled={isExecuting || !command.trim()}
|
||||
startIcon={<Send />}
|
||||
sx={{
|
||||
backgroundColor: '#5E2750',
|
||||
'&:hover': {
|
||||
backgroundColor: '#772953',
|
||||
},
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
|
||||
@@ -115,6 +115,90 @@ class ApiClient {
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async startContainer(containerId: string): Promise<any> {
|
||||
const token = this.getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/containers/${containerId}/start`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to start container');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async stopContainer(containerId: string): Promise<any> {
|
||||
const token = this.getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/containers/${containerId}/stop`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to stop container');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async restartContainer(containerId: string): Promise<any> {
|
||||
const token = this.getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/containers/${containerId}/restart`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to restart container');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async removeContainer(containerId: string): Promise<any> {
|
||||
const token = this.getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/containers/${containerId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to remove container');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
|
||||
@@ -28,28 +28,42 @@ const theme = createTheme({
|
||||
main: '#38b2ac',
|
||||
},
|
||||
},
|
||||
breakpoints: {
|
||||
values: {
|
||||
xs: 0,
|
||||
sm: 600,
|
||||
md: 900,
|
||||
lg: 1200,
|
||||
xl: 1536,
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"JetBrains Mono", "Courier New", monospace',
|
||||
h1: {
|
||||
fontWeight: 700,
|
||||
fontSize: '2rem',
|
||||
fontSize: 'clamp(1.5rem, 4vw, 2rem)',
|
||||
letterSpacing: '-0.02em',
|
||||
},
|
||||
h2: {
|
||||
fontWeight: 600,
|
||||
fontSize: '1.5rem',
|
||||
fontSize: 'clamp(1.125rem, 3vw, 1.5rem)',
|
||||
},
|
||||
h3: {
|
||||
fontWeight: 500,
|
||||
fontSize: '1.125rem',
|
||||
fontSize: 'clamp(1rem, 2.5vw, 1.125rem)',
|
||||
},
|
||||
body1: {
|
||||
fontSize: '0.875rem',
|
||||
fontSize: 'clamp(0.8rem, 1.5vw, 0.875rem)',
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
body2: {
|
||||
fontSize: 'clamp(0.75rem, 1.3vw, 0.8125rem)',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
button: {
|
||||
fontWeight: 500,
|
||||
textTransform: 'none',
|
||||
fontSize: 'clamp(0.8rem, 1.5vw, 0.875rem)',
|
||||
},
|
||||
},
|
||||
components: {
|
||||
|
||||
Reference in New Issue
Block a user