@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.
@@ -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
- const isAggregated = dateRangeDays > retentionConfig.rawRetentionDays;
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 { data: aggregatedData, isLoading: aggregatedLoading } =
123
- healthCheckClient.getDetailedAggregatedHistory.useQuery(
124
- {
125
- systemId,
126
- configurationId,
127
- startDate: dateRange.startDate,
128
- endDate: dateRange.endDate,
129
- targetPoints: 500,
130
- },
131
- {
132
- enabled:
133
- !!systemId &&
134
- !!configurationId &&
135
- hasAccess &&
136
- !accessLoading &&
137
- !retentionLoading &&
138
- isAggregated,
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
- void refetchRawData();
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
- const loading =
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 data with useQuery
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
+ }