@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.
@@ -3,27 +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
 
10
+ import type { ChartType } from "@checkstack/healthcheck-common";
11
+ import type {
12
+ JsonSchemaPropertyCore,
13
+ JsonSchemaBase,
14
+ } from "@checkstack/common";
15
+
8
16
  /**
9
- * Available chart types for auto-generated visualizations.
10
- * Mirrors the backend ChartType but defined locally since frontend
11
- * cannot import from backend-api.
17
+ * JSON Schema property with healthcheck result-specific x-* extensions.
18
+ * Uses the generic core type for proper recursive typing.
12
19
  */
13
- export type ChartType =
14
- | "line"
15
- | "bar"
16
- | "counter"
17
- | "gauge"
18
- | "boolean"
19
- | "text"
20
- | "status";
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>;
21
32
 
22
33
  /**
23
34
  * Chart field information extracted from JSON Schema.
24
35
  */
25
36
  export interface ChartField {
26
- /** Field name in the schema */
37
+ /** Field path (supports dot notation for nested fields like "collectors.request.responseTimeMs") */
27
38
  name: string;
28
39
  /** Chart type to render */
29
40
  chartType: ChartType;
@@ -33,33 +44,15 @@ export interface ChartField {
33
44
  unit?: string;
34
45
  /** JSON Schema type (number, string, boolean, etc.) */
35
46
  schemaType: string;
36
- }
37
-
38
- /**
39
- * JSON Schema property with potential chart metadata.
40
- */
41
- interface JsonSchemaProperty {
42
- type?: string;
43
- "x-chart-type"?: ChartType;
44
- "x-chart-label"?: string;
45
- "x-chart-unit"?: string;
46
- items?: JsonSchemaProperty;
47
- additionalProperties?: JsonSchemaProperty;
48
- }
49
-
50
- /**
51
- * JSON Schema object structure.
52
- */
53
- interface JsonSchema {
54
- type?: string;
55
- properties?: Record<string, JsonSchemaProperty>;
47
+ /** Collector ID if this field is from a collector */
48
+ collectorId?: string;
56
49
  }
57
50
 
58
51
  /**
59
52
  * Extract chart fields from a JSON Schema.
60
53
  *
61
54
  * Looks for properties with x-chart-type metadata and extracts
62
- * relevant chart configuration.
55
+ * relevant chart configuration. Supports nested collector schemas.
63
56
  *
64
57
  * @param schema - JSON Schema object
65
58
  * @returns Array of chart fields with metadata
@@ -69,41 +62,102 @@ export function extractChartFields(
69
62
  ): ChartField[] {
70
63
  if (!schema) return [];
71
64
 
72
- const typed = schema as JsonSchema;
65
+ const typed = schema as ResultSchema;
73
66
  if (typed.type !== "object" || !typed.properties) return [];
74
67
 
75
68
  const fields: ChartField[] = [];
76
69
 
77
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
+
78
90
  const chartType = prop["x-chart-type"];
79
91
  if (!chartType) continue;
80
92
 
81
- // Determine the underlying schema type
82
- let schemaType = prop.type ?? "unknown";
83
- if (prop.type === "array" && prop.items?.type) {
84
- schemaType = `array<${prop.items.type}>`;
85
- }
86
- if (prop.additionalProperties?.type) {
87
- schemaType = `record<${prop.additionalProperties.type}>`;
88
- }
93
+ fields.push(extractSingleField(name, prop));
94
+ }
89
95
 
90
- fields.push({
91
- name,
92
- chartType,
93
- label: prop["x-chart-label"] ?? formatFieldName(name),
94
- unit: prop["x-chart-unit"],
95
- schemaType,
96
- });
96
+ return fields;
97
+ }
98
+
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);
97
121
  }
98
122
 
99
123
  return fields;
100
124
  }
101
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
+
102
154
  /**
103
155
  * Convert camelCase or snake_case field name to human-readable label.
104
156
  */
105
157
  function formatFieldName(name: string): string {
106
- 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
107
161
  .replaceAll(/([a-z])([A-Z])/g, "$1 $2") // camelCase
108
162
  .replaceAll("_", " ") // snake_case
109
163
  .replaceAll(/\b\w/g, (c) => c.toUpperCase()); // Capitalize
@@ -111,11 +165,28 @@ function formatFieldName(name: string): string {
111
165
 
112
166
  /**
113
167
  * Get the value for a field from a data object.
168
+ * Supports dot-notation paths like "collectors.request.responseTimeMs".
114
169
  */
115
170
  export function getFieldValue(
116
171
  data: Record<string, unknown> | undefined,
117
172
  fieldName: string
118
173
  ): unknown {
119
174
  if (!data) return undefined;
120
- 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;
121
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
+ };