@checkstack/healthcheck-frontend 0.9.1 → 0.11.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,77 @@
1
1
  # @checkstack/healthcheck-frontend
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 84dd430: ## Single Run Auto-Charts
8
+
9
+ Added `SingleRunChartGrid` component to display auto-generated charts for individual health check runs when viewing run history details.
10
+
11
+ ### Features
12
+
13
+ - Renders charts based on the strategy's `resultSchema` metadata (same as aggregated charts)
14
+ - Supports all chart types: gauge, counter, boolean, text, status
15
+ - Groups fields by collector instance with assertion status display
16
+ - Updated `useStrategySchemas` hook to also return `resultSchema` for single-run visualization
17
+
18
+ ### Changes
19
+
20
+ - Simplified `ExpandedResultView` to show only basic run metadata (status, latency, connection)
21
+ - Collector results and detailed data now displayed via `SingleRunChartGrid`
22
+
23
+ ### Patch Changes
24
+
25
+ - c842373: ## Animated Numbers & Availability Stats Live Updates
26
+
27
+ ### Features
28
+
29
+ - **AnimatedNumber component** (`@checkstack/ui`): New reusable component that displays numbers with a smooth "rolling" animation when values change. Uses `requestAnimationFrame` with eased interpolation for a polished effect.
30
+ - **useAnimatedNumber hook** (`@checkstack/ui`): Underlying hook for the animation logic, can be used directly for custom implementations.
31
+ - **Live availability updates**: Availability stats (31-day and 365-day) now automatically refresh when new health check runs are received via signals.
32
+
33
+ ### Usage
34
+
35
+ ```tsx
36
+ import { AnimatedNumber } from "@checkstack/ui";
37
+
38
+ <AnimatedNumber
39
+ value={99.95}
40
+ suffix="%"
41
+ decimals={2}
42
+ duration={500}
43
+ className="text-2xl font-bold text-green-500"
44
+ />;
45
+ ```
46
+
47
+ - c842373: ## Fix Counter Chart Multiplier Display
48
+
49
+ Hide redundant "(1×)" multiplier suffix for single-value counters in auto-charts. For aggregated counter values like "Errors", the displayed value itself represents the count, so showing "(1×)" adds no information and is confusing.
50
+
51
+ - Updated dependencies [c842373]
52
+ - @checkstack/ui@1.1.0
53
+ - @checkstack/auth-frontend@0.5.10
54
+ - @checkstack/dashboard-frontend@0.3.16
55
+
56
+ ## 0.10.0
57
+
58
+ ### Minor Changes
59
+
60
+ - 3dd1914: Migrate health check strategies to VersionedAggregated with \_type discriminator
61
+
62
+ All 13 health check strategies now use `VersionedAggregated` for their `aggregatedResult` property, enabling automatic bucket merging with 100% mathematical fidelity.
63
+
64
+ **Key changes:**
65
+
66
+ - **`_type` discriminator**: All aggregated state objects now include a required `_type` field (`"average"`, `"rate"`, `"counter"`, `"minmax"`) for reliable type detection
67
+ - The `HealthCheckStrategy` interface now requires `aggregatedResult` to be a `VersionedAggregated<AggregatedResultShape>`
68
+ - Strategy/collector `mergeResult` methods return state objects with `_type` (e.g., `{ _type: "average", _sum, _count, avg }`)
69
+ - `mergeAggregatedBucketResults`, `combineBuckets`, and `reaggregateBuckets` now require `registry` and `strategyId` parameters
70
+ - `HealthCheckService` constructor now requires both `registry` and `collectorRegistry` parameters
71
+ - Frontend `extractComputedValue` now uses `_type` discriminator for robust type detection
72
+
73
+ **Breaking Change**: State objects now require `_type`. Merge functions automatically add `_type` to output. The bucket merging functions and `HealthCheckService` now require additional required parameters.
74
+
3
75
  ## 0.9.1
4
76
 
5
77
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-frontend",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "main": "src/index.tsx",
6
6
  "scripts": {
@@ -9,14 +9,14 @@
9
9
  "lint:code": "eslint . --max-warnings 0"
10
10
  },
11
11
  "dependencies": {
12
- "@checkstack/auth-frontend": "0.5.8",
13
- "@checkstack/catalog-common": "1.2.6",
14
- "@checkstack/common": "0.6.1",
15
- "@checkstack/dashboard-frontend": "0.3.14",
16
- "@checkstack/frontend-api": "0.3.4",
17
- "@checkstack/healthcheck-common": "0.8.1",
18
- "@checkstack/signal-frontend": "0.0.11",
19
- "@checkstack/ui": "0.5.3",
12
+ "@checkstack/auth-frontend": "0.5.9",
13
+ "@checkstack/catalog-common": "1.2.7",
14
+ "@checkstack/common": "0.6.2",
15
+ "@checkstack/dashboard-frontend": "0.3.15",
16
+ "@checkstack/frontend-api": "0.3.5",
17
+ "@checkstack/healthcheck-common": "0.8.2",
18
+ "@checkstack/signal-frontend": "0.0.12",
19
+ "@checkstack/ui": "1.0.0",
20
20
  "ajv": "^8.17.1",
21
21
  "ajv-formats": "^3.0.1",
22
22
  "date-fns": "^4.1.0",
@@ -465,15 +465,18 @@ function CounterRenderer({ field, context }: ChartRendererProps) {
465
465
  // Sort by count (descending) then by value
466
466
  entries.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
467
467
 
468
- // If there's only one unique value, show it prominently with count
468
+ // If there's only one unique value, show it prominently
469
+ // Only show count multiplier if > 1 (avoids confusing "(1×)" for aggregated counters)
469
470
  if (entries.length === 1) {
470
471
  const [value, count] = entries[0];
471
472
  return (
472
473
  <div className="text-2xl font-bold">
473
474
  {value}
474
- <span className="text-sm font-normal text-muted-foreground ml-2">
475
- ({count}×)
476
- </span>
475
+ {count > 1 && (
476
+ <span className="text-sm font-normal text-muted-foreground ml-2">
477
+ ({count}×)
478
+ </span>
479
+ )}
477
480
  </div>
478
481
  );
479
482
  }
@@ -0,0 +1,359 @@
1
+ /**
2
+ * SingleRunChartGrid - Renders auto-generated charts for a single health check run.
3
+ *
4
+ * Unlike AutoChartGrid which shows time series data, this component displays
5
+ * static values from a single run's result/metadata.
6
+ */
7
+
8
+ import type { ChartField } from "./schema-parser";
9
+ import { extractChartFields, getFieldValue } from "./schema-parser";
10
+ import { useStrategySchemas } from "./useStrategySchemas";
11
+ import { Card, CardContent, CardHeader, CardTitle } from "@checkstack/ui";
12
+ import { RadialBarChart, RadialBar, ResponsiveContainer } from "recharts";
13
+
14
+ interface SingleRunChartGridProps {
15
+ /** Strategy ID (qualified, e.g., "healthcheck-http-backend.http") */
16
+ strategyId: string;
17
+ /** The run's result data containing metadata */
18
+ result: Record<string, unknown>;
19
+ }
20
+
21
+ /**
22
+ * Main component that renders a grid of charts for a single run.
23
+ */
24
+ export function SingleRunChartGrid({
25
+ strategyId,
26
+ result,
27
+ }: SingleRunChartGridProps) {
28
+ const { schemas, loading } = useStrategySchemas(strategyId);
29
+
30
+ if (loading) {
31
+ return;
32
+ }
33
+
34
+ if (!schemas) {
35
+ return;
36
+ }
37
+
38
+ // Use result schema for per-run data
39
+ const schema = schemas.resultSchema;
40
+
41
+ const schemaFields = extractChartFields(schema);
42
+ if (schemaFields.length === 0) {
43
+ return;
44
+ }
45
+
46
+ // Get the metadata from the result
47
+ const metadata = result.metadata as Record<string, unknown> | undefined;
48
+ if (!metadata) {
49
+ return;
50
+ }
51
+
52
+ // Discover collector instances from result data
53
+ const collectors = metadata.collectors as
54
+ | Record<string, Record<string, unknown>>
55
+ | undefined;
56
+ const collectorEntries = collectors ? Object.entries(collectors) : [];
57
+
58
+ // Separate strategy-level fields from collector fields
59
+ const strategyFields = schemaFields.filter((f) => !f.collectorId);
60
+
61
+ return (
62
+ <div className="space-y-6">
63
+ {/* Strategy-level fields */}
64
+ {strategyFields.length > 0 && (
65
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
66
+ {strategyFields.map((field) => (
67
+ <SingleValueCard
68
+ key={field.name}
69
+ field={field}
70
+ value={getFieldValue(metadata, field.name)}
71
+ />
72
+ ))}
73
+ </div>
74
+ )}
75
+
76
+ {/* Collector groups */}
77
+ {collectorEntries
78
+ .filter(([, collectorData]) => {
79
+ const collectorId = collectorData._collectorId as string | undefined;
80
+ if (!collectorId) return false;
81
+ const collectorFields = schemaFields.filter(
82
+ (f) => f.collectorId === collectorId,
83
+ );
84
+ return collectorFields.length > 0;
85
+ })
86
+ .map(([instanceId, collectorData]) => {
87
+ const collectorId = collectorData._collectorId as string;
88
+ const collectorFields = schemaFields.filter(
89
+ (f) => f.collectorId === collectorId,
90
+ );
91
+ return (
92
+ <CollectorSection
93
+ key={instanceId}
94
+ instanceId={instanceId}
95
+ collectorId={collectorId}
96
+ fields={collectorFields}
97
+ data={collectorData}
98
+ />
99
+ );
100
+ })}
101
+ </div>
102
+ );
103
+ }
104
+
105
+ interface CollectorSectionProps {
106
+ instanceId: string;
107
+ collectorId: string;
108
+ fields: ChartField[];
109
+ data: Record<string, unknown>;
110
+ }
111
+
112
+ /**
113
+ * Section for a single collector instance.
114
+ */
115
+ function CollectorSection({
116
+ instanceId,
117
+ collectorId,
118
+ fields,
119
+ data,
120
+ }: CollectorSectionProps) {
121
+ const displayName = collectorId.split(".").pop() || collectorId;
122
+ const assertionFailed = data._assertionFailed as string | undefined;
123
+
124
+ return (
125
+ <div className="space-y-4">
126
+ <div className="flex items-center gap-2">
127
+ <h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
128
+ {displayName}
129
+ </h4>
130
+ <span className="text-xs text-muted-foreground">
131
+ ({instanceId.slice(0, 8)})
132
+ </span>
133
+ </div>
134
+
135
+ {/* Assertion status if present */}
136
+ {assertionFailed && (
137
+ <Card className="border-red-200 dark:border-red-900">
138
+ <CardHeader className="pb-2">
139
+ <CardTitle className="text-sm font-medium text-red-600">
140
+ Assertion Failed
141
+ </CardTitle>
142
+ </CardHeader>
143
+ <CardContent>
144
+ <div className="text-sm text-red-600 bg-red-50 dark:bg-red-950 px-2 py-1 rounded">
145
+ {assertionFailed}
146
+ </div>
147
+ </CardContent>
148
+ </Card>
149
+ )}
150
+
151
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
152
+ {fields.map((field) => (
153
+ <SingleValueCard
154
+ key={field.name}
155
+ field={field}
156
+ value={getFieldValue(data, field.name)}
157
+ />
158
+ ))}
159
+ </div>
160
+ </div>
161
+ );
162
+ }
163
+
164
+ interface SingleValueCardProps {
165
+ field: ChartField;
166
+ value: unknown;
167
+ }
168
+
169
+ /**
170
+ * Card displaying a single value based on its chart type.
171
+ */
172
+ function SingleValueCard({ field, value }: SingleValueCardProps) {
173
+ return (
174
+ <Card>
175
+ <CardHeader className="pb-2">
176
+ <CardTitle className="text-sm font-medium">{field.label}</CardTitle>
177
+ </CardHeader>
178
+ <CardContent>
179
+ <SingleValueRenderer field={field} value={value} />
180
+ </CardContent>
181
+ </Card>
182
+ );
183
+ }
184
+
185
+ interface SingleValueRendererProps {
186
+ field: ChartField;
187
+ value: unknown;
188
+ }
189
+
190
+ /**
191
+ * Dispatches to appropriate renderer based on chart type.
192
+ */
193
+ function SingleValueRenderer({ field, value }: SingleValueRendererProps) {
194
+ switch (field.chartType) {
195
+ case "line":
196
+ case "counter": {
197
+ return <NumberRenderer value={value} unit={field.unit} />;
198
+ }
199
+ case "gauge": {
200
+ return <GaugeRenderer value={value} unit={field.unit} />;
201
+ }
202
+ case "boolean": {
203
+ return <BooleanRenderer value={value} />;
204
+ }
205
+ case "text": {
206
+ return <TextRenderer value={value} />;
207
+ }
208
+ case "status": {
209
+ return <StatusRenderer value={value} />;
210
+ }
211
+ case "bar":
212
+ case "pie": {
213
+ // For bar/pie, just show the value since we can't do distributions with a single point
214
+ return <TextRenderer value={value} />;
215
+ }
216
+ default: {
217
+ return <TextRenderer value={value} />;
218
+ }
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Renders a numeric value with optional unit.
224
+ */
225
+ function NumberRenderer({ value, unit }: { value: unknown; unit?: string }) {
226
+ if (value === undefined || value === null) {
227
+ return <div className="text-muted-foreground">—</div>;
228
+ }
229
+
230
+ const numValue = typeof value === "number" ? value : Number(value);
231
+ if (Number.isNaN(numValue)) {
232
+ return <div className="text-muted-foreground">{String(value)}</div>;
233
+ }
234
+
235
+ const formatted = Number.isInteger(numValue)
236
+ ? String(numValue)
237
+ : numValue.toFixed(2);
238
+
239
+ return (
240
+ <div className="text-2xl font-bold">
241
+ {formatted}
242
+ {unit && (
243
+ <span className="text-sm font-normal text-muted-foreground ml-1">
244
+ {unit}
245
+ </span>
246
+ )}
247
+ </div>
248
+ );
249
+ }
250
+
251
+ /**
252
+ * Renders a percentage gauge visualization.
253
+ */
254
+ function GaugeRenderer({ value, unit }: { value: unknown; unit?: string }) {
255
+ if (value === undefined || value === null) {
256
+ return <div className="text-muted-foreground">—</div>;
257
+ }
258
+
259
+ const numValue = typeof value === "number" ? value : Number(value);
260
+ if (Number.isNaN(numValue)) {
261
+ return <div className="text-muted-foreground">{String(value)}</div>;
262
+ }
263
+
264
+ const clampedValue = Math.min(100, Math.max(0, numValue));
265
+ const displayUnit = unit ?? "%";
266
+
267
+ // Determine color based on value
268
+ const fillColor =
269
+ clampedValue >= 90
270
+ ? "hsl(var(--success))"
271
+ : clampedValue >= 70
272
+ ? "hsl(var(--warning))"
273
+ : "hsl(var(--destructive))";
274
+
275
+ const data = [{ name: "value", value: clampedValue, fill: fillColor }];
276
+
277
+ return (
278
+ <div className="flex items-center gap-3">
279
+ <ResponsiveContainer width={80} height={80}>
280
+ <RadialBarChart
281
+ cx="50%"
282
+ cy="50%"
283
+ innerRadius="60%"
284
+ outerRadius="100%"
285
+ barSize={8}
286
+ data={data}
287
+ startAngle={90}
288
+ endAngle={-270}
289
+ >
290
+ <RadialBar
291
+ dataKey="value"
292
+ cornerRadius={4}
293
+ background={{ fill: "hsl(var(--muted))" }}
294
+ />
295
+ </RadialBarChart>
296
+ </ResponsiveContainer>
297
+ <div className="text-2xl font-bold" style={{ color: fillColor }}>
298
+ {clampedValue.toFixed(1)}
299
+ {displayUnit}
300
+ </div>
301
+ </div>
302
+ );
303
+ }
304
+
305
+ /**
306
+ * Renders a boolean indicator.
307
+ */
308
+ function BooleanRenderer({ value }: { value: unknown }) {
309
+ if (value === undefined || value === null) {
310
+ return <div className="text-muted-foreground">—</div>;
311
+ }
312
+
313
+ const boolValue = Boolean(value);
314
+
315
+ return (
316
+ <div className="flex items-center gap-2">
317
+ <div
318
+ className={`w-3 h-3 rounded-full ${
319
+ boolValue ? "bg-green-500" : "bg-red-500"
320
+ }`}
321
+ />
322
+ <span className={boolValue ? "text-green-600" : "text-red-600"}>
323
+ {boolValue ? "Yes" : "No"}
324
+ </span>
325
+ </div>
326
+ );
327
+ }
328
+
329
+ /**
330
+ * Renders a text value.
331
+ */
332
+ function TextRenderer({ value }: { value: unknown }) {
333
+ if (value === undefined || value === null || value === "") {
334
+ return <div className="text-muted-foreground">—</div>;
335
+ }
336
+
337
+ const strValue = String(value);
338
+
339
+ return (
340
+ <div className="text-sm font-mono truncate" title={strValue}>
341
+ {strValue}
342
+ </div>
343
+ );
344
+ }
345
+
346
+ /**
347
+ * Renders an error/status badge.
348
+ */
349
+ function StatusRenderer({ value }: { value: unknown }) {
350
+ if (value === undefined || value === null || value === "") {
351
+ return <div className="text-sm text-muted-foreground">No errors</div>;
352
+ }
353
+
354
+ return (
355
+ <div className="text-sm text-red-600 bg-red-50 dark:bg-red-950 px-2 py-1 rounded truncate">
356
+ {String(value)}
357
+ </div>
358
+ );
359
+ }
@@ -8,5 +8,6 @@
8
8
  export { extractChartFields, getFieldValue } from "./schema-parser";
9
9
  export type { ChartField } from "./schema-parser";
10
10
  export { AutoChartGrid } from "./AutoChartGrid";
11
+ export { SingleRunChartGrid } from "./SingleRunChartGrid";
11
12
  export { useStrategySchemas } from "./useStrategySchemas";
12
13
  export { autoChartExtension } from "./extension";
@@ -17,8 +17,7 @@ import type {
17
17
  * JSON Schema property with healthcheck result-specific x-* extensions.
18
18
  * Uses the generic core type for proper recursive typing.
19
19
  */
20
- export interface ResultSchemaProperty
21
- extends JsonSchemaPropertyCore<ResultSchemaProperty> {
20
+ export interface ResultSchemaProperty extends JsonSchemaPropertyCore<ResultSchemaProperty> {
22
21
  // Result-specific x-* extensions for chart rendering
23
22
  "x-chart-type"?: ChartType;
24
23
  "x-chart-label"?: string;
@@ -58,7 +57,7 @@ export interface ChartField {
58
57
  * @returns Array of chart fields with metadata
59
58
  */
60
59
  export function extractChartFields(
61
- schema: Record<string, unknown> | null | undefined
60
+ schema: Record<string, unknown> | null | undefined,
62
61
  ): ChartField[] {
63
62
  if (!schema) return [];
64
63
 
@@ -72,13 +71,13 @@ export function extractChartFields(
72
71
  if (name === "collectors" && prop.type === "object" && prop.properties) {
73
72
  // Traverse each collector's schema
74
73
  for (const [collectorId, collectorProp] of Object.entries(
75
- prop.properties
74
+ prop.properties,
76
75
  )) {
77
76
  if (collectorProp.type === "object" && collectorProp.properties) {
78
77
  // Extract fields from the collector's result schema
79
78
  const collectorFields = extractFieldsFromProperties(
80
79
  collectorProp.properties,
81
- collectorId
80
+ collectorId,
82
81
  );
83
82
  fields.push(...collectorFields);
84
83
  }
@@ -100,7 +99,7 @@ export function extractChartFields(
100
99
  */
101
100
  function extractFieldsFromProperties(
102
101
  properties: Record<string, ResultSchemaProperty>,
103
- collectorId: string
102
+ collectorId: string,
104
103
  ): ChartField[] {
105
104
  const fields: ChartField[] = [];
106
105
 
@@ -126,7 +125,7 @@ function extractFieldsFromProperties(
126
125
  */
127
126
  function extractSingleField(
128
127
  name: string,
129
- prop: ResultSchemaProperty
128
+ prop: ResultSchemaProperty,
130
129
  ): ChartField {
131
130
  let schemaType = prop.type ?? "unknown";
132
131
  if (prop.type === "array" && prop.items?.type) {
@@ -164,6 +163,7 @@ function formatFieldName(name: string): string {
164
163
  /**
165
164
  * Get the value for a field from a data object.
166
165
  * For strategy-level fields, also searches inside collectors as fallback.
166
+ * Automatically extracts computed values from aggregated state objects.
167
167
  *
168
168
  * @param data - The metadata object
169
169
  * @param fieldName - Simple field name (no dot notation for collector fields)
@@ -172,7 +172,7 @@ function formatFieldName(name: string): string {
172
172
  export function getFieldValue(
173
173
  data: Record<string, unknown> | undefined,
174
174
  fieldName: string,
175
- collectorInstanceId?: string
175
+ collectorInstanceId?: string,
176
176
  ): unknown {
177
177
  if (!data) return undefined;
178
178
 
@@ -184,7 +184,7 @@ export function getFieldValue(
184
184
  if (collectors && typeof collectors === "object") {
185
185
  const collectorData = collectors[collectorInstanceId];
186
186
  if (collectorData && typeof collectorData === "object") {
187
- return collectorData[fieldName];
187
+ return extractComputedValue(collectorData[fieldName]);
188
188
  }
189
189
  }
190
190
  return undefined;
@@ -193,7 +193,7 @@ export function getFieldValue(
193
193
  // For non-collector fields, try direct lookup first
194
194
  const directValue = data[fieldName];
195
195
  if (directValue !== undefined) {
196
- return directValue;
196
+ return extractComputedValue(directValue);
197
197
  }
198
198
 
199
199
  // Fallback: search all collectors for the field (for strategy schema fields)
@@ -205,7 +205,7 @@ export function getFieldValue(
205
205
  if (collectorData && typeof collectorData === "object") {
206
206
  const value = collectorData[fieldName];
207
207
  if (value !== undefined) {
208
- return value;
208
+ return extractComputedValue(value);
209
209
  }
210
210
  }
211
211
  }
@@ -213,3 +213,49 @@ export function getFieldValue(
213
213
 
214
214
  return undefined;
215
215
  }
216
+
217
+ /**
218
+ * Extract the computed value from an aggregated state object.
219
+ * Uses the required `_type` discriminator field for type detection.
220
+ * Logs errors instead of throwing to avoid breaking the app.
221
+ *
222
+ * @param value - Value from API data (unknown type from JSON parsing)
223
+ */
224
+ function extractComputedValue(value: unknown): unknown {
225
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
226
+ return value;
227
+ }
228
+
229
+ const obj = value as Record<string, unknown>;
230
+
231
+ // _type is required for all aggregated state objects
232
+ if (!("_type" in obj)) {
233
+ console.error(
234
+ "[AutoChart] Missing _type discriminator in aggregated state:",
235
+ obj,
236
+ );
237
+ return value;
238
+ }
239
+
240
+ switch (obj._type) {
241
+ case "average": {
242
+ return obj.avg;
243
+ }
244
+ case "rate": {
245
+ return obj.rate;
246
+ }
247
+ case "counter": {
248
+ return obj.count;
249
+ }
250
+ case "minmax": {
251
+ // Default to max for minmax; caller can access min directly if needed
252
+ return obj.max;
253
+ }
254
+ default: {
255
+ console.error(
256
+ `[AutoChart] Unrecognized aggregated state type: ${String(obj._type)}`,
257
+ );
258
+ return value;
259
+ }
260
+ }
261
+ }
@@ -11,6 +11,7 @@ import { usePluginClient } from "@checkstack/frontend-api";
11
11
  import { HealthCheckApi } from "../api";
12
12
 
13
13
  interface StrategySchemas {
14
+ resultSchema: Record<string, unknown> | undefined;
14
15
  aggregatedResultSchema: Record<string, unknown> | undefined;
15
16
  }
16
17
 
@@ -65,7 +66,23 @@ export function useStrategySchemas(strategyId: string): {
65
66
  collectorAggregatedProperties,
66
67
  );
67
68
 
69
+ // Build collector result schemas for nesting under resultSchema.properties.collectors
70
+ const collectorResultProperties: Record<string, unknown> = {};
71
+
72
+ for (const collector of collectors) {
73
+ if (collector.resultSchema) {
74
+ collectorResultProperties[collector.id] = collector.resultSchema;
75
+ }
76
+ }
77
+
78
+ // Merge collector result schemas into strategy result schema
79
+ const mergedResultSchema = mergeCollectorSchemas(
80
+ strategy.resultSchema as Record<string, unknown> | undefined,
81
+ collectorResultProperties,
82
+ );
83
+
68
84
  setSchemas({
85
+ resultSchema: mergedResultSchema,
69
86
  aggregatedResultSchema: mergedAggregatedSchema,
70
87
  });
71
88
  }
@@ -1,5 +1,8 @@
1
1
  /**
2
- * ExpandedResultView - Displays health check result data in a structured format.
2
+ * ExpandedResultView - Displays basic health check result metadata.
3
+ *
4
+ * Shows status, latency, and connection time. Collector results and
5
+ * detailed visualizations are handled by SingleRunChartGrid.
3
6
  */
4
7
 
5
8
  interface ExpandedResultViewProps {
@@ -7,134 +10,28 @@ interface ExpandedResultViewProps {
7
10
  }
8
11
 
9
12
  /**
10
- * Displays the result data in a structured format.
11
- * Shows collector results as cards with key-value pairs.
13
+ * Displays basic run metadata (status, latency, connection time).
12
14
  */
13
15
  export function ExpandedResultView({ result }: ExpandedResultViewProps) {
14
16
  const metadata = result.metadata as Record<string, unknown> | undefined;
15
- const rawCollectors = metadata?.collectors;
16
-
17
- // Type guard for collectors object
18
- const collectors: Record<string, Record<string, unknown>> | undefined =
19
- rawCollectors &&
20
- typeof rawCollectors === "object" &&
21
- !Array.isArray(rawCollectors)
22
- ? (rawCollectors as Record<string, Record<string, unknown>>)
23
- : undefined;
24
-
25
- // Check if we have collectors to display
26
- const collectorEntries = collectors ? Object.entries(collectors) : [];
27
-
28
- // Extract connection time as typed value
29
17
  const connectionTimeMs = metadata?.connectionTimeMs as number | undefined;
30
18
 
31
19
  return (
32
- <div className="space-y-4">
33
- <div className="flex gap-4 text-sm">
34
- <div>
35
- <span className="text-muted-foreground">Status: </span>
36
- <span className="font-medium">{String(result.status)}</span>
37
- </div>
38
- <div>
39
- <span className="text-muted-foreground">Latency: </span>
40
- <span className="font-medium">{String(result.latencyMs)}ms</span>
41
- </div>
42
- {connectionTimeMs !== undefined && (
43
- <div>
44
- <span className="text-muted-foreground">Connection: </span>
45
- <span className="font-medium">{connectionTimeMs}ms</span>
46
- </div>
47
- )}
20
+ <div className="flex flex-wrap gap-4 text-sm">
21
+ <div>
22
+ <span className="text-muted-foreground">Status: </span>
23
+ <span className="font-medium">{String(result.status)}</span>
48
24
  </div>
49
-
50
- {collectorEntries.length > 0 && (
51
- <div className="space-y-3">
52
- <h4 className="text-sm font-medium">Collector Results</h4>
53
- <div className="grid gap-3 md:grid-cols-2">
54
- {collectorEntries.map(([collectorId, collectorResult]) => (
55
- <CollectorResultCard
56
- key={collectorId}
57
- collectorId={collectorId}
58
- result={collectorResult}
59
- />
60
- ))}
61
- </div>
25
+ <div>
26
+ <span className="text-muted-foreground">Latency: </span>
27
+ <span className="font-medium">{String(result.latencyMs)}ms</span>
28
+ </div>
29
+ {connectionTimeMs !== undefined && (
30
+ <div>
31
+ <span className="text-muted-foreground">Connection: </span>
32
+ <span className="font-medium">{connectionTimeMs}ms</span>
62
33
  </div>
63
34
  )}
64
-
65
- {result.message ? (
66
- <div className="text-sm text-muted-foreground">
67
- {String(result.message)}
68
- </div>
69
- ) : undefined}
70
- </div>
71
- );
72
- }
73
-
74
- interface CollectorResultCardProps {
75
- collectorId: string;
76
- result: Record<string, unknown>;
77
- }
78
-
79
- /**
80
- * Card displaying a single collector's result values.
81
- */
82
- function CollectorResultCard({
83
- collectorId,
84
- result,
85
- }: CollectorResultCardProps) {
86
- if (!result || typeof result !== "object") {
87
- return;
88
- }
89
-
90
- // Filter out null/undefined values
91
- const entries = Object.entries(result).filter(
92
- ([, value]) => value !== null && value !== undefined,
93
- );
94
-
95
- return (
96
- <div className="rounded-md border bg-card p-3 space-y-2">
97
- <h5 className="text-sm font-medium text-primary">{collectorId}</h5>
98
- <div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
99
- {entries.map(([key, value]) => (
100
- <div key={key} className="contents">
101
- <span className="text-muted-foreground truncate">
102
- {formatKey(key)}
103
- </span>
104
- <span className="font-mono text-xs truncate" title={String(value)}>
105
- {formatValue(value)}
106
- </span>
107
- </div>
108
- ))}
109
- </div>
110
35
  </div>
111
36
  );
112
37
  }
113
-
114
- /**
115
- * Format a camelCase key to a readable label.
116
- */
117
- function formatKey(key: string): string {
118
- return key
119
- .replaceAll(/([a-z])([A-Z])/g, "$1 $2")
120
- .replace(/^./, (c) => c.toUpperCase());
121
- }
122
-
123
- /**
124
- * Format a value for display.
125
- */
126
- function formatValue(value: unknown): string {
127
- if (value === null || value === undefined) return "—";
128
- if (typeof value === "boolean") return value ? "Yes" : "No";
129
- if (typeof value === "number") {
130
- return Number.isInteger(value) ? String(value) : value.toFixed(2);
131
- }
132
- if (Array.isArray(value)) {
133
- return value.length > 3
134
- ? `[${value.slice(0, 3).join(", ")}…]`
135
- : `[${value.join(", ")}]`;
136
- }
137
- if (typeof value === "object") return JSON.stringify(value);
138
- const str = String(value);
139
- return str.length > 50 ? `${str.slice(0, 47)}…` : str;
140
- }
@@ -36,6 +36,7 @@ import {
36
36
  CardContent,
37
37
  CardHeader,
38
38
  CardTitle,
39
+ AnimatedNumber,
39
40
  } from "@checkstack/ui";
40
41
  import { formatDistanceToNow } from "date-fns";
41
42
  import { ChevronDown, ChevronRight } from "lucide-react";
@@ -70,28 +71,21 @@ interface ExpandedRowProps {
70
71
  systemId: string;
71
72
  }
72
73
 
73
- // Helper to format availability percentage with color
74
- const formatAvailability = (
74
+ // Helper to get color class for availability percentage
75
+ const getAvailabilityColorClass = (
75
76
  value: number | null,
76
77
  totalRuns: number,
77
- ): { text: string; className: string } => {
78
+ ): string => {
78
79
  if (value === null || totalRuns === 0) {
79
- return { text: "N/A", className: "text-muted-foreground" };
80
+ return "text-muted-foreground";
80
81
  }
81
- const formatted = value.toFixed(2) + "%";
82
82
  if (value >= 99.9) {
83
- return {
84
- text: formatted,
85
- className: "text-green-600 dark:text-green-400",
86
- };
83
+ return "text-green-600 dark:text-green-400";
87
84
  }
88
85
  if (value >= 99) {
89
- return {
90
- text: formatted,
91
- className: "text-yellow-600 dark:text-yellow-400",
92
- };
86
+ return "text-yellow-600 dark:text-yellow-400";
93
87
  }
94
- return { text: formatted, className: "text-red-600 dark:text-red-400" };
88
+ return "text-red-600 dark:text-red-400";
95
89
  };
96
90
 
97
91
  const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
@@ -199,11 +193,12 @@ const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
199
193
  }
200
194
  const runs = displayRuns;
201
195
 
202
- // Listen for realtime health check updates to refresh history table
196
+ // Listen for realtime health check updates to refresh history table and availability stats
203
197
  // Charts are refreshed automatically by useHealthCheckData
204
198
  useSignal(HEALTH_CHECK_RUN_COMPLETED, ({ systemId: changedId }) => {
205
199
  if (changedId === systemId) {
206
200
  void refetch();
201
+ void refetchAvailability();
207
202
  }
208
203
  });
209
204
 
@@ -214,7 +209,7 @@ const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
214
209
  : "Using default thresholds";
215
210
 
216
211
  // Fetch availability stats
217
- const { data: availabilityData } =
212
+ const { data: availabilityData, refetch: refetchAvailability } =
218
213
  healthCheckClient.getAvailabilityStats.useQuery({
219
214
  systemId,
220
215
  configurationId: item.configurationId,
@@ -302,16 +297,11 @@ const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
302
297
  31-Day Availability
303
298
  </span>
304
299
  <div className="flex items-baseline gap-2">
305
- <span
306
- className={`text-2xl font-bold ${formatAvailability(availabilityData.availability31Days, availabilityData.totalRuns31Days).className}`}
307
- >
308
- {
309
- formatAvailability(
310
- availabilityData.availability31Days,
311
- availabilityData.totalRuns31Days,
312
- ).text
313
- }
314
- </span>
300
+ <AnimatedNumber
301
+ value={availabilityData.availability31Days ?? undefined}
302
+ suffix="%"
303
+ className={`text-2xl font-bold ${getAvailabilityColorClass(availabilityData.availability31Days, availabilityData.totalRuns31Days)}`}
304
+ />
315
305
  {availabilityData.totalRuns31Days > 0 && (
316
306
  <span className="text-sm text-muted-foreground">
317
307
  ({availabilityData.totalRuns31Days.toLocaleString()} runs)
@@ -324,16 +314,11 @@ const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
324
314
  365-Day Availability
325
315
  </span>
326
316
  <div className="flex items-baseline gap-2">
327
- <span
328
- className={`text-2xl font-bold ${formatAvailability(availabilityData.availability365Days, availabilityData.totalRuns365Days).className}`}
329
- >
330
- {
331
- formatAvailability(
332
- availabilityData.availability365Days,
333
- availabilityData.totalRuns365Days,
334
- ).text
335
- }
336
- </span>
317
+ <AnimatedNumber
318
+ value={availabilityData.availability365Days ?? undefined}
319
+ suffix="%"
320
+ className={`text-2xl font-bold ${getAvailabilityColorClass(availabilityData.availability365Days, availabilityData.totalRuns365Days)}`}
321
+ />
337
322
  {availabilityData.totalRuns365Days > 0 && (
338
323
  <span className="text-sm text-muted-foreground">
339
324
  ({availabilityData.totalRuns365Days.toLocaleString()} runs)
@@ -33,6 +33,7 @@ import {
33
33
  type HealthCheckRunDetailed,
34
34
  } from "../components/HealthCheckRunsTable";
35
35
  import { ExpandedResultView } from "../components/ExpandedResultView";
36
+ import { SingleRunChartGrid } from "../auto-charts";
36
37
 
37
38
  const HealthCheckHistoryDetailPageContent = () => {
38
39
  const { systemId, configurationId, runId } = useParams<{
@@ -63,6 +64,17 @@ const HealthCheckHistoryDetailPageContent = () => {
63
64
  },
64
65
  );
65
66
 
67
+ // Fetch configurations to get strategyId
68
+ const { data: configurations } = healthCheckClient.getConfigurations.useQuery(
69
+ {},
70
+ {
71
+ enabled: !!configurationId,
72
+ },
73
+ );
74
+ const configuration = configurations?.configurations.find(
75
+ (c) => c.id === configurationId,
76
+ );
77
+
66
78
  // Fetch data with useQuery - newest first for table display
67
79
  const { data, isLoading } = healthCheckClient.getDetailedHistory.useQuery({
68
80
  systemId,
@@ -127,8 +139,14 @@ const HealthCheckHistoryDetailPageContent = () => {
127
139
  {format(new Date(specificRun.timestamp), "PPpp")}
128
140
  </p>
129
141
  </CardHeader>
130
- <CardContent>
142
+ <CardContent className="space-y-4">
131
143
  <ExpandedResultView result={specificRun.result} />
144
+ {configuration?.strategyId && (
145
+ <SingleRunChartGrid
146
+ strategyId={configuration.strategyId}
147
+ result={specificRun.result}
148
+ />
149
+ )}
132
150
  </CardContent>
133
151
  </Card>
134
152
  )}