@checkstack/healthcheck-frontend 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,133 +18,15 @@ interface HealthCheckLatencyChartProps {
18
18
 
19
19
  /**
20
20
  * Area chart showing health check latency over time.
21
- * Supports both raw per-run data and aggregated bucket data.
21
+ * Uses aggregated bucket data with average latency per bucket.
22
22
  * Uses HSL CSS variables for theming consistency.
23
23
  */
24
24
  export const HealthCheckLatencyChart: React.FC<
25
25
  HealthCheckLatencyChartProps
26
26
  > = ({ context, height = 200, showAverage = true }) => {
27
- if (context.type === "aggregated") {
28
- const buckets = context.buckets.filter((b) => b.avgLatencyMs !== undefined);
27
+ const buckets = context.buckets.filter((b) => b.avgLatencyMs !== undefined);
29
28
 
30
- if (buckets.length === 0) {
31
- return (
32
- <div
33
- className="flex items-center justify-center text-muted-foreground"
34
- style={{ height }}
35
- >
36
- No latency data available
37
- </div>
38
- );
39
- }
40
-
41
- const chartData = buckets.map((d) => ({
42
- timestamp: new Date(d.bucketStart).getTime(),
43
- bucketEndTimestamp: new Date(d.bucketEnd).getTime(),
44
- latencyMs: d.avgLatencyMs!,
45
- minLatencyMs: d.minLatencyMs,
46
- maxLatencyMs: d.maxLatencyMs,
47
- }));
48
-
49
- const avgLatency =
50
- chartData.length > 0
51
- ? chartData.reduce((sum, d) => sum + d.latencyMs, 0) / chartData.length
52
- : 0;
53
-
54
- // Use daily format for intervals >= 6 hours, otherwise include time
55
- const timeFormat =
56
- (buckets[0]?.bucketIntervalSeconds ?? 3600) >= 21_600
57
- ? "MMM d"
58
- : "MMM d, HH:mm";
59
-
60
- return (
61
- <ResponsiveContainer width="100%" height={height}>
62
- <AreaChart data={chartData}>
63
- <defs>
64
- <linearGradient id="latencyGradient" x1="0" y1="0" x2="0" y2="1">
65
- <stop
66
- offset="5%"
67
- stopColor="hsl(var(--primary))"
68
- stopOpacity={0.3}
69
- />
70
- <stop
71
- offset="95%"
72
- stopColor="hsl(var(--primary))"
73
- stopOpacity={0}
74
- />
75
- </linearGradient>
76
- </defs>
77
- <XAxis
78
- dataKey="timestamp"
79
- type="number"
80
- domain={["dataMin", "dataMax"]}
81
- tickFormatter={(ts: number) => format(new Date(ts), timeFormat)}
82
- stroke="hsl(var(--muted-foreground))"
83
- fontSize={12}
84
- />
85
- <YAxis
86
- stroke="hsl(var(--muted-foreground))"
87
- fontSize={12}
88
- tickFormatter={(v: number) => `${v}ms`}
89
- />
90
- <Tooltip<number, "latencyMs">
91
- content={({ active, payload }) => {
92
- // eslint-disable-next-line unicorn/no-null -- recharts requires null return, not undefined
93
- if (!active || !payload?.length) return null;
94
- const data = payload[0].payload as (typeof chartData)[number];
95
- const startTime = format(
96
- new Date(data.timestamp),
97
- "MMM d, HH:mm",
98
- );
99
- const endTime = format(
100
- new Date(data.bucketEndTimestamp),
101
- "HH:mm",
102
- );
103
- return (
104
- <div
105
- className="rounded-md border bg-popover p-2 text-sm shadow-md"
106
- style={{
107
- backgroundColor: "hsl(var(--popover))",
108
- border: "1px solid hsl(var(--border))",
109
- }}
110
- >
111
- <p className="text-muted-foreground">
112
- {startTime} - {endTime}
113
- </p>
114
- <p className="font-medium">{data.latencyMs}ms (avg)</p>
115
- </div>
116
- );
117
- }}
118
- />
119
- {showAverage && (
120
- <ReferenceLine
121
- y={avgLatency}
122
- stroke="hsl(var(--muted-foreground))"
123
- strokeDasharray="3 3"
124
- label={{
125
- value: `Avg: ${avgLatency.toFixed(0)}ms`,
126
- position: "right",
127
- fill: "hsl(var(--muted-foreground))",
128
- fontSize: 12,
129
- }}
130
- />
131
- )}
132
- <Area
133
- type="monotone"
134
- dataKey="latencyMs"
135
- stroke="hsl(var(--primary))"
136
- fill="url(#latencyGradient)"
137
- strokeWidth={2}
138
- />
139
- </AreaChart>
140
- </ResponsiveContainer>
141
- );
142
- }
143
-
144
- // Raw data path
145
- const runs = context.runs.filter((r) => r.latencyMs !== undefined);
146
-
147
- if (runs.length === 0) {
29
+ if (buckets.length === 0) {
148
30
  return (
149
31
  <div
150
32
  className="flex items-center justify-center text-muted-foreground"
@@ -155,10 +37,12 @@ export const HealthCheckLatencyChart: React.FC<
155
37
  );
156
38
  }
157
39
 
158
- // Runs come in chronological order from API (oldest first, newest last)
159
- const chartData = runs.map((d) => ({
160
- timestamp: new Date(d.timestamp).getTime(),
161
- latencyMs: d.latencyMs!,
40
+ const chartData = buckets.map((d) => ({
41
+ timestamp: new Date(d.bucketStart).getTime(),
42
+ bucketEndTimestamp: new Date(d.bucketEnd).getTime(),
43
+ latencyMs: d.avgLatencyMs!,
44
+ minLatencyMs: d.minLatencyMs,
45
+ maxLatencyMs: d.maxLatencyMs,
162
46
  }));
163
47
 
164
48
  const avgLatency =
@@ -166,6 +50,12 @@ export const HealthCheckLatencyChart: React.FC<
166
50
  ? chartData.reduce((sum, d) => sum + d.latencyMs, 0) / chartData.length
167
51
  : 0;
168
52
 
53
+ // Use daily format for intervals >= 6 hours, otherwise include time
54
+ const timeFormat =
55
+ (buckets[0]?.bucketIntervalSeconds ?? 3600) >= 21_600
56
+ ? "MMM d"
57
+ : "MMM d, HH:mm";
58
+
169
59
  return (
170
60
  <ResponsiveContainer width="100%" height={height}>
171
61
  <AreaChart data={chartData}>
@@ -187,7 +77,7 @@ export const HealthCheckLatencyChart: React.FC<
187
77
  dataKey="timestamp"
188
78
  type="number"
189
79
  domain={["dataMin", "dataMax"]}
190
- tickFormatter={(ts: number) => format(new Date(ts), "HH:mm")}
80
+ tickFormatter={(ts: number) => format(new Date(ts), timeFormat)}
191
81
  stroke="hsl(var(--muted-foreground))"
192
82
  fontSize={12}
193
83
  />
@@ -201,6 +91,8 @@ export const HealthCheckLatencyChart: React.FC<
201
91
  // eslint-disable-next-line unicorn/no-null -- recharts requires null return, not undefined
202
92
  if (!active || !payload?.length) return null;
203
93
  const data = payload[0].payload as (typeof chartData)[number];
94
+ const startTime = format(new Date(data.timestamp), "MMM d, HH:mm");
95
+ const endTime = format(new Date(data.bucketEndTimestamp), "HH:mm");
204
96
  return (
205
97
  <div
206
98
  className="rounded-md border bg-popover p-2 text-sm shadow-md"
@@ -210,9 +102,9 @@ export const HealthCheckLatencyChart: React.FC<
210
102
  }}
211
103
  >
212
104
  <p className="text-muted-foreground">
213
- {format(new Date(data.timestamp), "MMM d, HH:mm:ss")}
105
+ {startTime} - {endTime}
214
106
  </p>
215
- <p className="font-medium">{data.latencyMs}ms</p>
107
+ <p className="font-medium">{data.latencyMs}ms (avg)</p>
216
108
  </div>
217
109
  );
218
110
  }}
@@ -1,7 +1,6 @@
1
1
  import { format } from "date-fns";
2
2
  import type { HealthCheckDiagramSlotContext } from "../slots";
3
3
  import { SparklineTooltip } from "./SparklineTooltip";
4
- import { downsampleSparkline } from "../utils/sparkline-downsampling";
5
4
 
6
5
  interface HealthCheckStatusTimelineProps {
7
6
  context: HealthCheckDiagramSlotContext;
@@ -15,104 +14,15 @@ const statusColors = {
15
14
  };
16
15
 
17
16
  /**
18
- * Timeline bar chart showing health check status changes over time.
19
- * For raw data: each bar represents a check run with color indicating status.
20
- * For aggregated data: each bar shows the distribution of statuses in that bucket.
17
+ * Timeline bar chart showing health check status distribution over time.
18
+ * Each bar shows the distribution of statuses in that aggregated bucket.
21
19
  */
22
20
  export const HealthCheckStatusTimeline: React.FC<
23
21
  HealthCheckStatusTimelineProps
24
22
  > = ({ context, height = 60 }) => {
25
- if (context.type === "aggregated") {
26
- const buckets = context.buckets;
23
+ const buckets = context.buckets;
27
24
 
28
- if (buckets.length === 0) {
29
- return (
30
- <div
31
- className="flex items-center justify-center text-muted-foreground"
32
- style={{ height }}
33
- >
34
- No status data available
35
- </div>
36
- );
37
- }
38
-
39
- // Use daily format for intervals >= 6 hours, otherwise include time
40
- const timeFormat =
41
- (buckets[0]?.bucketIntervalSeconds ?? 3600) >= 21_600
42
- ? "MMM d"
43
- : "MMM d HH:mm";
44
-
45
- // Calculate time range for labels
46
- const firstTime = new Date(buckets[0].bucketStart).getTime();
47
- const lastTime = new Date(buckets.at(-1)!.bucketStart).getTime();
48
-
49
- return (
50
- <div style={{ height }} className="flex flex-col justify-between">
51
- {/* Status strip - equal width stacked segments for each bucket */}
52
- <div className="flex h-4 gap-px rounded-md bg-muted/30">
53
- {buckets.map((bucket, index) => {
54
- const total = bucket.runCount || 1;
55
- const healthyPct = (bucket.healthyCount / total) * 100;
56
- const degradedPct = (bucket.degradedCount / total) * 100;
57
- const unhealthyPct = (bucket.unhealthyCount / total) * 100;
58
-
59
- // Use actual bucket end time from response (critical for last bucket which extends to query end)
60
- const bucketStart = new Date(bucket.bucketStart);
61
- const bucketEnd = new Date(bucket.bucketEnd);
62
- const timeSpan = `${format(bucketStart, "MMM d, HH:mm")} - ${format(bucketEnd, "HH:mm")}`;
63
-
64
- return (
65
- <SparklineTooltip
66
- key={index}
67
- content={`${timeSpan}\nHealthy: ${bucket.healthyCount}\nDegraded: ${bucket.degradedCount}\nUnhealthy: ${bucket.unhealthyCount}`}
68
- >
69
- <div className="flex-1 h-full flex flex-col cursor-pointer group">
70
- {bucket.healthyCount > 0 && (
71
- <div
72
- className="w-full transition-opacity group-hover:opacity-80"
73
- style={{
74
- height: `${healthyPct}%`,
75
- backgroundColor: statusColors.healthy,
76
- }}
77
- />
78
- )}
79
- {bucket.degradedCount > 0 && (
80
- <div
81
- className="w-full transition-opacity group-hover:opacity-80"
82
- style={{
83
- height: `${degradedPct}%`,
84
- backgroundColor: statusColors.degraded,
85
- }}
86
- />
87
- )}
88
- {bucket.unhealthyCount > 0 && (
89
- <div
90
- className="w-full transition-opacity group-hover:opacity-80"
91
- style={{
92
- height: `${unhealthyPct}%`,
93
- backgroundColor: statusColors.unhealthy,
94
- }}
95
- />
96
- )}
97
- </div>
98
- </SparklineTooltip>
99
- );
100
- })}
101
- </div>
102
-
103
- {/* Time axis labels */}
104
- <div className="flex justify-between text-xs text-muted-foreground mt-1">
105
- <span>{format(new Date(firstTime), timeFormat)}</span>
106
- <span>{format(new Date(lastTime), timeFormat)}</span>
107
- </div>
108
- </div>
109
- );
110
- }
111
-
112
- // Raw data path - use a continuous strip visualization
113
- const runs = context.runs;
114
-
115
- if (runs.length === 0) {
25
+ if (buckets.length === 0) {
116
26
  return (
117
27
  <div
118
28
  className="flex items-center justify-center text-muted-foreground"
@@ -123,74 +33,74 @@ export const HealthCheckStatusTimeline: React.FC<
123
33
  );
124
34
  }
125
35
 
126
- // Runs come in chronological order from API (oldest first, newest last)
127
- // No sorting needed
36
+ // Use daily format for intervals >= 6 hours, otherwise include time
37
+ const timeFormat =
38
+ (buckets[0]?.bucketIntervalSeconds ?? 3600) >= 21_600
39
+ ? "MMM d"
40
+ : "MMM d HH:mm";
128
41
 
129
42
  // Calculate time range for labels
130
- const firstTime = new Date(runs[0].timestamp).getTime();
131
- const lastTime = new Date(runs.at(-1)!.timestamp).getTime();
132
- const totalRange = lastTime - firstTime;
43
+ const firstTime = new Date(buckets[0].bucketStart).getTime();
44
+ const lastTime = new Date(buckets.at(-1)!.bucketStart).getTime();
133
45
 
134
46
  return (
135
47
  <div style={{ height }} className="flex flex-col justify-between">
136
- {/* Status strip - equal width segments for clarity */}
137
- {(() => {
138
- // Prepare runs with status info for downsampling
139
- const runsWithStatus = runs.map((run) => ({
140
- ...run,
141
- passed: run.status === "healthy",
142
- timeLabel: format(new Date(run.timestamp), "MMM d, HH:mm:ss"),
143
- }));
144
-
145
- const buckets = downsampleSparkline(runsWithStatus);
146
-
147
- // Determine bucket color based on worst status in bucket
148
- const getBucketColor = (
149
- items: typeof runsWithStatus,
150
- ): keyof typeof statusColors => {
151
- if (items.some((r) => r.status === "unhealthy")) return "unhealthy";
152
- if (items.some((r) => r.status === "degraded")) return "degraded";
153
- return "healthy";
154
- };
155
-
156
- return (
157
- <div className="flex h-4 gap-px rounded-md bg-muted/30">
158
- {buckets.map((bucket, index) => {
159
- const bucketStatus = getBucketColor(bucket.items);
160
- const statusCounts = {
161
- healthy: bucket.items.filter((r) => r.status === "healthy")
162
- .length,
163
- degraded: bucket.items.filter((r) => r.status === "degraded")
164
- .length,
165
- unhealthy: bucket.items.filter((r) => r.status === "unhealthy")
166
- .length,
167
- };
168
- const tooltip =
169
- bucket.items.length === 1
170
- ? `${bucket.timeLabel} - ${bucket.items[0].status}`
171
- : `${bucket.timeLabel}\nHealthy: ${statusCounts.healthy}, Degraded: ${statusCounts.degraded}, Unhealthy: ${statusCounts.unhealthy}`;
172
- return (
173
- <SparklineTooltip key={index} content={tooltip}>
48
+ {/* Status strip - equal width stacked segments for each bucket */}
49
+ <div className="flex h-4 gap-px rounded-md bg-muted/30">
50
+ {buckets.map((bucket, index) => {
51
+ const total = bucket.runCount || 1;
52
+ const healthyPct = (bucket.healthyCount / total) * 100;
53
+ const degradedPct = (bucket.degradedCount / total) * 100;
54
+ const unhealthyPct = (bucket.unhealthyCount / total) * 100;
55
+
56
+ // Use actual bucket end time from response (critical for last bucket which extends to query end)
57
+ const bucketStart = new Date(bucket.bucketStart);
58
+ const bucketEnd = new Date(bucket.bucketEnd);
59
+ const timeSpan = `${format(bucketStart, "MMM d, HH:mm")} - ${format(bucketEnd, "HH:mm")}`;
60
+
61
+ return (
62
+ <SparklineTooltip
63
+ key={index}
64
+ content={`${timeSpan}\nHealthy: ${bucket.healthyCount}\nDegraded: ${bucket.degradedCount}\nUnhealthy: ${bucket.unhealthyCount}`}
65
+ >
66
+ <div className="flex-1 h-full flex flex-col cursor-pointer group">
67
+ {bucket.healthyCount > 0 && (
68
+ <div
69
+ className="w-full transition-opacity group-hover:opacity-80"
70
+ style={{
71
+ height: `${healthyPct}%`,
72
+ backgroundColor: statusColors.healthy,
73
+ }}
74
+ />
75
+ )}
76
+ {bucket.degradedCount > 0 && (
77
+ <div
78
+ className="w-full transition-opacity group-hover:opacity-80"
79
+ style={{
80
+ height: `${degradedPct}%`,
81
+ backgroundColor: statusColors.degraded,
82
+ }}
83
+ />
84
+ )}
85
+ {bucket.unhealthyCount > 0 && (
174
86
  <div
175
- className="flex-1 h-full transition-opacity hover:opacity-80 cursor-pointer"
87
+ className="w-full transition-opacity group-hover:opacity-80"
176
88
  style={{
177
- backgroundColor: statusColors[bucketStatus],
89
+ height: `${unhealthyPct}%`,
90
+ backgroundColor: statusColors.unhealthy,
178
91
  }}
179
92
  />
180
- </SparklineTooltip>
181
- );
182
- })}
183
- </div>
184
- );
185
- })()}
93
+ )}
94
+ </div>
95
+ </SparklineTooltip>
96
+ );
97
+ })}
98
+ </div>
186
99
 
187
100
  {/* Time axis labels */}
188
101
  <div className="flex justify-between text-xs text-muted-foreground mt-1">
189
- <span>{format(new Date(firstTime), "HH:mm")}</span>
190
- {totalRange > 3_600_000 && (
191
- <span>{format(new Date(firstTime + totalRange / 2), "HH:mm")}</span>
192
- )}
193
- <span>{format(new Date(lastTime), "HH:mm")}</span>
102
+ <span>{format(new Date(firstTime), timeFormat)}</span>
103
+ <span>{format(new Date(lastTime), timeFormat)}</span>
194
104
  </div>
195
105
  </div>
196
106
  );
@@ -155,7 +155,6 @@ const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
155
155
  context: chartContext,
156
156
  loading: chartLoading,
157
157
  isFetching: chartFetching,
158
- isAggregated,
159
158
  bucketIntervalSeconds,
160
159
  } = useHealthCheckData({
161
160
  systemId,
@@ -163,7 +162,6 @@ const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
163
162
  strategyId: item.strategyId,
164
163
  dateRange,
165
164
  isRollingPreset,
166
- limit: 1000,
167
165
  // Update endDate to current time when new runs are detected (only for rolling presets)
168
166
  onDateRangeRefresh: (newEndDate) => {
169
167
  setDateRange((prev) => ({ ...prev, endDate: newEndDate }));
@@ -233,10 +231,7 @@ const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
233
231
  }
234
232
 
235
233
  // Check if we have data to show
236
- const hasData =
237
- chartContext.type === "raw"
238
- ? chartContext.runs.length > 0
239
- : chartContext.buckets.length > 0;
234
+ const hasData = chartContext.buckets.length > 0;
240
235
 
241
236
  if (!hasData) {
242
237
  return;
@@ -244,7 +239,7 @@ const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
244
239
 
245
240
  return (
246
241
  <div className="space-y-4">
247
- {isAggregated && bucketIntervalSeconds && (
242
+ {bucketIntervalSeconds && (
248
243
  <AggregatedDataBanner
249
244
  bucketIntervalSeconds={bucketIntervalSeconds}
250
245
  checkIntervalSeconds={item.intervalSeconds}
@@ -265,9 +260,7 @@ const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
265
260
  <Card>
266
261
  <CardHeader className="pb-2">
267
262
  <CardTitle className="text-sm font-medium">
268
- {isAggregated
269
- ? "Average Execution Duration"
270
- : "Execution Duration"}
263
+ Average Execution Duration
271
264
  </CardTitle>
272
265
  </CardHeader>
273
266
  <CardContent>