mirror of
https://github.com/johndoe6345789/docker-swarm-termina.git
synced 2026-04-24 13:45:01 +00:00
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
This commit is contained in:
@@ -1,25 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Button,
|
||||
Grid,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
IconButton,
|
||||
CircularProgress,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import { Logout, Refresh, Inventory2 } from '@mui/icons-material';
|
||||
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 { apiClient, Container as ContainerType } from '@/lib/api';
|
||||
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';
|
||||
|
||||
@@ -29,55 +18,15 @@ export default function Dashboard() {
|
||||
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);
|
||||
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');
|
||||
// Auth errors are now handled globally by authErrorHandler
|
||||
} 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 dispatch(logoutAction());
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchContainers();
|
||||
};
|
||||
|
||||
if (authLoading || isLoading) {
|
||||
return (
|
||||
<Box
|
||||
@@ -95,91 +44,13 @@ 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: { xs: '1.1rem', sm: '1.5rem' }
|
||||
}}
|
||||
>
|
||||
Container Shell
|
||||
</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 }}>
|
||||
{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>
|
||||
<DashboardHeader
|
||||
containerCount={containers.length}
|
||||
isMobile={isMobile}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={refreshContainers}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
|
||||
<Container maxWidth="xl" sx={{ py: { xs: 2, sm: 3, md: 4 } }}>
|
||||
{error && (
|
||||
@@ -189,46 +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)}
|
||||
onContainerUpdate={fetchContainers}
|
||||
onOpenShell={() => openTerminal(container)}
|
||||
onContainerUpdate={refreshContainers}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
@@ -239,7 +79,7 @@ export default function Dashboard() {
|
||||
{selectedContainer && (
|
||||
<TerminalModal
|
||||
open={isTerminalOpen}
|
||||
onClose={handleCloseTerminal}
|
||||
onClose={closeTerminal}
|
||||
containerName={selectedContainer.name}
|
||||
containerId={selectedContainer.id}
|
||||
/>
|
||||
|
||||
@@ -1,25 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Box,
|
||||
Chip,
|
||||
Divider,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Snackbar,
|
||||
} from '@mui/material';
|
||||
import { Terminal, PlayArrow, Stop, Refresh, Delete, Inventory2 } from '@mui/icons-material';
|
||||
import { Container, apiClient } from '@/lib/api';
|
||||
import { Card, CardContent, Divider, Snackbar, Alert } from '@mui/material';
|
||||
import { Container } from '@/lib/api';
|
||||
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;
|
||||
@@ -27,86 +15,29 @@ interface ContainerCardProps {
|
||||
onContainerUpdate?: () => void;
|
||||
}
|
||||
|
||||
const borderColors = {
|
||||
running: '#38b2ac',
|
||||
stopped: '#718096',
|
||||
paused: '#ecc94b',
|
||||
exited: '#718096',
|
||||
created: '#4299e1',
|
||||
};
|
||||
|
||||
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 {
|
||||
isLoading,
|
||||
snackbar,
|
||||
handleStart,
|
||||
handleStop,
|
||||
handleRestart,
|
||||
handleRemove,
|
||||
closeSnackbar,
|
||||
} = useContainerActions(container.id, onContainerUpdate);
|
||||
|
||||
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 () => {
|
||||
const confirmRemove = () => {
|
||||
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 });
|
||||
handleRemove();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -117,235 +48,41 @@ export default function ContainerCard({ container, onOpenShell, onContainerUpdat
|
||||
}}
|
||||
>
|
||||
<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: { 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' }}
|
||||
>
|
||||
{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} />
|
||||
|
||||
<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>
|
||||
<ContainerActions
|
||||
status={container.status}
|
||||
isLoading={isLoading}
|
||||
onStart={handleStart}
|
||||
onStop={handleStop}
|
||||
onRestart={handleRestart}
|
||||
onRemove={() => setShowDeleteDialog(true)}
|
||||
onOpenShell={onOpenShell}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog
|
||||
<DeleteConfirmDialog
|
||||
open={showDeleteDialog}
|
||||
containerName={container.name}
|
||||
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>
|
||||
onConfirm={confirmRemove}
|
||||
/>
|
||||
|
||||
{/* Snackbar for notifications */}
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
autoHideDuration={4000}
|
||||
onClose={handleCloseSnackbar}
|
||||
onClose={closeSnackbar}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<Alert onClose={handleCloseSnackbar} severity={snackbar.severity} sx={{ width: '100%' }}>
|
||||
<Alert onClose={closeSnackbar} severity={snackbar.severity} sx={{ width: '100%' }}>
|
||||
{snackbar.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
|
||||
125
frontend/components/ContainerCard/ContainerActions.tsx
Normal file
125
frontend/components/ContainerCard/ContainerActions.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import { Box, Button, CircularProgress } from '@mui/material';
|
||||
import { PlayArrow, Stop, Refresh, Delete, Terminal } from '@mui/icons-material';
|
||||
|
||||
interface ContainerActionsProps {
|
||||
status: string;
|
||||
isLoading: boolean;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
onRestart: () => void;
|
||||
onRemove: () => void;
|
||||
onOpenShell: () => void;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
77
frontend/components/ContainerCard/ContainerHeader.tsx
Normal file
77
frontend/components/ContainerCard/ContainerHeader.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Chip } from '@mui/material';
|
||||
import { PlayArrow, Inventory2 } from '@mui/icons-material';
|
||||
|
||||
interface ContainerHeaderProps {
|
||||
name: string;
|
||||
image: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
54
frontend/components/ContainerCard/ContainerInfo.tsx
Normal file
54
frontend/components/ContainerCard/ContainerInfo.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
|
||||
interface ContainerInfoProps {
|
||||
id: string;
|
||||
uptime: string;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
41
frontend/components/ContainerCard/DeleteConfirmDialog.tsx
Normal file
41
frontend/components/ContainerCard/DeleteConfirmDialog.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
|
||||
interface DeleteConfirmDialogProps {
|
||||
open: boolean;
|
||||
containerName: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
115
frontend/components/Dashboard/DashboardHeader.tsx
Normal file
115
frontend/components/Dashboard/DashboardHeader.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
IconButton,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { Logout, Refresh, Inventory2 } from '@mui/icons-material';
|
||||
|
||||
interface DashboardHeaderProps {
|
||||
containerCount: number;
|
||||
isMobile: boolean;
|
||||
isRefreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
39
frontend/components/Dashboard/EmptyState.tsx
Normal file
39
frontend/components/Dashboard/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
frontend/lib/hooks/useContainerActions.ts
Normal file
87
frontend/lib/hooks/useContainerActions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
39
frontend/lib/hooks/useContainerList.ts
Normal file
39
frontend/lib/hooks/useContainerList.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
24
frontend/lib/hooks/useTerminalModal.ts
Normal file
24
frontend/lib/hooks/useTerminalModal.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user