Skip to content

Commit e1f5104

Browse files
authored
feat: enhance historical data view with improved UI and availability trend
2 parents e3a5d86 + 0593df4 commit e1f5104

File tree

10 files changed

+694
-540
lines changed

10 files changed

+694
-540
lines changed

thingconnect.pulse.client/src/components/AvailabilityChart.tsx

Lines changed: 103 additions & 250 deletions
Large diffs are not rendered by default.
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
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+
}
Lines changed: 21 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { HStack } from '@chakra-ui/react';
2-
import { RadioCardItem, RadioCardRoot } from '@/components/ui/radio-card';
1+
import { HStack, SegmentGroup } from '@chakra-ui/react';
32
import { Clock, BarChart3, Calendar } from 'lucide-react';
43
import type { BucketType } from '@/types/bucket';
54

@@ -13,46 +12,43 @@ const bucketOptions = [
1312
{
1413
value: 'raw' as const,
1514
label: 'Raw Data',
16-
description: 'Individual check results',
17-
icon: <Clock size={24} />,
15+
icon: <Clock size={16} />,
1816
},
1917
{
2018
value: '15m' as const,
2119
label: '15 Minute',
22-
description: 'Aggregated every 15 minutes',
23-
icon: <BarChart3 size={24} />,
20+
icon: <BarChart3 size={16} />,
2421
},
2522
{
2623
value: 'daily' as const,
2724
label: 'Daily',
28-
description: 'Daily summaries',
29-
icon: <Calendar size={24} />,
25+
icon: <Calendar size={16} />,
3026
},
3127
];
3228

3329
export function BucketSelector({ value, onChange, disabled = false }: BucketSelectorProps) {
3430
return (
35-
<RadioCardRoot
31+
<SegmentGroup.Root
3632
value={value}
3733
onValueChange={details => onChange(details.value as BucketType)}
38-
orientation='horizontal'
3934
size='sm'
4035
disabled={disabled}
4136
>
42-
<HStack gap={3}>
43-
{bucketOptions.map(option => (
44-
<RadioCardItem
45-
key={option.value}
46-
value={option.value}
47-
flex='1'
48-
minW='0'
49-
icon={option.icon}
50-
label={option.label}
51-
description={option.description}
52-
indicator={null}
53-
/>
54-
))}
55-
</HStack>
56-
</RadioCardRoot>
37+
<SegmentGroup.Indicator />
38+
<SegmentGroup.Items
39+
items={bucketOptions.map(option => ({
40+
value: option.value,
41+
label: (
42+
<HStack gap={2}>
43+
{option.icon}
44+
{option.label}
45+
</HStack>
46+
),
47+
}))}
48+
px={3}
49+
fontWeight={'light'}
50+
cursor={'pointer'}
51+
/>
52+
</SegmentGroup.Root>
5753
);
5854
}

thingconnect.pulse.client/src/components/DateRangePicker.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,10 @@ export function DateRangePicker({ value, onChange, disabled = false }: DateRange
6969
variant='outline'
7070
onClick={() => setIsExpanded(!isExpanded)}
7171
disabled={disabled}
72-
size='sm'
72+
size='xs'
73+
px={2}
74+
py={1}
75+
w='full'
7376
>
7477
<Calendar size={16} />
7578
{value.from && value.to
@@ -86,7 +89,7 @@ export function DateRangePicker({ value, onChange, disabled = false }: DateRange
8689
bg='white'
8790
border='1px'
8891
borderColor='gray.200'
89-
_dark={{ bg: 'gray.800', borderColor: 'gray.600' }}
92+
_dark={{ bg: 'gray.900', borderColor: 'gray.600' }}
9093
borderRadius='md'
9194
shadow='lg'
9295
minW='320px'

0 commit comments

Comments
 (0)