@checkstack/healthcheck-frontend 0.5.0 → 0.7.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.
@@ -10,6 +10,7 @@ import { extractChartFields, getFieldValue } from "./schema-parser";
10
10
  import { useStrategySchemas } from "./useStrategySchemas";
11
11
  import type { HealthCheckDiagramSlotContext } from "../slots";
12
12
  import type { StoredHealthCheckResult } from "@checkstack/healthcheck-common";
13
+ import { SparklineTooltip } from "../components/SparklineTooltip";
13
14
  import { Card, CardContent, CardHeader, CardTitle } from "@checkstack/ui";
14
15
  import {
15
16
  PieChart,
@@ -26,6 +27,11 @@ import {
26
27
  RadialBarChart,
27
28
  RadialBar,
28
29
  } from "recharts";
30
+ import { format } from "date-fns";
31
+ import {
32
+ downsampleSparkline,
33
+ MAX_SPARKLINE_BARS,
34
+ } from "../utils/sparkline-downsampling";
29
35
 
30
36
  interface AutoChartGridProps {
31
37
  context: HealthCheckDiagramSlotContext;
@@ -110,7 +116,7 @@ interface CollectorGroupData {
110
116
  */
111
117
  function buildCollectorGroups(
112
118
  schemaFields: ChartField[],
113
- instanceMap: Record<string, string[]>
119
+ instanceMap: Record<string, string[]>,
114
120
  ): CollectorGroupData[] {
115
121
  const groups: CollectorGroupData[] = [];
116
122
 
@@ -118,7 +124,7 @@ function buildCollectorGroups(
118
124
  for (const [collectorId, instanceKeys] of Object.entries(instanceMap)) {
119
125
  // Get fields for this collector type
120
126
  const collectorFields = schemaFields.filter(
121
- (f) => f.collectorId === collectorId
127
+ (f) => f.collectorId === collectorId,
122
128
  );
123
129
  if (collectorFields.length === 0) continue;
124
130
 
@@ -146,6 +152,8 @@ function buildCollectorGroups(
146
152
 
147
153
  /**
148
154
  * Renders a collector group with heading, assertion status, and field cards.
155
+ * Cards are organized into two sections: narrow cards that fill together,
156
+ * and wide timeline cards that span full width.
149
157
  */
150
158
  function CollectorGroup({
151
159
  group,
@@ -154,20 +162,41 @@ function CollectorGroup({
154
162
  group: CollectorGroupData;
155
163
  context: HealthCheckDiagramSlotContext;
156
164
  }) {
157
- // Get assertion status for this collector instance
158
- const assertionFailed = getAssertionFailed(context, group.instanceKey);
165
+ // Separate fields into narrow (grid) and wide (full-width) categories
166
+ const narrowFields = group.fields.filter(
167
+ (f) => !WIDE_CHART_TYPES.has(f.chartType),
168
+ );
169
+ const wideFields = group.fields.filter((f) =>
170
+ WIDE_CHART_TYPES.has(f.chartType),
171
+ );
159
172
 
160
173
  return (
161
- <div>
162
- <h4 className="text-sm font-medium text-muted-foreground mb-3 uppercase tracking-wide">
174
+ <div className="space-y-4">
175
+ <h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
163
176
  {group.displayName}
164
177
  </h4>
165
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
166
- {/* Assertion status card */}
167
- <AssertionStatusCard assertionFailed={assertionFailed} />
168
178
 
169
- {/* Field cards */}
170
- {group.fields.map((field) => (
179
+ {/* Narrow cards grid - these pack together nicely */}
180
+ {narrowFields.length > 0 && (
181
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
182
+ {narrowFields.map((field) => (
183
+ <AutoChartCard
184
+ key={`${field.instanceKey}-${field.name}`}
185
+ field={field}
186
+ context={context}
187
+ />
188
+ ))}
189
+ </div>
190
+ )}
191
+
192
+ {/* Wide timeline cards - assertion plus timeline fields */}
193
+ <div className="space-y-4">
194
+ <AssertionStatusCard
195
+ context={context}
196
+ instanceKey={group.instanceKey}
197
+ />
198
+
199
+ {wideFields.map((field) => (
171
200
  <AutoChartCard
172
201
  key={`${field.instanceKey}-${field.name}`}
173
202
  field={field}
@@ -180,59 +209,142 @@ function CollectorGroup({
180
209
  }
181
210
 
182
211
  /**
183
- * Get the _assertionFailed value for a specific collector instance.
212
+ * Get all assertion results for a specific collector instance.
213
+ * 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.
184
217
  */
185
- function getAssertionFailed(
218
+ function getAllAssertionResults(
186
219
  context: HealthCheckDiagramSlotContext,
187
- instanceKey: string
188
- ): string | undefined {
189
- if (context.type === "raw" && context.runs.length > 0) {
190
- const latestRun = context.runs[0];
191
- const result = latestRun.result as StoredHealthCheckResult | undefined;
192
- const collectors = result?.metadata?.collectors as
193
- | Record<string, Record<string, unknown>>
194
- | undefined;
195
- const collectorData = collectors?.[instanceKey];
196
- return collectorData?._assertionFailed as string | undefined;
220
+ _instanceKey: string,
221
+ ): { 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
+ });
197
232
  }
198
- return undefined;
233
+
234
+ // For aggregated data, return one result per bucket with time span
235
+ return context.buckets.map((bucket) => {
236
+ const failedCount = bucket.degradedCount + bucket.unhealthyCount;
237
+ const passed = failedCount === 0;
238
+ const bucketStart = new Date(bucket.bucketStart);
239
+ const bucketEnd = new Date(bucket.bucketEnd);
240
+ const timeSpan = `${format(bucketStart, "MMM d, HH:mm")} - ${format(bucketEnd, "HH:mm")}`;
241
+ return {
242
+ passed,
243
+ errorMessage: passed
244
+ ? undefined
245
+ : `${failedCount} failed of ${bucket.runCount}`,
246
+ timeLabel: timeSpan,
247
+ };
248
+ });
199
249
  }
200
250
 
201
251
  /**
202
- * Card showing assertion pass/fail status.
252
+ * Card showing assertion pass/fail status with historical sparkline.
203
253
  */
204
254
  function AssertionStatusCard({
205
- assertionFailed,
255
+ context,
256
+ instanceKey,
206
257
  }: {
207
- assertionFailed: string | undefined;
258
+ context: HealthCheckDiagramSlotContext;
259
+ instanceKey: string;
208
260
  }) {
209
- if (!assertionFailed) {
261
+ const results = getAllAssertionResults(context, instanceKey);
262
+
263
+ if (results.length === 0) {
210
264
  return (
211
265
  <Card>
212
266
  <CardHeader className="pb-2">
213
267
  <CardTitle className="text-sm font-medium">Assertion</CardTitle>
214
268
  </CardHeader>
215
269
  <CardContent>
216
- <div className="flex items-center gap-2 text-green-600">
217
- <div className="w-3 h-3 rounded-full bg-green-500" />
218
- <span>Passed</span>
219
- </div>
270
+ <div className="text-sm text-muted-foreground">No data</div>
220
271
  </CardContent>
221
272
  </Card>
222
273
  );
223
274
  }
224
275
 
276
+ const latestResult = results.at(-1)!;
277
+ const passCount = results.filter((r) => r.passed).length;
278
+ const passRate = Math.round((passCount / results.length) * 100);
279
+ const allPassed = results.every((r) => r.passed);
280
+ const allFailed = results.every((r) => !r.passed);
281
+
225
282
  return (
226
- <Card className="border-red-200 dark:border-red-900">
283
+ <Card
284
+ className={
285
+ latestResult.passed ? "" : "border-red-200 dark:border-red-900"
286
+ }
287
+ >
227
288
  <CardHeader className="pb-2">
228
- <CardTitle className="text-sm font-medium text-red-600">
229
- Assertion Failed
289
+ <CardTitle
290
+ className={`text-sm font-medium ${latestResult.passed ? "" : "text-red-600"}`}
291
+ >
292
+ {latestResult.passed ? "Assertion" : "Assertion Failed"}
230
293
  </CardTitle>
231
294
  </CardHeader>
232
- <CardContent>
233
- <div className="text-sm text-red-600 bg-red-50 dark:bg-red-950 px-2 py-1 rounded">
234
- {assertionFailed}
295
+ <CardContent className="space-y-2">
296
+ {/* Current status with rate */}
297
+ <div className="flex items-center gap-2">
298
+ <div
299
+ className={`w-3 h-3 rounded-full ${
300
+ latestResult.passed ? "bg-green-500" : "bg-red-500"
301
+ }`}
302
+ />
303
+ <span
304
+ className={latestResult.passed ? "text-green-600" : "text-red-600"}
305
+ >
306
+ {latestResult.passed ? "Passed" : "Failed"}
307
+ </span>
308
+ {!allPassed && !allFailed && (
309
+ <span className="text-xs text-muted-foreground">
310
+ ({passRate}% passed)
311
+ </span>
312
+ )}
235
313
  </div>
314
+
315
+ {/* Error message if failed */}
316
+ {!latestResult.passed && latestResult.errorMessage && (
317
+ <div className="text-sm text-red-600 bg-red-50 dark:bg-red-950 px-2 py-1 rounded truncate">
318
+ {latestResult.errorMessage}
319
+ </div>
320
+ )}
321
+
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
+ })()}
236
348
  </CardContent>
237
349
  </Card>
238
350
  );
@@ -243,7 +355,7 @@ function AssertionStatusCard({
243
355
  * Returns a map from base collector ID (type) to array of instance UUIDs.
244
356
  */
245
357
  function discoverCollectorInstances(
246
- context: HealthCheckDiagramSlotContext
358
+ context: HealthCheckDiagramSlotContext,
247
359
  ): Record<string, string[]> {
248
360
  const instanceMap: Record<string, Set<string>> = {};
249
361
 
@@ -302,6 +414,11 @@ interface AutoChartCardProps {
302
414
  context: HealthCheckDiagramSlotContext;
303
415
  }
304
416
 
417
+ /**
418
+ * Chart types that display historical timelines and benefit from wider display.
419
+ */
420
+ const WIDE_CHART_TYPES = new Set(["line", "boolean", "text"]);
421
+
305
422
  /**
306
423
  * Individual chart card that renders based on field type.
307
424
  */
@@ -422,8 +539,8 @@ function GaugeRenderer({ field, context }: ChartRendererProps) {
422
539
  numValue >= 90
423
540
  ? "hsl(var(--success))"
424
541
  : numValue >= 70
425
- ? "hsl(var(--warning))"
426
- : "hsl(var(--destructive))";
542
+ ? "hsl(var(--warning))"
543
+ : "hsl(var(--destructive))";
427
544
 
428
545
  const data = [{ name: field.label, value: numValue, fill: fillColor }];
429
546
 
@@ -456,39 +573,176 @@ function GaugeRenderer({ field, context }: ChartRendererProps) {
456
573
  }
457
574
 
458
575
  /**
459
- * Renders a boolean indicator (success/failure).
576
+ * Renders a boolean indicator with historical sparkline.
460
577
  */
461
578
  function BooleanRenderer({ field, context }: ChartRendererProps) {
462
- const value = getLatestValue(field.name, context, field.instanceKey);
463
- const isTrue = value === true;
579
+ const valuesWithTime = getAllBooleanValuesWithTime(
580
+ field.name,
581
+ context,
582
+ field.instanceKey,
583
+ );
584
+
585
+ if (valuesWithTime.length === 0) {
586
+ return <div className="text-sm text-muted-foreground">No data</div>;
587
+ }
588
+
589
+ // Calculate success rate
590
+ const trueCount = valuesWithTime.filter((v) => v.value === true).length;
591
+ const successRate = Math.round((trueCount / valuesWithTime.length) * 100);
592
+ const latestValue = valuesWithTime.at(-1)?.value;
593
+ const allSame = valuesWithTime.every(
594
+ (v) => v.value === valuesWithTime[0].value,
595
+ );
464
596
 
465
597
  return (
466
- <div className="flex items-center gap-2">
467
- <div
468
- className={`w-3 h-3 rounded-full ${
469
- isTrue ? "bg-green-500" : "bg-red-500"
470
- }`}
471
- />
472
- <span className={isTrue ? "text-green-600" : "text-red-600"}>
473
- {isTrue ? "Yes" : "No"}
474
- </span>
598
+ <div className="space-y-2">
599
+ {/* Current status with rate */}
600
+ <div className="flex items-center gap-2">
601
+ <div
602
+ className={`w-3 h-3 rounded-full ${
603
+ latestValue ? "bg-green-500" : "bg-red-500"
604
+ }`}
605
+ />
606
+ <span className={latestValue ? "text-green-600" : "text-red-600"}>
607
+ {latestValue ? "Yes" : "No"}
608
+ </span>
609
+ {!allSame && (
610
+ <span className="text-xs text-muted-foreground">
611
+ ({successRate}% success)
612
+ </span>
613
+ )}
614
+ </div>
615
+
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
+ })()}
475
642
  </div>
476
643
  );
477
644
  }
478
645
 
479
646
  /**
480
- * Renders text value.
647
+ * Renders text value with historical sparkline for status-type fields.
481
648
  */
482
649
  function TextRenderer({ field, context }: ChartRendererProps) {
483
- const value = getLatestValue(field.name, context, field.instanceKey);
484
- const displayValue = formatTextValue(value);
650
+ const valuesWithTime = getAllStringValuesWithTime(
651
+ field.name,
652
+ context,
653
+ field.instanceKey,
654
+ );
655
+
656
+ if (valuesWithTime.length === 0) {
657
+ return <div className="text-sm text-muted-foreground">—</div>;
658
+ }
659
+
660
+ const latestValue = valuesWithTime.at(-1)?.value ?? "";
661
+ const uniqueValues = [...new Set(valuesWithTime.map((v) => v.value))];
662
+ const allSame = uniqueValues.length === 1;
663
+ const latestCount = valuesWithTime.filter(
664
+ (v) => v.value === latestValue,
665
+ ).length;
485
666
 
486
667
  return (
487
- <div
488
- className="text-sm font-mono text-muted-foreground truncate"
489
- title={displayValue}
490
- >
491
- {displayValue || "—"}
668
+ <div className="space-y-2">
669
+ {/* Current value with count */}
670
+ <div className="flex items-center gap-2">
671
+ <span className="text-sm font-mono">{latestValue || "—"}</span>
672
+ {!allSame && (
673
+ <span className="text-xs text-muted-foreground">
674
+ ({latestCount}/{valuesWithTime.length}×)
675
+ </span>
676
+ )}
677
+ </div>
678
+
679
+ {/* Sparkline timeline - always show for historical context */}
680
+ {(() => {
681
+ // Downsample for string values - bucket is "primary" if all values match latest
682
+ const bucketSize =
683
+ valuesWithTime.length <= MAX_SPARKLINE_BARS
684
+ ? 1
685
+ : Math.ceil(valuesWithTime.length / MAX_SPARKLINE_BARS);
686
+
687
+ const buckets: Array<{
688
+ items: typeof valuesWithTime;
689
+ matchesLatest: boolean;
690
+ timeLabel?: string;
691
+ }> = [];
692
+ for (let i = 0; i < valuesWithTime.length; i += bucketSize) {
693
+ const items = valuesWithTime.slice(i, i + bucketSize);
694
+ const matchesLatest = items.every((v) => v.value === latestValue);
695
+ const startLabel = items[0]?.timeLabel;
696
+ const endLabel = items.at(-1)?.timeLabel;
697
+ buckets.push({
698
+ items,
699
+ matchesLatest,
700
+ timeLabel:
701
+ startLabel && endLabel && startLabel !== endLabel
702
+ ? `${startLabel} - ${endLabel}`
703
+ : startLabel,
704
+ });
705
+ }
706
+
707
+ return (
708
+ <div className="flex h-2 gap-px rounded">
709
+ {buckets.map((bucket, index) => {
710
+ // Build value distribution for tooltip
711
+ let valueInfo: string;
712
+ if (bucket.items.length === 1) {
713
+ valueInfo = bucket.items[0]?.value ?? "";
714
+ } else {
715
+ // Count occurrences of each value
716
+ const counts: Record<string, number> = {};
717
+ for (const item of bucket.items) {
718
+ counts[item.value] = (counts[item.value] || 0) + 1;
719
+ }
720
+ // Format as "value: Nx" entries, sorted by count
721
+ valueInfo = Object.entries(counts)
722
+ .toSorted((a, b) => b[1] - a[1])
723
+ .slice(0, 3) // Show top 3
724
+ .map(([val, count]) => `${val}: ${count}×`)
725
+ .join(", ");
726
+ if (Object.keys(counts).length > 3) {
727
+ valueInfo += ` (+${Object.keys(counts).length - 3} more)`;
728
+ }
729
+ }
730
+ const tooltip = bucket.timeLabel
731
+ ? `${bucket.timeLabel}\n${valueInfo}`
732
+ : valueInfo;
733
+ return (
734
+ <SparklineTooltip key={index} content={tooltip}>
735
+ <div
736
+ className={`flex-1 h-full ${
737
+ bucket.matchesLatest ? "bg-primary" : "bg-amber-500"
738
+ } hover:opacity-80`}
739
+ />
740
+ </SparklineTooltip>
741
+ );
742
+ })}
743
+ </div>
744
+ );
745
+ })()}
492
746
  </div>
493
747
  );
494
748
  }
@@ -515,20 +769,26 @@ function StatusRenderer({ field, context }: ChartRendererProps) {
515
769
  * Renders an area chart for time series data using Recharts AreaChart.
516
770
  */
517
771
  function LineChartRenderer({ field, context }: ChartRendererProps) {
518
- const values = getAllValues(field.name, context, field.instanceKey);
772
+ const valuesWithTime = getAllValuesWithTime(
773
+ field.name,
774
+ context,
775
+ field.instanceKey,
776
+ );
519
777
  const unit = field.unit ?? "";
520
778
 
521
- if (values.length === 0) {
779
+ if (valuesWithTime.length === 0) {
522
780
  return <div className="text-muted-foreground">No data</div>;
523
781
  }
524
782
 
525
- // Transform values to recharts data format
526
- const chartData = values.map((value, index) => ({
783
+ // Transform values to recharts data format with time labels
784
+ const chartData = valuesWithTime.map((item, index) => ({
527
785
  index,
528
- value,
786
+ value: item.value,
787
+ timeLabel: item.timeLabel,
529
788
  }));
530
789
 
531
- const avg = values.reduce((a, b) => a + b, 0) / values.length;
790
+ const avg =
791
+ valuesWithTime.reduce((a, b) => a + b.value, 0) / valuesWithTime.length;
532
792
 
533
793
  return (
534
794
  <div className="space-y-2">
@@ -572,7 +832,10 @@ function LineChartRenderer({ field, context }: ChartRendererProps) {
572
832
  <Tooltip
573
833
  content={({ active, payload }) => {
574
834
  if (!active || !payload?.length) return;
575
- const data = payload[0].payload as { value: number };
835
+ const data = payload[0].payload as {
836
+ value: number;
837
+ timeLabel: string;
838
+ };
576
839
  return (
577
840
  <div
578
841
  className="rounded-md border bg-popover p-2 text-sm shadow-md"
@@ -581,6 +844,9 @@ function LineChartRenderer({ field, context }: ChartRendererProps) {
581
844
  border: "1px solid hsl(var(--border))",
582
845
  }}
583
846
  >
847
+ <p className="text-xs text-muted-foreground mb-1">
848
+ {data.timeLabel}
849
+ </p>
584
850
  <p className="font-medium">
585
851
  {data.value.toFixed(1)}
586
852
  {unit}
@@ -771,7 +1037,7 @@ function PieChartRenderer({ field, context }: ChartRendererProps) {
771
1037
  function getLatestValue(
772
1038
  fieldName: string,
773
1039
  context: HealthCheckDiagramSlotContext,
774
- collectorId?: string
1040
+ collectorId?: string,
775
1041
  ): unknown {
776
1042
  if (context.type === "raw") {
777
1043
  const runs = context.runs;
@@ -787,8 +1053,8 @@ function getLatestValue(
787
1053
  if (firstVal && typeof firstVal === "object" && !Array.isArray(firstVal)) {
788
1054
  return combineRecordValues(allValues as Record<string, number>[]);
789
1055
  }
790
- // For simple values, return the latest
791
- return allValues.at(-1);
1056
+ // For simple values, return the latest (first in array since runs are newest-first)
1057
+ return allValues.at(0);
792
1058
  } else {
793
1059
  const buckets = context.buckets;
794
1060
  if (buckets.length === 0) return undefined;
@@ -797,8 +1063,8 @@ function getLatestValue(
797
1063
  const allValues = buckets.map((bucket) =>
798
1064
  getFieldValue(
799
1065
  bucket.aggregatedResult as Record<string, unknown>,
800
- fieldName
801
- )
1066
+ fieldName,
1067
+ ),
802
1068
  );
803
1069
 
804
1070
  // If the values are record types (like statusCodeCounts), combine them
@@ -812,8 +1078,8 @@ function getLatestValue(
812
1078
  .filter((v): v is number => typeof v === "number")
813
1079
  .reduce((sum, v) => sum + v, 0);
814
1080
  }
815
- // For other types, return the latest
816
- return allValues.at(-1);
1081
+ // For other types, return the latest (first in array since buckets are newest-first)
1082
+ return allValues.at(0);
817
1083
  }
818
1084
  }
819
1085
 
@@ -822,7 +1088,7 @@ function getLatestValue(
822
1088
  * Adds up the counts for each key.
823
1089
  */
824
1090
  function combineRecordValues(
825
- values: (Record<string, number> | undefined)[]
1091
+ values: (Record<string, number> | undefined)[],
826
1092
  ): Record<string, number> {
827
1093
  const combined: Record<string, number> = {};
828
1094
  for (const val of values) {
@@ -843,7 +1109,7 @@ function combineRecordValues(
843
1109
  function getValueCounts(
844
1110
  fieldName: string,
845
1111
  context: HealthCheckDiagramSlotContext,
846
- collectorId?: string
1112
+ collectorId?: string,
847
1113
  ): Record<string, number> {
848
1114
  const counts: Record<string, number> = {};
849
1115
 
@@ -862,7 +1128,7 @@ function getValueCounts(
862
1128
  const value = getFieldValue(
863
1129
  bucket.aggregatedResult as Record<string, unknown>,
864
1130
  fieldName,
865
- collectorId
1131
+ collectorId,
866
1132
  );
867
1133
  if (value !== undefined && value !== null) {
868
1134
  const key = String(value);
@@ -875,41 +1141,123 @@ function getValueCounts(
875
1141
  }
876
1142
 
877
1143
  /**
878
- * Get all numeric values for a field from the context.
879
- *
880
- * For raw runs, the strategy-specific data is inside result.metadata.
881
- * For aggregated buckets, the data is directly in aggregatedResult.
1144
+ * Get all numeric values for a field with time labels.
1145
+ * Returns values in chronological order with timestamps/time spans for tooltips.
882
1146
  */
883
- function getAllValues(
1147
+ function getAllValuesWithTime(
884
1148
  fieldName: string,
885
1149
  context: HealthCheckDiagramSlotContext,
886
- collectorId?: string
887
- ): number[] {
1150
+ collectorId?: string,
1151
+ ): { value: number; timeLabel: string }[] {
888
1152
  if (context.type === "raw") {
889
1153
  return context.runs
890
1154
  .map((run) => {
891
- // result is typed as StoredHealthCheckResult with { status, latencyMs, message, metadata }
892
1155
  const result = run.result as StoredHealthCheckResult;
893
- return getFieldValue(result?.metadata, fieldName, collectorId);
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
+ };
894
1162
  })
895
- .filter((v): v is number => typeof v === "number");
1163
+ .filter(
1164
+ (v): v is { value: number; timeLabel: string } => v !== undefined,
1165
+ );
896
1166
  }
897
1167
  return context.buckets
898
- .map((bucket) =>
899
- getFieldValue(
1168
+ .map((bucket) => {
1169
+ const value = getFieldValue(
1170
+ bucket.aggregatedResult as Record<string, unknown>,
1171
+ fieldName,
1172
+ collectorId,
1173
+ );
1174
+ if (typeof value !== "number") return;
1175
+ const bucketStart = new Date(bucket.bucketStart);
1176
+ const bucketEnd = new Date(bucket.bucketEnd);
1177
+ return {
1178
+ value,
1179
+ timeLabel: `${format(bucketStart, "MMM d, HH:mm")} - ${format(bucketEnd, "HH:mm")}`,
1180
+ };
1181
+ })
1182
+ .filter((v): v is { value: number; timeLabel: string } => v !== undefined);
1183
+ }
1184
+
1185
+ /**
1186
+ * Get all boolean values for a field from the context.
1187
+ * Returns values with time labels in chronological order for sparkline display.
1188
+ */
1189
+ function getAllBooleanValuesWithTime(
1190
+ fieldName: string,
1191
+ context: HealthCheckDiagramSlotContext,
1192
+ collectorId?: string,
1193
+ ): { 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((v): v is { value: boolean; timeLabel: string } => v !== null);
1206
+ }
1207
+ return context.buckets
1208
+ .map((bucket) => {
1209
+ const value = getFieldValue(
900
1210
  bucket.aggregatedResult as Record<string, unknown>,
901
1211
  fieldName,
902
- collectorId
903
- )
904
- )
905
- .filter((v): v is number => typeof v === "number");
1212
+ collectorId,
1213
+ );
1214
+ if (typeof value !== "boolean") return;
1215
+ const bucketStart = new Date(bucket.bucketStart);
1216
+ const bucketEnd = new Date(bucket.bucketEnd);
1217
+ return {
1218
+ value,
1219
+ timeLabel: `${format(bucketStart, "MMM d, HH:mm")} - ${format(bucketEnd, "HH:mm")}`,
1220
+ };
1221
+ })
1222
+ .filter((v): v is { value: boolean; timeLabel: string } => v !== null);
906
1223
  }
907
1224
 
908
1225
  /**
909
- * Format a value for text display.
1226
+ * Get all string values for a field from the context.
1227
+ * Returns values with time labels in chronological order for sparkline display.
910
1228
  */
911
- function formatTextValue(value: unknown): string {
912
- if (value === undefined || value === null) return "";
913
- if (Array.isArray(value)) return value.join(", ");
914
- return String(value);
1229
+ function getAllStringValuesWithTime(
1230
+ fieldName: string,
1231
+ context: HealthCheckDiagramSlotContext,
1232
+ collectorId?: string,
1233
+ ): { value: string; timeLabel: string }[] {
1234
+ if (context.type === "raw") {
1235
+ return context.runs
1236
+ .map((run) => {
1237
+ const result = run.result as StoredHealthCheckResult;
1238
+ const value = getFieldValue(result?.metadata, fieldName, collectorId);
1239
+ if (typeof value !== "string") return;
1240
+ return {
1241
+ value,
1242
+ timeLabel: format(new Date(run.timestamp), "MMM d, HH:mm:ss"),
1243
+ };
1244
+ })
1245
+ .filter((v): v is { value: string; timeLabel: string } => v !== null);
1246
+ }
1247
+ return context.buckets
1248
+ .map((bucket) => {
1249
+ const value = getFieldValue(
1250
+ bucket.aggregatedResult as Record<string, unknown>,
1251
+ fieldName,
1252
+ collectorId,
1253
+ );
1254
+ if (typeof value !== "string") return;
1255
+ const bucketStart = new Date(bucket.bucketStart);
1256
+ const bucketEnd = new Date(bucket.bucketEnd);
1257
+ return {
1258
+ value,
1259
+ timeLabel: `${format(bucketStart, "MMM d, HH:mm")} - ${format(bucketEnd, "HH:mm")}`,
1260
+ };
1261
+ })
1262
+ .filter((v): v is { value: string; timeLabel: string } => v !== null);
915
1263
  }