@checkstack/healthcheck-frontend 0.0.2 → 0.1.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,78 @@
1
1
  # @checkstack/healthcheck-frontend
2
2
 
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f5b1f49: Added support for nested collector result display in auto-charts and history table.
8
+
9
+ - Updated `schema-parser.ts` to traverse `collectors.*` nested schemas and extract chart fields with dot-notation paths
10
+ - Added `getFieldValue()` support for dot-notation paths like `collectors.request.responseTimeMs`
11
+ - Added `ExpandedResultView` component to `HealthCheckRunsTable.tsx` that displays:
12
+ - Connection info (status, latency, connection time)
13
+ - Per-collector results as structured cards with key-value pairs
14
+
15
+ - f5b1f49: Added JSONPath assertions for response body validation and fully qualified strategy IDs.
16
+
17
+ **JSONPath Assertions:**
18
+
19
+ - Added `healthResultJSONPath()` factory in healthcheck-common for fields supporting JSONPath queries
20
+ - Extended AssertionBuilder with jsonpath field type showing path input (e.g., `$.data.status`)
21
+ - Added `jsonPath` field to `CollectorAssertionSchema` for persistence
22
+ - HTTP Request collector body field now supports JSONPath assertions
23
+
24
+ **Fully Qualified Strategy IDs:**
25
+
26
+ - HealthCheckRegistry now uses scoped factories like CollectorRegistry
27
+ - Strategies are stored with `pluginId.strategyId` format
28
+ - Added `getStrategiesWithMeta()` method to HealthCheckRegistry interface
29
+ - Router returns qualified IDs so frontend can correctly fetch collectors
30
+
31
+ **UI Improvements:**
32
+
33
+ - Save button disabled when collector configs have invalid required fields
34
+ - Fixed nested button warning in CollectorList accordion
35
+
36
+ ### Patch Changes
37
+
38
+ - Updated dependencies [f5b1f49]
39
+ - Updated dependencies [f5b1f49]
40
+ - Updated dependencies [f5b1f49]
41
+ - Updated dependencies [f5b1f49]
42
+ - @checkstack/healthcheck-common@0.1.0
43
+ - @checkstack/common@0.0.3
44
+ - @checkstack/ui@0.0.4
45
+ - @checkstack/catalog-common@0.0.3
46
+ - @checkstack/frontend-api@0.0.3
47
+ - @checkstack/signal-frontend@0.0.4
48
+
49
+ ## 0.0.3
50
+
51
+ ### Patch Changes
52
+
53
+ - cb82e4d: Improved `counter` and `pie` auto-chart types to show frequency distributions instead of just the latest value. Both chart types now count occurrences of each unique value across all runs/buckets, making them more intuitive for visualizing data like HTTP status codes.
54
+
55
+ Changed HTTP health check chart annotations: `statusCode` now uses `pie` chart (distribution view), `contentType` now uses `counter` chart (frequency count).
56
+
57
+ Fixed scrollbar hopping when health check signals update the accordion content. All charts now update silently without layout shift or loading state flicker.
58
+
59
+ Refactored health check visualization architecture:
60
+
61
+ - `HealthCheckStatusTimeline` and `HealthCheckLatencyChart` now accept `HealthCheckDiagramSlotContext` directly, handling data transformation internally
62
+ - `HealthCheckDiagram` refactored to accept context from parent, ensuring all visualizations share the same data source and update together on signals
63
+ - `HealthCheckSystemOverview` simplified to use `useHealthCheckData` hook for consolidated data fetching with automatic signal-driven refresh
64
+
65
+ Added `silentRefetch()` method to `usePagination` hook for background data refreshes without showing loading indicators.
66
+
67
+ Fixed `useSignal` hook to use a ref pattern internally, preventing stale closure issues. Callbacks now always access the latest values without requiring manual memoization or refs in consumer components.
68
+
69
+ Added signal handling to `useHealthCheckData` hook for automatic chart refresh when health check runs complete.
70
+
71
+ - Updated dependencies [cb82e4d]
72
+ - @checkstack/healthcheck-common@0.0.3
73
+ - @checkstack/signal-frontend@0.0.3
74
+ - @checkstack/ui@0.0.3
75
+
3
76
  ## 0.0.2
4
77
 
5
78
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-frontend",
3
- "version": "0.0.2",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "main": "src/index.tsx",
6
6
  "scripts": {
@@ -10,12 +10,22 @@ 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 { Card, CardContent, CardHeader, CardTitle } from "@checkstack/ui";
13
14
  import {
14
- Card,
15
- CardContent,
16
- CardHeader,
17
- CardTitle,
18
- } from "@checkstack/ui";
15
+ PieChart,
16
+ Pie,
17
+ Cell,
18
+ BarChart,
19
+ Bar,
20
+ XAxis,
21
+ YAxis,
22
+ Tooltip,
23
+ ResponsiveContainer,
24
+ AreaChart,
25
+ Area,
26
+ RadialBarChart,
27
+ RadialBar,
28
+ } from "recharts";
19
29
 
20
30
  interface AutoChartGridProps {
21
31
  context: HealthCheckDiagramSlotContext;
@@ -102,6 +112,9 @@ function ChartRenderer({ field, context }: ChartRendererProps) {
102
112
  case "bar": {
103
113
  return <BarChartRenderer field={field} context={context} />;
104
114
  }
115
+ case "pie": {
116
+ return <PieChartRenderer field={field} context={context} />;
117
+ }
105
118
  case "boolean": {
106
119
  return <BooleanRenderer field={field} context={context} />;
107
120
  }
@@ -122,27 +135,53 @@ function ChartRenderer({ field, context }: ChartRendererProps) {
122
135
  // =============================================================================
123
136
 
124
137
  /**
125
- * Renders a large counter value with optional trend.
138
+ * Renders a counter showing frequency distribution of all unique values.
139
+ * Counts how many times each value appears across all runs/buckets.
126
140
  */
127
141
  function CounterRenderer({ field, context }: ChartRendererProps) {
128
- const value = getLatestValue(field.name, context);
129
- const displayValue = typeof value === "number" ? value : "—";
130
- const unit = field.unit ?? "";
142
+ const counts = getValueCounts(field.name, context);
143
+ const entries = Object.entries(counts);
131
144
 
132
- return (
133
- <div className="text-2xl font-bold">
134
- {displayValue}
135
- {unit && (
136
- <span className="text-sm font-normal text-muted-foreground ml-1">
137
- {unit}
145
+ if (entries.length === 0) {
146
+ return <div className="text-muted-foreground">No data</div>;
147
+ }
148
+
149
+ // Sort by count (descending) then by value
150
+ entries.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
151
+
152
+ // If there's only one unique value, show it prominently with count
153
+ if (entries.length === 1) {
154
+ const [value, count] = entries[0];
155
+ return (
156
+ <div className="text-2xl font-bold">
157
+ {value}
158
+ <span className="text-sm font-normal text-muted-foreground ml-2">
159
+ ({count}×)
138
160
  </span>
161
+ </div>
162
+ );
163
+ }
164
+
165
+ // Multiple unique values: show as a compact list
166
+ return (
167
+ <div className="space-y-1">
168
+ {entries.slice(0, 5).map(([value, count]) => (
169
+ <div key={value} className="flex items-center justify-between">
170
+ <span className="font-mono text-sm">{value}</span>
171
+ <span className="text-muted-foreground text-sm">{count}×</span>
172
+ </div>
173
+ ))}
174
+ {entries.length > 5 && (
175
+ <div className="text-xs text-muted-foreground">
176
+ +{entries.length - 5} more
177
+ </div>
139
178
  )}
140
179
  </div>
141
180
  );
142
181
  }
143
182
 
144
183
  /**
145
- * Renders a percentage gauge visualization.
184
+ * Renders a percentage gauge visualization using Recharts RadialBarChart.
146
185
  */
147
186
  function GaugeRenderer({ field, context }: ChartRendererProps) {
148
187
  const value = getLatestValue(field.name, context);
@@ -151,38 +190,36 @@ function GaugeRenderer({ field, context }: ChartRendererProps) {
151
190
  const unit = field.unit ?? "%";
152
191
 
153
192
  // Determine color based on value (for rates: higher is better)
154
- const colorClass =
193
+ const fillColor =
155
194
  numValue >= 90
156
- ? "text-green-500"
195
+ ? "hsl(var(--success))"
157
196
  : numValue >= 70
158
- ? "text-yellow-500"
159
- : "text-red-500";
197
+ ? "hsl(var(--warning))"
198
+ : "hsl(var(--destructive))";
199
+
200
+ const data = [{ name: field.label, value: numValue, fill: fillColor }];
160
201
 
161
202
  return (
162
203
  <div className="flex items-center gap-3">
163
- <div className="relative w-16 h-16">
164
- <svg className="w-16 h-16 transform -rotate-90" viewBox="0 0 36 36">
165
- <circle
166
- cx="18"
167
- cy="18"
168
- r="15.5"
169
- fill="none"
170
- className="stroke-muted"
171
- strokeWidth="3"
172
- />
173
- <circle
174
- cx="18"
175
- cy="18"
176
- r="15.5"
177
- fill="none"
178
- className={colorClass.replace("text-", "stroke-")}
179
- strokeWidth="3"
180
- strokeDasharray={`${numValue} 100`}
181
- strokeLinecap="round"
204
+ <ResponsiveContainer width={80} height={80}>
205
+ <RadialBarChart
206
+ cx="50%"
207
+ cy="50%"
208
+ innerRadius="60%"
209
+ outerRadius="100%"
210
+ barSize={8}
211
+ data={data}
212
+ startAngle={90}
213
+ endAngle={-270}
214
+ >
215
+ <RadialBar
216
+ dataKey="value"
217
+ cornerRadius={4}
218
+ background={{ fill: "hsl(var(--muted))" }}
182
219
  />
183
- </svg>
184
- </div>
185
- <div className={`text-2xl font-bold ${colorClass}`}>
220
+ </RadialBarChart>
221
+ </ResponsiveContainer>
222
+ <div className="text-2xl font-bold" style={{ color: fillColor }}>
186
223
  {numValue.toFixed(1)}
187
224
  {unit}
188
225
  </div>
@@ -247,8 +284,7 @@ function StatusRenderer({ field, context }: ChartRendererProps) {
247
284
  }
248
285
 
249
286
  /**
250
- * Renders a simple line chart visualization.
251
- * For now, shows min/avg/max summary. Full charts can be added later.
287
+ * Renders an area chart for time series data using Recharts AreaChart.
252
288
  */
253
289
  function LineChartRenderer({ field, context }: ChartRendererProps) {
254
290
  const values = getAllValues(field.name, context);
@@ -258,31 +294,81 @@ function LineChartRenderer({ field, context }: ChartRendererProps) {
258
294
  return <div className="text-muted-foreground">No data</div>;
259
295
  }
260
296
 
261
- const min = Math.min(...values);
262
- const max = Math.max(...values);
297
+ // Transform values to recharts data format
298
+ const chartData = values.map((value, index) => ({
299
+ index,
300
+ value,
301
+ }));
302
+
263
303
  const avg = values.reduce((a, b) => a + b, 0) / values.length;
264
304
 
265
305
  return (
266
- <div className="space-y-1">
267
- <div className="text-2xl font-bold">
268
- {avg.toFixed(1)}
306
+ <div className="space-y-2">
307
+ <div className="text-lg font-medium">
308
+ Avg: {avg.toFixed(1)}
269
309
  {unit && (
270
310
  <span className="text-sm font-normal text-muted-foreground ml-1">
271
311
  {unit}
272
312
  </span>
273
313
  )}
274
314
  </div>
275
- <div className="text-xs text-muted-foreground">
276
- Min: {min.toFixed(1)}
277
- {unit} · Max: {max.toFixed(1)}
278
- {unit}
279
- </div>
315
+ <ResponsiveContainer width="100%" height={60}>
316
+ <AreaChart data={chartData}>
317
+ <defs>
318
+ <linearGradient
319
+ id={`gradient-${field.name}`}
320
+ x1="0"
321
+ y1="0"
322
+ x2="0"
323
+ y2="1"
324
+ >
325
+ <stop
326
+ offset="5%"
327
+ stopColor="hsl(var(--primary))"
328
+ stopOpacity={0.3}
329
+ />
330
+ <stop
331
+ offset="95%"
332
+ stopColor="hsl(var(--primary))"
333
+ stopOpacity={0}
334
+ />
335
+ </linearGradient>
336
+ </defs>
337
+ <Area
338
+ type="monotone"
339
+ dataKey="value"
340
+ stroke="hsl(var(--primary))"
341
+ fill={`url(#gradient-${field.name})`}
342
+ strokeWidth={2}
343
+ />
344
+ <Tooltip
345
+ content={({ active, payload }) => {
346
+ if (!active || !payload?.length) return;
347
+ const data = payload[0].payload as { value: number };
348
+ return (
349
+ <div
350
+ className="rounded-md border bg-popover p-2 text-sm shadow-md"
351
+ style={{
352
+ backgroundColor: "hsl(var(--popover))",
353
+ border: "1px solid hsl(var(--border))",
354
+ }}
355
+ >
356
+ <p className="font-medium">
357
+ {data.value.toFixed(1)}
358
+ {unit}
359
+ </p>
360
+ </div>
361
+ );
362
+ }}
363
+ />
364
+ </AreaChart>
365
+ </ResponsiveContainer>
280
366
  </div>
281
367
  );
282
368
  }
283
369
 
284
370
  /**
285
- * Renders a bar chart for record values.
371
+ * Renders a horizontal bar chart for record values using Recharts BarChart.
286
372
  */
287
373
  function BarChartRenderer({ field, context }: ChartRendererProps) {
288
374
  const value = getLatestValue(field.name, context);
@@ -291,25 +377,154 @@ function BarChartRenderer({ field, context }: ChartRendererProps) {
291
377
  return <div className="text-muted-foreground">No data</div>;
292
378
  }
293
379
 
294
- const entries = Object.entries(value as Record<string, number>).slice(0, 5);
295
- const maxValue = Math.max(...entries.map(([, v]) => v), 1);
380
+ const entries = Object.entries(value as Record<string, number>).slice(0, 8);
381
+ const chartData = entries.map(([name, value]) => ({ name, value }));
296
382
 
297
383
  return (
298
- <div className="space-y-2">
299
- {entries.map(([key, val]) => (
300
- <div key={key} className="flex items-center gap-2">
301
- <span className="text-xs w-12 text-right text-muted-foreground">
302
- {key}
303
- </span>
304
- <div className="flex-1 h-4 bg-muted rounded overflow-hidden">
384
+ <ResponsiveContainer
385
+ width="100%"
386
+ height={Math.max(100, entries.length * 28)}
387
+ >
388
+ <BarChart
389
+ data={chartData}
390
+ layout="vertical"
391
+ margin={{ left: 20, right: 20 }}
392
+ >
393
+ <XAxis type="number" hide />
394
+ <YAxis
395
+ type="category"
396
+ dataKey="name"
397
+ tickLine={false}
398
+ axisLine={false}
399
+ tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
400
+ width={50}
401
+ />
402
+ <Tooltip
403
+ content={({ active, payload }) => {
404
+ if (!active || !payload?.length) return;
405
+ const data = payload[0].payload as { name: string; value: number };
406
+ return (
407
+ <div
408
+ className="rounded-md border bg-popover p-2 text-sm shadow-md"
409
+ style={{
410
+ backgroundColor: "hsl(var(--popover))",
411
+ border: "1px solid hsl(var(--border))",
412
+ }}
413
+ >
414
+ <p className="font-medium">
415
+ {data.name}: {data.value}
416
+ </p>
417
+ </div>
418
+ );
419
+ }}
420
+ />
421
+ <Bar dataKey="value" fill="hsl(var(--primary))" radius={[0, 4, 4, 0]} />
422
+ </BarChart>
423
+ </ResponsiveContainer>
424
+ );
425
+ }
426
+
427
+ // Color palette for pie segments
428
+ const CHART_COLORS = [
429
+ "hsl(var(--chart-1))",
430
+ "hsl(var(--chart-2))",
431
+ "hsl(var(--chart-3))",
432
+ "hsl(var(--chart-4))",
433
+ "hsl(var(--chart-5))",
434
+ "hsl(var(--primary))",
435
+ "hsl(var(--secondary))",
436
+ "hsl(var(--muted))",
437
+ ];
438
+
439
+ /**
440
+ * Renders a pie chart for category distribution values using Recharts PieChart.
441
+ * Supports both pre-aggregated objects (like statusCodeCounts) and simple values
442
+ * that need to be counted (like statusCode).
443
+ */
444
+ function PieChartRenderer({ field, context }: ChartRendererProps) {
445
+ // First, try to get a pre-aggregated object value
446
+ const value = getLatestValue(field.name, context);
447
+
448
+ // Determine the data source: use pre-aggregated object or count simple values
449
+ let dataRecord: Record<string, number>;
450
+ // eslint-disable-next-line unicorn/prefer-ternary
451
+ if (value && typeof value === "object" && !Array.isArray(value)) {
452
+ // Already an object (like statusCodeCounts from aggregated schema)
453
+ dataRecord = value as Record<string, number>;
454
+ } else {
455
+ // Simple values (like statusCode) - count occurrences
456
+ dataRecord = getValueCounts(field.name, context);
457
+ }
458
+
459
+ const entries = Object.entries(dataRecord).slice(0, 8);
460
+ const total = entries.reduce((sum, [, v]) => sum + v, 0);
461
+
462
+ if (total === 0) {
463
+ return <div className="text-muted-foreground">No data</div>;
464
+ }
465
+
466
+ const chartData = entries.map(([name, value]) => ({ name, value }));
467
+
468
+ return (
469
+ <div className="flex items-center gap-4">
470
+ <ResponsiveContainer width={100} height={100}>
471
+ <PieChart>
472
+ <Pie
473
+ data={chartData}
474
+ dataKey="value"
475
+ nameKey="name"
476
+ cx="50%"
477
+ cy="50%"
478
+ innerRadius={25}
479
+ outerRadius={45}
480
+ strokeWidth={0}
481
+ >
482
+ {chartData.map((_, index) => (
483
+ <Cell
484
+ key={`cell-${index}`}
485
+ fill={CHART_COLORS[index % CHART_COLORS.length]}
486
+ />
487
+ ))}
488
+ </Pie>
489
+ <Tooltip
490
+ content={({ active, payload }) => {
491
+ if (!active || !payload?.length) return;
492
+ const data = payload[0].payload as {
493
+ name: string;
494
+ value: number;
495
+ };
496
+ return (
497
+ <div
498
+ className="rounded-md border bg-popover p-2 text-sm shadow-md"
499
+ style={{
500
+ backgroundColor: "hsl(var(--popover))",
501
+ border: "1px solid hsl(var(--border))",
502
+ }}
503
+ >
504
+ <p className="font-medium">
505
+ {data.name}: {data.value} (
506
+ {((data.value / total) * 100).toFixed(0)}%)
507
+ </p>
508
+ </div>
509
+ );
510
+ }}
511
+ />
512
+ </PieChart>
513
+ </ResponsiveContainer>
514
+ <div className="flex-1 space-y-1 text-xs">
515
+ {chartData.map((item, i) => (
516
+ <div key={item.name} className="flex items-center gap-2">
305
517
  <div
306
- className="h-full bg-primary"
307
- style={{ width: `${(val / maxValue) * 100}%` }}
518
+ className="w-3 h-3 rounded-sm"
519
+ style={{ backgroundColor: CHART_COLORS[i % CHART_COLORS.length] }}
308
520
  />
521
+ <span className="text-muted-foreground">{item.name}</span>
522
+ <span className="ml-auto font-medium">
523
+ {item.value} ({((item.value / total) * 100).toFixed(0)}%)
524
+ </span>
309
525
  </div>
310
- <span className="text-xs w-8">{val}</span>
311
- </div>
312
- ))}
526
+ ))}
527
+ </div>
313
528
  </div>
314
529
  );
315
530
  }
@@ -319,10 +534,11 @@ function BarChartRenderer({ field, context }: ChartRendererProps) {
319
534
  // =============================================================================
320
535
 
321
536
  /**
322
- * Get the latest value for a field from the context.
537
+ * Get the aggregated value for a field from the context.
323
538
  *
324
- * For raw runs, the strategy-specific data is inside result.metadata.
325
- * For aggregated buckets, the data is directly in aggregatedResult.
539
+ * For raw runs: returns the latest value from result.metadata
540
+ * For aggregated buckets: combines record values (counters) across ALL buckets,
541
+ * or returns the latest for non-aggregatable types.
326
542
  */
327
543
  function getLatestValue(
328
544
  fieldName: string,
@@ -331,19 +547,102 @@ function getLatestValue(
331
547
  if (context.type === "raw") {
332
548
  const runs = context.runs;
333
549
  if (runs.length === 0) return undefined;
334
- // result is typed as StoredHealthCheckResult with { status, latencyMs, message, metadata }
335
- const result = runs.at(-1)?.result as StoredHealthCheckResult | undefined;
336
- return getFieldValue(result?.metadata, fieldName);
550
+ // For raw runs, aggregate across all runs for record types
551
+ const allValues = runs.map((run) => {
552
+ const result = run.result as StoredHealthCheckResult | undefined;
553
+ return getFieldValue(result?.metadata, fieldName);
554
+ });
555
+
556
+ // If the values are record types (like statusCodeCounts), combine them
557
+ const firstVal = allValues.find((v) => v !== undefined);
558
+ if (firstVal && typeof firstVal === "object" && !Array.isArray(firstVal)) {
559
+ return combineRecordValues(allValues as Record<string, number>[]);
560
+ }
561
+ // For simple values, return the latest
562
+ return allValues.at(-1);
337
563
  } else {
338
564
  const buckets = context.buckets;
339
565
  if (buckets.length === 0) return undefined;
340
- return getFieldValue(
341
- buckets.at(-1)?.aggregatedResult as Record<string, unknown>,
342
- fieldName
566
+
567
+ // Get all values for this field from all buckets
568
+ const allValues = buckets.map((bucket) =>
569
+ getFieldValue(
570
+ bucket.aggregatedResult as Record<string, unknown>,
571
+ fieldName
572
+ )
343
573
  );
574
+
575
+ // If the values are record types (like statusCodeCounts), combine them
576
+ const firstVal = allValues.find((v) => v !== undefined);
577
+ if (firstVal && typeof firstVal === "object" && !Array.isArray(firstVal)) {
578
+ return combineRecordValues(allValues as Record<string, number>[]);
579
+ }
580
+ // For simple values (like errorCount), sum them
581
+ if (typeof firstVal === "number") {
582
+ return allValues
583
+ .filter((v): v is number => typeof v === "number")
584
+ .reduce((sum, v) => sum + v, 0);
585
+ }
586
+ // For other types, return the latest
587
+ return allValues.at(-1);
344
588
  }
345
589
  }
346
590
 
591
+ /**
592
+ * Combine record values (like statusCodeCounts) across multiple buckets/runs.
593
+ * Adds up the counts for each key.
594
+ */
595
+ function combineRecordValues(
596
+ values: (Record<string, number> | undefined)[]
597
+ ): Record<string, number> {
598
+ const combined: Record<string, number> = {};
599
+ for (const val of values) {
600
+ if (!val || typeof val !== "object") continue;
601
+ for (const [key, count] of Object.entries(val)) {
602
+ if (typeof count === "number") {
603
+ combined[key] = (combined[key] || 0) + count;
604
+ }
605
+ }
606
+ }
607
+ return combined;
608
+ }
609
+
610
+ /**
611
+ * Count occurrences of each unique value for a field across all runs/buckets.
612
+ * Returns a record mapping each unique value to its count.
613
+ */
614
+ function getValueCounts(
615
+ fieldName: string,
616
+ context: HealthCheckDiagramSlotContext
617
+ ): Record<string, number> {
618
+ const counts: Record<string, number> = {};
619
+
620
+ if (context.type === "raw") {
621
+ for (const run of context.runs) {
622
+ const result = run.result as StoredHealthCheckResult | undefined;
623
+ const value = getFieldValue(result?.metadata, fieldName);
624
+ if (value !== undefined && value !== null) {
625
+ const key = String(value);
626
+ counts[key] = (counts[key] || 0) + 1;
627
+ }
628
+ }
629
+ } else {
630
+ // For aggregated buckets, we need to look at each bucket's data
631
+ for (const bucket of context.buckets) {
632
+ const value = getFieldValue(
633
+ bucket.aggregatedResult as Record<string, unknown>,
634
+ fieldName
635
+ );
636
+ if (value !== undefined && value !== null) {
637
+ const key = String(value);
638
+ counts[key] = (counts[key] || 0) + 1;
639
+ }
640
+ }
641
+ }
642
+
643
+ return counts;
644
+ }
645
+
347
646
  /**
348
647
  * Get all numeric values for a field from the context.
349
648
  *
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  export { extractChartFields, getFieldValue } from "./schema-parser";
9
- export type { ChartField, ChartType } from "./schema-parser";
9
+ export type { ChartField } from "./schema-parser";
10
10
  export { AutoChartGrid } from "./AutoChartGrid";
11
11
  export { useStrategySchemas } from "./useStrategySchemas";
12
12
  export { autoChartExtension } from "./extension";