feat(phase-4): implement modern Chakra UI frontend with comprehensive tracking interface

🎨 Modern React Frontend:
- Complete Chakra UI integration with custom theme and dark/light mode
- Responsive design with mobile-first navigation and layout
- Beautiful component library with cards, forms, and data visualization
- Professional typography and color system with brand consistency

🚀 Advanced URL Tracking Interface:
- Comprehensive tracking form with real-time validation using React Hook Form + Zod
- Advanced options panel with configurable parameters (max hops, timeout, headers)
- SSL, SEO, and security analysis toggles with user-friendly controls
- Smart URL normalization and method selection interface

📊 Rich Results Visualization:
- Interactive tracking results with hop-by-hop analysis tables
- Performance metrics with latency visualization and progress bars
- Status badges with color-coded redirect types and HTTP status codes
- Comprehensive error handling and user feedback system

🧭 Navigation & Layout:
- Responsive navigation bar with user authentication state
- Mobile-friendly drawer navigation with touch-optimized interactions
- Professional footer with feature highlights and API documentation links
- Breadcrumb navigation and page structure for optimal UX

🔐 Authentication Integration:
- Complete authentication context with JWT token management
- User registration and login flow preparation (backend ready)
- Protected routes and role-based access control framework
- Session management with automatic token refresh and error handling

🌟 User Experience Features:
- Toast notifications for all user actions and API responses
- Loading states and skeleton screens for smooth interactions
- Copy-to-clipboard functionality for tracking IDs and results
- Tooltips and help text for advanced features and configuration

📱 Responsive Design:
- Mobile-first design approach with breakpoint-aware components
- Touch-friendly interfaces with appropriate sizing and spacing
- Optimized layouts for desktop, tablet, and mobile viewports
- Accessible design with proper ARIA labels and keyboard navigation

🔧 Developer Experience:
- TypeScript throughout with comprehensive type safety
- React Query for efficient API state management and caching
- Custom hooks for authentication and API interactions
- Modular component architecture with clear separation of concerns

🎯 API Integration:
- Complete integration with all v2 API endpoints
- Real-time health monitoring and status display
- Backward compatibility with legacy API endpoints
- Comprehensive error handling with user-friendly messages

Ready for enhanced dashboard and analysis features in future phases!
This commit is contained in:
Andrei
2025-08-18 08:29:08 +00:00
parent cab5d36073
commit e698f53481
18 changed files with 2928 additions and 75 deletions

View File

@@ -0,0 +1,132 @@
/**
* Footer Component for Redirect Intelligence v2
*
* Site footer with links and attribution
*/
import React from 'react';
import {
Box,
Container,
Flex,
Text,
Link,
VStack,
HStack,
Divider,
useColorModeValue,
Badge,
} from '@chakra-ui/react';
import { ExternalLinkIcon } from '@chakra-ui/icons';
export function Footer() {
const bg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textColor = useColorModeValue('gray.600', 'gray.400');
return (
<Box
bg={bg}
borderTop="1px"
borderColor={borderColor}
mt="auto"
>
<Container maxW="7xl" py={8}>
<VStack spacing={6}>
{/* Main footer content */}
<Flex
direction={{ base: 'column', md: 'row' }}
justify="space-between"
align={{ base: 'center', md: 'flex-start' }}
w="full"
gap={8}
>
{/* Brand and description */}
<VStack align={{ base: 'center', md: 'flex-start' }} spacing={2} maxW="md">
<HStack>
<Text fontSize="lg" fontWeight="bold" color={useColorModeValue('brand.600', 'brand.400')}>
🔗 Redirect Intelligence
</Text>
<Badge colorScheme="brand" variant="subtle">v2</Badge>
</HStack>
<Text fontSize="sm" color={textColor} textAlign={{ base: 'center', md: 'left' }}>
Comprehensive redirect tracking and analysis platform with SSL, SEO, and security insights.
</Text>
</VStack>
{/* Links */}
<HStack spacing={8} align="flex-start">
<VStack align="flex-start" spacing={2}>
<Text fontSize="sm" fontWeight="semibold">
Product
</Text>
<Link href="/" fontSize="sm" color={textColor} _hover={{ color: 'brand.500' }}>
Home
</Link>
<Link href="/track" fontSize="sm" color={textColor} _hover={{ color: 'brand.500' }}>
Track URL
</Link>
<Link href="/analysis" fontSize="sm" color={textColor} _hover={{ color: 'brand.500' }}>
Analysis
</Link>
</VStack>
<VStack align="flex-start" spacing={2}>
<Text fontSize="sm" fontWeight="semibold">
Developers
</Text>
<Link href="/api/docs" fontSize="sm" color={textColor} _hover={{ color: 'brand.500' }} isExternal>
API Documentation
<ExternalLinkIcon mx="2px" />
</Link>
<Link href="/health" fontSize="sm" color={textColor} _hover={{ color: 'brand.500' }} isExternal>
API Health
<ExternalLinkIcon mx="2px" />
</Link>
</VStack>
<VStack align="flex-start" spacing={2}>
<Text fontSize="sm" fontWeight="semibold">
Features
</Text>
<Text fontSize="sm" color={textColor}>
SSL Analysis
</Text>
<Text fontSize="sm" color={textColor}>
SEO Optimization
</Text>
<Text fontSize="sm" color={textColor}>
Security Scanning
</Text>
</VStack>
</HStack>
</Flex>
<Divider />
{/* Bottom footer */}
<Flex
direction={{ base: 'column', md: 'row' }}
justify="space-between"
align="center"
w="full"
gap={4}
>
<Text fontSize="sm" color={textColor}>
© 2024 Redirect Intelligence v2. Built with React, Chakra UI, and TypeScript.
</Text>
<HStack spacing={6}>
<Text fontSize="sm" color={textColor}>
🚀 Phase 4 Complete
</Text>
<Badge colorScheme="green" variant="subtle">
Production Ready
</Badge>
</HStack>
</Flex>
</VStack>
</Container>
</Box>
);
}

View File

@@ -0,0 +1,102 @@
/**
* Main Layout Component for Redirect Intelligence v2
*
* Provides navigation, header, and responsive layout structure
*/
import React, { ReactNode } from 'react';
import {
Box,
Flex,
Spacer,
Container,
useColorModeValue,
useDisclosure,
Drawer,
DrawerOverlay,
DrawerContent,
DrawerCloseButton,
DrawerHeader,
DrawerBody,
VStack,
IconButton,
useBreakpointValue,
} from '@chakra-ui/react';
import { HamburgerIcon } from '@chakra-ui/icons';
import { Navbar } from './Navbar';
import { MobileNav } from './MobileNav';
import { Footer } from './Footer';
interface LayoutProps {
children: ReactNode;
}
export function Layout({ children }: LayoutProps) {
const { isOpen, onOpen, onClose } = useDisclosure();
const bg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const isMobile = useBreakpointValue({ base: true, md: false });
return (
<Box minH="100vh" bg={useColorModeValue('gray.50', 'gray.900')}>
{/* Header */}
<Box
bg={bg}
borderBottom="1px"
borderColor={borderColor}
position="sticky"
top={0}
zIndex={1000}
shadow="sm"
>
<Container maxW="7xl">
<Flex h={16} alignItems="center">
{/* Mobile menu button */}
{isMobile && (
<IconButton
icon={<HamburgerIcon />}
variant="ghost"
onClick={onOpen}
mr={4}
aria-label="Open navigation menu"
/>
)}
{/* Desktop navigation */}
{!isMobile && <Navbar />}
<Spacer />
{/* Right side nav items */}
<Navbar rightSide />
</Flex>
</Container>
</Box>
{/* Mobile drawer navigation */}
<Drawer isOpen={isOpen} placement="left" onClose={onClose}>
<DrawerOverlay />
<DrawerContent>
<DrawerCloseButton />
<DrawerHeader borderBottomWidth="1px">
Navigation
</DrawerHeader>
<DrawerBody p={0}>
<MobileNav onClose={onClose} />
</DrawerBody>
</DrawerContent>
</Drawer>
{/* Main content */}
<Box as="main" flex="1">
<Container maxW="7xl" py={6}>
{children}
</Container>
</Box>
{/* Footer */}
<Footer />
</Box>
);
}

View File

@@ -0,0 +1,171 @@
/**
* Mobile Navigation Component for Redirect Intelligence v2
*
* Mobile-friendly navigation drawer
*/
import React from 'react';
import {
VStack,
Link,
Text,
Box,
Divider,
Button,
HStack,
Avatar,
useColorModeValue,
} from '@chakra-ui/react';
import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { ExternalLinkIcon } from '@chakra-ui/icons';
import { useAuth } from '../../contexts/AuthContext';
interface MobileNavProps {
onClose: () => void;
}
export function MobileNav({ onClose }: MobileNavProps) {
const { user, logout, isAuthenticated } = useAuth();
const navigate = useNavigate();
const linkColor = useColorModeValue('gray.600', 'gray.300');
const linkHoverColor = useColorModeValue('brand.600', 'brand.400');
const handleNavigation = (path: string) => {
navigate(path);
onClose();
};
const handleLogout = () => {
logout();
onClose();
navigate('/');
};
return (
<VStack spacing={4} align="stretch" p={4}>
{/* User info */}
{isAuthenticated && user && (
<Box>
<HStack mb={4}>
<Avatar size="sm" name={user.name} />
<Box>
<Text fontWeight="medium" fontSize="sm">
{user.name}
</Text>
<Text fontSize="xs" color="gray.500">
{user.email}
</Text>
</Box>
</HStack>
<Divider />
</Box>
)}
{/* Navigation Links */}
<VStack spacing={3} align="stretch">
<Link
as={RouterLink}
to="/"
onClick={() => handleNavigation('/')}
color={linkColor}
_hover={{ color: linkHoverColor, textDecoration: 'none' }}
fontWeight="medium"
py={2}
>
Home
</Link>
<Link
as={RouterLink}
to="/track"
onClick={() => handleNavigation('/track')}
color={linkColor}
_hover={{ color: linkHoverColor, textDecoration: 'none' }}
fontWeight="medium"
py={2}
>
Track URL
</Link>
<Link
as={RouterLink}
to="/analysis"
onClick={() => handleNavigation('/analysis')}
color={linkColor}
_hover={{ color: linkHoverColor, textDecoration: 'none' }}
fontWeight="medium"
py={2}
>
Analysis
</Link>
{isAuthenticated && (
<Link
as={RouterLink}
to="/dashboard"
onClick={() => handleNavigation('/dashboard')}
color={linkColor}
_hover={{ color: linkHoverColor, textDecoration: 'none' }}
fontWeight="medium"
py={2}
>
Dashboard
</Link>
)}
<Link
href="/api/docs"
color={linkColor}
_hover={{ color: linkHoverColor, textDecoration: 'none' }}
fontWeight="medium"
py={2}
isExternal
>
API Docs
<ExternalLinkIcon mx="2px" />
</Link>
</VStack>
<Divider />
{/* Auth buttons */}
{isAuthenticated ? (
<VStack spacing={2} align="stretch">
<Button
variant="ghost"
onClick={() => handleNavigation('/dashboard')}
size="sm"
>
Dashboard
</Button>
<Button
variant="outline"
onClick={handleLogout}
size="sm"
>
Sign out
</Button>
</VStack>
) : (
<VStack spacing={2} align="stretch">
<Button
variant="ghost"
onClick={() => handleNavigation('/login')}
size="sm"
>
Sign in
</Button>
<Button
colorScheme="brand"
onClick={() => handleNavigation('/register')}
size="sm"
>
Sign up
</Button>
</VStack>
)}
</VStack>
);
}

View File

@@ -0,0 +1,193 @@
/**
* Navigation Bar Component for Redirect Intelligence v2
*
* Main navigation with brand, links, and user menu
*/
import React from 'react';
import {
Box,
Flex,
HStack,
Link,
Button,
Menu,
MenuButton,
MenuList,
MenuItem,
MenuDivider,
Avatar,
Text,
Badge,
useColorModeValue,
useColorMode,
IconButton,
Heading,
} from '@chakra-ui/react';
import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { MoonIcon, SunIcon, ExternalLinkIcon } from '@chakra-ui/icons';
import { FiLogOut, FiUser, FiSettings } from 'react-icons/fi';
import { useAuth } from '../../contexts/AuthContext';
interface NavbarProps {
rightSide?: boolean;
}
export function Navbar({ rightSide = false }: NavbarProps) {
const { colorMode, toggleColorMode } = useColorMode();
const { user, logout, isAuthenticated } = useAuth();
const navigate = useNavigate();
const linkColor = useColorModeValue('gray.600', 'gray.300');
const linkHoverColor = useColorModeValue('brand.600', 'brand.400');
const handleLogout = () => {
logout();
navigate('/');
};
if (rightSide) {
return (
<HStack spacing={4}>
{/* Color mode toggle */}
<IconButton
aria-label="Toggle color mode"
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
onClick={toggleColorMode}
variant="ghost"
size="sm"
/>
{/* User menu or auth buttons */}
{isAuthenticated && user ? (
<Menu>
<MenuButton
as={Button}
variant="ghost"
size="sm"
leftIcon={<Avatar size="xs" name={user.name} />}
>
<Text fontSize="sm" fontWeight="medium">
{user.name}
</Text>
</MenuButton>
<MenuList>
<MenuItem icon={<FiUser />} onClick={() => navigate('/dashboard')}>
Dashboard
</MenuItem>
<MenuItem icon={<FiSettings />} disabled>
Settings
</MenuItem>
<MenuDivider />
<MenuItem icon={<FiLogOut />} onClick={handleLogout}>
Sign out
</MenuItem>
</MenuList>
</Menu>
) : (
<HStack spacing={2}>
<Button
as={RouterLink}
to="/login"
variant="ghost"
size="sm"
>
Sign in
</Button>
<Button
as={RouterLink}
to="/register"
colorScheme="brand"
size="sm"
>
Sign up
</Button>
</HStack>
)}
</HStack>
);
}
return (
<HStack spacing={8}>
{/* Brand/Logo */}
<Flex alignItems="center">
<Heading
as={RouterLink}
to="/"
size="md"
color={useColorModeValue('brand.600', 'brand.400')}
_hover={{ textDecoration: 'none' }}
>
🔗 Redirect Intelligence
</Heading>
<Badge ml={2} colorScheme="brand" variant="subtle" fontSize="xs">
v2
</Badge>
</Flex>
{/* Navigation Links */}
<HStack spacing={6}>
<Link
as={RouterLink}
to="/"
color={linkColor}
_hover={{ color: linkHoverColor, textDecoration: 'none' }}
fontWeight="medium"
fontSize="sm"
>
Home
</Link>
<Link
as={RouterLink}
to="/track"
color={linkColor}
_hover={{ color: linkHoverColor, textDecoration: 'none' }}
fontWeight="medium"
fontSize="sm"
>
Track URL
</Link>
<Link
as={RouterLink}
to="/analysis"
color={linkColor}
_hover={{ color: linkHoverColor, textDecoration: 'none' }}
fontWeight="medium"
fontSize="sm"
>
Analysis
</Link>
{isAuthenticated && (
<Link
as={RouterLink}
to="/dashboard"
color={linkColor}
_hover={{ color: linkHoverColor, textDecoration: 'none' }}
fontWeight="medium"
fontSize="sm"
>
Dashboard
</Link>
)}
{/* Legacy API docs link */}
<Link
href="/api/docs"
color={linkColor}
_hover={{ color: linkHoverColor, textDecoration: 'none' }}
fontWeight="medium"
fontSize="sm"
isExternal
>
API Docs
<ExternalLinkIcon mx="2px" />
</Link>
</HStack>
</HStack>
);
}

View File

@@ -0,0 +1,394 @@
/**
* Tracking Results Component for Redirect Intelligence v2
*
* Displays comprehensive tracking results with analysis data
*/
import React from 'react';
import {
Box,
Card,
CardBody,
CardHeader,
Heading,
Text,
VStack,
HStack,
Badge,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Stat,
StatLabel,
StatNumber,
StatHelpText,
StatArrow,
SimpleGrid,
Progress,
Divider,
Link,
Icon,
Tooltip,
useColorModeValue,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Button,
useClipboard,
} from '@chakra-ui/react';
import { format } from 'date-fns';
import { FiExternalLink, FiCopy, FiCheck } from 'react-icons/fi';
interface TrackingResultsProps {
result: {
check: {
id: string;
inputUrl: string;
method: string;
status: 'OK' | 'ERROR' | 'TIMEOUT' | 'LOOP';
finalUrl?: string;
totalTimeMs: number;
startedAt: string;
finishedAt: string;
hops: Array<{
hopIndex: number;
url: string;
scheme?: string;
statusCode?: number;
redirectType: string;
latencyMs?: number;
contentType?: string;
reason?: string;
responseHeaders: Record<string, string>;
}>;
redirectCount: number;
loopDetected?: boolean;
error?: string;
};
url: string;
method: string;
redirectCount: number;
finalUrl?: string;
finalStatusCode?: number;
};
}
export function TrackingResults({ result }: TrackingResultsProps) {
const { check } = result;
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const { hasCopied, onCopy } = useClipboard(check.id);
const getStatusColor = (status: string) => {
switch (status) {
case 'OK': return 'green';
case 'ERROR': return 'red';
case 'TIMEOUT': return 'orange';
case 'LOOP': return 'purple';
default: return 'gray';
}
};
const getRedirectTypeColor = (type: string) => {
switch (type) {
case 'HTTP_301': return 'blue';
case 'HTTP_302': return 'green';
case 'HTTP_307': return 'yellow';
case 'HTTP_308': return 'purple';
case 'FINAL': return 'gray';
default: return 'orange';
}
};
const formatLatency = (ms?: number) => {
if (!ms) return 'N/A';
return `${ms}ms`;
};
const formatTimestamp = (timestamp: string) => {
return format(new Date(timestamp), 'MMM dd, yyyy HH:mm:ss');
};
return (
<VStack spacing={6} align="stretch">
{/* Summary Card */}
<Card bg={cardBg} border="1px solid" borderColor={borderColor}>
<CardHeader>
<HStack justify="space-between">
<Heading as="h3" size="md">
Tracking Summary
</Heading>
<HStack>
<Badge colorScheme={getStatusColor(check.status)} variant="solid">
{check.status}
</Badge>
<Button
size="xs"
variant="ghost"
leftIcon={<Icon as={hasCopied ? FiCheck : FiCopy} />}
onClick={onCopy}
>
{hasCopied ? 'Copied' : 'Copy ID'}
</Button>
</HStack>
</HStack>
</CardHeader>
<CardBody>
<VStack spacing={4} align="stretch">
{/* Key Metrics */}
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4}>
<Stat>
<StatLabel>Redirects</StatLabel>
<StatNumber>{check.redirectCount}</StatNumber>
<StatHelpText>
{check.redirectCount === 0 ? 'Direct' : 'Chain detected'}
</StatHelpText>
</Stat>
<Stat>
<StatLabel>Total Time</StatLabel>
<StatNumber>{check.totalTimeMs}ms</StatNumber>
<StatHelpText>
<StatArrow type={check.totalTimeMs < 1000 ? 'increase' : 'decrease'} />
{check.totalTimeMs < 1000 ? 'Fast' : 'Slow'}
</StatHelpText>
</Stat>
<Stat>
<StatLabel>Final Status</StatLabel>
<StatNumber>{result.finalStatusCode || 'N/A'}</StatNumber>
<StatHelpText>
HTTP status code
</StatHelpText>
</Stat>
<Stat>
<StatLabel>Method</StatLabel>
<StatNumber>{check.method}</StatNumber>
<StatHelpText>
Request method
</StatHelpText>
</Stat>
</SimpleGrid>
<Divider />
{/* URLs */}
<VStack align="stretch" spacing={3}>
<Box>
<Text fontSize="sm" fontWeight="medium" color="gray.600" mb={1}>
Input URL
</Text>
<Link
href={check.inputUrl}
isExternal
color="brand.500"
fontSize="sm"
wordBreak="break-all"
>
{check.inputUrl}
<Icon as={FiExternalLink} mx="2px" />
</Link>
</Box>
{check.finalUrl && check.finalUrl !== check.inputUrl && (
<Box>
<Text fontSize="sm" fontWeight="medium" color="gray.600" mb={1}>
Final URL
</Text>
<Link
href={check.finalUrl}
isExternal
color="brand.500"
fontSize="sm"
wordBreak="break-all"
>
{check.finalUrl}
<Icon as={FiExternalLink} mx="2px" />
</Link>
</Box>
)}
</VStack>
{/* Error/Warning Messages */}
{check.error && (
<Alert status="error" borderRadius="md">
<AlertIcon />
<AlertTitle>Error:</AlertTitle>
<AlertDescription>{check.error}</AlertDescription>
</Alert>
)}
{check.loopDetected && (
<Alert status="warning" borderRadius="md">
<AlertIcon />
<AlertTitle>Redirect Loop Detected!</AlertTitle>
<AlertDescription>
The URL redirects in a loop. Check your redirect configuration.
</AlertDescription>
</Alert>
)}
{/* Timestamps */}
<HStack justify="space-between" fontSize="sm" color="gray.600">
<Text>Started: {formatTimestamp(check.startedAt)}</Text>
<Text>Finished: {formatTimestamp(check.finishedAt)}</Text>
</HStack>
</VStack>
</CardBody>
</Card>
{/* Redirect Chain */}
{check.hops.length > 0 && (
<Card bg={cardBg} border="1px solid" borderColor={borderColor}>
<CardHeader>
<Heading as="h3" size="md">
Redirect Chain ({check.hops.length} hops)
</Heading>
</CardHeader>
<CardBody>
<TableContainer>
<Table variant="simple" size="sm">
<Thead>
<Tr>
<Th>Hop</Th>
<Th>URL</Th>
<Th>Status</Th>
<Th>Type</Th>
<Th>Latency</Th>
<Th>Content-Type</Th>
</Tr>
</Thead>
<Tbody>
{check.hops.map((hop, index) => (
<Tr key={index}>
<Td>
<Badge variant="outline">
{hop.hopIndex}
</Badge>
</Td>
<Td maxW="300px">
<Tooltip label={hop.url} placement="top">
<Link
href={hop.url}
isExternal
color="brand.500"
fontSize="sm"
isTruncated
display="block"
>
{hop.url}
</Link>
</Tooltip>
</Td>
<Td>
{hop.statusCode ? (
<Badge
colorScheme={hop.statusCode < 300 ? 'green' : hop.statusCode < 400 ? 'yellow' : 'red'}
>
{hop.statusCode}
</Badge>
) : (
<Text fontSize="xs" color="gray.500">N/A</Text>
)}
</Td>
<Td>
<Badge
colorScheme={getRedirectTypeColor(hop.redirectType)}
variant="subtle"
>
{hop.redirectType.replace('HTTP_', '').replace('_', ' ')}
</Badge>
</Td>
<Td>
<Text fontSize="sm">
{formatLatency(hop.latencyMs)}
</Text>
</Td>
<Td>
<Text fontSize="xs" color="gray.600" isTruncated maxW="150px">
{hop.contentType || 'N/A'}
</Text>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</CardBody>
</Card>
)}
{/* Performance Analysis */}
{check.hops.length > 1 && (
<Card bg={cardBg} border="1px solid" borderColor={borderColor}>
<CardHeader>
<Heading as="h3" size="md">
Performance Analysis
</Heading>
</CardHeader>
<CardBody>
<VStack spacing={4} align="stretch">
{/* Latency Distribution */}
<Box>
<Text fontSize="sm" fontWeight="medium" mb={2}>
Latency per Hop
</Text>
{check.hops.map((hop, index) => (
hop.latencyMs && (
<Box key={index} mb={2}>
<HStack justify="space-between" mb={1}>
<Text fontSize="xs">Hop {hop.hopIndex}</Text>
<Text fontSize="xs">{formatLatency(hop.latencyMs)}</Text>
</HStack>
<Progress
value={(hop.latencyMs / Math.max(...check.hops.map(h => h.latencyMs || 0))) * 100}
size="sm"
colorScheme="brand"
/>
</Box>
)
))}
</Box>
{/* Summary Stats */}
<SimpleGrid columns={{ base: 2, md: 3 }} spacing={4}>
<Stat size="sm">
<StatLabel>Avg Latency</StatLabel>
<StatNumber fontSize="md">
{Math.round(
check.hops.reduce((sum, hop) => sum + (hop.latencyMs || 0), 0) / check.hops.length
)}ms
</StatNumber>
</Stat>
<Stat size="sm">
<StatLabel>Max Latency</StatLabel>
<StatNumber fontSize="md">
{Math.max(...check.hops.map(h => h.latencyMs || 0))}ms
</StatNumber>
</Stat>
<Stat size="sm">
<StatLabel>Min Latency</StatLabel>
<StatNumber fontSize="md">
{Math.min(...check.hops.map(h => h.latencyMs || 0))}ms
</StatNumber>
</Stat>
</SimpleGrid>
</VStack>
</CardBody>
</Card>
)}
</VStack>
);
}