@checkstack/healthcheck-frontend 0.8.2 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # @checkstack/healthcheck-frontend
2
2
 
3
+ ## 0.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 1b9cb25: Unified chart data to always use aggregated history with fixed target points.
8
+
9
+ **Breaking Changes:**
10
+
11
+ - Removed `RawDiagramContext` type - chart context no longer has a `type` discriminator
12
+ - Removed `TypedHealthCheckRun` type export - charts only use aggregated buckets now
13
+ - Removed `createStrategyDiagramExtension` deprecated function
14
+ - Removed `isAggregated` and `retentionConfig` from `useHealthCheckData` return value
15
+
16
+ **Migration:**
17
+
18
+ - Strategy diagram extensions should use `createDiagramExtensionFactory` instead of `createStrategyDiagramExtension`
19
+ - Extensions no longer need separate `rawComponent` and `aggregatedComponent` - use a single `component` prop
20
+ - `HealthCheckDiagramSlotContext` now always contains `buckets` array (no `type` field)
21
+
22
+ **Benefits:**
23
+
24
+ - Simplified frontend logic - no more mode switching based on retention config
25
+ - Consistent chart visualization regardless of selected time range
26
+ - Backend's cross-tier aggregation engine automatically selects optimal data source
27
+
28
+ **Other Changes:**
29
+
30
+ - Added warning message when configuring sub-minute check intervals, alerting users about potential performance implications
31
+
32
+ ### Patch Changes
33
+
34
+ - f1ebac2: - Fixed raw data visualization being cut off when viewing "Last 24 hours" timeframe. The `useHealthCheckData` hook was incorrectly applying pagination limits to chart data queries, causing only the oldest runs to be displayed when there were more runs than the limit. Charts now fetch all runs within the selected date range.
35
+ - Updated Status Timeline visualization for raw data to show stacked status distribution (green/yellow/red proportions) instead of the previous "worst status wins" approach. This makes the raw data view consistent with the aggregated data view.
36
+
3
37
  ## 0.8.2
4
38
 
5
39
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-frontend",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "main": "src/index.tsx",
6
6
  "scripts": {
@@ -9,7 +9,6 @@ import type { ChartField } from "./schema-parser";
9
9
  import { extractChartFields, getFieldValue } from "./schema-parser";
10
10
  import { useStrategySchemas } from "./useStrategySchemas";
11
11
  import type { HealthCheckDiagramSlotContext } from "../slots";
12
- import type { StoredHealthCheckResult } from "@checkstack/healthcheck-common";
13
12
  import { SparklineTooltip } from "../components/SparklineTooltip";
14
13
  import { Card, CardContent, CardHeader, CardTitle } from "@checkstack/ui";
15
14
  import {
@@ -28,10 +27,7 @@ import {
28
27
  RadialBar,
29
28
  } from "recharts";
30
29
  import { format } from "date-fns";
31
- import {
32
- downsampleSparkline,
33
- MAX_SPARKLINE_BARS,
34
- } from "../utils/sparkline-downsampling";
30
+ import { MAX_SPARKLINE_BARS } from "../utils/sparkline-downsampling";
35
31
 
36
32
  interface AutoChartGridProps {
37
33
  context: HealthCheckDiagramSlotContext;
@@ -54,11 +50,8 @@ export function AutoChartGrid({ context }: AutoChartGridProps) {
54
50
  return;
55
51
  }
56
52
 
57
- // Choose schema based on context type
58
- const schema =
59
- context.type === "raw"
60
- ? schemas.resultSchema
61
- : schemas.aggregatedResultSchema;
53
+ // Always use aggregated result schema
54
+ const schema = schemas.aggregatedResultSchema;
62
55
 
63
56
  if (!schema) {
64
57
  return;
@@ -211,38 +204,35 @@ function CollectorGroup({
211
204
  /**
212
205
  * Get all assertion results for a specific collector instance.
213
206
  * Returns array of results with timestamps/time spans in chronological order.
214
- *
215
- * For raw data: extracts run status with timestamp.
216
- * For aggregated data: uses bucket counts with time span.
207
+ * Uses bucket counts with time span from aggregated data.
217
208
  */
218
209
  function getAllAssertionResults(
219
210
  context: HealthCheckDiagramSlotContext,
220
211
  _instanceKey: string,
221
212
  ): { passed: boolean; errorMessage?: string; timeLabel?: string }[] {
222
- if (context.type === "raw") {
223
- return context.runs.map((run) => {
224
- const result = run.result as StoredHealthCheckResult | undefined;
225
- const isUnhealthy = result?.status === "unhealthy";
226
- return {
227
- passed: !isUnhealthy,
228
- errorMessage: isUnhealthy ? result?.message : undefined,
229
- timeLabel: format(new Date(run.timestamp), "MMM d, HH:mm:ss"),
230
- };
231
- });
232
- }
233
-
234
- // For aggregated data, return one result per bucket with time span
235
213
  return context.buckets.map((bucket) => {
236
214
  const failedCount = bucket.degradedCount + bucket.unhealthyCount;
237
215
  const passed = failedCount === 0;
238
216
  const bucketStart = new Date(bucket.bucketStart);
239
217
  const bucketEnd = new Date(bucket.bucketEnd);
240
218
  const timeSpan = `${format(bucketStart, "MMM d, HH:mm")} - ${format(bucketEnd, "HH:mm")}`;
219
+
220
+ // Build detailed error message showing breakdown by type
221
+ let errorMessage: string | undefined;
222
+ if (!passed) {
223
+ const parts: string[] = [];
224
+ if (bucket.unhealthyCount > 0) {
225
+ parts.push(`${bucket.unhealthyCount} unhealthy`);
226
+ }
227
+ if (bucket.degradedCount > 0) {
228
+ parts.push(`${bucket.degradedCount} degraded`);
229
+ }
230
+ errorMessage = `${parts.join(", ")} of ${bucket.runCount} runs`;
231
+ }
232
+
241
233
  return {
242
234
  passed,
243
- errorMessage: passed
244
- ? undefined
245
- : `${failedCount} failed of ${bucket.runCount}`,
235
+ errorMessage,
246
236
  timeLabel: timeSpan,
247
237
  };
248
238
  });
@@ -319,39 +309,30 @@ function AssertionStatusCard({
319
309
  </div>
320
310
  )}
321
311
 
322
- {/* Sparkline timeline - always show for historical context */}
323
- {(() => {
324
- const buckets = downsampleSparkline(results);
325
- return (
326
- <div className="flex h-2 gap-px rounded">
327
- {buckets.map((bucket, index) => {
328
- const passedCount = bucket.items.filter((r) => r.passed).length;
329
- const failedCount = bucket.items.length - passedCount;
330
- const tooltip = bucket.timeLabel
331
- ? bucket.items.length > 1
332
- ? `${bucket.timeLabel}\n${passedCount} passed, ${failedCount} failed`
333
- : `${bucket.timeLabel}\n${bucket.passed ? "Passed" : "Failed"}`
334
- : bucket.passed
335
- ? "Passed"
336
- : "Failed";
337
- return (
338
- <SparklineTooltip key={index} content={tooltip}>
339
- <div
340
- className={`flex-1 h-full ${bucket.passed ? "bg-green-500" : "bg-red-500"} hover:opacity-80`}
341
- />
342
- </SparklineTooltip>
343
- );
344
- })}
345
- </div>
346
- );
347
- })()}
312
+ {/* Sparkline timeline - render each bucket as a bar */}
313
+ <div className="flex h-2 gap-px rounded">
314
+ {results.map((result, index) => {
315
+ const tooltip = result.timeLabel
316
+ ? `${result.timeLabel}\n${result.passed ? "Passed" : result.errorMessage || "Failed"}`
317
+ : result.passed
318
+ ? "Passed"
319
+ : "Failed";
320
+ return (
321
+ <SparklineTooltip key={index} content={tooltip}>
322
+ <div
323
+ className={`flex-1 h-full ${result.passed ? "bg-green-500" : "bg-red-500"} hover:opacity-80`}
324
+ />
325
+ </SparklineTooltip>
326
+ );
327
+ })}
328
+ </div>
348
329
  </CardContent>
349
330
  </Card>
350
331
  );
351
332
  }
352
333
 
353
334
  /**
354
- * Discover collector instances from actual run data.
335
+ * Discover collector instances from aggregated bucket data.
355
336
  * Returns a map from base collector ID (type) to array of instance UUIDs.
356
337
  */
357
338
  function discoverCollectorInstances(
@@ -376,21 +357,11 @@ function discoverCollectorInstances(
376
357
  }
377
358
  };
378
359
 
379
- if (context.type === "raw") {
380
- for (const run of context.runs) {
381
- const result = run.result as StoredHealthCheckResult | undefined;
382
- const collectors = result?.metadata?.collectors as
383
- | Record<string, unknown>
384
- | undefined;
385
- addInstances(collectors);
386
- }
387
- } else {
388
- for (const bucket of context.buckets) {
389
- const collectors = (
390
- bucket.aggregatedResult as Record<string, unknown> | undefined
391
- )?.collectors as Record<string, unknown> | undefined;
392
- addInstances(collectors);
393
- }
360
+ for (const bucket of context.buckets) {
361
+ const collectors = (
362
+ bucket.aggregatedResult as Record<string, unknown> | undefined
363
+ )?.collectors as Record<string, unknown> | undefined;
364
+ addInstances(collectors);
394
365
  }
395
366
 
396
367
  // Convert sets to arrays
@@ -613,32 +584,23 @@ function BooleanRenderer({ field, context }: ChartRendererProps) {
613
584
  )}
614
585
  </div>
615
586
 
616
- {/* Sparkline timeline - always show for historical context */}
617
- {(() => {
618
- const buckets = downsampleSparkline(valuesWithTime);
619
- return (
620
- <div className="flex h-2 gap-px rounded">
621
- {buckets.map((bucket, index) => {
622
- const yesCount = bucket.items.filter((r) => r.value).length;
623
- const noCount = bucket.items.length - yesCount;
624
- const tooltip = bucket.timeLabel
625
- ? bucket.items.length > 1
626
- ? `${bucket.timeLabel}\n${yesCount} yes, ${noCount} no`
627
- : `${bucket.timeLabel}\n${bucket.passed ? "Yes" : "No"}`
628
- : bucket.passed
629
- ? "Yes"
630
- : "No";
631
- return (
632
- <SparklineTooltip key={index} content={tooltip}>
633
- <div
634
- className={`flex-1 h-full ${bucket.passed ? "bg-green-500" : "bg-red-500"} hover:opacity-80`}
635
- />
636
- </SparklineTooltip>
637
- );
638
- })}
639
- </div>
640
- );
641
- })()}
587
+ {/* Sparkline timeline - render each value as a bar */}
588
+ <div className="flex h-2 gap-px rounded">
589
+ {valuesWithTime.map((item, index) => {
590
+ const tooltip = item.timeLabel
591
+ ? `${item.timeLabel}\n${item.value ? "Yes" : "No"}`
592
+ : item.value
593
+ ? "Yes"
594
+ : "No";
595
+ return (
596
+ <SparklineTooltip key={index} content={tooltip}>
597
+ <div
598
+ className={`flex-1 h-full ${item.value ? "bg-green-500" : "bg-red-500"} hover:opacity-80`}
599
+ />
600
+ </SparklineTooltip>
601
+ );
602
+ })}
603
+ </div>
642
604
  </div>
643
605
  );
644
606
  }
@@ -1029,9 +991,7 @@ function PieChartRenderer({ field, context }: ChartRendererProps) {
1029
991
 
1030
992
  /**
1031
993
  * Get the aggregated value for a field from the context.
1032
- *
1033
- * For raw runs: returns the latest value from result.metadata
1034
- * For aggregated buckets: combines record values (counters) across ALL buckets,
994
+ * Combines record values (counters) across ALL buckets,
1035
995
  * or returns the latest for non-aggregatable types.
1036
996
  */
1037
997
  function getLatestValue(
@@ -1039,48 +999,31 @@ function getLatestValue(
1039
999
  context: HealthCheckDiagramSlotContext,
1040
1000
  collectorId?: string,
1041
1001
  ): unknown {
1042
- if (context.type === "raw") {
1043
- const runs = context.runs;
1044
- if (runs.length === 0) return undefined;
1045
- // For raw runs, aggregate across all runs for record types
1046
- const allValues = runs.map((run) => {
1047
- const result = run.result as StoredHealthCheckResult | undefined;
1048
- return getFieldValue(result?.metadata, fieldName, collectorId);
1049
- });
1050
-
1051
- // If the values are record types (like statusCodeCounts), combine them
1052
- const firstVal = allValues.find((v) => v !== undefined);
1053
- if (firstVal && typeof firstVal === "object" && !Array.isArray(firstVal)) {
1054
- return combineRecordValues(allValues as Record<string, number>[]);
1055
- }
1056
- // For simple values, return the latest (first in array since runs are newest-first)
1057
- return allValues.at(0);
1058
- } else {
1059
- const buckets = context.buckets;
1060
- if (buckets.length === 0) return undefined;
1061
-
1062
- // Get all values for this field from all buckets
1063
- const allValues = buckets.map((bucket) =>
1064
- getFieldValue(
1065
- bucket.aggregatedResult as Record<string, unknown>,
1066
- fieldName,
1067
- ),
1068
- );
1002
+ const buckets = context.buckets;
1003
+ if (buckets.length === 0) return undefined;
1004
+
1005
+ // Get all values for this field from all buckets
1006
+ const allValues = buckets.map((bucket) =>
1007
+ getFieldValue(
1008
+ bucket.aggregatedResult as Record<string, unknown>,
1009
+ fieldName,
1010
+ collectorId,
1011
+ ),
1012
+ );
1069
1013
 
1070
- // If the values are record types (like statusCodeCounts), combine them
1071
- const firstVal = allValues.find((v) => v !== undefined);
1072
- if (firstVal && typeof firstVal === "object" && !Array.isArray(firstVal)) {
1073
- return combineRecordValues(allValues as Record<string, number>[]);
1074
- }
1075
- // For simple values (like errorCount), sum them
1076
- if (typeof firstVal === "number") {
1077
- return allValues
1078
- .filter((v): v is number => typeof v === "number")
1079
- .reduce((sum, v) => sum + v, 0);
1080
- }
1081
- // For other types, return the latest (first in array since buckets are newest-first)
1082
- return allValues.at(0);
1014
+ // If the values are record types (like statusCodeCounts), combine them
1015
+ const firstVal = allValues.find((v) => v !== undefined);
1016
+ if (firstVal && typeof firstVal === "object" && !Array.isArray(firstVal)) {
1017
+ return combineRecordValues(allValues as Record<string, number>[]);
1083
1018
  }
1019
+ // For simple values (like errorCount), sum them
1020
+ if (typeof firstVal === "number") {
1021
+ return allValues
1022
+ .filter((v): v is number => typeof v === "number")
1023
+ .reduce((sum, v) => sum + v, 0);
1024
+ }
1025
+ // For other types, return the latest
1026
+ return allValues.at(-1);
1084
1027
  }
1085
1028
 
1086
1029
  /**
@@ -1103,7 +1046,7 @@ function combineRecordValues(
1103
1046
  }
1104
1047
 
1105
1048
  /**
1106
- * Count occurrences of each unique value for a field across all runs/buckets.
1049
+ * Count occurrences of each unique value for a field across all buckets.
1107
1050
  * Returns a record mapping each unique value to its count.
1108
1051
  */
1109
1052
  function getValueCounts(
@@ -1113,27 +1056,15 @@ function getValueCounts(
1113
1056
  ): Record<string, number> {
1114
1057
  const counts: Record<string, number> = {};
1115
1058
 
1116
- if (context.type === "raw") {
1117
- for (const run of context.runs) {
1118
- const result = run.result as StoredHealthCheckResult | undefined;
1119
- const value = getFieldValue(result?.metadata, fieldName, collectorId);
1120
- if (value !== undefined && value !== null) {
1121
- const key = String(value);
1122
- counts[key] = (counts[key] || 0) + 1;
1123
- }
1124
- }
1125
- } else {
1126
- // For aggregated buckets, we need to look at each bucket's data
1127
- for (const bucket of context.buckets) {
1128
- const value = getFieldValue(
1129
- bucket.aggregatedResult as Record<string, unknown>,
1130
- fieldName,
1131
- collectorId,
1132
- );
1133
- if (value !== undefined && value !== null) {
1134
- const key = String(value);
1135
- counts[key] = (counts[key] || 0) + 1;
1136
- }
1059
+ for (const bucket of context.buckets) {
1060
+ const value = getFieldValue(
1061
+ bucket.aggregatedResult as Record<string, unknown>,
1062
+ fieldName,
1063
+ collectorId,
1064
+ );
1065
+ if (value !== undefined && value !== null) {
1066
+ const key = String(value);
1067
+ counts[key] = (counts[key] || 0) + 1;
1137
1068
  }
1138
1069
  }
1139
1070
 
@@ -1142,28 +1073,13 @@ function getValueCounts(
1142
1073
 
1143
1074
  /**
1144
1075
  * Get all numeric values for a field with time labels.
1145
- * Returns values in chronological order with timestamps/time spans for tooltips.
1076
+ * Returns values in chronological order with time spans for tooltips.
1146
1077
  */
1147
1078
  function getAllValuesWithTime(
1148
1079
  fieldName: string,
1149
1080
  context: HealthCheckDiagramSlotContext,
1150
1081
  collectorId?: string,
1151
1082
  ): { value: number; timeLabel: string }[] {
1152
- if (context.type === "raw") {
1153
- return context.runs
1154
- .map((run) => {
1155
- const result = run.result as StoredHealthCheckResult;
1156
- const value = getFieldValue(result?.metadata, fieldName, collectorId);
1157
- if (typeof value !== "number") return;
1158
- return {
1159
- value,
1160
- timeLabel: format(new Date(run.timestamp), "MMM d, HH:mm:ss"),
1161
- };
1162
- })
1163
- .filter(
1164
- (v): v is { value: number; timeLabel: string } => v !== undefined,
1165
- );
1166
- }
1167
1083
  return context.buckets
1168
1084
  .map((bucket) => {
1169
1085
  const value = getFieldValue(
@@ -1191,21 +1107,6 @@ function getAllBooleanValuesWithTime(
1191
1107
  context: HealthCheckDiagramSlotContext,
1192
1108
  collectorId?: string,
1193
1109
  ): { value: boolean; timeLabel: string }[] {
1194
- if (context.type === "raw") {
1195
- return context.runs
1196
- .map((run) => {
1197
- const result = run.result as StoredHealthCheckResult;
1198
- const value = getFieldValue(result?.metadata, fieldName, collectorId);
1199
- if (typeof value !== "boolean") return;
1200
- return {
1201
- value,
1202
- timeLabel: format(new Date(run.timestamp), "MMM d, HH:mm:ss"),
1203
- };
1204
- })
1205
- .filter(
1206
- (v): v is { value: boolean; timeLabel: string } => v !== undefined,
1207
- );
1208
- }
1209
1110
  return context.buckets
1210
1111
  .map((bucket) => {
1211
1112
  const value = getFieldValue(
@@ -1233,21 +1134,6 @@ function getAllStringValuesWithTime(
1233
1134
  context: HealthCheckDiagramSlotContext,
1234
1135
  collectorId?: string,
1235
1136
  ): { value: string; timeLabel: string }[] {
1236
- if (context.type === "raw") {
1237
- return context.runs
1238
- .map((run) => {
1239
- const result = run.result as StoredHealthCheckResult;
1240
- const value = getFieldValue(result?.metadata, fieldName, collectorId);
1241
- if (typeof value !== "string") return;
1242
- return {
1243
- value,
1244
- timeLabel: format(new Date(run.timestamp), "MMM d, HH:mm:ss"),
1245
- };
1246
- })
1247
- .filter(
1248
- (v): v is { value: string; timeLabel: string } => v !== undefined,
1249
- );
1250
- }
1251
1137
  return context.buckets
1252
1138
  .map((bucket) => {
1253
1139
  const value = getFieldValue(
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Hook to fetch and cache strategy schemas.
2
+ * Hook to fetch and cache strategy schemas for auto-chart rendering.
3
3
  *
4
- * Fetches both strategy result schemas AND collector result schemas,
4
+ * Fetches strategy aggregated result schemas AND collector aggregated result schemas,
5
5
  * merging them into a unified schema where collector schemas are nested
6
6
  * under `properties.collectors.<collectorId>`.
7
7
  */
@@ -11,15 +11,14 @@ import { usePluginClient } from "@checkstack/frontend-api";
11
11
  import { HealthCheckApi } from "../api";
12
12
 
13
13
  interface StrategySchemas {
14
- resultSchema: Record<string, unknown> | undefined;
15
14
  aggregatedResultSchema: Record<string, unknown> | undefined;
16
15
  }
17
16
 
18
17
  /**
19
18
  * Fetch and cache strategy schemas for auto-chart rendering.
20
19
  *
21
- * Also fetches collector schemas and merges them into the result schema
22
- * so that chart fields from collectors are properly extracted.
20
+ * Fetches collector aggregated schemas and merges them into the strategy's
21
+ * aggregated result schema so that chart fields from collectors are properly extracted.
23
22
  *
24
23
  * @param strategyId - The strategy ID to fetch schemas for
25
24
  * @returns Schemas for the strategy, or undefined if not found
@@ -49,27 +48,17 @@ export function useStrategySchemas(strategyId: string): {
49
48
  const strategy = strategies.find((s) => s.id === strategyId);
50
49
 
51
50
  if (strategy) {
52
- // Build collector schemas object for nesting under resultSchema.properties.collectors
53
- const collectorProperties: Record<string, unknown> = {};
51
+ // Build collector aggregated schemas for nesting under aggregatedResultSchema.properties.collectors
54
52
  const collectorAggregatedProperties: Record<string, unknown> = {};
55
53
 
56
54
  for (const collector of collectors) {
57
55
  // Use full ID so it matches stored data keys like "healthcheck-http.request"
58
- collectorProperties[collector.id] = collector.resultSchema;
59
-
60
- // Also collect aggregated schemas if available
61
56
  if (collector.aggregatedResultSchema) {
62
57
  collectorAggregatedProperties[collector.id] =
63
58
  collector.aggregatedResultSchema;
64
59
  }
65
60
  }
66
61
 
67
- // Merge collector schemas into strategy result schema
68
- const mergedResultSchema = mergeCollectorSchemas(
69
- strategy.resultSchema as Record<string, unknown> | undefined,
70
- collectorProperties,
71
- );
72
-
73
62
  // Merge collector aggregated schemas into strategy aggregated schema
74
63
  const mergedAggregatedSchema = mergeCollectorSchemas(
75
64
  strategy.aggregatedResultSchema as Record<string, unknown> | undefined,
@@ -77,7 +66,6 @@ export function useStrategySchemas(strategyId: string): {
77
66
  );
78
67
 
79
68
  setSchemas({
80
- resultSchema: mergedResultSchema,
81
69
  aggregatedResultSchema: mergedAggregatedSchema,
82
70
  });
83
71
  }
@@ -132,6 +132,14 @@ export const HealthCheckEditor: React.FC<HealthCheckEditorProps> = ({
132
132
  onChange={(e) => setInterval(e.target.value)}
133
133
  required
134
134
  />
135
+ {Number.parseInt(interval, 10) < 60 && (
136
+ <p className="text-sm text-amber-600 dark:text-amber-400">
137
+ ⚠️ Sub-minute intervals generate large amounts of data and may
138
+ impact chart loading performance. Consider using intervals of
139
+ 60 seconds or more or drastically reduce the retention period
140
+ for raw data.
141
+ </p>
142
+ )}
135
143
  </div>
136
144
 
137
145
  <PluginConfigForm