@checkstack/healthcheck-frontend 0.0.3 → 0.2.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 +144 -0
- package/package.json +3 -2
- package/src/auto-charts/AutoChartGrid.tsx +256 -23
- package/src/auto-charts/schema-parser.ts +147 -41
- package/src/auto-charts/useStrategySchemas.ts +73 -3
- package/src/components/AssertionBuilder.tsx +432 -0
- package/src/components/CollectorList.tsx +309 -0
- package/src/components/HealthCheckEditor.tsx +54 -8
- package/src/components/HealthCheckRunsTable.tsx +142 -6
- package/src/components/SystemHealthCheckAssignment.tsx +9 -4
- package/src/hooks/useCollectors.ts +63 -0
- package/src/pages/HealthCheckConfigPage.tsx +1 -1
|
@@ -0,0 +1,432 @@
|
|
|
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
|
+
// Safely get operators with fallback to empty array
|
|
306
|
+
const operators = field ? OPERATORS[field.type] ?? [] : [];
|
|
307
|
+
const needsValue = !VALUE_LESS_OPERATORS.has(assertion.operator);
|
|
308
|
+
|
|
309
|
+
// Check if current values match available options (prevents Radix UI crash)
|
|
310
|
+
const fieldValueValid = fields.some((f) => f.path === assertion.field);
|
|
311
|
+
const operatorValueValid = operators.some(
|
|
312
|
+
(op) => op.value === assertion.operator
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
return (
|
|
316
|
+
<div key={index} className="flex items-start gap-2 flex-wrap">
|
|
317
|
+
{/* Field selector */}
|
|
318
|
+
<div className="flex-1 min-w-[120px]">
|
|
319
|
+
<Select
|
|
320
|
+
value={fieldValueValid ? assertion.field : undefined}
|
|
321
|
+
onValueChange={(v) => handleFieldChange(index, v)}
|
|
322
|
+
>
|
|
323
|
+
<SelectTrigger>
|
|
324
|
+
<SelectValue placeholder="Select field..." />
|
|
325
|
+
</SelectTrigger>
|
|
326
|
+
<SelectContent>
|
|
327
|
+
{fields.map((f) => (
|
|
328
|
+
<SelectItem key={f.path} value={f.path}>
|
|
329
|
+
{f.displayName}
|
|
330
|
+
</SelectItem>
|
|
331
|
+
))}
|
|
332
|
+
</SelectContent>
|
|
333
|
+
</Select>
|
|
334
|
+
</div>
|
|
335
|
+
|
|
336
|
+
{/* JSONPath input (for jsonpath fields) */}
|
|
337
|
+
{field?.type === "jsonpath" && (
|
|
338
|
+
<div className="flex-1 min-w-[120px]">
|
|
339
|
+
<Input
|
|
340
|
+
value={assertion.jsonPath ?? ""}
|
|
341
|
+
onChange={(e) => handleJsonPathChange(index, e.target.value)}
|
|
342
|
+
placeholder="$.path.to.value"
|
|
343
|
+
/>
|
|
344
|
+
</div>
|
|
345
|
+
)}
|
|
346
|
+
|
|
347
|
+
{/* Operator selector */}
|
|
348
|
+
<div className="flex-1 min-w-[100px]">
|
|
349
|
+
<Select
|
|
350
|
+
value={operatorValueValid ? assertion.operator : undefined}
|
|
351
|
+
onValueChange={(v) => handleOperatorChange(index, v)}
|
|
352
|
+
>
|
|
353
|
+
<SelectTrigger>
|
|
354
|
+
<SelectValue placeholder="Select operator..." />
|
|
355
|
+
</SelectTrigger>
|
|
356
|
+
<SelectContent>
|
|
357
|
+
{operators.map((op) => (
|
|
358
|
+
<SelectItem key={op.value} value={op.value}>
|
|
359
|
+
{op.label}
|
|
360
|
+
</SelectItem>
|
|
361
|
+
))}
|
|
362
|
+
</SelectContent>
|
|
363
|
+
</Select>
|
|
364
|
+
</div>
|
|
365
|
+
|
|
366
|
+
{/* Value input */}
|
|
367
|
+
{needsValue && (
|
|
368
|
+
<div className="flex-1 min-w-[120px]">
|
|
369
|
+
{field?.type === "enum" && field.enumValues ? (
|
|
370
|
+
<Select
|
|
371
|
+
value={String(assertion.value ?? "")}
|
|
372
|
+
onValueChange={(v) => handleValueChange(index, v)}
|
|
373
|
+
>
|
|
374
|
+
<SelectTrigger>
|
|
375
|
+
<SelectValue placeholder="Select value..." />
|
|
376
|
+
</SelectTrigger>
|
|
377
|
+
<SelectContent>
|
|
378
|
+
{field.enumValues.map((v) => (
|
|
379
|
+
<SelectItem key={String(v)} value={String(v)}>
|
|
380
|
+
{String(v)}
|
|
381
|
+
</SelectItem>
|
|
382
|
+
))}
|
|
383
|
+
</SelectContent>
|
|
384
|
+
</Select>
|
|
385
|
+
) : field?.type === "number" ? (
|
|
386
|
+
<Input
|
|
387
|
+
type="number"
|
|
388
|
+
value={(assertion.value as number) ?? ""}
|
|
389
|
+
onChange={(e) =>
|
|
390
|
+
handleValueChange(
|
|
391
|
+
index,
|
|
392
|
+
e.target.value ? Number(e.target.value) : undefined
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
placeholder="Value"
|
|
396
|
+
/>
|
|
397
|
+
) : (
|
|
398
|
+
<Input
|
|
399
|
+
value={String(assertion.value ?? "")}
|
|
400
|
+
onChange={(e) => handleValueChange(index, e.target.value)}
|
|
401
|
+
placeholder="Value"
|
|
402
|
+
/>
|
|
403
|
+
)}
|
|
404
|
+
</div>
|
|
405
|
+
)}
|
|
406
|
+
|
|
407
|
+
{/* Remove button */}
|
|
408
|
+
<Button
|
|
409
|
+
variant="ghost"
|
|
410
|
+
size="icon"
|
|
411
|
+
className="text-destructive hover:text-destructive"
|
|
412
|
+
onClick={() => handleRemoveAssertion(index)}
|
|
413
|
+
>
|
|
414
|
+
<Trash2 className="h-4 w-4" />
|
|
415
|
+
</Button>
|
|
416
|
+
</div>
|
|
417
|
+
);
|
|
418
|
+
})}
|
|
419
|
+
|
|
420
|
+
{/* Add assertion button */}
|
|
421
|
+
<Button
|
|
422
|
+
type="button"
|
|
423
|
+
variant="outline"
|
|
424
|
+
size="sm"
|
|
425
|
+
onClick={handleAddAssertion}
|
|
426
|
+
>
|
|
427
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
428
|
+
Add Assertion
|
|
429
|
+
</Button>
|
|
430
|
+
</div>
|
|
431
|
+
);
|
|
432
|
+
};
|