@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 +46 -0
- package/package.json +1 -1
- package/src/auto-charts/schema-parser.ts +123 -40
- package/src/components/AssertionBuilder.tsx +425 -0
- package/src/components/CollectorList.tsx +303 -0
- package/src/components/HealthCheckEditor.tsx +37 -8
- package/src/components/HealthCheckRunsTable.tsx +142 -6
- package/src/hooks/useCollectors.ts +63 -0
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
|
@@ -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
|
|
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
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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={
|
|
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
|
-
<
|
|
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
|
+
}
|