From 70d32f13b231ba80dc5d19c5e4ebe6013ee4f5fa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Jan 2026 23:00:37 +0000 Subject: [PATCH] Break down large components under 150 LOC ContainerCard: 354 -> 91 LOC - Extract useContainerActions custom hook (88 LOC) - Split into sub-components: - ContainerHeader (77 LOC) - ContainerInfo (54 LOC) - ContainerActions (125 LOC) - DeleteConfirmDialog (41 LOC) Dashboard: 249 -> 89 LOC - Extract useContainerList hook (39 LOC) - Extract useTerminalModal hook (24 LOC) - Split into sub-components: - DashboardHeader (118 LOC) - EmptyState (44 LOC) All React components now under 150 LOC for better maintainability https://claude.ai/code/session_01U3wVqokhrL3dTeq2dTq73n --- frontend/app/dashboard/page.tsx | 196 +--------- frontend/components/ContainerCard.tsx | 357 +++--------------- .../ContainerCard/ContainerActions.tsx | 125 ++++++ .../ContainerCard/ContainerHeader.tsx | 77 ++++ .../ContainerCard/ContainerInfo.tsx | 54 +++ .../ContainerCard/DeleteConfirmDialog.tsx | 41 ++ .../components/Dashboard/DashboardHeader.tsx | 115 ++++++ frontend/components/Dashboard/EmptyState.tsx | 39 ++ frontend/lib/hooks/useContainerActions.ts | 87 +++++ frontend/lib/hooks/useContainerList.ts | 39 ++ frontend/lib/hooks/useTerminalModal.ts | 24 ++ 11 files changed, 666 insertions(+), 488 deletions(-) create mode 100644 frontend/components/ContainerCard/ContainerActions.tsx create mode 100644 frontend/components/ContainerCard/ContainerHeader.tsx create mode 100644 frontend/components/ContainerCard/ContainerInfo.tsx create mode 100644 frontend/components/ContainerCard/DeleteConfirmDialog.tsx create mode 100644 frontend/components/Dashboard/DashboardHeader.tsx create mode 100644 frontend/components/Dashboard/EmptyState.tsx create mode 100644 frontend/lib/hooks/useContainerActions.ts create mode 100644 frontend/lib/hooks/useContainerList.ts create mode 100644 frontend/lib/hooks/useTerminalModal.ts diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index 7348d79..db8b9f9 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -1,25 +1,14 @@ 'use client'; -import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { - Box, - Container, - Typography, - Button, - Grid, - AppBar, - Toolbar, - IconButton, - CircularProgress, - useMediaQuery, - useTheme, -} from '@mui/material'; -import { Logout, Refresh, Inventory2 } from '@mui/icons-material'; +import { Box, Container, Typography, Grid, CircularProgress, useMediaQuery, useTheme } from '@mui/material'; import { useAppDispatch } from '@/lib/store/hooks'; import { logout as logoutAction } from '@/lib/store/authSlice'; import { useAuthRedirect } from '@/lib/hooks/useAuthRedirect'; -import { apiClient, Container as ContainerType } from '@/lib/api'; +import { useContainerList } from '@/lib/hooks/useContainerList'; +import { useTerminalModal } from '@/lib/hooks/useTerminalModal'; +import DashboardHeader from '@/components/Dashboard/DashboardHeader'; +import EmptyState from '@/components/Dashboard/EmptyState'; import ContainerCard from '@/components/ContainerCard'; import TerminalModal from '@/components/TerminalModal'; @@ -29,55 +18,15 @@ export default function Dashboard() { const router = useRouter(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); - const [containers, setContainers] = useState([]); - const [selectedContainer, setSelectedContainer] = useState(null); - const [isTerminalOpen, setIsTerminalOpen] = useState(false); - const [isRefreshing, setIsRefreshing] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(''); - const fetchContainers = async () => { - setIsRefreshing(true); - setError(''); - try { - const data = await apiClient.getContainers(); - setContainers(data); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to fetch containers'); - // Auth errors are now handled globally by authErrorHandler - } finally { - setIsRefreshing(false); - setIsLoading(false); - } - }; - - useEffect(() => { - if (isAuthenticated) { - fetchContainers(); - const interval = setInterval(fetchContainers, 10000); - return () => clearInterval(interval); - } - }, [isAuthenticated]); - - const handleOpenShell = (container: ContainerType) => { - setSelectedContainer(container); - setIsTerminalOpen(true); - }; - - const handleCloseTerminal = () => { - setIsTerminalOpen(false); - setTimeout(() => setSelectedContainer(null), 300); - }; + const { containers, isRefreshing, isLoading, error, refreshContainers } = useContainerList(isAuthenticated); + const { selectedContainer, isTerminalOpen, openTerminal, closeTerminal } = useTerminalModal(); const handleLogout = async () => { await dispatch(logoutAction()); router.push('/'); }; - const handleRefresh = () => { - fetchContainers(); - }; - if (authLoading || isLoading) { return ( - - - - - - - - - Container Shell - - {!isMobile && ( - - {containers.length} active {containers.length === 1 ? 'container' : 'containers'} - - )} - - - - - {isMobile ? ( - <> - - {isRefreshing ? : } - - - - - - ) : ( - <> - - - - )} - - - + {error && ( @@ -189,46 +60,15 @@ export default function Dashboard() { )} {containers.length === 0 && !isLoading ? ( - - - - - - No Active Containers - - - There are currently no running containers to display. Start a container to see it - appear here. - - + ) : ( {containers.map((container) => ( handleOpenShell(container)} - onContainerUpdate={fetchContainers} + onOpenShell={() => openTerminal(container)} + onContainerUpdate={refreshContainers} /> ))} @@ -239,7 +79,7 @@ export default function Dashboard() { {selectedContainer && ( diff --git a/frontend/components/ContainerCard.tsx b/frontend/components/ContainerCard.tsx index f1227f8..600b9a3 100644 --- a/frontend/components/ContainerCard.tsx +++ b/frontend/components/ContainerCard.tsx @@ -1,25 +1,13 @@ 'use client'; import React, { useState } from 'react'; -import { - Card, - CardContent, - Typography, - Button, - Box, - Chip, - Divider, - Dialog, - DialogTitle, - DialogContent, - DialogContentText, - DialogActions, - CircularProgress, - Alert, - Snackbar, -} from '@mui/material'; -import { Terminal, PlayArrow, Stop, Refresh, Delete, Inventory2 } from '@mui/icons-material'; -import { Container, apiClient } from '@/lib/api'; +import { Card, CardContent, Divider, Snackbar, Alert } from '@mui/material'; +import { Container } from '@/lib/api'; +import { useContainerActions } from '@/lib/hooks/useContainerActions'; +import ContainerHeader from './ContainerCard/ContainerHeader'; +import ContainerInfo from './ContainerCard/ContainerInfo'; +import ContainerActions from './ContainerCard/ContainerActions'; +import DeleteConfirmDialog from './ContainerCard/DeleteConfirmDialog'; interface ContainerCardProps { container: Container; @@ -27,86 +15,29 @@ interface ContainerCardProps { onContainerUpdate?: () => void; } +const borderColors = { + running: '#38b2ac', + stopped: '#718096', + paused: '#ecc94b', + exited: '#718096', + created: '#4299e1', +}; + export default function ContainerCard({ container, onOpenShell, onContainerUpdate }: ContainerCardProps) { - const [isLoading, setIsLoading] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({ - open: false, - message: '', - severity: 'success', - }); + const { + isLoading, + snackbar, + handleStart, + handleStop, + handleRestart, + handleRemove, + closeSnackbar, + } = useContainerActions(container.id, onContainerUpdate); - const statusColors = { - running: 'success', - stopped: 'default', - paused: 'warning', - exited: 'default', - created: 'info', - } as const; - - const borderColors = { - running: '#38b2ac', - stopped: '#718096', - paused: '#ecc94b', - exited: '#718096', - created: '#4299e1', - }; - - const handleStart = async () => { - setIsLoading(true); - try { - await apiClient.startContainer(container.id); - setSnackbar({ open: true, message: 'Container started successfully', severity: 'success' }); - onContainerUpdate?.(); - } catch (error) { - setSnackbar({ open: true, message: `Failed to start: ${error instanceof Error ? error.message : 'Unknown error'}`, severity: 'error' }); - } finally { - setIsLoading(false); - } - }; - - const handleStop = async () => { - setIsLoading(true); - try { - await apiClient.stopContainer(container.id); - setSnackbar({ open: true, message: 'Container stopped successfully', severity: 'success' }); - onContainerUpdate?.(); - } catch (error) { - setSnackbar({ open: true, message: `Failed to stop: ${error instanceof Error ? error.message : 'Unknown error'}`, severity: 'error' }); - } finally { - setIsLoading(false); - } - }; - - const handleRestart = async () => { - setIsLoading(true); - try { - await apiClient.restartContainer(container.id); - setSnackbar({ open: true, message: 'Container restarted successfully', severity: 'success' }); - onContainerUpdate?.(); - } catch (error) { - setSnackbar({ open: true, message: `Failed to restart: ${error instanceof Error ? error.message : 'Unknown error'}`, severity: 'error' }); - } finally { - setIsLoading(false); - } - }; - - const handleRemove = async () => { + const confirmRemove = () => { setShowDeleteDialog(false); - setIsLoading(true); - try { - await apiClient.removeContainer(container.id); - setSnackbar({ open: true, message: 'Container removed successfully', severity: 'success' }); - onContainerUpdate?.(); - } catch (error) { - setSnackbar({ open: true, message: `Failed to remove: ${error instanceof Error ? error.message : 'Unknown error'}`, severity: 'error' }); - } finally { - setIsLoading(false); - } - }; - - const handleCloseSnackbar = () => { - setSnackbar({ ...snackbar, open: false }); + handleRemove(); }; return ( @@ -117,235 +48,41 @@ export default function ContainerCard({ container, onOpenShell, onContainerUpdat }} > - - - - - - - - {container.name} - - - {container.image} - - - - - : undefined} - sx={{ - fontFamily: '"JetBrains Mono", monospace', - textTransform: 'capitalize', - }} - /> - + - - - - Container ID - - - {container.id} - - - - - Uptime - - - {container.uptime} - - - + - - {/* Action buttons based on status */} - - {(container.status === 'stopped' || container.status === 'exited' || container.status === 'created') && ( - - )} - - {container.status === 'running' && ( - <> - - - - )} - - - - - {/* Terminal button */} - - + setShowDeleteDialog(true)} + onOpenShell={onOpenShell} + /> - {/* Delete confirmation dialog */} - setShowDeleteDialog(false)} - > - Confirm Container Removal - - - Are you sure you want to remove container {container.name}? - This action cannot be undone. - - - - - - - + onConfirm={confirmRemove} + /> - {/* Snackbar for notifications */} - + {snackbar.message} diff --git a/frontend/components/ContainerCard/ContainerActions.tsx b/frontend/components/ContainerCard/ContainerActions.tsx new file mode 100644 index 0000000..1b0245f --- /dev/null +++ b/frontend/components/ContainerCard/ContainerActions.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { Box, Button, CircularProgress } from '@mui/material'; +import { PlayArrow, Stop, Refresh, Delete, Terminal } from '@mui/icons-material'; + +interface ContainerActionsProps { + status: string; + isLoading: boolean; + onStart: () => void; + onStop: () => void; + onRestart: () => void; + onRemove: () => void; + onOpenShell: () => void; +} + +export default function ContainerActions({ + status, + isLoading, + onStart, + onStop, + onRestart, + onRemove, + onOpenShell, +}: ContainerActionsProps) { + const isRunning = status === 'running'; + const isStopped = status === 'stopped' || status === 'exited' || status === 'created'; + + return ( + + + {isStopped && ( + + )} + + {isRunning && ( + <> + + + + )} + + + + + + + ); +} diff --git a/frontend/components/ContainerCard/ContainerHeader.tsx b/frontend/components/ContainerCard/ContainerHeader.tsx new file mode 100644 index 0000000..0231f5a --- /dev/null +++ b/frontend/components/ContainerCard/ContainerHeader.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { Box, Typography, Chip } from '@mui/material'; +import { PlayArrow, Inventory2 } from '@mui/icons-material'; + +interface ContainerHeaderProps { + name: string; + image: string; + status: string; +} + +const statusColors = { + running: 'success', + stopped: 'default', + paused: 'warning', + exited: 'default', + created: 'info', +} as const; + +export default function ContainerHeader({ name, image, status }: ContainerHeaderProps) { + return ( + + + + + + + + {name} + + + {image} + + + + + : undefined} + sx={{ + fontFamily: '"JetBrains Mono", monospace', + textTransform: 'capitalize', + }} + /> + + ); +} diff --git a/frontend/components/ContainerCard/ContainerInfo.tsx b/frontend/components/ContainerCard/ContainerInfo.tsx new file mode 100644 index 0000000..13a3b6d --- /dev/null +++ b/frontend/components/ContainerCard/ContainerInfo.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Box, Typography } from '@mui/material'; + +interface ContainerInfoProps { + id: string; + uptime: string; +} + +export default function ContainerInfo({ id, uptime }: ContainerInfoProps) { + return ( + + + + Container ID + + + {id} + + + + + Uptime + + + {uptime} + + + + ); +} diff --git a/frontend/components/ContainerCard/DeleteConfirmDialog.tsx b/frontend/components/ContainerCard/DeleteConfirmDialog.tsx new file mode 100644 index 0000000..9df10f2 --- /dev/null +++ b/frontend/components/ContainerCard/DeleteConfirmDialog.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, + Button, +} from '@mui/material'; + +interface DeleteConfirmDialogProps { + open: boolean; + containerName: string; + onClose: () => void; + onConfirm: () => void; +} + +export default function DeleteConfirmDialog({ + open, + containerName, + onClose, + onConfirm, +}: DeleteConfirmDialogProps) { + return ( + + Confirm Container Removal + + + Are you sure you want to remove container {containerName}? + This action cannot be undone. + + + + + + + + ); +} diff --git a/frontend/components/Dashboard/DashboardHeader.tsx b/frontend/components/Dashboard/DashboardHeader.tsx new file mode 100644 index 0000000..4d71972 --- /dev/null +++ b/frontend/components/Dashboard/DashboardHeader.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { + AppBar, + Toolbar, + Box, + Typography, + Button, + IconButton, + CircularProgress, +} from '@mui/material'; +import { Logout, Refresh, Inventory2 } from '@mui/icons-material'; + +interface DashboardHeaderProps { + containerCount: number; + isMobile: boolean; + isRefreshing: boolean; + onRefresh: () => void; + onLogout: () => void; +} + +export default function DashboardHeader({ + containerCount, + isMobile, + isRefreshing, + onRefresh, + onLogout, +}: DashboardHeaderProps) { + return ( + + + + + + + + + Container Shell + + {!isMobile && ( + + {containerCount} active {containerCount === 1 ? 'container' : 'containers'} + + )} + + + + + {isMobile ? ( + <> + + {isRefreshing ? : } + + + + + + ) : ( + <> + + + + )} + + + + ); +} diff --git a/frontend/components/Dashboard/EmptyState.tsx b/frontend/components/Dashboard/EmptyState.tsx new file mode 100644 index 0000000..68e9e96 --- /dev/null +++ b/frontend/components/Dashboard/EmptyState.tsx @@ -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 ( + + + + + + No Active Containers + + + There are currently no running containers to display. Start a container to see it appear here. + + + ); +} diff --git a/frontend/lib/hooks/useContainerActions.ts b/frontend/lib/hooks/useContainerActions.ts new file mode 100644 index 0000000..76fee4a --- /dev/null +++ b/frontend/lib/hooks/useContainerActions.ts @@ -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, + }; +} diff --git a/frontend/lib/hooks/useContainerList.ts b/frontend/lib/hooks/useContainerList.ts new file mode 100644 index 0000000..3a6fc07 --- /dev/null +++ b/frontend/lib/hooks/useContainerList.ts @@ -0,0 +1,39 @@ +import { useState, useEffect } from 'react'; +import { apiClient, Container } from '@/lib/api'; + +export function useContainerList(isAuthenticated: boolean) { + const [containers, setContainers] = useState([]); + 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, + }; +} diff --git a/frontend/lib/hooks/useTerminalModal.ts b/frontend/lib/hooks/useTerminalModal.ts new file mode 100644 index 0000000..d2e5dd6 --- /dev/null +++ b/frontend/lib/hooks/useTerminalModal.ts @@ -0,0 +1,24 @@ +import { useState } from 'react'; +import { Container } from '@/lib/api'; + +export function useTerminalModal() { + const [selectedContainer, setSelectedContainer] = useState(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, + }; +}