@checkstack/healthcheck-frontend 0.0.3 → 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,51 @@
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
+
3
49
  ## 0.0.3
4
50
 
5
51
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-frontend",
3
- "version": "0.0.3",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "main": "src/index.tsx",
6
6
  "scripts": {
@@ -3,15 +3,38 @@
3
3
  *
4
4
  * Parses JSON Schema objects and extracts x-chart-type, x-chart-label,
5
5
  * and x-chart-unit metadata for auto-chart rendering.
6
+ *
7
+ * Supports nested schemas under `collectors.*` for per-collector metrics.
6
8
  */
7
9
 
8
10
  import type { ChartType } from "@checkstack/healthcheck-common";
11
+ import type {
12
+ JsonSchemaPropertyCore,
13
+ JsonSchemaBase,
14
+ } from "@checkstack/common";
15
+
16
+ /**
17
+ * JSON Schema property with healthcheck result-specific x-* extensions.
18
+ * Uses the generic core type for proper recursive typing.
19
+ */
20
+ export interface ResultSchemaProperty
21
+ extends JsonSchemaPropertyCore<ResultSchemaProperty> {
22
+ // Result-specific x-* extensions for chart rendering
23
+ "x-chart-type"?: ChartType;
24
+ "x-chart-label"?: string;
25
+ "x-chart-unit"?: string;
26
+ }
27
+
28
+ /**
29
+ * JSON Schema for result schemas with chart metadata.
30
+ */
31
+ export type ResultSchema = JsonSchemaBase<ResultSchemaProperty>;
9
32
 
10
33
  /**
11
34
  * Chart field information extracted from JSON Schema.
12
35
  */
13
36
  export interface ChartField {
14
- /** Field name in the schema */
37
+ /** Field path (supports dot notation for nested fields like "collectors.request.responseTimeMs") */
15
38
  name: string;
16
39
  /** Chart type to render */
17
40
  chartType: ChartType;
@@ -21,33 +44,15 @@ export interface ChartField {
21
44
  unit?: string;
22
45
  /** JSON Schema type (number, string, boolean, etc.) */
23
46
  schemaType: string;
24
- }
25
-
26
- /**
27
- * JSON Schema property with potential chart metadata.
28
- */
29
- interface JsonSchemaProperty {
30
- type?: string;
31
- "x-chart-type"?: ChartType;
32
- "x-chart-label"?: string;
33
- "x-chart-unit"?: string;
34
- items?: JsonSchemaProperty;
35
- additionalProperties?: JsonSchemaProperty;
36
- }
37
-
38
- /**
39
- * JSON Schema object structure.
40
- */
41
- interface JsonSchema {
42
- type?: string;
43
- properties?: Record<string, JsonSchemaProperty>;
47
+ /** Collector ID if this field is from a collector */
48
+ collectorId?: string;
44
49
  }
45
50
 
46
51
  /**
47
52
  * Extract chart fields from a JSON Schema.
48
53
  *
49
54
  * Looks for properties with x-chart-type metadata and extracts
50
- * relevant chart configuration.
55
+ * relevant chart configuration. Supports nested collector schemas.
51
56
  *
52
57
  * @param schema - JSON Schema object
53
58
  * @returns Array of chart fields with metadata
@@ -57,41 +62,102 @@ export function extractChartFields(
57
62
  ): ChartField[] {
58
63
  if (!schema) return [];
59
64
 
60
- const typed = schema as JsonSchema;
65
+ const typed = schema as ResultSchema;
61
66
  if (typed.type !== "object" || !typed.properties) return [];
62
67
 
63
68
  const fields: ChartField[] = [];
64
69
 
65
70
  for (const [name, prop] of Object.entries(typed.properties)) {
71
+ // Check for nested collector schemas
72
+ if (name === "collectors" && prop.type === "object" && prop.properties) {
73
+ // Traverse each collector's schema
74
+ for (const [collectorId, collectorProp] of Object.entries(
75
+ prop.properties
76
+ )) {
77
+ if (collectorProp.type === "object" && collectorProp.properties) {
78
+ // Extract fields from the collector's result schema
79
+ const collectorFields = extractFieldsFromProperties(
80
+ collectorProp.properties,
81
+ `collectors.${collectorId}`,
82
+ collectorId
83
+ );
84
+ fields.push(...collectorFields);
85
+ }
86
+ }
87
+ continue;
88
+ }
89
+
66
90
  const chartType = prop["x-chart-type"];
67
91
  if (!chartType) continue;
68
92
 
69
- // Determine the underlying schema type
70
- let schemaType = prop.type ?? "unknown";
71
- if (prop.type === "array" && prop.items?.type) {
72
- schemaType = `array<${prop.items.type}>`;
73
- }
74
- if (prop.additionalProperties?.type) {
75
- schemaType = `record<${prop.additionalProperties.type}>`;
76
- }
93
+ fields.push(extractSingleField(name, prop));
94
+ }
95
+
96
+ return fields;
97
+ }
77
98
 
78
- fields.push({
79
- name,
80
- chartType,
81
- label: prop["x-chart-label"] ?? formatFieldName(name),
82
- unit: prop["x-chart-unit"],
83
- schemaType,
84
- });
99
+ /**
100
+ * Extract fields from a nested properties object.
101
+ */
102
+ function extractFieldsFromProperties(
103
+ properties: Record<string, ResultSchemaProperty>,
104
+ pathPrefix: string,
105
+ collectorId: string
106
+ ): ChartField[] {
107
+ const fields: ChartField[] = [];
108
+
109
+ for (const [fieldName, prop] of Object.entries(properties)) {
110
+ const chartType = prop["x-chart-type"];
111
+ if (!chartType) continue;
112
+
113
+ const fullPath = `${pathPrefix}.${fieldName}`;
114
+ const field = extractSingleField(fullPath, prop);
115
+ field.collectorId = collectorId;
116
+ // Prefix label with collector ID for clarity
117
+ if (!prop["x-chart-label"]?.includes(collectorId)) {
118
+ field.label = `${collectorId}: ${field.label}`;
119
+ }
120
+ fields.push(field);
85
121
  }
86
122
 
87
123
  return fields;
88
124
  }
89
125
 
126
+ /**
127
+ * Extract a single field from a property.
128
+ */
129
+ function extractSingleField(
130
+ name: string,
131
+ prop: ResultSchemaProperty
132
+ ): ChartField {
133
+ let schemaType = prop.type ?? "unknown";
134
+ if (prop.type === "array" && prop.items?.type) {
135
+ schemaType = `array<${prop.items.type}>`;
136
+ }
137
+ if (
138
+ prop.additionalProperties &&
139
+ typeof prop.additionalProperties === "object" &&
140
+ prop.additionalProperties.type
141
+ ) {
142
+ schemaType = `record<${prop.additionalProperties.type}>`;
143
+ }
144
+
145
+ return {
146
+ name,
147
+ chartType: prop["x-chart-type"]!,
148
+ label: prop["x-chart-label"] ?? formatFieldName(name),
149
+ unit: prop["x-chart-unit"],
150
+ schemaType,
151
+ };
152
+ }
153
+
90
154
  /**
91
155
  * Convert camelCase or snake_case field name to human-readable label.
92
156
  */
93
157
  function formatFieldName(name: string): string {
94
- return name
158
+ // Extract just the field name if it's a path
159
+ const baseName = name.includes(".") ? name.split(".").pop()! : name;
160
+ return baseName
95
161
  .replaceAll(/([a-z])([A-Z])/g, "$1 $2") // camelCase
96
162
  .replaceAll("_", " ") // snake_case
97
163
  .replaceAll(/\b\w/g, (c) => c.toUpperCase()); // Capitalize
@@ -99,11 +165,28 @@ function formatFieldName(name: string): string {
99
165
 
100
166
  /**
101
167
  * Get the value for a field from a data object.
168
+ * Supports dot-notation paths like "collectors.request.responseTimeMs".
102
169
  */
103
170
  export function getFieldValue(
104
171
  data: Record<string, unknown> | undefined,
105
172
  fieldName: string
106
173
  ): unknown {
107
174
  if (!data) return undefined;
108
- return data[fieldName];
175
+
176
+ // Simple case: no dot notation
177
+ if (!fieldName.includes(".")) {
178
+ return data[fieldName];
179
+ }
180
+
181
+ // Dot notation: traverse the path
182
+ const parts = fieldName.split(".");
183
+ let current: unknown = data;
184
+
185
+ for (const part of parts) {
186
+ if (current === null || current === undefined) return undefined;
187
+ if (typeof current !== "object") return undefined;
188
+ current = (current as Record<string, unknown>)[part];
189
+ }
190
+
191
+ return current;
109
192
  }
@@ -0,0 +1,425 @@
1
+ import React, { useMemo } from "react";
2
+ import {
3
+ Button,
4
+ Select,
5
+ SelectContent,
6
+ SelectItem,
7
+ SelectTrigger,
8
+ SelectValue,
9
+ Input,
10
+ } from "@checkstack/ui";
11
+ import type {
12
+ JsonSchemaBase,
13
+ JsonSchemaPropertyBase,
14
+ } from "@checkstack/common";
15
+ import { Plus, Trash2 } from "lucide-react";
16
+
17
+ // ============================================================================
18
+ // TYPES
19
+ // ============================================================================
20
+
21
+ // Use base types for assertion building - works with any JSON Schema
22
+ type JsonSchema = JsonSchemaBase<JsonSchemaPropertyBase>;
23
+ type JsonSchemaProperty = JsonSchemaPropertyBase;
24
+
25
+ type FieldType =
26
+ | "string"
27
+ | "number"
28
+ | "boolean"
29
+ | "enum"
30
+ | "array"
31
+ | "jsonpath";
32
+
33
+ export interface AssertableField {
34
+ path: string;
35
+ displayName: string;
36
+ type: FieldType;
37
+ enumValues?: unknown[];
38
+ /** For jsonpath fields, the original field path (e.g., "body") */
39
+ sourceField?: string;
40
+ }
41
+
42
+ export interface Assertion {
43
+ field: string;
44
+ operator: string;
45
+ value?: unknown;
46
+ /** JSONPath expression for jsonpath-type fields */
47
+ jsonPath?: string;
48
+ }
49
+
50
+ interface AssertionBuilderProps {
51
+ resultSchema: JsonSchema;
52
+ assertions: Assertion[];
53
+ onChange: (assertions: Assertion[]) => void;
54
+ }
55
+
56
+ // ============================================================================
57
+ // OPERATORS BY FIELD TYPE
58
+ // ============================================================================
59
+
60
+ const OPERATORS: Record<FieldType, { value: string; label: string }[]> = {
61
+ string: [
62
+ { value: "equals", label: "Equals" },
63
+ { value: "notEquals", label: "Not Equals" },
64
+ { value: "contains", label: "Contains" },
65
+ { value: "startsWith", label: "Starts With" },
66
+ { value: "endsWith", label: "Ends With" },
67
+ { value: "matches", label: "Matches (Regex)" },
68
+ { value: "isEmpty", label: "Is Empty" },
69
+ ],
70
+ number: [
71
+ { value: "equals", label: "Equals" },
72
+ { value: "notEquals", label: "Not Equals" },
73
+ { value: "lessThan", label: "Less Than" },
74
+ { value: "lessThanOrEqual", label: "Less Than or Equal" },
75
+ { value: "greaterThan", label: "Greater Than" },
76
+ { value: "greaterThanOrEqual", label: "Greater Than or Equal" },
77
+ ],
78
+ boolean: [
79
+ { value: "isTrue", label: "Is True" },
80
+ { value: "isFalse", label: "Is False" },
81
+ ],
82
+ enum: [{ value: "equals", label: "Equals" }],
83
+ array: [
84
+ { value: "exists", label: "Exists" },
85
+ { value: "notExists", label: "Not Exists" },
86
+ ],
87
+ jsonpath: [
88
+ { value: "exists", label: "Exists" },
89
+ { value: "notExists", label: "Not Exists" },
90
+ { value: "equals", label: "Equals" },
91
+ { value: "notEquals", label: "Not Equals" },
92
+ { value: "contains", label: "Contains" },
93
+ { value: "greaterThan", label: "Greater Than" },
94
+ { value: "lessThan", label: "Less Than" },
95
+ ],
96
+ };
97
+
98
+ // Operators that don't need a value input
99
+ const VALUE_LESS_OPERATORS = new Set([
100
+ "isEmpty",
101
+ "isTrue",
102
+ "isFalse",
103
+ "exists",
104
+ "notExists",
105
+ ]);
106
+
107
+ // ============================================================================
108
+ // SCHEMA EXTRACTION
109
+ // ============================================================================
110
+
111
+ /**
112
+ * Extract assertable fields from a JSON Schema, supporting nested objects.
113
+ * Reuses the JsonSchemaProperty type from @checkstack/ui DynamicForm.
114
+ */
115
+ export function extractAssertableFields(
116
+ schema: JsonSchema,
117
+ prefix = ""
118
+ ): AssertableField[] {
119
+ const fields: AssertableField[] = [];
120
+
121
+ if (!schema.properties) return fields;
122
+
123
+ for (const [key, prop] of Object.entries(schema.properties)) {
124
+ const path = prefix ? `${prefix}.${key}` : key;
125
+ const displayName = path
126
+ .split(".")
127
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
128
+ .join(" → ");
129
+
130
+ // Handle enum type
131
+ if (prop.enum && prop.enum.length > 0) {
132
+ fields.push({
133
+ path,
134
+ displayName,
135
+ type: "enum",
136
+ enumValues: prop.enum,
137
+ });
138
+ continue;
139
+ }
140
+
141
+ // Determine type (handle type arrays like ["string", "null"] if present)
142
+ const type = prop.type;
143
+
144
+ switch (type) {
145
+ case "string": {
146
+ // Check for x-jsonpath metadata
147
+ const hasJsonPath =
148
+ (prop as Record<string, unknown>)["x-jsonpath"] === true;
149
+ if (hasJsonPath) {
150
+ fields.push({
151
+ path: `${path}.$`,
152
+ displayName: `${displayName} (JSONPath)`,
153
+ type: "jsonpath",
154
+ sourceField: path,
155
+ });
156
+ }
157
+ fields.push({ path, displayName, type: "string" });
158
+ break;
159
+ }
160
+ case "number":
161
+ case "integer": {
162
+ fields.push({ path, displayName, type: "number" });
163
+ break;
164
+ }
165
+ case "boolean": {
166
+ fields.push({ path, displayName, type: "boolean" });
167
+ break;
168
+ }
169
+ case "array": {
170
+ // Add array-level assertions
171
+ fields.push({ path, displayName, type: "array" });
172
+ // If array has typed items with properties, add item-level fields
173
+ if (
174
+ prop.items &&
175
+ typeof prop.items === "object" &&
176
+ "properties" in prop.items
177
+ ) {
178
+ const itemsWithProps = prop.items as JsonSchemaProperty;
179
+ if (itemsWithProps.properties) {
180
+ const itemFields = extractAssertableFields(
181
+ { properties: itemsWithProps.properties },
182
+ `${path}[*]`
183
+ );
184
+ fields.push(...itemFields);
185
+ }
186
+ }
187
+ break;
188
+ }
189
+ case "object": {
190
+ // Recurse into nested objects
191
+ if (prop.properties) {
192
+ const nestedFields = extractAssertableFields(
193
+ { properties: prop.properties },
194
+ path
195
+ );
196
+ fields.push(...nestedFields);
197
+ }
198
+ break;
199
+ }
200
+ default: {
201
+ // Unknown type - treat as string for flexibility
202
+ if (type) {
203
+ fields.push({ path, displayName, type: "string" });
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ return fields;
210
+ }
211
+
212
+ // ============================================================================
213
+ // COMPONENT
214
+ // ============================================================================
215
+
216
+ /**
217
+ * AssertionBuilder component for creating assertions based on JSON Schema.
218
+ * Automatically derives available fields and operators from the result schema.
219
+ */
220
+ export const AssertionBuilder: React.FC<AssertionBuilderProps> = ({
221
+ resultSchema,
222
+ assertions,
223
+ onChange,
224
+ }) => {
225
+ // Extract assertable fields from schema
226
+ const fields = useMemo(
227
+ () => extractAssertableFields(resultSchema),
228
+ [resultSchema]
229
+ );
230
+
231
+ const getFieldByPath = (path: string) => {
232
+ return fields.find((f) => f.path === path);
233
+ };
234
+
235
+ const handleAddAssertion = () => {
236
+ if (fields.length === 0) return;
237
+
238
+ const firstField = fields[0];
239
+ const operators = OPERATORS[firstField.type];
240
+
241
+ onChange([
242
+ ...assertions,
243
+ {
244
+ field: firstField.path,
245
+ operator: operators[0].value,
246
+ value: undefined,
247
+ },
248
+ ]);
249
+ };
250
+
251
+ const handleRemoveAssertion = (index: number) => {
252
+ const updated = [...assertions];
253
+ updated.splice(index, 1);
254
+ onChange(updated);
255
+ };
256
+
257
+ const handleFieldChange = (index: number, fieldPath: string) => {
258
+ const field = getFieldByPath(fieldPath);
259
+ if (!field) return;
260
+
261
+ const operators = OPERATORS[field.type];
262
+ const updated = [...assertions];
263
+ updated[index] = {
264
+ field: fieldPath,
265
+ operator: operators[0].value,
266
+ value: undefined,
267
+ };
268
+ onChange(updated);
269
+ };
270
+
271
+ const handleOperatorChange = (index: number, operator: string) => {
272
+ const updated = [...assertions];
273
+ updated[index] = { ...updated[index], operator };
274
+ // Clear value if operator doesn't need one
275
+ if (VALUE_LESS_OPERATORS.has(operator)) {
276
+ updated[index].value = undefined;
277
+ }
278
+ onChange(updated);
279
+ };
280
+
281
+ const handleValueChange = (index: number, value: unknown) => {
282
+ const updated = [...assertions];
283
+ updated[index] = { ...updated[index], value };
284
+ onChange(updated);
285
+ };
286
+
287
+ const handleJsonPathChange = (index: number, jsonPath: string) => {
288
+ const updated = [...assertions];
289
+ updated[index] = { ...updated[index], jsonPath };
290
+ onChange(updated);
291
+ };
292
+
293
+ if (fields.length === 0) {
294
+ return (
295
+ <div className="text-muted-foreground text-sm">
296
+ No assertable fields available in result schema.
297
+ </div>
298
+ );
299
+ }
300
+
301
+ return (
302
+ <div className="space-y-4">
303
+ {assertions.map((assertion, index) => {
304
+ const field = getFieldByPath(assertion.field);
305
+ const operators = field ? OPERATORS[field.type] : [];
306
+ const needsValue = !VALUE_LESS_OPERATORS.has(assertion.operator);
307
+
308
+ return (
309
+ <div key={index} className="flex items-start gap-2 flex-wrap">
310
+ {/* Field selector */}
311
+ <div className="flex-1 min-w-[120px]">
312
+ <Select
313
+ value={assertion.field}
314
+ onValueChange={(v) => handleFieldChange(index, v)}
315
+ >
316
+ <SelectTrigger>
317
+ <SelectValue placeholder="Select field..." />
318
+ </SelectTrigger>
319
+ <SelectContent>
320
+ {fields.map((f) => (
321
+ <SelectItem key={f.path} value={f.path}>
322
+ {f.displayName}
323
+ </SelectItem>
324
+ ))}
325
+ </SelectContent>
326
+ </Select>
327
+ </div>
328
+
329
+ {/* JSONPath input (for jsonpath fields) */}
330
+ {field?.type === "jsonpath" && (
331
+ <div className="flex-1 min-w-[120px]">
332
+ <Input
333
+ value={assertion.jsonPath ?? ""}
334
+ onChange={(e) => handleJsonPathChange(index, e.target.value)}
335
+ placeholder="$.path.to.value"
336
+ />
337
+ </div>
338
+ )}
339
+
340
+ {/* Operator selector */}
341
+ <div className="flex-1 min-w-[100px]">
342
+ <Select
343
+ value={assertion.operator}
344
+ onValueChange={(v) => handleOperatorChange(index, v)}
345
+ >
346
+ <SelectTrigger>
347
+ <SelectValue placeholder="Select operator..." />
348
+ </SelectTrigger>
349
+ <SelectContent>
350
+ {operators.map((op) => (
351
+ <SelectItem key={op.value} value={op.value}>
352
+ {op.label}
353
+ </SelectItem>
354
+ ))}
355
+ </SelectContent>
356
+ </Select>
357
+ </div>
358
+
359
+ {/* Value input */}
360
+ {needsValue && (
361
+ <div className="flex-1 min-w-[120px]">
362
+ {field?.type === "enum" && field.enumValues ? (
363
+ <Select
364
+ value={String(assertion.value ?? "")}
365
+ onValueChange={(v) => handleValueChange(index, v)}
366
+ >
367
+ <SelectTrigger>
368
+ <SelectValue placeholder="Select value..." />
369
+ </SelectTrigger>
370
+ <SelectContent>
371
+ {field.enumValues.map((v) => (
372
+ <SelectItem key={String(v)} value={String(v)}>
373
+ {String(v)}
374
+ </SelectItem>
375
+ ))}
376
+ </SelectContent>
377
+ </Select>
378
+ ) : field?.type === "number" ? (
379
+ <Input
380
+ type="number"
381
+ value={(assertion.value as number) ?? ""}
382
+ onChange={(e) =>
383
+ handleValueChange(
384
+ index,
385
+ e.target.value ? Number(e.target.value) : undefined
386
+ )
387
+ }
388
+ placeholder="Value"
389
+ />
390
+ ) : (
391
+ <Input
392
+ value={String(assertion.value ?? "")}
393
+ onChange={(e) => handleValueChange(index, e.target.value)}
394
+ placeholder="Value"
395
+ />
396
+ )}
397
+ </div>
398
+ )}
399
+
400
+ {/* Remove button */}
401
+ <Button
402
+ variant="ghost"
403
+ size="icon"
404
+ className="text-destructive hover:text-destructive"
405
+ onClick={() => handleRemoveAssertion(index)}
406
+ >
407
+ <Trash2 className="h-4 w-4" />
408
+ </Button>
409
+ </div>
410
+ );
411
+ })}
412
+
413
+ {/* Add assertion button */}
414
+ <Button
415
+ type="button"
416
+ variant="outline"
417
+ size="sm"
418
+ onClick={handleAddAssertion}
419
+ >
420
+ <Plus className="h-4 w-4 mr-2" />
421
+ Add Assertion
422
+ </Button>
423
+ </div>
424
+ );
425
+ };
@@ -0,0 +1,303 @@
1
+ import React, { useState, useEffect, useCallback } from "react";
2
+ import {
3
+ CollectorDto,
4
+ CollectorConfigEntry,
5
+ } from "@checkstack/healthcheck-common";
6
+ import {
7
+ Card,
8
+ CardContent,
9
+ CardHeader,
10
+ CardTitle,
11
+ Label,
12
+ Select,
13
+ SelectContent,
14
+ SelectItem,
15
+ SelectTrigger,
16
+ SelectValue,
17
+ Accordion,
18
+ AccordionItem,
19
+ AccordionTrigger,
20
+ AccordionContent,
21
+ DynamicForm,
22
+ Badge,
23
+ } from "@checkstack/ui";
24
+ import { Plus, Trash2 } from "lucide-react";
25
+ import { isBuiltInCollector } from "../hooks/useCollectors";
26
+ import { AssertionBuilder, type Assertion } from "./AssertionBuilder";
27
+
28
+ interface CollectorListProps {
29
+ strategyId: string;
30
+ availableCollectors: CollectorDto[];
31
+ configuredCollectors: CollectorConfigEntry[];
32
+ onChange: (collectors: CollectorConfigEntry[]) => void;
33
+ loading?: boolean;
34
+ /** Called when collector form validity changes */
35
+ onValidChange?: (isValid: boolean) => void;
36
+ }
37
+
38
+ /**
39
+ * Component for managing collector configurations within a health check.
40
+ * Shows currently configured collectors and allows adding new ones.
41
+ */
42
+ export const CollectorList: React.FC<CollectorListProps> = ({
43
+ strategyId,
44
+ availableCollectors,
45
+ configuredCollectors,
46
+ onChange,
47
+ loading,
48
+ onValidChange,
49
+ }) => {
50
+ // Track validity state per collector index
51
+ const [validityMap, setValidityMap] = useState<Record<number, boolean>>({});
52
+
53
+ // Compute overall validity and report changes
54
+ useEffect(() => {
55
+ if (!onValidChange) return;
56
+
57
+ // All collectors must be valid (or have no config schema)
58
+ const isValid = configuredCollectors.every((_, index) => {
59
+ // If no validity recorded for this collector, assume valid (no schema)
60
+ return validityMap[index] !== false;
61
+ });
62
+
63
+ onValidChange(isValid);
64
+ }, [validityMap, configuredCollectors, onValidChange]);
65
+
66
+ const handleCollectorValidChange = useCallback(
67
+ (index: number, isValid: boolean) => {
68
+ setValidityMap((prev) => ({ ...prev, [index]: isValid }));
69
+ },
70
+ []
71
+ );
72
+ // Separate built-in and external collectors
73
+ const builtInCollectors = availableCollectors.filter((c) =>
74
+ isBuiltInCollector(c.id, strategyId)
75
+ );
76
+ const externalCollectors = availableCollectors.filter(
77
+ (c) => !isBuiltInCollector(c.id, strategyId)
78
+ );
79
+
80
+ // Get collectors that can still be added
81
+ const getAddableCollectors = () => {
82
+ const configuredIds = new Set(
83
+ configuredCollectors.map((c) => c.collectorId)
84
+ );
85
+
86
+ return availableCollectors.filter((c) => {
87
+ // Already configured?
88
+ if (configuredIds.has(c.id)) {
89
+ // Can add multiple?
90
+ return c.allowMultiple;
91
+ }
92
+ return true;
93
+ });
94
+ };
95
+
96
+ const addableCollectors = getAddableCollectors();
97
+
98
+ const handleAdd = (collectorId: string) => {
99
+ const collector = availableCollectors.find((c) => c.id === collectorId);
100
+ if (!collector) return;
101
+
102
+ const newEntry: CollectorConfigEntry = {
103
+ collectorId,
104
+ config: {},
105
+ assertions: [],
106
+ };
107
+
108
+ onChange([...configuredCollectors, newEntry]);
109
+ };
110
+
111
+ const handleRemove = (index: number) => {
112
+ const updated = [...configuredCollectors];
113
+ updated.splice(index, 1);
114
+ onChange(updated);
115
+ };
116
+
117
+ const handleConfigChange = (
118
+ index: number,
119
+ config: Record<string, unknown>
120
+ ) => {
121
+ const updated = [...configuredCollectors];
122
+ updated[index] = { ...updated[index], config };
123
+ onChange(updated);
124
+ };
125
+
126
+ const handleAssertionsChange = (index: number, assertions: Assertion[]) => {
127
+ const updated = [...configuredCollectors];
128
+ updated[index] = { ...updated[index], assertions };
129
+ onChange(updated);
130
+ };
131
+
132
+ const getCollectorDetails = (collectorId: string) => {
133
+ return availableCollectors.find((c) => c.id === collectorId);
134
+ };
135
+
136
+ if (loading) {
137
+ return (
138
+ <Card>
139
+ <CardHeader>
140
+ <CardTitle className="text-base">Check Items</CardTitle>
141
+ </CardHeader>
142
+ <CardContent>
143
+ <div className="text-muted-foreground text-sm">
144
+ Loading collectors...
145
+ </div>
146
+ </CardContent>
147
+ </Card>
148
+ );
149
+ }
150
+
151
+ return (
152
+ <Card>
153
+ <CardHeader className="flex flex-row items-center justify-between">
154
+ <CardTitle className="text-base">Check Items</CardTitle>
155
+ {addableCollectors.length > 0 && (
156
+ <Select value="" onValueChange={handleAdd}>
157
+ <SelectTrigger className="w-[200px]">
158
+ <Plus className="h-4 w-4 mr-2" />
159
+ <SelectValue placeholder="Add collector..." />
160
+ </SelectTrigger>
161
+ <SelectContent>
162
+ {/* Built-in collectors first */}
163
+ {builtInCollectors.length > 0 && (
164
+ <>
165
+ <div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
166
+ Built-in
167
+ </div>
168
+ {builtInCollectors
169
+ .filter((c) => addableCollectors.some((a) => a.id === c.id))
170
+ .map((collector) => (
171
+ <SelectItem key={collector.id} value={collector.id}>
172
+ <div className="flex items-center gap-2">
173
+ <span>{collector.displayName}</span>
174
+ {collector.allowMultiple && (
175
+ <Badge variant="outline" className="text-xs">
176
+ Multiple
177
+ </Badge>
178
+ )}
179
+ </div>
180
+ </SelectItem>
181
+ ))}
182
+ </>
183
+ )}
184
+ {/* External collectors */}
185
+ {externalCollectors.length > 0 && (
186
+ <>
187
+ <div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
188
+ External
189
+ </div>
190
+ {externalCollectors
191
+ .filter((c) => addableCollectors.some((a) => a.id === c.id))
192
+ .map((collector) => (
193
+ <SelectItem key={collector.id} value={collector.id}>
194
+ <div className="flex items-center gap-2">
195
+ <span>{collector.displayName}</span>
196
+ {collector.allowMultiple && (
197
+ <Badge variant="outline" className="text-xs">
198
+ Multiple
199
+ </Badge>
200
+ )}
201
+ </div>
202
+ </SelectItem>
203
+ ))}
204
+ </>
205
+ )}
206
+ </SelectContent>
207
+ </Select>
208
+ )}
209
+ </CardHeader>
210
+ <CardContent>
211
+ {configuredCollectors.length === 0 ? (
212
+ <div className="text-muted-foreground text-sm text-center py-4">
213
+ No check items configured. Add a collector to define what to check.
214
+ </div>
215
+ ) : (
216
+ <Accordion type="multiple" className="w-full">
217
+ {configuredCollectors.map((entry, index) => {
218
+ const collector = getCollectorDetails(entry.collectorId);
219
+ const isBuiltIn = isBuiltInCollector(
220
+ entry.collectorId,
221
+ strategyId
222
+ );
223
+
224
+ return (
225
+ <AccordionItem key={index} value={`item-${index}`}>
226
+ <AccordionTrigger className="hover:no-underline">
227
+ <div className="flex items-center gap-2 flex-1">
228
+ <span className="font-medium">
229
+ {collector?.displayName || entry.collectorId}
230
+ </span>
231
+ {isBuiltIn && (
232
+ <Badge variant="secondary" className="text-xs">
233
+ Built-in
234
+ </Badge>
235
+ )}
236
+ </div>
237
+ <div
238
+ role="button"
239
+ tabIndex={0}
240
+ className="inline-flex items-center justify-center rounded-md h-8 w-8 text-destructive hover:text-destructive hover:bg-accent cursor-pointer"
241
+ onClick={(e) => {
242
+ e.stopPropagation();
243
+ handleRemove(index);
244
+ }}
245
+ onKeyDown={(e) => {
246
+ if (e.key === "Enter" || e.key === " ") {
247
+ e.stopPropagation();
248
+ handleRemove(index);
249
+ }
250
+ }}
251
+ >
252
+ <Trash2 className="h-4 w-4" />
253
+ </div>
254
+ </AccordionTrigger>
255
+ <AccordionContent>
256
+ <div className="space-y-6 pt-4">
257
+ {/* Configuration Section */}
258
+ {collector?.configSchema && (
259
+ <div className="space-y-4">
260
+ <Label className="text-sm font-medium">
261
+ Configuration
262
+ </Label>
263
+ <DynamicForm
264
+ schema={collector.configSchema}
265
+ value={entry.config}
266
+ onChange={(config) =>
267
+ handleConfigChange(index, config)
268
+ }
269
+ onValidChange={(isValid) =>
270
+ handleCollectorValidChange(index, isValid)
271
+ }
272
+ />
273
+ </div>
274
+ )}
275
+
276
+ {/* Assertion Builder Section */}
277
+ {collector?.resultSchema && (
278
+ <div className="space-y-4">
279
+ <Label className="text-sm font-medium">
280
+ Assertions
281
+ </Label>
282
+ <AssertionBuilder
283
+ resultSchema={collector.resultSchema}
284
+ assertions={
285
+ (entry.assertions as unknown as Assertion[]) ?? []
286
+ }
287
+ onChange={(assertions) =>
288
+ handleAssertionsChange(index, assertions)
289
+ }
290
+ />
291
+ </div>
292
+ )}
293
+ </div>
294
+ </AccordionContent>
295
+ </AccordionItem>
296
+ );
297
+ })}
298
+ </Accordion>
299
+ )}
300
+ </CardContent>
301
+ </Card>
302
+ );
303
+ };
@@ -1,8 +1,9 @@
1
- import React, { useState } from "react";
1
+ import React, { useState, useEffect } from "react";
2
2
  import {
3
3
  HealthCheckConfiguration,
4
4
  HealthCheckStrategyDto,
5
5
  CreateHealthCheckConfiguration,
6
+ CollectorConfigEntry,
6
7
  } from "@checkstack/healthcheck-common";
7
8
  import {
8
9
  Button,
@@ -16,6 +17,8 @@ import {
16
17
  DialogTitle,
17
18
  DialogFooter,
18
19
  } from "@checkstack/ui";
20
+ import { useCollectors } from "../hooks/useCollectors";
21
+ import { CollectorList } from "./CollectorList";
19
22
 
20
23
  interface HealthCheckEditorProps {
21
24
  strategies: HealthCheckStrategyDto[];
@@ -40,20 +43,36 @@ export const HealthCheckEditor: React.FC<HealthCheckEditorProps> = ({
40
43
  const [config, setConfig] = useState<Record<string, unknown>>(
41
44
  (initialData?.config as Record<string, unknown>) || {}
42
45
  );
46
+ const [collectors, setCollectors] = useState<CollectorConfigEntry[]>(
47
+ initialData?.collectors || []
48
+ );
43
49
 
44
50
  const toast = useToast();
45
51
  const [loading, setLoading] = useState(false);
52
+ const [collectorsValid, setCollectorsValid] = useState(true);
53
+
54
+ // Fetch available collectors for the selected strategy
55
+ const { collectors: availableCollectors, loading: collectorsLoading } =
56
+ useCollectors(strategyId);
46
57
 
47
58
  // Reset form when dialog opens with new data
48
- React.useEffect(() => {
59
+ useEffect(() => {
49
60
  if (open) {
50
61
  setName(initialData?.name || "");
51
62
  setStrategyId(initialData?.strategyId || "");
52
63
  setInterval(initialData?.intervalSeconds?.toString() || "60");
53
64
  setConfig((initialData?.config as Record<string, unknown>) || {});
65
+ setCollectors(initialData?.collectors || []);
54
66
  }
55
67
  }, [open, initialData]);
56
68
 
69
+ // Clear collectors when strategy changes (new strategy = different collectors)
70
+ const handleStrategyChange = (id: string) => {
71
+ setStrategyId(id);
72
+ setConfig({});
73
+ setCollectors([]);
74
+ };
75
+
57
76
  const handleSave = async (e: React.FormEvent) => {
58
77
  e.preventDefault();
59
78
  setLoading(true);
@@ -63,6 +82,7 @@ export const HealthCheckEditor: React.FC<HealthCheckEditorProps> = ({
63
82
  strategyId,
64
83
  intervalSeconds: Number.parseInt(interval, 10),
65
84
  config,
85
+ collectors: collectors.length > 0 ? collectors : undefined,
66
86
  });
67
87
  } catch (error) {
68
88
  const message =
@@ -84,7 +104,7 @@ export const HealthCheckEditor: React.FC<HealthCheckEditorProps> = ({
84
104
  </DialogTitle>
85
105
  </DialogHeader>
86
106
 
87
- <div className="space-y-4 py-4">
107
+ <div className="space-y-6 py-4 max-h-[70vh] overflow-y-auto">
88
108
  <div className="space-y-2">
89
109
  <Label htmlFor="name">Name</Label>
90
110
  <Input
@@ -111,21 +131,30 @@ export const HealthCheckEditor: React.FC<HealthCheckEditorProps> = ({
111
131
  label="Strategy"
112
132
  plugins={strategies}
113
133
  selectedPluginId={strategyId}
114
- onPluginChange={(id) => {
115
- setStrategyId(id);
116
- setConfig({});
117
- }}
134
+ onPluginChange={handleStrategyChange}
118
135
  config={config}
119
136
  onConfigChange={setConfig}
120
137
  disabled={!!initialData}
121
138
  />
139
+
140
+ {/* Collector Configuration Section */}
141
+ {strategyId && (
142
+ <CollectorList
143
+ strategyId={strategyId}
144
+ availableCollectors={availableCollectors}
145
+ configuredCollectors={collectors}
146
+ onChange={setCollectors}
147
+ loading={collectorsLoading}
148
+ onValidChange={setCollectorsValid}
149
+ />
150
+ )}
122
151
  </div>
123
152
 
124
153
  <DialogFooter>
125
154
  <Button type="button" variant="outline" onClick={onCancel}>
126
155
  Cancel
127
156
  </Button>
128
- <Button type="submit" disabled={loading}>
157
+ <Button type="submit" disabled={loading || !collectorsValid}>
129
158
  {loading ? "Saving..." : "Save"}
130
159
  </Button>
131
160
  </DialogFooter>
@@ -153,12 +153,7 @@ export const HealthCheckRunsTable: React.FC<HealthCheckRunsTableProps> = ({
153
153
  colSpan={calculatedColSpan}
154
154
  className="bg-muted/30 p-4"
155
155
  >
156
- <div className="space-y-2">
157
- <h4 className="text-sm font-medium">Result Data</h4>
158
- <pre className="text-xs bg-card rounded-md p-3 overflow-auto max-h-64 border">
159
- {JSON.stringify(run.result, undefined, 2)}
160
- </pre>
161
- </div>
156
+ <ExpandedResultView result={run.result} />
162
157
  </TableCell>
163
158
  </TableRow>
164
159
  )}
@@ -185,3 +180,144 @@ export const HealthCheckRunsTable: React.FC<HealthCheckRunsTableProps> = ({
185
180
  </>
186
181
  );
187
182
  };
183
+
184
+ // =============================================================================
185
+ // EXPANDED RESULT VIEW
186
+ // =============================================================================
187
+
188
+ interface ExpandedResultViewProps {
189
+ result: Record<string, unknown>;
190
+ }
191
+
192
+ /**
193
+ * Displays the result data in a structured format.
194
+ * Shows collector results as cards with key-value pairs.
195
+ */
196
+ function ExpandedResultView({ result }: ExpandedResultViewProps) {
197
+ const metadata = result.metadata as Record<string, unknown> | undefined;
198
+ const rawCollectors = metadata?.collectors;
199
+
200
+ // Type guard for collectors object
201
+ const collectors: Record<string, Record<string, unknown>> | undefined =
202
+ rawCollectors &&
203
+ typeof rawCollectors === "object" &&
204
+ !Array.isArray(rawCollectors)
205
+ ? (rawCollectors as Record<string, Record<string, unknown>>)
206
+ : undefined;
207
+
208
+ // Check if we have collectors to display
209
+ const collectorEntries = collectors ? Object.entries(collectors) : [];
210
+
211
+ // Extract connection time as typed value
212
+ const connectionTimeMs = metadata?.connectionTimeMs as number | undefined;
213
+
214
+ return (
215
+ <div className="space-y-4">
216
+ <div className="flex gap-4 text-sm">
217
+ <div>
218
+ <span className="text-muted-foreground">Status: </span>
219
+ <span className="font-medium">{String(result.status)}</span>
220
+ </div>
221
+ <div>
222
+ <span className="text-muted-foreground">Latency: </span>
223
+ <span className="font-medium">{String(result.latencyMs)}ms</span>
224
+ </div>
225
+ {connectionTimeMs !== undefined && (
226
+ <div>
227
+ <span className="text-muted-foreground">Connection: </span>
228
+ <span className="font-medium">{connectionTimeMs}ms</span>
229
+ </div>
230
+ )}
231
+ </div>
232
+
233
+ {collectorEntries.length > 0 && (
234
+ <div className="space-y-3">
235
+ <h4 className="text-sm font-medium">Collector Results</h4>
236
+ <div className="grid gap-3 md:grid-cols-2">
237
+ {collectorEntries.map(([collectorId, collectorResult]) => (
238
+ <CollectorResultCard
239
+ key={collectorId}
240
+ collectorId={collectorId}
241
+ result={collectorResult}
242
+ />
243
+ ))}
244
+ </div>
245
+ </div>
246
+ )}
247
+
248
+ {result.message ? (
249
+ <div className="text-sm text-muted-foreground">
250
+ {String(result.message)}
251
+ </div>
252
+ ) : undefined}
253
+ </div>
254
+ );
255
+ }
256
+
257
+ interface CollectorResultCardProps {
258
+ collectorId: string;
259
+ result: Record<string, unknown>;
260
+ }
261
+
262
+ /**
263
+ * Card displaying a single collector's result values.
264
+ */
265
+ function CollectorResultCard({
266
+ collectorId,
267
+ result,
268
+ }: CollectorResultCardProps) {
269
+ if (!result || typeof result !== "object") {
270
+ return;
271
+ }
272
+
273
+ // Filter out null/undefined values
274
+ const entries = Object.entries(result).filter(
275
+ ([, value]) => value !== null && value !== undefined
276
+ );
277
+
278
+ return (
279
+ <div className="rounded-md border bg-card p-3 space-y-2">
280
+ <h5 className="text-sm font-medium text-primary">{collectorId}</h5>
281
+ <div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
282
+ {entries.map(([key, value]) => (
283
+ <div key={key} className="contents">
284
+ <span className="text-muted-foreground truncate">
285
+ {formatKey(key)}
286
+ </span>
287
+ <span className="font-mono text-xs truncate" title={String(value)}>
288
+ {formatValue(value)}
289
+ </span>
290
+ </div>
291
+ ))}
292
+ </div>
293
+ </div>
294
+ );
295
+ }
296
+
297
+ /**
298
+ * Format a camelCase key to a readable label.
299
+ */
300
+ function formatKey(key: string): string {
301
+ return key
302
+ .replaceAll(/([a-z])([A-Z])/g, "$1 $2")
303
+ .replaceAll(/^./, (c) => c.toUpperCase());
304
+ }
305
+
306
+ /**
307
+ * Format a value for display.
308
+ */
309
+ function formatValue(value: unknown): string {
310
+ if (value === null || value === undefined) return "—";
311
+ if (typeof value === "boolean") return value ? "Yes" : "No";
312
+ if (typeof value === "number") {
313
+ return Number.isInteger(value) ? String(value) : value.toFixed(2);
314
+ }
315
+ if (Array.isArray(value)) {
316
+ return value.length > 3
317
+ ? `[${value.slice(0, 3).join(", ")}…]`
318
+ : `[${value.join(", ")}]`;
319
+ }
320
+ if (typeof value === "object") return JSON.stringify(value);
321
+ const str = String(value);
322
+ return str.length > 50 ? `${str.slice(0, 47)}…` : str;
323
+ }
@@ -0,0 +1,63 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { useApi } from "@checkstack/frontend-api";
3
+ import { healthCheckApiRef } from "../api";
4
+ import { CollectorDto } from "@checkstack/healthcheck-common";
5
+
6
+ interface UseCollectorsResult {
7
+ collectors: CollectorDto[];
8
+ loading: boolean;
9
+ error: Error | undefined;
10
+ refetch: () => Promise<void>;
11
+ }
12
+
13
+ /**
14
+ * Hook to fetch collectors for a given strategy.
15
+ * @param strategyId - The strategy ID to fetch collectors for
16
+ */
17
+ export function useCollectors(strategyId: string): UseCollectorsResult {
18
+ const api = useApi(healthCheckApiRef);
19
+ const [collectors, setCollectors] = useState<CollectorDto[]>([]);
20
+ const [loading, setLoading] = useState(false);
21
+ const [error, setError] = useState<Error>();
22
+
23
+ const refetch = useCallback(async () => {
24
+ if (!strategyId) {
25
+ setCollectors([]);
26
+ return;
27
+ }
28
+
29
+ setLoading(true);
30
+ setError(undefined);
31
+ try {
32
+ const result = await api.getCollectors({ strategyId });
33
+ setCollectors(result);
34
+ } catch (error_) {
35
+ setError(
36
+ error_ instanceof Error
37
+ ? error_
38
+ : new Error("Failed to fetch collectors")
39
+ );
40
+ } finally {
41
+ setLoading(false);
42
+ }
43
+ }, [api, strategyId]);
44
+
45
+ useEffect(() => {
46
+ refetch();
47
+ }, [refetch]);
48
+
49
+ return { collectors, loading, error, refetch };
50
+ }
51
+
52
+ /**
53
+ * Check if a collector is built-in for a given strategy.
54
+ * Built-in collectors are those registered by the same plugin as the strategy.
55
+ */
56
+ export function isBuiltInCollector(
57
+ collectorId: string,
58
+ strategyId: string
59
+ ): boolean {
60
+ // Collector ID format: ownerPluginId.collectorId
61
+ // Strategy ID typically equals its plugin ID
62
+ return collectorId.startsWith(`${strategyId}.`);
63
+ }