|
| 1 | +import { useMemo } from 'react'; |
| 2 | +import { SimpleGrid, Stat, HStack, Badge, Icon, Skeleton } from '@chakra-ui/react'; |
| 3 | +import type { BucketType } from '@/types/bucket'; |
| 4 | +import { Database } from 'lucide-react'; |
| 5 | +import type { AvailabilityChartProps } from './AvailabilityChart'; |
| 6 | + |
| 7 | +export function AvailabilityStats({ |
| 8 | + data, |
| 9 | + bucket, |
| 10 | + isLoading, |
| 11 | +}: { |
| 12 | + data: AvailabilityChartProps['data'] | null | undefined; |
| 13 | + bucket: BucketType; |
| 14 | + isLoading?: boolean; |
| 15 | +}) { |
| 16 | + const stats = useMemo(() => { |
| 17 | + if (!data) return null; |
| 18 | + |
| 19 | + let totalPoints = 0; |
| 20 | + let upPoints = 0; |
| 21 | + let totalResponseTime = 0; |
| 22 | + let responseTimeCount = 0; |
| 23 | + let totalDownEvents = 0; |
| 24 | + |
| 25 | + switch (bucket) { |
| 26 | + case 'raw': { |
| 27 | + totalPoints = data.raw.length; |
| 28 | + upPoints = data.raw.filter(check => check.status === 'up').length; |
| 29 | + const validRttChecks = data.raw.filter(check => check.rttMs != null); |
| 30 | + totalResponseTime = validRttChecks.reduce((sum, check) => sum + (check.rttMs || 0), 0); |
| 31 | + responseTimeCount = validRttChecks.length; |
| 32 | + break; |
| 33 | + } |
| 34 | + |
| 35 | + case '15m': { |
| 36 | + totalPoints = data.rollup15m.length; |
| 37 | + const totalUptime = data.rollup15m.reduce((sum, bucket) => sum + bucket.upPct, 0); |
| 38 | + upPoints = totalUptime / 100; // Convert percentage to equivalent "up points" |
| 39 | + totalDownEvents = data.rollup15m.reduce((sum, bucket) => sum + bucket.downEvents, 0); |
| 40 | + const validRttRollups = data.rollup15m.filter(bucket => bucket.avgRttMs != null); |
| 41 | + totalResponseTime = validRttRollups.reduce( |
| 42 | + (sum, bucket) => sum + (bucket.avgRttMs || 0), |
| 43 | + 0 |
| 44 | + ); |
| 45 | + responseTimeCount = validRttRollups.length; |
| 46 | + break; |
| 47 | + } |
| 48 | + |
| 49 | + case 'daily': { |
| 50 | + totalPoints = data.rollupDaily.length; |
| 51 | + const totalDailyUptime = data.rollupDaily.reduce((sum, bucket) => sum + bucket.upPct, 0); |
| 52 | + upPoints = totalDailyUptime / 100; // Convert percentage to equivalent "up points" |
| 53 | + totalDownEvents = data.rollupDaily.reduce((sum, bucket) => sum + bucket.downEvents, 0); |
| 54 | + const validDailyRollups = data.rollupDaily.filter(bucket => bucket.avgRttMs != null); |
| 55 | + totalResponseTime = validDailyRollups.reduce( |
| 56 | + (sum, bucket) => sum + (bucket.avgRttMs || 0), |
| 57 | + 0 |
| 58 | + ); |
| 59 | + responseTimeCount = validDailyRollups.length; |
| 60 | + break; |
| 61 | + } |
| 62 | + } |
| 63 | + |
| 64 | + const availabilityPct = totalPoints > 0 ? (upPoints / totalPoints) * 100 : 0; |
| 65 | + const avgResponseTime = responseTimeCount > 0 ? totalResponseTime / responseTimeCount : null; |
| 66 | + let downEventPct = 0; |
| 67 | + if (totalPoints > 0) { |
| 68 | + if (bucket !== 'daily') downEventPct = (totalDownEvents / totalPoints) * 100; |
| 69 | + else downEventPct = totalDownEvents / totalPoints; |
| 70 | + } |
| 71 | + |
| 72 | + return { |
| 73 | + availabilityPct, |
| 74 | + avgResponseTime, |
| 75 | + totalDownEvents, |
| 76 | + downEventPct, |
| 77 | + totalPoints, |
| 78 | + upPoints, |
| 79 | + }; |
| 80 | + }, [data, bucket]); |
| 81 | + |
| 82 | + if (isLoading) { |
| 83 | + return ( |
| 84 | + <SimpleGrid columns={{ base: 1, sm: 2, md: 5 }} gap={2}> |
| 85 | + {Array.from({ length: 5 }).map((_, i) => ( |
| 86 | + <Stat.Root key={i} p={3} borderWidth='1px' rounded='md' _dark={{ bg: 'gray.800' }}> |
| 87 | + <Skeleton height='20px' mb={2} /> |
| 88 | + <Skeleton height='28px' mb={1} /> |
| 89 | + <Skeleton height='16px' /> |
| 90 | + </Stat.Root> |
| 91 | + ))} |
| 92 | + </SimpleGrid> |
| 93 | + ); |
| 94 | + } |
| 95 | + |
| 96 | + if (!stats) return null; |
| 97 | + |
| 98 | + return ( |
| 99 | + <SimpleGrid columns={{ base: 1, sm: 2, md: 5 }} gap={2}> |
| 100 | + {/* Data Points */} |
| 101 | + <Stat.Root p={3} borderWidth='1px' rounded='md' _dark={{ bg: 'gray.800' }}> |
| 102 | + <HStack justify='space-between'> |
| 103 | + <Stat.Label>Data Points</Stat.Label> |
| 104 | + <Icon as={Database} color='fg.muted' boxSize={4} /> |
| 105 | + </HStack> |
| 106 | + <Stat.ValueText>{stats.totalPoints}</Stat.ValueText> |
| 107 | + <Stat.HelpText>Checks included</Stat.HelpText> |
| 108 | + </Stat.Root> |
| 109 | + {/* Availability % */} |
| 110 | + <Stat.Root p={3} borderWidth='1px' rounded='md' _dark={{ bg: 'gray.800' }}> |
| 111 | + <Stat.Label>Availability</Stat.Label> |
| 112 | + <HStack> |
| 113 | + <Stat.ValueText |
| 114 | + color={ |
| 115 | + stats.availabilityPct >= 99 |
| 116 | + ? 'green.600' |
| 117 | + : stats.availabilityPct >= 95 |
| 118 | + ? 'yellow.600' |
| 119 | + : 'red.600' |
| 120 | + } |
| 121 | + > |
| 122 | + {stats.availabilityPct.toFixed(2)}% |
| 123 | + </Stat.ValueText> |
| 124 | + {stats.availabilityPct >= 99 ? ( |
| 125 | + <Stat.UpIndicator color='green.500' /> |
| 126 | + ) : ( |
| 127 | + <Stat.DownIndicator color={stats.availabilityPct >= 95 ? 'yellow.500' : 'red.500'} /> |
| 128 | + )} |
| 129 | + </HStack> |
| 130 | + <Stat.HelpText>Based on {stats.totalPoints} checks</Stat.HelpText> |
| 131 | + </Stat.Root> |
| 132 | + {/* Uptime Events */} |
| 133 | + <Stat.Root p={3} borderWidth='1px' rounded='md' _dark={{ bg: 'gray.800' }}> |
| 134 | + <Stat.Label>Uptime Events</Stat.Label> |
| 135 | + <HStack> |
| 136 | + <Stat.ValueText color='green.600'>{Math.round(stats.upPoints)}</Stat.ValueText> |
| 137 | + <Badge colorPalette='green' gap='0'> |
| 138 | + <Stat.UpIndicator /> |
| 139 | + {stats.availabilityPct.toFixed(2)}% |
| 140 | + </Badge> |
| 141 | + </HStack> |
| 142 | + <Stat.HelpText>Successful checks</Stat.HelpText> |
| 143 | + </Stat.Root> |
| 144 | + {/* Down Events */} |
| 145 | + {bucket !== 'raw' && ( |
| 146 | + <Stat.Root p={2} borderWidth='1px' rounded='md' _dark={{ bg: 'gray.800' }}> |
| 147 | + <Stat.Label>Down Events</Stat.Label> |
| 148 | + <HStack> |
| 149 | + <Stat.ValueText color={stats.totalDownEvents > 0 ? 'red.600' : 'green.600'}> |
| 150 | + {stats.totalDownEvents} |
| 151 | + </Stat.ValueText> |
| 152 | + <Badge colorPalette={stats.totalDownEvents > 0 ? 'red' : 'green'} gap='0'> |
| 153 | + <Stat.DownIndicator color={stats.totalDownEvents > 0 ? 'red' : 'green'} /> |
| 154 | + {stats.downEventPct.toFixed(2)}% |
| 155 | + </Badge> |
| 156 | + </HStack> |
| 157 | + <Stat.HelpText>Recorded in this range</Stat.HelpText> |
| 158 | + </Stat.Root> |
| 159 | + )} |
| 160 | + {/* Response Time */} |
| 161 | + {stats.avgResponseTime && ( |
| 162 | + <Stat.Root p={2} borderWidth='1px' rounded='md' _dark={{ bg: 'gray.800' }}> |
| 163 | + <Stat.Label>Avg Response</Stat.Label> |
| 164 | + <Stat.ValueText alignItems='baseline'> |
| 165 | + {stats.avgResponseTime.toFixed(2)} <Stat.ValueUnit>ms</Stat.ValueUnit> |
| 166 | + </Stat.ValueText> |
| 167 | + <Stat.HelpText>Across successful checks</Stat.HelpText> |
| 168 | + </Stat.Root> |
| 169 | + )} |
| 170 | + </SimpleGrid> |
| 171 | + ); |
| 172 | +} |
0 commit comments