@checkstack/healthcheck-frontend 0.4.9 → 0.5.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,110 @@
1
1
  # @checkstack/healthcheck-frontend
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ac3a4cf: ### Dynamic Bucket Sizing for Health Check Visualization
8
+
9
+ Implements industry-standard dynamic bucket sizing for health check data aggregation, following patterns from Grafana/VictoriaMetrics.
10
+
11
+ **What changed:**
12
+
13
+ - Replaced fixed `bucketSize: "hourly" | "daily" | "auto"` with dynamic `targetPoints` parameter (default: 500)
14
+ - Bucket interval is now calculated as `(endDate - startDate) / targetPoints` with a minimum of 1 second
15
+ - Added `bucketIntervalSeconds` to aggregated response and individual buckets
16
+ - Updated chart components to use dynamic time formatting based on bucket interval
17
+
18
+ **Why:**
19
+
20
+ - A 24-hour view with 1-second health checks previously returned 86,400+ data points, causing lag
21
+ - Now returns ~500 data points regardless of timeframe, ensuring consistent chart performance
22
+ - Charts still preserve visual fidelity through proper aggregation
23
+
24
+ **Breaking Change:**
25
+
26
+ - `bucketSize` parameter removed from `getAggregatedHistory` and `getDetailedAggregatedHistory` endpoints
27
+ - Use `targetPoints` instead (defaults to 500 if not specified)
28
+
29
+ ***
30
+
31
+ ### Collector Aggregated Charts Fix
32
+
33
+ Fixed issue where collector auto-charts (like HTTP request response time charts) were not showing in aggregated data mode.
34
+
35
+ **What changed:**
36
+
37
+ - Added `aggregatedResultSchema` to `CollectorDtoSchema`
38
+ - Backend now returns collector aggregated schemas via `getCollectors` endpoint
39
+ - Frontend `useStrategySchemas` hook now merges collector aggregated schemas
40
+ - Service now calls each collector's `aggregateResult()` when building buckets
41
+ - Aggregated collector data stored in `aggregatedResult.collectors[uuid]`
42
+
43
+ **Why:**
44
+
45
+ - Previously only strategy-level aggregated results were computed
46
+ - Collectors like HTTP Request Collector have their own `aggregateResult` method
47
+ - Without calling these, fields like `avgResponseTimeMs` and `successRate` were missing from aggregated buckets
48
+
49
+ ### Patch Changes
50
+
51
+ - 095cf4e: ### Cross-Tier Data Aggregation
52
+
53
+ Implements intelligent cross-tier querying for health check history, enabling seamless data retrieval across raw, hourly, and daily storage tiers.
54
+
55
+ **What changed:**
56
+
57
+ - `getAggregatedHistory` now queries all three tiers (raw, hourly, daily) in parallel
58
+ - Added `NormalizedBucket` type for unified bucket format across tiers
59
+ - Added `mergeTieredBuckets()` to merge data with priority (raw > hourly > daily)
60
+ - Added `combineBuckets()` and `reaggregateBuckets()` for re-aggregation to target bucket size
61
+ - Raw data preserves full granularity when available (uses target bucket interval)
62
+
63
+ **Why:**
64
+
65
+ - Previously, the API only queried raw runs, which are retained for a limited period (default 7 days)
66
+ - For longer time ranges, data was missing because hourly/daily aggregates weren't queried
67
+ - The retention job only runs periodically, so we can't assume tier boundaries based on config
68
+ - Querying all tiers ensures no gaps in data coverage
69
+
70
+ **Technical details:**
71
+
72
+ - Additive metrics (counts, latencySum) are summed correctly for accurate averages
73
+ - p95 latency uses max of source p95s as conservative upper-bound approximation
74
+ - `aggregatedResult` (strategy-specific) is preserved for raw-only buckets
75
+
76
+ - 538e45d: Fixed 24-hour date range not returning correct data and improved chart display
77
+
78
+ - Fixed missing `endDate` parameter in raw data queries causing data to extend beyond selected time range
79
+ - Fixed incorrect 24-hour date calculation using `setHours()` - now uses `date-fns` `subHours()` for correct date math
80
+ - Refactored `DateRangePreset` from string union to enum for improved type safety and IDE support
81
+ - Exported `getPresetRange` function for reuse across components
82
+ - Changed chart x-axis domain from `["auto", "auto"]` to `["dataMin", "dataMax"]` to remove padding gaps
83
+
84
+ - Updated dependencies [ac3a4cf]
85
+ - Updated dependencies [db1f56f]
86
+ - Updated dependencies [538e45d]
87
+ - @checkstack/healthcheck-common@0.5.0
88
+ - @checkstack/common@0.6.0
89
+ - @checkstack/ui@0.4.1
90
+ - @checkstack/dashboard-frontend@0.3.9
91
+ - @checkstack/auth-frontend@0.5.4
92
+ - @checkstack/catalog-common@1.2.4
93
+ - @checkstack/frontend-api@0.3.3
94
+ - @checkstack/signal-frontend@0.0.10
95
+
96
+ ## 0.4.10
97
+
98
+ ### Patch Changes
99
+
100
+ - d1324e6: Removed redundant inner scroll wrapper from HealthCheckEditor - Dialog now handles scrolling
101
+ - Updated dependencies [d1324e6]
102
+ - Updated dependencies [1f1f6c2]
103
+ - Updated dependencies [2c0822d]
104
+ - @checkstack/ui@0.4.0
105
+ - @checkstack/dashboard-frontend@0.3.8
106
+ - @checkstack/auth-frontend@0.5.3
107
+
3
108
  ## 0.4.9
4
109
 
5
110
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-frontend",
3
- "version": "0.4.9",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "main": "src/index.tsx",
6
6
  "scripts": {
@@ -38,7 +38,7 @@ export function useStrategySchemas(strategyId: string): {
38
38
  // Fetch collectors with useQuery
39
39
  const { data: collectors } = healthCheckClient.getCollectors.useQuery(
40
40
  { strategyId },
41
- { enabled: !!strategyId }
41
+ { enabled: !!strategyId },
42
42
  );
43
43
 
44
44
  useEffect(() => {
@@ -51,22 +51,34 @@ export function useStrategySchemas(strategyId: string): {
51
51
  if (strategy) {
52
52
  // Build collector schemas object for nesting under resultSchema.properties.collectors
53
53
  const collectorProperties: Record<string, unknown> = {};
54
+ const collectorAggregatedProperties: Record<string, unknown> = {};
55
+
54
56
  for (const collector of collectors) {
55
57
  // Use full ID so it matches stored data keys like "healthcheck-http.request"
56
58
  collectorProperties[collector.id] = collector.resultSchema;
59
+
60
+ // Also collect aggregated schemas if available
61
+ if (collector.aggregatedResultSchema) {
62
+ collectorAggregatedProperties[collector.id] =
63
+ collector.aggregatedResultSchema;
64
+ }
57
65
  }
58
66
 
59
67
  // Merge collector schemas into strategy result schema
60
68
  const mergedResultSchema = mergeCollectorSchemas(
61
69
  strategy.resultSchema as Record<string, unknown> | undefined,
62
- collectorProperties
70
+ collectorProperties,
71
+ );
72
+
73
+ // Merge collector aggregated schemas into strategy aggregated schema
74
+ const mergedAggregatedSchema = mergeCollectorSchemas(
75
+ strategy.aggregatedResultSchema as Record<string, unknown> | undefined,
76
+ collectorAggregatedProperties,
63
77
  );
64
78
 
65
79
  setSchemas({
66
80
  resultSchema: mergedResultSchema,
67
- aggregatedResultSchema:
68
- (strategy.aggregatedResultSchema as Record<string, unknown>) ??
69
- undefined,
81
+ aggregatedResultSchema: mergedAggregatedSchema,
70
82
  });
71
83
  }
72
84
 
@@ -85,7 +97,7 @@ export function useStrategySchemas(strategyId: string): {
85
97
  */
86
98
  function mergeCollectorSchemas(
87
99
  strategySchema: Record<string, unknown> | undefined,
88
- collectorProperties: Record<string, unknown>
100
+ collectorProperties: Record<string, unknown>,
89
101
  ): Record<string, unknown> | undefined {
90
102
  // If no collectors, return original schema
91
103
  if (Object.keys(collectorProperties).length === 0) {
@@ -1,24 +1,67 @@
1
1
  import { InfoBanner } from "@checkstack/ui";
2
2
 
3
3
  interface AggregatedDataBannerProps {
4
- bucketSize: "hourly" | "daily";
5
- rawRetentionDays: number;
4
+ /** Bucket interval in seconds */
5
+ bucketIntervalSeconds: number;
6
+ /** The configured check interval in seconds (optional, for comparison) */
7
+ checkIntervalSeconds?: number;
6
8
  }
7
9
 
10
+ /**
11
+ * Format seconds into a human-readable duration string.
12
+ */
13
+ function formatDuration(seconds: number): string {
14
+ if (seconds < 60) {
15
+ return `${seconds}s`;
16
+ }
17
+ if (seconds < 3600) {
18
+ const mins = Math.round(seconds / 60);
19
+ return `${mins}min`;
20
+ }
21
+ const hours = Math.round(seconds / 3600);
22
+ return `${hours}h`;
23
+ }
24
+
25
+ /**
26
+ * Get resolution tier label based on bucket interval.
27
+ */
28
+ function getResolutionTier(
29
+ bucketIntervalSeconds: number,
30
+ ): "high" | "medium" | "low" {
31
+ if (bucketIntervalSeconds >= 86_400) {
32
+ return "low"; // Daily aggregates
33
+ }
34
+ if (bucketIntervalSeconds >= 3600) {
35
+ return "medium"; // Hourly aggregates
36
+ }
37
+ return "high"; // Raw data
38
+ }
39
+
40
+ const TIER_LABELS: Record<"high" | "medium" | "low", string> = {
41
+ high: "High resolution",
42
+ medium: "Medium resolution",
43
+ low: "Low resolution",
44
+ };
45
+
8
46
  /**
9
47
  * Banner shown when viewing aggregated health check data.
10
- * Informs users about the aggregation level and how to see detailed data.
48
+ * Informs users about the aggregation level due to high data volume.
11
49
  */
12
50
  export function AggregatedDataBanner({
13
- bucketSize,
14
- rawRetentionDays,
51
+ bucketIntervalSeconds,
52
+ checkIntervalSeconds,
15
53
  }: AggregatedDataBannerProps) {
16
- const bucketLabel = bucketSize === "hourly" ? "hourly" : "daily";
54
+ // Only show if bucket interval is larger than check interval (data is being aggregated)
55
+ if (checkIntervalSeconds && bucketIntervalSeconds <= checkIntervalSeconds) {
56
+ return;
57
+ }
58
+
59
+ const tier = getResolutionTier(bucketIntervalSeconds);
17
60
 
18
61
  return (
19
62
  <InfoBanner variant="info">
20
- Showing {bucketLabel} aggregates. For per-run data, select a range ≤{" "}
21
- {rawRetentionDays} days.
63
+ {TIER_LABELS[tier]} Data aggregated into{" "}
64
+ {formatDuration(bucketIntervalSeconds)} intervals
22
65
  </InfoBanner>
23
66
  );
24
67
  }
@@ -11,8 +11,10 @@ interface HealthCheckDiagramProps {
11
11
  context: HealthCheckDiagramSlotContext;
12
12
  /** Whether the data is aggregated (for showing the info banner) */
13
13
  isAggregated?: boolean;
14
- /** Raw retention days (for the info banner) */
15
- rawRetentionDays?: number;
14
+ /** The bucket interval in seconds (from aggregated response) */
15
+ bucketIntervalSeconds?: number;
16
+ /** The check interval in seconds (for comparison in banner) */
17
+ checkIntervalSeconds?: number;
16
18
  }
17
19
 
18
20
  /**
@@ -22,20 +24,15 @@ interface HealthCheckDiagramProps {
22
24
  export function HealthCheckDiagram({
23
25
  context,
24
26
  isAggregated = false,
25
- rawRetentionDays = 7,
27
+ bucketIntervalSeconds,
28
+ checkIntervalSeconds,
26
29
  }: HealthCheckDiagramProps) {
27
- // Determine bucket size from context for aggregated data info banner
28
- const bucketSize =
29
- context.type === "aggregated" && context.buckets.length > 0
30
- ? context.buckets[0].bucketSize
31
- : "hourly";
32
-
33
30
  return (
34
31
  <>
35
- {isAggregated && (
32
+ {isAggregated && bucketIntervalSeconds && (
36
33
  <AggregatedDataBanner
37
- bucketSize={bucketSize}
38
- rawRetentionDays={rawRetentionDays}
34
+ bucketIntervalSeconds={bucketIntervalSeconds}
35
+ checkIntervalSeconds={checkIntervalSeconds}
39
36
  />
40
37
  )}
41
38
  <ExtensionSlot slot={HealthCheckDiagramSlot} context={context} />
@@ -40,13 +40,13 @@ export const HealthCheckEditor: React.FC<HealthCheckEditorProps> = ({
40
40
  const [name, setName] = useState(initialData?.name || "");
41
41
  const [strategyId, setStrategyId] = useState(initialData?.strategyId || "");
42
42
  const [interval, setInterval] = useState(
43
- initialData?.intervalSeconds?.toString() || "60"
43
+ initialData?.intervalSeconds?.toString() || "60",
44
44
  );
45
45
  const [config, setConfig] = useState<Record<string, unknown>>(
46
- (initialData?.config as Record<string, unknown>) || {}
46
+ (initialData?.config as Record<string, unknown>) || {},
47
47
  );
48
48
  const [collectors, setCollectors] = useState<CollectorConfigEntry[]>(
49
- initialData?.collectors || []
49
+ initialData?.collectors || [],
50
50
  );
51
51
 
52
52
  const toast = useToast();
@@ -111,7 +111,7 @@ export const HealthCheckEditor: React.FC<HealthCheckEditorProps> = ({
111
111
  </DialogDescription>
112
112
  </DialogHeader>
113
113
 
114
- <div className="space-y-6 py-4 max-h-[70vh] overflow-y-auto">
114
+ <div className="space-y-6 py-4">
115
115
  <div className="space-y-2">
116
116
  <Label htmlFor="name">Name</Label>
117
117
  <Input
@@ -50,8 +50,11 @@ export const HealthCheckLatencyChart: React.FC<
50
50
  ? chartData.reduce((sum, d) => sum + d.latencyMs, 0) / chartData.length
51
51
  : 0;
52
52
 
53
+ // Use daily format for intervals >= 6 hours, otherwise include time
53
54
  const timeFormat =
54
- buckets[0]?.bucketSize === "daily" ? "MMM d" : "MMM d HH:mm";
55
+ (buckets[0]?.bucketIntervalSeconds ?? 3600) >= 21_600
56
+ ? "MMM d"
57
+ : "MMM d HH:mm";
55
58
 
56
59
  return (
57
60
  <ResponsiveContainer width="100%" height={height}>
@@ -73,7 +76,7 @@ export const HealthCheckLatencyChart: React.FC<
73
76
  <XAxis
74
77
  dataKey="timestamp"
75
78
  type="number"
76
- domain={["auto", "auto"]}
79
+ domain={["dataMin", "dataMax"]}
77
80
  tickFormatter={(ts: number) => format(new Date(ts), timeFormat)}
78
81
  stroke="hsl(var(--muted-foreground))"
79
82
  fontSize={12}
@@ -172,7 +175,7 @@ export const HealthCheckLatencyChart: React.FC<
172
175
  <XAxis
173
176
  dataKey="timestamp"
174
177
  type="number"
175
- domain={["auto", "auto"]}
178
+ domain={["dataMin", "dataMax"]}
176
179
  tickFormatter={(ts: number) => format(new Date(ts), "HH:mm")}
177
180
  stroke="hsl(var(--muted-foreground))"
178
181
  fontSize={12}
@@ -50,8 +50,11 @@ export const HealthCheckStatusTimeline: React.FC<
50
50
  total: d.runCount,
51
51
  }));
52
52
 
53
+ // Use daily format for intervals >= 6 hours, otherwise include time
53
54
  const timeFormat =
54
- buckets[0]?.bucketSize === "daily" ? "MMM d" : "MMM d HH:mm";
55
+ (buckets[0]?.bucketIntervalSeconds ?? 3600) >= 21_600
56
+ ? "MMM d"
57
+ : "MMM d HH:mm";
55
58
 
56
59
  return (
57
60
  <ResponsiveContainer width="100%" height={height}>
@@ -59,7 +62,7 @@ export const HealthCheckStatusTimeline: React.FC<
59
62
  <XAxis
60
63
  dataKey="timestamp"
61
64
  type="number"
62
- domain={["auto", "auto"]}
65
+ domain={["dataMin", "dataMax"]}
63
66
  tickFormatter={(ts: number) => format(new Date(ts), timeFormat)}
64
67
  stroke="hsl(var(--muted-foreground))"
65
68
  fontSize={10}
@@ -133,7 +136,7 @@ export const HealthCheckStatusTimeline: React.FC<
133
136
  <XAxis
134
137
  dataKey="timestamp"
135
138
  type="number"
136
- domain={["auto", "auto"]}
139
+ domain={["dataMin", "dataMax"]}
137
140
  tickFormatter={(ts: number) => format(new Date(ts), "HH:mm")}
138
141
  stroke="hsl(var(--muted-foreground))"
139
142
  fontSize={10}
@@ -20,6 +20,8 @@ import {
20
20
  usePagination,
21
21
  usePaginationSync,
22
22
  DateRangeFilter,
23
+ getPresetRange,
24
+ DateRangePreset,
23
25
  } from "@checkstack/ui";
24
26
  import { formatDistanceToNow } from "date-fns";
25
27
  import { ChevronDown, ChevronRight } from "lucide-react";
@@ -55,17 +57,10 @@ interface ExpandedRowProps {
55
57
  const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
56
58
  const healthCheckClient = usePluginClient(HealthCheckApi);
57
59
 
58
- // Date range state for filtering
59
- const [dateRange, setDateRange] = useState<{
60
- startDate: Date;
61
- endDate: Date;
62
- }>(() => {
63
- // Default to last 24 hours
64
- const end = new Date();
65
- const start = new Date();
66
- start.setHours(start.getHours() - 24);
67
- return { startDate: start, endDate: end };
68
- });
60
+ // Date range state for filtering - default to last 24 hours
61
+ const [dateRange, setDateRange] = useState(() =>
62
+ getPresetRange(DateRangePreset.Last24Hours),
63
+ );
69
64
 
70
65
  // Use shared hook for chart data - handles both raw and aggregated modes
71
66
  // and includes signal handling for automatic refresh
@@ -73,7 +68,7 @@ const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
73
68
  context: chartContext,
74
69
  loading: chartLoading,
75
70
  isAggregated,
76
- retentionConfig,
71
+ bucketIntervalSeconds,
77
72
  } = useHealthCheckData({
78
73
  systemId,
79
74
  configurationId: item.configurationId,
@@ -160,7 +155,8 @@ const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
160
155
  <HealthCheckDiagram
161
156
  context={chartContext}
162
157
  isAggregated={isAggregated}
163
- rawRetentionDays={retentionConfig.rawRetentionDays}
158
+ bucketIntervalSeconds={bucketIntervalSeconds}
159
+ checkIntervalSeconds={item.intervalSeconds}
164
160
  />
165
161
  </div>
166
162
  );
@@ -281,8 +277,8 @@ export function HealthCheckSystemOverview(props: SlotProps) {
281
277
  void refetch();
282
278
  }
283
279
  },
284
- [systemId, refetch]
285
- )
280
+ [systemId, refetch],
281
+ ),
286
282
  );
287
283
 
288
284
  if (initialLoading) {
@@ -44,6 +44,8 @@ interface UseHealthCheckDataResult {
44
44
  hasAccess: boolean;
45
45
  /** Whether access is still loading */
46
46
  accessLoading: boolean;
47
+ /** Bucket interval in seconds (only for aggregated mode) */
48
+ bucketIntervalSeconds: number | undefined;
47
49
  }
48
50
 
49
51
  /**
@@ -67,14 +69,14 @@ export function useHealthCheckData({
67
69
 
68
70
  // Access state
69
71
  const { allowed: hasAccess, loading: accessLoading } = accessApi.useAccess(
70
- healthCheckAccess.details
72
+ healthCheckAccess.details,
71
73
  );
72
74
 
73
75
  // Calculate date range in days
74
76
  const dateRangeDays = useMemo(() => {
75
77
  return Math.ceil(
76
78
  (dateRange.endDate.getTime() - dateRange.startDate.getTime()) /
77
- (1000 * 60 * 60 * 24)
79
+ (1000 * 60 * 60 * 24),
78
80
  );
79
81
  }, [dateRange.startDate, dateRange.endDate]);
80
82
 
@@ -82,7 +84,7 @@ export function useHealthCheckData({
82
84
  const { data: retentionData, isLoading: retentionLoading } =
83
85
  healthCheckClient.getRetentionConfig.useQuery(
84
86
  { systemId, configurationId },
85
- { enabled: !!systemId && !!configurationId && hasAccess }
87
+ { enabled: !!systemId && !!configurationId && hasAccess },
86
88
  );
87
89
 
88
90
  const retentionConfig =
@@ -101,6 +103,7 @@ export function useHealthCheckData({
101
103
  systemId,
102
104
  configurationId,
103
105
  startDate: dateRange.startDate,
106
+ endDate: dateRange.endDate,
104
107
  limit,
105
108
  offset,
106
109
  },
@@ -112,11 +115,10 @@ export function useHealthCheckData({
112
115
  !accessLoading &&
113
116
  !retentionLoading &&
114
117
  !isAggregated,
115
- }
118
+ },
116
119
  );
117
120
 
118
121
  // Query: Fetch aggregated data (when in aggregated mode)
119
- const bucketSize = dateRangeDays > 30 ? "daily" : "hourly";
120
122
  const { data: aggregatedData, isLoading: aggregatedLoading } =
121
123
  healthCheckClient.getDetailedAggregatedHistory.useQuery(
122
124
  {
@@ -124,7 +126,7 @@ export function useHealthCheckData({
124
126
  configurationId,
125
127
  startDate: dateRange.startDate,
126
128
  endDate: dateRange.endDate,
127
- bucketSize,
129
+ targetPoints: 500,
128
130
  },
129
131
  {
130
132
  enabled:
@@ -134,7 +136,7 @@ export function useHealthCheckData({
134
136
  !accessLoading &&
135
137
  !retentionLoading &&
136
138
  isAggregated,
137
- }
139
+ },
138
140
  );
139
141
 
140
142
  // Listen for realtime health check updates to refresh data silently
@@ -223,5 +225,6 @@ export function useHealthCheckData({
223
225
  retentionConfig,
224
226
  hasAccess,
225
227
  accessLoading,
228
+ bucketIntervalSeconds: aggregatedData?.bucketIntervalSeconds,
226
229
  };
227
230
  }