Merge pull request #14 from johndoe6345789/claude/fix-logout-auth-redirects-G4kZm

Migrate auth from context to Redux state management
This commit is contained in:
2026-01-30 23:30:14 +00:00
committed by GitHub
50 changed files with 7018 additions and 1233 deletions

View File

@@ -1,88 +1,32 @@
'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 { 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 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('');
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
@@ -100,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 && (
@@ -194,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>
))}
@@ -244,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,112 +1,38 @@
'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 { 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;
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 +43,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>

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,48 +1,14 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Box,
Typography,
IconButton,
Paper,
useMediaQuery,
useTheme,
ToggleButtonGroup,
ToggleButton,
Tooltip,
Alert,
Snackbar,
} from '@mui/material';
import { Close, Send, Terminal as TerminalIcon, Code, Warning } from '@mui/icons-material';
import { apiClient, API_BASE_URL } from '@/lib/api';
import { io, Socket } from 'socket.io-client';
// Import types only (no runtime code)
import type { Terminal } from '@xterm/xterm';
import type { FitAddon } from '@xterm/addon-fit';
// Import CSS at top level (safe for SSR)
import '@xterm/xterm/css/xterm.css';
interface TerminalModalProps {
open: boolean;
onClose: () => void;
containerName: string;
containerId: string;
}
interface OutputLine {
type: 'command' | 'output' | 'error';
content: string;
workdir?: string;
}
import React, { useState } from 'react';
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,
@@ -53,301 +19,33 @@ export default function TerminalModal({
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
// Mode selection: 'simple' or 'interactive'
const [mode, setMode] = useState<'simple' | 'interactive'>('interactive');
// Fallback tracking
const [interactiveFailed, setInteractiveFailed] = useState(false);
const [fallbackReason, setFallbackReason] = useState('');
const [showFallbackNotification, setShowFallbackNotification] = useState(false);
// Simple mode state
const [command, setCommand] = useState('');
const [output, setOutput] = useState<OutputLine[]>([]);
const [isExecuting, setIsExecuting] = useState(false);
const [workdir, setWorkdir] = useState('/');
const outputRef = useRef<HTMLDivElement>(null);
const simpleTerminal = useSimpleTerminal(containerId);
// Interactive mode state
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<Terminal | null>(null);
const socketRef = useRef<Socket | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const connectionAttempts = useRef(0);
// Auto-scroll to bottom when output changes (simple mode)
useEffect(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
}, [output]);
// Function to fallback to simple mode
const fallbackToSimpleMode = (reason: string) => {
const handleFallback = (reason: string) => {
console.warn('Falling back to simple mode:', reason);
setInteractiveFailed(true);
setFallbackReason(reason);
setMode('simple');
setShowFallbackNotification(true);
// Cleanup interactive terminal if it exists
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
}
if (xtermRef.current) {
xtermRef.current.dispose();
xtermRef.current = null;
}
interactiveTerminal.cleanup();
};
// Initialize interactive terminal
useEffect(() => {
if (!open || mode !== 'interactive' || !terminalRef.current) return;
let term: Terminal | null = null;
let fitAddon: FitAddon | null = null;
let socket: Socket | null = null;
// Dynamically import xterm modules (browser-only)
const initTerminal = async () => {
try {
// Dynamic imports to avoid SSR issues
const [{ Terminal }, { FitAddon }] = await Promise.all([
import('@xterm/xterm'),
import('@xterm/addon-fit'),
]);
if (!terminalRef.current) return; // Component might have unmounted
// Create terminal instance
term = new Terminal({
cursorBlink: true,
fontSize: isMobile ? 12 : 14,
fontFamily: '"Ubuntu Mono", "Courier New", monospace',
theme: {
background: '#300A24',
foreground: '#F8F8F2',
cursor: '#F8F8F2',
black: '#2C0922',
red: '#FF5555',
green: '#50FA7B',
yellow: '#F1FA8C',
blue: '#8BE9FD',
magenta: '#FF79C6',
cyan: '#8BE9FD',
white: '#F8F8F2',
brightBlack: '#6272A4',
brightRed: '#FF6E6E',
brightGreen: '#69FF94',
brightYellow: '#FFFFA5',
brightBlue: '#D6ACFF',
brightMagenta: '#FF92DF',
brightCyan: '#A4FFFF',
brightWhite: '#FFFFFF',
},
});
fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(terminalRef.current);
// Fit terminal to container
setTimeout(() => {
try {
if (fitAddon) fitAddon.fit();
} catch (e) {
console.error('Error fitting terminal:', e);
}
}, 0);
xtermRef.current = term;
fitAddonRef.current = fitAddon;
// Connect to WebSocket
const wsUrl = API_BASE_URL.replace(/^http/, 'ws');
socket = io(`${wsUrl}/terminal`, {
transports: ['websocket', 'polling'],
});
socketRef.current = socket;
socket.on('connect', () => {
console.log('WebSocket connected');
connectionAttempts.current = 0; // Reset on successful connection
// Start terminal session
const token = apiClient.getToken();
const termSize = fitAddon?.proposeDimensions();
socket?.emit('start_terminal', {
container_id: containerId,
token: token,
cols: termSize?.cols || 80,
rows: termSize?.rows || 24,
});
});
socket.on('connect_error', (error) => {
console.error('WebSocket connection error:', error);
connectionAttempts.current++;
// After 2 failed attempts, fallback to simple mode
if (connectionAttempts.current >= 2) {
fallbackToSimpleMode('Failed to establish WebSocket connection. Network or server may be unavailable.');
}
});
socket.on('started', () => {
term?.write('\r\n*** Interactive Terminal Started ***\r\n');
term?.write('You can now use sudo, nano, vim, and other interactive commands.\r\n\r\n');
});
socket.on('output', (data: { data: string }) => {
term?.write(data.data);
});
socket.on('error', (data: { error: string }) => {
console.error('Terminal error:', data.error);
term?.write(`\r\n\x1b[31mError: ${data.error}\x1b[0m\r\n`);
// Check for critical errors that should trigger fallback
const criticalErrors = ['Unauthorized', 'Cannot connect to Docker', 'Invalid session'];
if (criticalErrors.some(err => data.error.includes(err))) {
setTimeout(() => {
fallbackToSimpleMode(`Interactive terminal failed: ${data.error}`);
}, 2000); // Give user time to see the error
}
});
socket.on('exit', () => {
term?.write('\r\n\r\n*** Terminal Session Ended ***\r\n');
});
socket.on('disconnect', (reason) => {
console.log('WebSocket disconnected:', reason);
// If disconnect was unexpected and not user-initiated
if (reason === 'transport error' || reason === 'transport close') {
fallbackToSimpleMode('WebSocket connection lost unexpectedly.');
}
});
// Handle terminal input
term.onData((data) => {
socket?.emit('input', { data });
});
// Handle terminal resize
const handleResize = () => {
try {
if (fitAddon) {
fitAddon.fit();
const termSize = fitAddon.proposeDimensions();
if (termSize) {
socket?.emit('resize', {
cols: termSize.cols,
rows: termSize.rows,
});
}
}
} catch (e) {
console.error('Error resizing terminal:', e);
}
};
window.addEventListener('resize', handleResize);
// Return cleanup function for this terminal instance
return () => {
window.removeEventListener('resize', handleResize);
if (term) term.dispose();
if (socket) socket.disconnect();
};
} catch (error) {
console.error('Failed to initialize terminal:', error);
fallbackToSimpleMode('Failed to load terminal. Switching to simple mode.');
}
};
// Start terminal initialization
const cleanup = initTerminal();
// Cleanup
return () => {
cleanup.then((cleanupFn) => {
if (cleanupFn) cleanupFn();
});
xtermRef.current = null;
socketRef.current = null;
fitAddonRef.current = null;
};
}, [open, mode, containerId, isMobile]);
const handleExecute = async () => {
if (!command.trim()) return;
setIsExecuting(true);
// Add command to output with current working directory
setOutput((prev) => [...prev, {
type: 'command',
content: command,
workdir: workdir
}]);
try {
const result = await apiClient.executeCommand(containerId, command);
// Update working directory if provided
if (result.workdir) {
setWorkdir(result.workdir);
}
// Add command output
if (result.output && result.output.trim()) {
setOutput((prev) => [...prev, {
type: result.exit_code === 0 ? 'output' : 'error',
content: result.output
}]);
} else if (command.trim().startsWith('ls')) {
// If ls command returns empty output, indicate empty directory
setOutput((prev) => [...prev, {
type: 'output',
content: '(empty directory)'
}]);
}
} catch (error) {
setOutput((prev) => [...prev, {
type: 'error',
content: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`
}]);
} finally {
setIsExecuting(false);
setCommand('');
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleExecute();
}
};
const interactiveTerminal = useInteractiveTerminal({
open: open && mode === 'interactive',
containerId,
containerName,
isMobile,
onFallback: handleFallback,
});
const handleClose = () => {
// Cleanup interactive terminal
if (socketRef.current) {
socketRef.current.disconnect();
}
if (xtermRef.current) {
xtermRef.current.dispose();
}
// Reset simple mode state
setOutput([]);
setCommand('');
setWorkdir('/');
interactiveTerminal.cleanup();
simpleTerminal.reset();
onClose();
};
@@ -356,11 +54,9 @@ export default function TerminalModal({
newMode: 'simple' | 'interactive' | null,
) => {
if (newMode !== null) {
// If switching to interactive mode after a failure, reset the failure state
if (newMode === 'interactive' && interactiveFailed) {
setInteractiveFailed(false);
setFallbackReason('');
connectionAttempts.current = 0;
}
setMode(newMode);
}
@@ -370,47 +66,13 @@ export default function TerminalModal({
setInteractiveFailed(false);
setFallbackReason('');
setShowFallbackNotification(false);
connectionAttempts.current = 0;
setMode('interactive');
};
const formatPrompt = (workdir: string) => {
// Shorten workdir if it's too long (show ~ for home, or just basename)
let displayDir = workdir;
if (workdir.length > 30) {
const parts = workdir.split('/');
displayDir = '.../' + parts[parts.length - 1];
}
return `root@${containerName}:${displayDir}#`;
};
const highlightCommand = (line: OutputLine) => {
if (line.type === 'command') {
const prompt = formatPrompt(line.workdir || '/');
const parts = line.content.split(' ');
const cmd = parts[0];
const args = parts.slice(1).join(' ');
return (
<div style={{ marginBottom: '4px' }}>
<span style={{ color: '#8BE9FD', fontWeight: 'bold' }}>{prompt}</span>
{' '}
<span style={{ color: '#50FA7B', fontWeight: 'bold' }}>{cmd}</span>
{args && <span style={{ color: '#F8F8F2' }}> {args}</span>}
</div>
);
} else if (line.type === 'error') {
return (
<div style={{ color: '#FF5555', marginBottom: '2px' }}>
{line.content}
</div>
);
} else {
return (
<div style={{ color: '#F8F8F2', marginBottom: '2px', whiteSpace: 'pre-wrap' }}>
{line.content}
</div>
);
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
simpleTerminal.executeCommand();
}
};
@@ -428,238 +90,30 @@ export default function TerminalModal({
},
}}
>
<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={handleModeChange}
size="small"
sx={{ display: 'flex' }}
>
<Tooltip title={interactiveFailed ? "Interactive mode failed - click to retry" : "Interactive mode with full terminal support (sudo, nano, vim)"}>
<ToggleButton
value="interactive"
sx={{
flex: 1,
fontSize: { xs: '0.75rem', sm: '0.875rem' },
...(interactiveFailed && {
borderColor: '#f59e0b',
color: '#f59e0b',
'&:hover': {
borderColor: '#d97706',
backgroundColor: 'rgba(245, 158, 11, 0.1)',
},
}),
}}
>
{interactiveFailed ? (
<Warning sx={{ mr: 0.5, fontSize: '1rem' }} />
) : (
<TerminalIcon sx={{ mr: 0.5, fontSize: '1rem' }} />
)}
Interactive
</ToggleButton>
</Tooltip>
<Tooltip title="Simple command execution mode">
<ToggleButton value="simple" sx={{ flex: 1, fontSize: { xs: '0.75rem', sm: '0.875rem' } }}>
<Code sx={{ mr: 0.5, fontSize: '1rem' }} />
Simple
</ToggleButton>
</Tooltip>
</ToggleButtonGroup>
</Box>
<IconButton onClick={handleClose} size="small">
<Close />
</IconButton>
</DialogTitle>
<TerminalHeader
containerName={containerName}
mode={mode}
interactiveFailed={interactiveFailed}
onModeChange={handleModeChange}
onClose={handleClose}
/>
<DialogContent dividers>
{mode === 'interactive' ? (
/* Interactive terminal with xterm.js */
<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',
},
}}
/>
<InteractiveTerminal terminalRef={interactiveTerminal.terminalRef} />
) : (
/* Simple command execution mode */
<>
<Paper
ref={outputRef}
elevation={0}
sx={{
backgroundColor: '#300A24',
color: '#F8F8F2',
fontFamily: '"Ubuntu Mono", "Courier New", monospace',
fontSize: { xs: '12px', sm: '14px' },
padding: { xs: 1.5, sm: 2 },
minHeight: { xs: '300px', sm: '400px' },
maxHeight: { xs: '400px', sm: '500px' },
overflowY: 'auto',
mb: 2,
border: '1px solid #5E2750',
borderRadius: '4px',
'&::-webkit-scrollbar': {
width: { xs: '6px', sm: '10px' },
},
'&::-webkit-scrollbar-track': {
background: '#2C0922',
},
'&::-webkit-scrollbar-thumb': {
background: '#5E2750',
borderRadius: '5px',
'&:hover': {
background: '#772953',
}
},
}}
>
{output.length === 0 ? (
<Box>
<Typography sx={{
color: '#8BE9FD',
fontFamily: 'inherit',
fontSize: '13px',
mb: 1
}}>
Ubuntu-style Terminal - Connected to <span style={{ color: '#50FA7B', fontWeight: 'bold' }}>{containerName}</span>
</Typography>
<Typography sx={{
color: '#6272A4',
fontFamily: 'inherit',
fontSize: '12px'
}}>
Type a command and press Enter or click Execute...
</Typography>
</Box>
) : (
<Box>
{output.map((line, index) => (
<React.Fragment key={index}>
{highlightCommand(line)}
</React.Fragment>
))}
</Box>
)}
</Paper>
<Box sx={{
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
gap: 1,
alignItems: isMobile ? 'stretch' : 'center'
}}>
<Typography sx={{
fontFamily: '"Ubuntu Mono", monospace',
fontSize: { xs: '12px', sm: '14px' },
color: '#8BE9FD',
fontWeight: 'bold',
whiteSpace: 'nowrap',
alignSelf: isMobile ? 'flex-start' : 'center'
}}>
{formatPrompt(workdir)}
</Typography>
<Box sx={{ display: 'flex', gap: 1, flex: 1 }}>
<TextField
fullWidth
value={command}
onChange={(e) => setCommand(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="ls -la"
disabled={isExecuting}
variant="outlined"
size="small"
autoFocus
sx={{
fontFamily: '"Ubuntu Mono", monospace',
'& input': {
fontFamily: '"Ubuntu Mono", monospace',
fontSize: { xs: '12px', sm: '14px' },
padding: { xs: '6px 10px', sm: '8px 12px' },
color: '#F8F8F2',
},
'& .MuiOutlinedInput-root': {
backgroundColor: '#1E1E1E',
'& fieldset': {
borderColor: '#5E2750',
},
'&:hover fieldset': {
borderColor: '#772953',
},
'&.Mui-focused fieldset': {
borderColor: '#8BE9FD',
},
},
}}
/>
{isMobile ? (
<IconButton
onClick={handleExecute}
disabled={isExecuting || !command.trim()}
sx={{
backgroundColor: '#5E2750',
color: 'white',
'&:hover': {
backgroundColor: '#772953',
},
'&:disabled': {
backgroundColor: '#3a1a2f',
},
}}
>
<Send />
</IconButton>
) : (
<Button
variant="contained"
onClick={handleExecute}
disabled={isExecuting || !command.trim()}
startIcon={<Send />}
sx={{
backgroundColor: '#5E2750',
'&:hover': {
backgroundColor: '#772953',
},
textTransform: 'none',
fontWeight: 'bold',
}}
>
Run
</Button>
)}
</Box>
</Box>
</>
<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}
/>
)}
</DialogContent>
@@ -669,32 +123,12 @@ export default function TerminalModal({
</Button>
</DialogActions>
{/* Fallback notification */}
<Snackbar
open={showFallbackNotification}
autoHideDuration={10000}
<FallbackNotification
show={showFallbackNotification}
reason={fallbackReason}
onClose={() => setShowFallbackNotification(false)}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
>
<Alert
severity="warning"
icon={<Warning />}
action={
<Button color="inherit" size="small" onClick={handleRetryInteractive}>
Retry
</Button>
}
onClose={() => setShowFallbackNotification(false)}
sx={{ width: '100%', maxWidth: '600px' }}
>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Switched to Simple Mode
</Typography>
<Typography variant="body2" sx={{ fontSize: '0.875rem' }}>
{fallbackReason}
</Typography>
</Alert>
</Snackbar>
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,6 +134,11 @@ 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');
}
@@ -119,6 +148,7 @@ class ApiClient {
async startContainer(containerId: string): Promise<any> {
const token = this.getToken();
if (!token) {
triggerAuthError();
throw new Error('Not authenticated');
}
@@ -130,6 +160,11 @@ class ApiClient {
});
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');
}
@@ -140,6 +175,7 @@ class ApiClient {
async stopContainer(containerId: string): Promise<any> {
const token = this.getToken();
if (!token) {
triggerAuthError();
throw new Error('Not authenticated');
}
@@ -151,6 +187,11 @@ class ApiClient {
});
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');
}
@@ -161,6 +202,7 @@ class ApiClient {
async restartContainer(containerId: string): Promise<any> {
const token = this.getToken();
if (!token) {
triggerAuthError();
throw new Error('Not authenticated');
}
@@ -172,6 +214,11 @@ class ApiClient {
});
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');
}
@@ -182,6 +229,7 @@ class ApiClient {
async removeContainer(containerId: string): Promise<any> {
const token = this.getToken();
if (!token) {
triggerAuthError();
throw new Error('Not authenticated');
}
@@ -193,6 +241,11 @@ class ApiClient {
});
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');
}

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

@@ -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,27 +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",
"@xterm/xterm": "^5.5.0",
"@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-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"
}