@checkstack/healthcheck-frontend 0.6.0 → 0.7.1
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.
- package/CHANGELOG.md +92 -0
- package/package.json +1 -1
- package/src/auto-charts/AutoChartGrid.tsx +447 -99
- package/src/components/AggregatedDataBanner.tsx +3 -3
- package/src/components/ExpandedResultView.tsx +140 -0
- package/src/components/HealthCheckDiagram.tsx +0 -40
- package/src/components/HealthCheckHistory.tsx +3 -3
- package/src/components/HealthCheckLatencyChart.tsx +18 -6
- package/src/components/HealthCheckRunsTable.tsx +59 -230
- package/src/components/HealthCheckSparkline.tsx +12 -11
- package/src/components/HealthCheckStatusTimeline.tsx +133 -112
- package/src/components/HealthCheckSystemOverview.tsx +174 -32
- package/src/components/SparklineTooltip.tsx +51 -0
- package/src/hooks/useHealthCheckData.ts +78 -28
- package/src/index.tsx +6 -0
- package/src/pages/HealthCheckHistoryDetailPage.tsx +58 -4
- package/src/pages/HealthCheckHistoryPage.tsx +2 -1
- package/src/utils/sparkline-downsampling.ts +88 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useMemo } from "react";
|
|
1
|
+
import { useMemo, useRef } from "react";
|
|
2
2
|
import {
|
|
3
3
|
usePluginClient,
|
|
4
4
|
accessApiRef,
|
|
@@ -26,6 +26,10 @@ interface UseHealthCheckDataProps {
|
|
|
26
26
|
startDate: Date;
|
|
27
27
|
endDate: Date;
|
|
28
28
|
};
|
|
29
|
+
/** Whether the date range is a rolling preset (e.g., 'Last 7 days') that should auto-update */
|
|
30
|
+
isRollingPreset?: boolean;
|
|
31
|
+
/** Callback to update the date range (e.g., to refresh endDate to current time) */
|
|
32
|
+
onDateRangeRefresh?: (newEndDate: Date) => void;
|
|
29
33
|
/** Pagination for raw data mode */
|
|
30
34
|
limit?: number;
|
|
31
35
|
offset?: number;
|
|
@@ -34,8 +38,10 @@ interface UseHealthCheckDataProps {
|
|
|
34
38
|
interface UseHealthCheckDataResult {
|
|
35
39
|
/** The context to pass to HealthCheckDiagramSlot */
|
|
36
40
|
context: HealthCheckDiagramSlotContext | undefined;
|
|
37
|
-
/** Whether data is currently loading */
|
|
41
|
+
/** Whether data is currently loading (no previous data available) */
|
|
38
42
|
loading: boolean;
|
|
43
|
+
/** Whether data is being fetched (even if previous data is shown) */
|
|
44
|
+
isFetching: boolean;
|
|
39
45
|
/** Whether aggregated data mode is active */
|
|
40
46
|
isAggregated: boolean;
|
|
41
47
|
/** The resolved retention config */
|
|
@@ -61,6 +67,8 @@ export function useHealthCheckData({
|
|
|
61
67
|
configurationId,
|
|
62
68
|
strategyId,
|
|
63
69
|
dateRange,
|
|
70
|
+
isRollingPreset = false,
|
|
71
|
+
onDateRangeRefresh,
|
|
64
72
|
limit = 100,
|
|
65
73
|
offset = 0,
|
|
66
74
|
}: UseHealthCheckDataProps): UseHealthCheckDataResult {
|
|
@@ -91,9 +99,11 @@ export function useHealthCheckData({
|
|
|
91
99
|
retentionData?.retentionConfig ?? DEFAULT_RETENTION_CONFIG;
|
|
92
100
|
|
|
93
101
|
// Determine if we should use aggregated data
|
|
94
|
-
|
|
102
|
+
// Use >= so that a range equal to retention days uses aggregation (e.g., 7-day range with 7-day retention)
|
|
103
|
+
const isAggregated = dateRangeDays >= retentionConfig.rawRetentionDays;
|
|
95
104
|
|
|
96
105
|
// Query: Fetch raw data (when in raw mode)
|
|
106
|
+
// Use 'asc' order for chronological chart display (oldest first, newest last)
|
|
97
107
|
const {
|
|
98
108
|
data: rawData,
|
|
99
109
|
isLoading: rawLoading,
|
|
@@ -106,6 +116,7 @@ export function useHealthCheckData({
|
|
|
106
116
|
endDate: dateRange.endDate,
|
|
107
117
|
limit,
|
|
108
118
|
offset,
|
|
119
|
+
sortOrder: "asc",
|
|
109
120
|
},
|
|
110
121
|
{
|
|
111
122
|
enabled:
|
|
@@ -115,41 +126,55 @@ export function useHealthCheckData({
|
|
|
115
126
|
!accessLoading &&
|
|
116
127
|
!retentionLoading &&
|
|
117
128
|
!isAggregated,
|
|
129
|
+
// Keep previous data visible during refetch to prevent layout shift
|
|
130
|
+
placeholderData: (prev) => prev,
|
|
118
131
|
},
|
|
119
132
|
);
|
|
120
133
|
|
|
121
134
|
// Query: Fetch aggregated data (when in aggregated mode)
|
|
122
|
-
const {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
135
|
+
const {
|
|
136
|
+
data: aggregatedData,
|
|
137
|
+
isLoading: aggregatedLoading,
|
|
138
|
+
refetch: refetchAggregatedData,
|
|
139
|
+
} = healthCheckClient.getDetailedAggregatedHistory.useQuery(
|
|
140
|
+
{
|
|
141
|
+
systemId,
|
|
142
|
+
configurationId,
|
|
143
|
+
startDate: dateRange.startDate,
|
|
144
|
+
endDate: dateRange.endDate,
|
|
145
|
+
targetPoints: 500,
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
enabled:
|
|
149
|
+
!!systemId &&
|
|
150
|
+
!!configurationId &&
|
|
151
|
+
hasAccess &&
|
|
152
|
+
!accessLoading &&
|
|
153
|
+
!retentionLoading &&
|
|
154
|
+
isAggregated,
|
|
155
|
+
// Keep previous data visible during refetch to prevent layout shift
|
|
156
|
+
placeholderData: (prev) => prev,
|
|
157
|
+
},
|
|
158
|
+
);
|
|
141
159
|
|
|
142
160
|
// Listen for realtime health check updates to refresh data silently
|
|
143
161
|
useSignal(HEALTH_CHECK_RUN_COMPLETED, ({ systemId: changedId }) => {
|
|
144
|
-
// Only refresh if we're in raw mode (not aggregated) and have access
|
|
145
162
|
if (
|
|
146
163
|
changedId === systemId &&
|
|
147
164
|
hasAccess &&
|
|
148
165
|
!accessLoading &&
|
|
149
|
-
!retentionLoading
|
|
150
|
-
!isAggregated
|
|
166
|
+
!retentionLoading
|
|
151
167
|
) {
|
|
152
|
-
|
|
168
|
+
// Update endDate to current time only for rolling presets (not custom ranges)
|
|
169
|
+
if (isRollingPreset && onDateRangeRefresh) {
|
|
170
|
+
onDateRangeRefresh(new Date());
|
|
171
|
+
}
|
|
172
|
+
// Refetch the appropriate data
|
|
173
|
+
if (isAggregated) {
|
|
174
|
+
void refetchAggregatedData();
|
|
175
|
+
} else {
|
|
176
|
+
void refetchRawData();
|
|
177
|
+
}
|
|
153
178
|
}
|
|
154
179
|
});
|
|
155
180
|
|
|
@@ -185,6 +210,10 @@ export function useHealthCheckData({
|
|
|
185
210
|
}
|
|
186
211
|
|
|
187
212
|
if (isAggregated) {
|
|
213
|
+
// Don't create context with empty buckets during loading
|
|
214
|
+
if (aggregatedBuckets.length === 0) {
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
188
217
|
return {
|
|
189
218
|
type: "aggregated",
|
|
190
219
|
systemId,
|
|
@@ -194,6 +223,10 @@ export function useHealthCheckData({
|
|
|
194
223
|
};
|
|
195
224
|
}
|
|
196
225
|
|
|
226
|
+
// Don't create context with empty runs during loading
|
|
227
|
+
if (rawRuns.length === 0) {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
197
230
|
return {
|
|
198
231
|
type: "raw",
|
|
199
232
|
systemId,
|
|
@@ -213,14 +246,31 @@ export function useHealthCheckData({
|
|
|
213
246
|
aggregatedBuckets,
|
|
214
247
|
]);
|
|
215
248
|
|
|
216
|
-
|
|
249
|
+
// Keep previous valid context to prevent layout shift during refetch
|
|
250
|
+
const previousContextRef = useRef<
|
|
251
|
+
HealthCheckDiagramSlotContext | undefined
|
|
252
|
+
>();
|
|
253
|
+
if (context) {
|
|
254
|
+
previousContextRef.current = context;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const isQueryLoading =
|
|
217
258
|
accessLoading ||
|
|
218
259
|
retentionLoading ||
|
|
219
260
|
(isAggregated ? aggregatedLoading : rawLoading);
|
|
220
261
|
|
|
262
|
+
// Return previous context while loading to prevent layout shift
|
|
263
|
+
const stableContext =
|
|
264
|
+
context ?? (isQueryLoading ? previousContextRef.current : undefined);
|
|
265
|
+
|
|
266
|
+
// Only report loading when we don't have any context to show
|
|
267
|
+
// This prevents showing loading spinner during refetch when we have previous data
|
|
268
|
+
const loading = isQueryLoading && !stableContext;
|
|
269
|
+
|
|
221
270
|
return {
|
|
222
|
-
context,
|
|
271
|
+
context: stableContext,
|
|
223
272
|
loading,
|
|
273
|
+
isFetching: isQueryLoading,
|
|
224
274
|
isAggregated,
|
|
225
275
|
retentionConfig,
|
|
226
276
|
hasAccess,
|
package/src/index.tsx
CHANGED
|
@@ -59,6 +59,12 @@ export default createFrontendPlugin({
|
|
|
59
59
|
title: "Health Check Detail",
|
|
60
60
|
accessRule: healthCheckAccess.details,
|
|
61
61
|
},
|
|
62
|
+
{
|
|
63
|
+
route: healthcheckRoutes.routes.historyRun,
|
|
64
|
+
element: <HealthCheckHistoryDetailPage />,
|
|
65
|
+
title: "Health Check Run",
|
|
66
|
+
accessRule: healthCheckAccess.details,
|
|
67
|
+
},
|
|
62
68
|
],
|
|
63
69
|
// No APIs needed - components use usePluginClient() directly
|
|
64
70
|
apis: [],
|
|
@@ -22,21 +22,26 @@ import {
|
|
|
22
22
|
BackLink,
|
|
23
23
|
DateRangeFilter,
|
|
24
24
|
getDefaultDateRange,
|
|
25
|
+
HealthBadge,
|
|
25
26
|
type DateRange,
|
|
26
27
|
} from "@checkstack/ui";
|
|
27
|
-
import { useParams } from "react-router-dom";
|
|
28
|
-
import { History } from "lucide-react";
|
|
28
|
+
import { useParams, useNavigate } from "react-router-dom";
|
|
29
|
+
import { History, X } from "lucide-react";
|
|
30
|
+
import { format } from "date-fns";
|
|
29
31
|
import {
|
|
30
32
|
HealthCheckRunsTable,
|
|
31
33
|
type HealthCheckRunDetailed,
|
|
32
34
|
} from "../components/HealthCheckRunsTable";
|
|
35
|
+
import { ExpandedResultView } from "../components/ExpandedResultView";
|
|
33
36
|
|
|
34
37
|
const HealthCheckHistoryDetailPageContent = () => {
|
|
35
|
-
const { systemId, configurationId } = useParams<{
|
|
38
|
+
const { systemId, configurationId, runId } = useParams<{
|
|
36
39
|
systemId: string;
|
|
37
40
|
configurationId: string;
|
|
41
|
+
runId?: string;
|
|
38
42
|
}>();
|
|
39
43
|
|
|
44
|
+
const navigate = useNavigate();
|
|
40
45
|
const healthCheckClient = usePluginClient(HealthCheckApi);
|
|
41
46
|
const accessApi = useApi(accessApiRef);
|
|
42
47
|
const { allowed: canManage, loading: accessLoading } = accessApi.useAccess(
|
|
@@ -48,7 +53,17 @@ const HealthCheckHistoryDetailPageContent = () => {
|
|
|
48
53
|
// Pagination state
|
|
49
54
|
const pagination = usePagination({ defaultLimit: 20 });
|
|
50
55
|
|
|
51
|
-
// Fetch
|
|
56
|
+
// Fetch specific run if runId is provided
|
|
57
|
+
const { data: specificRun } = healthCheckClient.getRunById.useQuery(
|
|
58
|
+
{
|
|
59
|
+
runId: runId!,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
enabled: !!runId,
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// Fetch data with useQuery - newest first for table display
|
|
52
67
|
const { data, isLoading } = healthCheckClient.getDetailedHistory.useQuery({
|
|
53
68
|
systemId,
|
|
54
69
|
configurationId,
|
|
@@ -56,6 +71,7 @@ const HealthCheckHistoryDetailPageContent = () => {
|
|
|
56
71
|
endDate: dateRange.endDate,
|
|
57
72
|
limit: pagination.limit,
|
|
58
73
|
offset: pagination.offset,
|
|
74
|
+
sortOrder: "desc",
|
|
59
75
|
});
|
|
60
76
|
|
|
61
77
|
// Sync total from response
|
|
@@ -63,6 +79,17 @@ const HealthCheckHistoryDetailPageContent = () => {
|
|
|
63
79
|
|
|
64
80
|
const runs = (data?.runs ?? []) as HealthCheckRunDetailed[];
|
|
65
81
|
|
|
82
|
+
// Handler to dismiss the highlighted run
|
|
83
|
+
const dismissHighlightedRun = () => {
|
|
84
|
+
navigate(
|
|
85
|
+
resolveRoute(healthcheckRoutes.routes.historyDetail, {
|
|
86
|
+
systemId,
|
|
87
|
+
configurationId,
|
|
88
|
+
}),
|
|
89
|
+
{ replace: true },
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
|
|
66
93
|
return (
|
|
67
94
|
<PageLayout
|
|
68
95
|
title="Health Check Run History"
|
|
@@ -79,6 +106,33 @@ const HealthCheckHistoryDetailPageContent = () => {
|
|
|
79
106
|
</BackLink>
|
|
80
107
|
}
|
|
81
108
|
>
|
|
109
|
+
{/* Highlighted specific run when navigated with runId */}
|
|
110
|
+
{runId && specificRun && (
|
|
111
|
+
<Card className="mb-4 border-primary/50 bg-primary/5">
|
|
112
|
+
<CardHeader className="pb-2">
|
|
113
|
+
<div className="flex items-center justify-between">
|
|
114
|
+
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
115
|
+
<span>Selected Run</span>
|
|
116
|
+
<HealthBadge status={specificRun.status} />
|
|
117
|
+
</CardTitle>
|
|
118
|
+
<button
|
|
119
|
+
onClick={dismissHighlightedRun}
|
|
120
|
+
className="p-1 hover:bg-muted rounded"
|
|
121
|
+
title="Dismiss"
|
|
122
|
+
>
|
|
123
|
+
<X className="h-4 w-4 text-muted-foreground" />
|
|
124
|
+
</button>
|
|
125
|
+
</div>
|
|
126
|
+
<p className="text-xs text-muted-foreground">
|
|
127
|
+
{format(new Date(specificRun.timestamp), "PPpp")}
|
|
128
|
+
</p>
|
|
129
|
+
</CardHeader>
|
|
130
|
+
<CardContent>
|
|
131
|
+
<ExpandedResultView result={specificRun.result} />
|
|
132
|
+
</CardContent>
|
|
133
|
+
</Card>
|
|
134
|
+
)}
|
|
135
|
+
|
|
82
136
|
<Card>
|
|
83
137
|
<CardHeader>
|
|
84
138
|
<CardTitle>Run History</CardTitle>
|
|
@@ -33,10 +33,11 @@ const HealthCheckHistoryPageContent = () => {
|
|
|
33
33
|
// Pagination state
|
|
34
34
|
const pagination = usePagination({ defaultLimit: 20 });
|
|
35
35
|
|
|
36
|
-
// Fetch data with useQuery
|
|
36
|
+
// Fetch data with useQuery - newest first for table display
|
|
37
37
|
const { data, isLoading } = healthCheckClient.getDetailedHistory.useQuery({
|
|
38
38
|
limit: pagination.limit,
|
|
39
39
|
offset: pagination.offset,
|
|
40
|
+
sortOrder: "desc",
|
|
40
41
|
});
|
|
41
42
|
|
|
42
43
|
// Sync total from response
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sparkline downsampling utilities for managing large datasets in visualizations.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Maximum number of bars to show in a sparkline for readability */
|
|
6
|
+
export const MAX_SPARKLINE_BARS = 60;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A downsampled bucket containing multiple items aggregated together.
|
|
10
|
+
*/
|
|
11
|
+
export interface DownsampledBucket<T> {
|
|
12
|
+
/** The original items in this bucket */
|
|
13
|
+
items: T[];
|
|
14
|
+
/** Whether all items in the bucket "passed" (for pass/fail type data) */
|
|
15
|
+
passed: boolean;
|
|
16
|
+
/** Time label for this bucket (range if multiple items) */
|
|
17
|
+
timeLabel?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Downsample sparkline data to ensure readability.
|
|
22
|
+
* Groups consecutive items into buckets, representing each bucket with
|
|
23
|
+
* a "worst case" status (if any item failed, bucket shows failed).
|
|
24
|
+
*
|
|
25
|
+
* @param items - Array of items to downsample
|
|
26
|
+
* @param options - Configuration options
|
|
27
|
+
* @returns Array of downsampled buckets
|
|
28
|
+
*/
|
|
29
|
+
export function downsampleSparkline<
|
|
30
|
+
T extends { passed?: boolean; value?: boolean; timeLabel?: string },
|
|
31
|
+
>(
|
|
32
|
+
items: T[],
|
|
33
|
+
options: {
|
|
34
|
+
maxBars?: number;
|
|
35
|
+
/** Custom function to determine if an item "passed" */
|
|
36
|
+
getPassed?: (item: T) => boolean;
|
|
37
|
+
} = {},
|
|
38
|
+
): DownsampledBucket<T>[] {
|
|
39
|
+
const { maxBars = MAX_SPARKLINE_BARS, getPassed } = options;
|
|
40
|
+
|
|
41
|
+
const getItemPassed =
|
|
42
|
+
getPassed ?? ((item: T) => item.passed ?? item.value ?? true);
|
|
43
|
+
|
|
44
|
+
if (items.length <= maxBars) {
|
|
45
|
+
// No downsampling needed - return original items wrapped
|
|
46
|
+
return items.map((item) => ({
|
|
47
|
+
items: [item],
|
|
48
|
+
passed: getItemPassed(item),
|
|
49
|
+
timeLabel: item.timeLabel,
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const bucketSize = Math.ceil(items.length / maxBars);
|
|
54
|
+
const buckets: DownsampledBucket<T>[] = [];
|
|
55
|
+
|
|
56
|
+
for (let i = 0; i < items.length; i += bucketSize) {
|
|
57
|
+
const bucketItems = items.slice(i, i + bucketSize);
|
|
58
|
+
// Bucket is "failed" if any item failed (worst case visualization)
|
|
59
|
+
const passed = bucketItems.every((item) => getItemPassed(item));
|
|
60
|
+
|
|
61
|
+
const firstItem = bucketItems[0];
|
|
62
|
+
const lastItem = bucketItems.at(-1);
|
|
63
|
+
const startLabel = firstItem?.timeLabel;
|
|
64
|
+
const endLabel = lastItem?.timeLabel;
|
|
65
|
+
|
|
66
|
+
buckets.push({
|
|
67
|
+
items: bucketItems,
|
|
68
|
+
passed,
|
|
69
|
+
timeLabel:
|
|
70
|
+
startLabel && endLabel && startLabel !== endLabel
|
|
71
|
+
? `${startLabel} - ${endLabel}`
|
|
72
|
+
: startLabel,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return buckets;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Calculate the bucket size needed for a given item count.
|
|
81
|
+
* Returns 1 if no downsampling is needed.
|
|
82
|
+
*/
|
|
83
|
+
export function getDownsampleBucketSize(
|
|
84
|
+
itemCount: number,
|
|
85
|
+
maxBars: number = MAX_SPARKLINE_BARS,
|
|
86
|
+
): number {
|
|
87
|
+
return itemCount <= maxBars ? 1 : Math.ceil(itemCount / maxBars);
|
|
88
|
+
}
|