@checkstack/healthcheck-frontend 0.0.2 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -0
- package/package.json +1 -1
- package/src/auto-charts/AutoChartGrid.tsx +377 -78
- package/src/auto-charts/index.ts +1 -1
- package/src/auto-charts/schema-parser.ts +122 -51
- package/src/components/AssertionBuilder.tsx +425 -0
- package/src/components/CollectorList.tsx +303 -0
- package/src/components/HealthCheckDiagram.tsx +39 -62
- package/src/components/HealthCheckEditor.tsx +37 -8
- package/src/components/HealthCheckLatencyChart.tsx +119 -59
- package/src/components/HealthCheckRunsTable.tsx +142 -6
- package/src/components/HealthCheckStatusTimeline.tsx +35 -52
- package/src/components/HealthCheckSystemOverview.tsx +208 -185
- package/src/hooks/useCollectors.ts +63 -0
- package/src/hooks/useHealthCheckData.ts +52 -33
|
@@ -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
|
-
*
|
|
10
|
-
*
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|