@checkstack/healthcheck-frontend 0.1.0 → 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 +98 -0
- package/package.json +3 -2
- package/src/auto-charts/AutoChartGrid.tsx +256 -23
- package/src/auto-charts/schema-parser.ts +42 -19
- package/src/auto-charts/useStrategySchemas.ts +73 -3
- package/src/components/AssertionBuilder.tsx +10 -3
- package/src/components/CollectorList.tsx +6 -0
- package/src/components/HealthCheckEditor.tsx +18 -1
- package/src/components/HealthCheckRunsTable.tsx +1 -1
- package/src/components/SystemHealthCheckAssignment.tsx +9 -4
- package/src/pages/HealthCheckConfigPage.tsx +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,103 @@
|
|
|
1
1
|
# @checkstack/healthcheck-frontend
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 8e43507: # Teams and Resource-Level Access Control
|
|
8
|
+
|
|
9
|
+
This release introduces a comprehensive Teams system for organizing users and controlling access to resources at a granular level.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
### Team Management
|
|
14
|
+
|
|
15
|
+
- Create, update, and delete teams with name and description
|
|
16
|
+
- Add/remove users from teams
|
|
17
|
+
- Designate team managers with elevated privileges
|
|
18
|
+
- View team membership and manager status
|
|
19
|
+
|
|
20
|
+
### Resource-Level Access Control
|
|
21
|
+
|
|
22
|
+
- Grant teams access to specific resources (systems, health checks, incidents, maintenances)
|
|
23
|
+
- Configure read-only or manage permissions per team
|
|
24
|
+
- Resource-level "Team Only" mode that restricts access exclusively to team members
|
|
25
|
+
- Separate `resourceAccessSettings` table for resource-level settings (not per-grant)
|
|
26
|
+
- Automatic cleanup of grants when teams are deleted (database cascade)
|
|
27
|
+
|
|
28
|
+
### Middleware Integration
|
|
29
|
+
|
|
30
|
+
- Extended `autoAuthMiddleware` to support resource access checks
|
|
31
|
+
- Single-resource pre-handler validation for detail endpoints
|
|
32
|
+
- Automatic list filtering for collection endpoints
|
|
33
|
+
- S2S endpoints for access verification
|
|
34
|
+
|
|
35
|
+
### Frontend Components
|
|
36
|
+
|
|
37
|
+
- `TeamsTab` component for managing teams in Auth Settings
|
|
38
|
+
- `TeamAccessEditor` component for assigning team access to resources
|
|
39
|
+
- Resource-level "Team Only" toggle in `TeamAccessEditor`
|
|
40
|
+
- Integration into System, Health Check, Incident, and Maintenance editors
|
|
41
|
+
|
|
42
|
+
## Breaking Changes
|
|
43
|
+
|
|
44
|
+
### API Response Format Changes
|
|
45
|
+
|
|
46
|
+
List endpoints now return objects with named keys instead of arrays directly:
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
// Before
|
|
50
|
+
const systems = await catalogApi.getSystems();
|
|
51
|
+
|
|
52
|
+
// After
|
|
53
|
+
const { systems } = await catalogApi.getSystems();
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Affected endpoints:
|
|
57
|
+
|
|
58
|
+
- `catalog.getSystems` → `{ systems: [...] }`
|
|
59
|
+
- `healthcheck.getConfigurations` → `{ configurations: [...] }`
|
|
60
|
+
- `incident.listIncidents` → `{ incidents: [...] }`
|
|
61
|
+
- `maintenance.listMaintenances` → `{ maintenances: [...] }`
|
|
62
|
+
|
|
63
|
+
### User Identity Enrichment
|
|
64
|
+
|
|
65
|
+
`RealUser` and `ApplicationUser` types now include `teamIds: string[]` field with team memberships.
|
|
66
|
+
|
|
67
|
+
## Documentation
|
|
68
|
+
|
|
69
|
+
See `docs/backend/teams.md` for complete API reference and integration guide.
|
|
70
|
+
|
|
71
|
+
- 97c5a6b: Add UUID-based collector identification for better multiple collector support
|
|
72
|
+
|
|
73
|
+
**Breaking Change**: Existing health check configurations with collectors need to be recreated.
|
|
74
|
+
|
|
75
|
+
- Each collector instance now has a unique UUID assigned on creation
|
|
76
|
+
- Collector results are stored under the UUID key with `_collectorId` and `_assertionFailed` metadata
|
|
77
|
+
- Auto-charts correctly display separate charts for each collector instance
|
|
78
|
+
- Charts are now grouped by collector instance with clear headings
|
|
79
|
+
- Assertion status card shows pass/fail for each collector
|
|
80
|
+
- Renamed "Success" to "HTTP Success" to clarify it's about HTTP request success
|
|
81
|
+
- Fixed deletion of collectors not persisting to database
|
|
82
|
+
- Fixed duplicate React key warnings in auto-chart grid
|
|
83
|
+
|
|
84
|
+
### Patch Changes
|
|
85
|
+
|
|
86
|
+
- 97c5a6b: Fix Radix UI accessibility warning in dialog components by adding visually hidden DialogDescription components
|
|
87
|
+
- Updated dependencies [8e43507]
|
|
88
|
+
- Updated dependencies [97c5a6b]
|
|
89
|
+
- Updated dependencies [97c5a6b]
|
|
90
|
+
- Updated dependencies [8e43507]
|
|
91
|
+
- Updated dependencies [8e43507]
|
|
92
|
+
- Updated dependencies [97c5a6b]
|
|
93
|
+
- @checkstack/ui@0.1.0
|
|
94
|
+
- @checkstack/auth-frontend@0.1.0
|
|
95
|
+
- @checkstack/catalog-common@1.0.0
|
|
96
|
+
- @checkstack/common@0.1.0
|
|
97
|
+
- @checkstack/healthcheck-common@0.2.0
|
|
98
|
+
- @checkstack/frontend-api@0.0.4
|
|
99
|
+
- @checkstack/signal-frontend@0.0.5
|
|
100
|
+
|
|
3
101
|
## 0.1.0
|
|
4
102
|
|
|
5
103
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/healthcheck-frontend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.tsx",
|
|
6
6
|
"scripts": {
|
|
@@ -9,13 +9,13 @@
|
|
|
9
9
|
"lint:code": "eslint . --max-warnings 0"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
+
"@checkstack/auth-frontend": "workspace:*",
|
|
12
13
|
"@checkstack/catalog-common": "workspace:*",
|
|
13
14
|
"@checkstack/common": "workspace:*",
|
|
14
15
|
"@checkstack/frontend-api": "workspace:*",
|
|
15
16
|
"@checkstack/healthcheck-common": "workspace:*",
|
|
16
17
|
"@checkstack/signal-frontend": "workspace:*",
|
|
17
18
|
"@checkstack/ui": "workspace:*",
|
|
18
|
-
"@types/prismjs": "^1.26.5",
|
|
19
19
|
"ajv": "^8.17.1",
|
|
20
20
|
"ajv-formats": "^3.0.1",
|
|
21
21
|
"date-fns": "^4.1.0",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"zod": "^4.2.1"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
+
"@types/prismjs": "^1.26.5",
|
|
31
32
|
"typescript": "^5.0.0",
|
|
32
33
|
"@types/react": "^18.2.0",
|
|
33
34
|
"@checkstack/tsconfig": "workspace:*",
|
|
@@ -33,6 +33,9 @@ interface AutoChartGridProps {
|
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* Main component that renders a grid of auto-generated charts.
|
|
36
|
+
*
|
|
37
|
+
* Discovers actual collector instances from run data and creates
|
|
38
|
+
* separate charts for each instance, grouped with headings.
|
|
36
39
|
*/
|
|
37
40
|
export function AutoChartGrid({ context }: AutoChartGridProps) {
|
|
38
41
|
const { schemas, loading } = useStrategySchemas(context.strategyId);
|
|
@@ -55,22 +58,247 @@ export function AutoChartGrid({ context }: AutoChartGridProps) {
|
|
|
55
58
|
return;
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
61
|
+
const schemaFields = extractChartFields(schema);
|
|
62
|
+
if (schemaFields.length === 0) {
|
|
60
63
|
return;
|
|
61
64
|
}
|
|
62
65
|
|
|
66
|
+
// Discover actual collector instances from run data
|
|
67
|
+
const instanceMap = discoverCollectorInstances(context);
|
|
68
|
+
|
|
69
|
+
// Separate strategy-level fields from collector fields
|
|
70
|
+
const strategyFields = schemaFields.filter((f) => !f.collectorId);
|
|
71
|
+
|
|
72
|
+
// Build grouped collector fields
|
|
73
|
+
const collectorGroups = buildCollectorGroups(schemaFields, instanceMap);
|
|
74
|
+
|
|
63
75
|
return (
|
|
64
|
-
<div className="
|
|
65
|
-
{fields
|
|
66
|
-
|
|
76
|
+
<div className="space-y-6 mt-4">
|
|
77
|
+
{/* Strategy-level fields */}
|
|
78
|
+
{strategyFields.length > 0 && (
|
|
79
|
+
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
80
|
+
{strategyFields.map((field) => (
|
|
81
|
+
<AutoChartCard key={field.name} field={field} context={context} />
|
|
82
|
+
))}
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
|
|
86
|
+
{/* Collector groups */}
|
|
87
|
+
{collectorGroups.map((group) => (
|
|
88
|
+
<CollectorGroup
|
|
89
|
+
key={group.instanceKey}
|
|
90
|
+
group={group}
|
|
91
|
+
context={context}
|
|
92
|
+
/>
|
|
67
93
|
))}
|
|
68
94
|
</div>
|
|
69
95
|
);
|
|
70
96
|
}
|
|
71
97
|
|
|
98
|
+
/**
|
|
99
|
+
* A group of fields for a single collector instance.
|
|
100
|
+
*/
|
|
101
|
+
interface CollectorGroupData {
|
|
102
|
+
instanceKey: string;
|
|
103
|
+
collectorId: string;
|
|
104
|
+
displayName: string;
|
|
105
|
+
fields: ExpandedChartField[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build grouped collector data from schema fields and instance map.
|
|
110
|
+
*/
|
|
111
|
+
function buildCollectorGroups(
|
|
112
|
+
schemaFields: ChartField[],
|
|
113
|
+
instanceMap: Record<string, string[]>
|
|
114
|
+
): CollectorGroupData[] {
|
|
115
|
+
const groups: CollectorGroupData[] = [];
|
|
116
|
+
|
|
117
|
+
// Process each collector type
|
|
118
|
+
for (const [collectorId, instanceKeys] of Object.entries(instanceMap)) {
|
|
119
|
+
// Get fields for this collector type
|
|
120
|
+
const collectorFields = schemaFields.filter(
|
|
121
|
+
(f) => f.collectorId === collectorId
|
|
122
|
+
);
|
|
123
|
+
if (collectorFields.length === 0) continue;
|
|
124
|
+
|
|
125
|
+
// Create a group for each instance
|
|
126
|
+
for (const [index, instanceKey] of instanceKeys.entries()) {
|
|
127
|
+
const displayName =
|
|
128
|
+
instanceKeys.length === 1
|
|
129
|
+
? collectorId.split(".").pop() || collectorId
|
|
130
|
+
: `${collectorId.split(".").pop() || collectorId} #${index + 1}`;
|
|
131
|
+
|
|
132
|
+
groups.push({
|
|
133
|
+
instanceKey,
|
|
134
|
+
collectorId,
|
|
135
|
+
displayName,
|
|
136
|
+
fields: collectorFields.map((field) => ({
|
|
137
|
+
...field,
|
|
138
|
+
instanceKey,
|
|
139
|
+
})),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return groups;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Renders a collector group with heading, assertion status, and field cards.
|
|
149
|
+
*/
|
|
150
|
+
function CollectorGroup({
|
|
151
|
+
group,
|
|
152
|
+
context,
|
|
153
|
+
}: {
|
|
154
|
+
group: CollectorGroupData;
|
|
155
|
+
context: HealthCheckDiagramSlotContext;
|
|
156
|
+
}) {
|
|
157
|
+
// Get assertion status for this collector instance
|
|
158
|
+
const assertionFailed = getAssertionFailed(context, group.instanceKey);
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div>
|
|
162
|
+
<h4 className="text-sm font-medium text-muted-foreground mb-3 uppercase tracking-wide">
|
|
163
|
+
{group.displayName}
|
|
164
|
+
</h4>
|
|
165
|
+
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
166
|
+
{/* Assertion status card */}
|
|
167
|
+
<AssertionStatusCard assertionFailed={assertionFailed} />
|
|
168
|
+
|
|
169
|
+
{/* Field cards */}
|
|
170
|
+
{group.fields.map((field) => (
|
|
171
|
+
<AutoChartCard
|
|
172
|
+
key={`${field.instanceKey}-${field.name}`}
|
|
173
|
+
field={field}
|
|
174
|
+
context={context}
|
|
175
|
+
/>
|
|
176
|
+
))}
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Get the _assertionFailed value for a specific collector instance.
|
|
184
|
+
*/
|
|
185
|
+
function getAssertionFailed(
|
|
186
|
+
context: HealthCheckDiagramSlotContext,
|
|
187
|
+
instanceKey: string
|
|
188
|
+
): string | undefined {
|
|
189
|
+
if (context.type === "raw" && context.runs.length > 0) {
|
|
190
|
+
const latestRun = context.runs[0];
|
|
191
|
+
const result = latestRun.result as StoredHealthCheckResult | undefined;
|
|
192
|
+
const collectors = result?.metadata?.collectors as
|
|
193
|
+
| Record<string, Record<string, unknown>>
|
|
194
|
+
| undefined;
|
|
195
|
+
const collectorData = collectors?.[instanceKey];
|
|
196
|
+
return collectorData?._assertionFailed as string | undefined;
|
|
197
|
+
}
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Card showing assertion pass/fail status.
|
|
203
|
+
*/
|
|
204
|
+
function AssertionStatusCard({
|
|
205
|
+
assertionFailed,
|
|
206
|
+
}: {
|
|
207
|
+
assertionFailed: string | undefined;
|
|
208
|
+
}) {
|
|
209
|
+
if (!assertionFailed) {
|
|
210
|
+
return (
|
|
211
|
+
<Card>
|
|
212
|
+
<CardHeader className="pb-2">
|
|
213
|
+
<CardTitle className="text-sm font-medium">Assertion</CardTitle>
|
|
214
|
+
</CardHeader>
|
|
215
|
+
<CardContent>
|
|
216
|
+
<div className="flex items-center gap-2 text-green-600">
|
|
217
|
+
<div className="w-3 h-3 rounded-full bg-green-500" />
|
|
218
|
+
<span>Passed</span>
|
|
219
|
+
</div>
|
|
220
|
+
</CardContent>
|
|
221
|
+
</Card>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
<Card className="border-red-200 dark:border-red-900">
|
|
227
|
+
<CardHeader className="pb-2">
|
|
228
|
+
<CardTitle className="text-sm font-medium text-red-600">
|
|
229
|
+
Assertion Failed
|
|
230
|
+
</CardTitle>
|
|
231
|
+
</CardHeader>
|
|
232
|
+
<CardContent>
|
|
233
|
+
<div className="text-sm text-red-600 bg-red-50 dark:bg-red-950 px-2 py-1 rounded">
|
|
234
|
+
{assertionFailed}
|
|
235
|
+
</div>
|
|
236
|
+
</CardContent>
|
|
237
|
+
</Card>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Discover collector instances from actual run data.
|
|
243
|
+
* Returns a map from base collector ID (type) to array of instance UUIDs.
|
|
244
|
+
*/
|
|
245
|
+
function discoverCollectorInstances(
|
|
246
|
+
context: HealthCheckDiagramSlotContext
|
|
247
|
+
): Record<string, string[]> {
|
|
248
|
+
const instanceMap: Record<string, Set<string>> = {};
|
|
249
|
+
|
|
250
|
+
const addInstances = (collectors: Record<string, unknown> | undefined) => {
|
|
251
|
+
if (!collectors || typeof collectors !== "object") return;
|
|
252
|
+
|
|
253
|
+
for (const [uuid, data] of Object.entries(collectors)) {
|
|
254
|
+
// Read the collector type from the stored _collectorId metadata
|
|
255
|
+
const collectorData = data as Record<string, unknown> | undefined;
|
|
256
|
+
const collectorId = collectorData?._collectorId as string | undefined;
|
|
257
|
+
|
|
258
|
+
if (collectorId) {
|
|
259
|
+
if (!instanceMap[collectorId]) {
|
|
260
|
+
instanceMap[collectorId] = new Set();
|
|
261
|
+
}
|
|
262
|
+
instanceMap[collectorId].add(uuid);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
if (context.type === "raw") {
|
|
268
|
+
for (const run of context.runs) {
|
|
269
|
+
const result = run.result as StoredHealthCheckResult | undefined;
|
|
270
|
+
const collectors = result?.metadata?.collectors as
|
|
271
|
+
| Record<string, unknown>
|
|
272
|
+
| undefined;
|
|
273
|
+
addInstances(collectors);
|
|
274
|
+
}
|
|
275
|
+
} else {
|
|
276
|
+
for (const bucket of context.buckets) {
|
|
277
|
+
const collectors = (
|
|
278
|
+
bucket.aggregatedResult as Record<string, unknown> | undefined
|
|
279
|
+
)?.collectors as Record<string, unknown> | undefined;
|
|
280
|
+
addInstances(collectors);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Convert sets to arrays
|
|
285
|
+
const result: Record<string, string[]> = {};
|
|
286
|
+
for (const [collectorId, uuids] of Object.entries(instanceMap)) {
|
|
287
|
+
result[collectorId] = [...uuids].toSorted();
|
|
288
|
+
}
|
|
289
|
+
return result;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Extended ChartField with instance UUID for data lookup.
|
|
294
|
+
*/
|
|
295
|
+
interface ExpandedChartField extends ChartField {
|
|
296
|
+
/** Instance UUID for data lookup */
|
|
297
|
+
instanceKey?: string;
|
|
298
|
+
}
|
|
299
|
+
|
|
72
300
|
interface AutoChartCardProps {
|
|
73
|
-
field:
|
|
301
|
+
field: ExpandedChartField;
|
|
74
302
|
context: HealthCheckDiagramSlotContext;
|
|
75
303
|
}
|
|
76
304
|
|
|
@@ -91,7 +319,7 @@ function AutoChartCard({ field, context }: AutoChartCardProps) {
|
|
|
91
319
|
}
|
|
92
320
|
|
|
93
321
|
interface ChartRendererProps {
|
|
94
|
-
field:
|
|
322
|
+
field: ExpandedChartField;
|
|
95
323
|
context: HealthCheckDiagramSlotContext;
|
|
96
324
|
}
|
|
97
325
|
|
|
@@ -139,7 +367,7 @@ function ChartRenderer({ field, context }: ChartRendererProps) {
|
|
|
139
367
|
* Counts how many times each value appears across all runs/buckets.
|
|
140
368
|
*/
|
|
141
369
|
function CounterRenderer({ field, context }: ChartRendererProps) {
|
|
142
|
-
const counts = getValueCounts(field.name, context);
|
|
370
|
+
const counts = getValueCounts(field.name, context, field.instanceKey);
|
|
143
371
|
const entries = Object.entries(counts);
|
|
144
372
|
|
|
145
373
|
if (entries.length === 0) {
|
|
@@ -184,7 +412,7 @@ function CounterRenderer({ field, context }: ChartRendererProps) {
|
|
|
184
412
|
* Renders a percentage gauge visualization using Recharts RadialBarChart.
|
|
185
413
|
*/
|
|
186
414
|
function GaugeRenderer({ field, context }: ChartRendererProps) {
|
|
187
|
-
const value = getLatestValue(field.name, context);
|
|
415
|
+
const value = getLatestValue(field.name, context, field.instanceKey);
|
|
188
416
|
const numValue =
|
|
189
417
|
typeof value === "number" ? Math.min(100, Math.max(0, value)) : 0;
|
|
190
418
|
const unit = field.unit ?? "%";
|
|
@@ -231,7 +459,7 @@ function GaugeRenderer({ field, context }: ChartRendererProps) {
|
|
|
231
459
|
* Renders a boolean indicator (success/failure).
|
|
232
460
|
*/
|
|
233
461
|
function BooleanRenderer({ field, context }: ChartRendererProps) {
|
|
234
|
-
const value = getLatestValue(field.name, context);
|
|
462
|
+
const value = getLatestValue(field.name, context, field.instanceKey);
|
|
235
463
|
const isTrue = value === true;
|
|
236
464
|
|
|
237
465
|
return (
|
|
@@ -252,7 +480,7 @@ function BooleanRenderer({ field, context }: ChartRendererProps) {
|
|
|
252
480
|
* Renders text value.
|
|
253
481
|
*/
|
|
254
482
|
function TextRenderer({ field, context }: ChartRendererProps) {
|
|
255
|
-
const value = getLatestValue(field.name, context);
|
|
483
|
+
const value = getLatestValue(field.name, context, field.instanceKey);
|
|
256
484
|
const displayValue = formatTextValue(value);
|
|
257
485
|
|
|
258
486
|
return (
|
|
@@ -269,7 +497,7 @@ function TextRenderer({ field, context }: ChartRendererProps) {
|
|
|
269
497
|
* Renders error/status badge.
|
|
270
498
|
*/
|
|
271
499
|
function StatusRenderer({ field, context }: ChartRendererProps) {
|
|
272
|
-
const value = getLatestValue(field.name, context);
|
|
500
|
+
const value = getLatestValue(field.name, context, field.instanceKey);
|
|
273
501
|
const hasValue = value !== undefined && value !== null && value !== "";
|
|
274
502
|
|
|
275
503
|
if (!hasValue) {
|
|
@@ -287,7 +515,7 @@ function StatusRenderer({ field, context }: ChartRendererProps) {
|
|
|
287
515
|
* Renders an area chart for time series data using Recharts AreaChart.
|
|
288
516
|
*/
|
|
289
517
|
function LineChartRenderer({ field, context }: ChartRendererProps) {
|
|
290
|
-
const values = getAllValues(field.name, context);
|
|
518
|
+
const values = getAllValues(field.name, context, field.instanceKey);
|
|
291
519
|
const unit = field.unit ?? "";
|
|
292
520
|
|
|
293
521
|
if (values.length === 0) {
|
|
@@ -443,7 +671,7 @@ const CHART_COLORS = [
|
|
|
443
671
|
*/
|
|
444
672
|
function PieChartRenderer({ field, context }: ChartRendererProps) {
|
|
445
673
|
// First, try to get a pre-aggregated object value
|
|
446
|
-
const value = getLatestValue(field.name, context);
|
|
674
|
+
const value = getLatestValue(field.name, context, field.instanceKey);
|
|
447
675
|
|
|
448
676
|
// Determine the data source: use pre-aggregated object or count simple values
|
|
449
677
|
let dataRecord: Record<string, number>;
|
|
@@ -453,7 +681,7 @@ function PieChartRenderer({ field, context }: ChartRendererProps) {
|
|
|
453
681
|
dataRecord = value as Record<string, number>;
|
|
454
682
|
} else {
|
|
455
683
|
// Simple values (like statusCode) - count occurrences
|
|
456
|
-
dataRecord = getValueCounts(field.name, context);
|
|
684
|
+
dataRecord = getValueCounts(field.name, context, field.instanceKey);
|
|
457
685
|
}
|
|
458
686
|
|
|
459
687
|
const entries = Object.entries(dataRecord).slice(0, 8);
|
|
@@ -542,7 +770,8 @@ function PieChartRenderer({ field, context }: ChartRendererProps) {
|
|
|
542
770
|
*/
|
|
543
771
|
function getLatestValue(
|
|
544
772
|
fieldName: string,
|
|
545
|
-
context: HealthCheckDiagramSlotContext
|
|
773
|
+
context: HealthCheckDiagramSlotContext,
|
|
774
|
+
collectorId?: string
|
|
546
775
|
): unknown {
|
|
547
776
|
if (context.type === "raw") {
|
|
548
777
|
const runs = context.runs;
|
|
@@ -550,7 +779,7 @@ function getLatestValue(
|
|
|
550
779
|
// For raw runs, aggregate across all runs for record types
|
|
551
780
|
const allValues = runs.map((run) => {
|
|
552
781
|
const result = run.result as StoredHealthCheckResult | undefined;
|
|
553
|
-
return getFieldValue(result?.metadata, fieldName);
|
|
782
|
+
return getFieldValue(result?.metadata, fieldName, collectorId);
|
|
554
783
|
});
|
|
555
784
|
|
|
556
785
|
// If the values are record types (like statusCodeCounts), combine them
|
|
@@ -613,14 +842,15 @@ function combineRecordValues(
|
|
|
613
842
|
*/
|
|
614
843
|
function getValueCounts(
|
|
615
844
|
fieldName: string,
|
|
616
|
-
context: HealthCheckDiagramSlotContext
|
|
845
|
+
context: HealthCheckDiagramSlotContext,
|
|
846
|
+
collectorId?: string
|
|
617
847
|
): Record<string, number> {
|
|
618
848
|
const counts: Record<string, number> = {};
|
|
619
849
|
|
|
620
850
|
if (context.type === "raw") {
|
|
621
851
|
for (const run of context.runs) {
|
|
622
852
|
const result = run.result as StoredHealthCheckResult | undefined;
|
|
623
|
-
const value = getFieldValue(result?.metadata, fieldName);
|
|
853
|
+
const value = getFieldValue(result?.metadata, fieldName, collectorId);
|
|
624
854
|
if (value !== undefined && value !== null) {
|
|
625
855
|
const key = String(value);
|
|
626
856
|
counts[key] = (counts[key] || 0) + 1;
|
|
@@ -631,7 +861,8 @@ function getValueCounts(
|
|
|
631
861
|
for (const bucket of context.buckets) {
|
|
632
862
|
const value = getFieldValue(
|
|
633
863
|
bucket.aggregatedResult as Record<string, unknown>,
|
|
634
|
-
fieldName
|
|
864
|
+
fieldName,
|
|
865
|
+
collectorId
|
|
635
866
|
);
|
|
636
867
|
if (value !== undefined && value !== null) {
|
|
637
868
|
const key = String(value);
|
|
@@ -651,14 +882,15 @@ function getValueCounts(
|
|
|
651
882
|
*/
|
|
652
883
|
function getAllValues(
|
|
653
884
|
fieldName: string,
|
|
654
|
-
context: HealthCheckDiagramSlotContext
|
|
885
|
+
context: HealthCheckDiagramSlotContext,
|
|
886
|
+
collectorId?: string
|
|
655
887
|
): number[] {
|
|
656
888
|
if (context.type === "raw") {
|
|
657
889
|
return context.runs
|
|
658
890
|
.map((run) => {
|
|
659
891
|
// result is typed as StoredHealthCheckResult with { status, latencyMs, message, metadata }
|
|
660
892
|
const result = run.result as StoredHealthCheckResult;
|
|
661
|
-
return getFieldValue(result?.metadata, fieldName);
|
|
893
|
+
return getFieldValue(result?.metadata, fieldName, collectorId);
|
|
662
894
|
})
|
|
663
895
|
.filter((v): v is number => typeof v === "number");
|
|
664
896
|
}
|
|
@@ -666,7 +898,8 @@ function getAllValues(
|
|
|
666
898
|
.map((bucket) =>
|
|
667
899
|
getFieldValue(
|
|
668
900
|
bucket.aggregatedResult as Record<string, unknown>,
|
|
669
|
-
fieldName
|
|
901
|
+
fieldName,
|
|
902
|
+
collectorId
|
|
670
903
|
)
|
|
671
904
|
)
|
|
672
905
|
.filter((v): v is number => typeof v === "number");
|
|
@@ -34,7 +34,7 @@ export type ResultSchema = JsonSchemaBase<ResultSchemaProperty>;
|
|
|
34
34
|
* Chart field information extracted from JSON Schema.
|
|
35
35
|
*/
|
|
36
36
|
export interface ChartField {
|
|
37
|
-
/** Field
|
|
37
|
+
/** Field name (simple name for collector fields, path for others) */
|
|
38
38
|
name: string;
|
|
39
39
|
/** Chart type to render */
|
|
40
40
|
chartType: ChartType;
|
|
@@ -44,7 +44,7 @@ export interface ChartField {
|
|
|
44
44
|
unit?: string;
|
|
45
45
|
/** JSON Schema type (number, string, boolean, etc.) */
|
|
46
46
|
schemaType: string;
|
|
47
|
-
/** Collector ID if this field is from a collector */
|
|
47
|
+
/** Collector ID if this field is from a collector (used for data lookup) */
|
|
48
48
|
collectorId?: string;
|
|
49
49
|
}
|
|
50
50
|
|
|
@@ -78,7 +78,6 @@ export function extractChartFields(
|
|
|
78
78
|
// Extract fields from the collector's result schema
|
|
79
79
|
const collectorFields = extractFieldsFromProperties(
|
|
80
80
|
collectorProp.properties,
|
|
81
|
-
`collectors.${collectorId}`,
|
|
82
81
|
collectorId
|
|
83
82
|
);
|
|
84
83
|
fields.push(...collectorFields);
|
|
@@ -101,7 +100,6 @@ export function extractChartFields(
|
|
|
101
100
|
*/
|
|
102
101
|
function extractFieldsFromProperties(
|
|
103
102
|
properties: Record<string, ResultSchemaProperty>,
|
|
104
|
-
pathPrefix: string,
|
|
105
103
|
collectorId: string
|
|
106
104
|
): ChartField[] {
|
|
107
105
|
const fields: ChartField[] = [];
|
|
@@ -110,8 +108,8 @@ function extractFieldsFromProperties(
|
|
|
110
108
|
const chartType = prop["x-chart-type"];
|
|
111
109
|
if (!chartType) continue;
|
|
112
110
|
|
|
113
|
-
|
|
114
|
-
const field = extractSingleField(
|
|
111
|
+
// Use just field name - collectorId is stored separately for data lookup
|
|
112
|
+
const field = extractSingleField(fieldName, prop);
|
|
115
113
|
field.collectorId = collectorId;
|
|
116
114
|
// Prefix label with collector ID for clarity
|
|
117
115
|
if (!prop["x-chart-label"]?.includes(collectorId)) {
|
|
@@ -165,28 +163,53 @@ function formatFieldName(name: string): string {
|
|
|
165
163
|
|
|
166
164
|
/**
|
|
167
165
|
* Get the value for a field from a data object.
|
|
168
|
-
*
|
|
166
|
+
* For strategy-level fields, also searches inside collectors as fallback.
|
|
167
|
+
*
|
|
168
|
+
* @param data - The metadata object
|
|
169
|
+
* @param fieldName - Simple field name (no dot notation for collector fields)
|
|
170
|
+
* @param collectorInstanceId - Optional: if provided, looks in collectors[collectorInstanceId]
|
|
169
171
|
*/
|
|
170
172
|
export function getFieldValue(
|
|
171
173
|
data: Record<string, unknown> | undefined,
|
|
172
|
-
fieldName: string
|
|
174
|
+
fieldName: string,
|
|
175
|
+
collectorInstanceId?: string
|
|
173
176
|
): unknown {
|
|
174
177
|
if (!data) return undefined;
|
|
175
178
|
|
|
176
|
-
//
|
|
177
|
-
if (
|
|
178
|
-
|
|
179
|
+
// If collectorInstanceId is provided, look in that specific collector's data
|
|
180
|
+
if (collectorInstanceId) {
|
|
181
|
+
const collectors = data.collectors as
|
|
182
|
+
| Record<string, Record<string, unknown>>
|
|
183
|
+
| undefined;
|
|
184
|
+
if (collectors && typeof collectors === "object") {
|
|
185
|
+
const collectorData = collectors[collectorInstanceId];
|
|
186
|
+
if (collectorData && typeof collectorData === "object") {
|
|
187
|
+
return collectorData[fieldName];
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return undefined;
|
|
179
191
|
}
|
|
180
192
|
|
|
181
|
-
//
|
|
182
|
-
const
|
|
183
|
-
|
|
193
|
+
// For non-collector fields, try direct lookup first
|
|
194
|
+
const directValue = data[fieldName];
|
|
195
|
+
if (directValue !== undefined) {
|
|
196
|
+
return directValue;
|
|
197
|
+
}
|
|
184
198
|
|
|
185
|
-
for (
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
199
|
+
// Fallback: search all collectors for the field (for strategy schema fields)
|
|
200
|
+
const collectors = data.collectors as
|
|
201
|
+
| Record<string, Record<string, unknown>>
|
|
202
|
+
| undefined;
|
|
203
|
+
if (collectors && typeof collectors === "object") {
|
|
204
|
+
for (const collectorData of Object.values(collectors)) {
|
|
205
|
+
if (collectorData && typeof collectorData === "object") {
|
|
206
|
+
const value = collectorData[fieldName];
|
|
207
|
+
if (value !== undefined) {
|
|
208
|
+
return value;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
189
212
|
}
|
|
190
213
|
|
|
191
|
-
return
|
|
214
|
+
return undefined;
|
|
192
215
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Hook to fetch and cache strategy schemas.
|
|
3
|
+
*
|
|
4
|
+
* Fetches both strategy result schemas AND collector result schemas,
|
|
5
|
+
* merging them into a unified schema where collector schemas are nested
|
|
6
|
+
* under `properties.collectors.<collectorId>`.
|
|
3
7
|
*/
|
|
4
8
|
|
|
5
9
|
import { useEffect, useState } from "react";
|
|
@@ -14,6 +18,9 @@ interface StrategySchemas {
|
|
|
14
18
|
/**
|
|
15
19
|
* Fetch and cache strategy schemas for auto-chart rendering.
|
|
16
20
|
*
|
|
21
|
+
* Also fetches collector schemas and merges them into the result schema
|
|
22
|
+
* so that chart fields from collectors are properly extracted.
|
|
23
|
+
*
|
|
17
24
|
* @param strategyId - The strategy ID to fetch schemas for
|
|
18
25
|
* @returns Schemas for the strategy, or undefined if not found
|
|
19
26
|
*/
|
|
@@ -30,13 +37,30 @@ export function useStrategySchemas(strategyId: string): {
|
|
|
30
37
|
|
|
31
38
|
async function fetchSchemas() {
|
|
32
39
|
try {
|
|
33
|
-
|
|
40
|
+
// Fetch strategy and collectors in parallel
|
|
41
|
+
const [strategies, collectors] = await Promise.all([
|
|
42
|
+
api.getStrategies(),
|
|
43
|
+
api.getCollectors({ strategyId }),
|
|
44
|
+
]);
|
|
45
|
+
|
|
34
46
|
const strategy = strategies.find((s) => s.id === strategyId);
|
|
35
47
|
|
|
36
48
|
if (!cancelled && strategy) {
|
|
49
|
+
// Build collector schemas object for nesting under resultSchema.properties.collectors
|
|
50
|
+
const collectorProperties: Record<string, unknown> = {};
|
|
51
|
+
for (const collector of collectors) {
|
|
52
|
+
// Use full ID so it matches stored data keys like "healthcheck-http.request"
|
|
53
|
+
collectorProperties[collector.id] = collector.resultSchema;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Merge collector schemas into strategy result schema
|
|
57
|
+
const mergedResultSchema = mergeCollectorSchemas(
|
|
58
|
+
strategy.resultSchema as Record<string, unknown> | undefined,
|
|
59
|
+
collectorProperties
|
|
60
|
+
);
|
|
61
|
+
|
|
37
62
|
setSchemas({
|
|
38
|
-
resultSchema:
|
|
39
|
-
(strategy.resultSchema as Record<string, unknown>) ?? undefined,
|
|
63
|
+
resultSchema: mergedResultSchema,
|
|
40
64
|
aggregatedResultSchema:
|
|
41
65
|
(strategy.aggregatedResultSchema as Record<string, unknown>) ??
|
|
42
66
|
undefined,
|
|
@@ -60,3 +84,49 @@ export function useStrategySchemas(strategyId: string): {
|
|
|
60
84
|
|
|
61
85
|
return { schemas, loading };
|
|
62
86
|
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Merge collector result schemas into a strategy result schema.
|
|
90
|
+
*
|
|
91
|
+
* Creates a schema structure where collectors are nested under
|
|
92
|
+
* `properties.collectors.<collectorId>`, matching the actual data structure
|
|
93
|
+
* stored by the health check executor.
|
|
94
|
+
*/
|
|
95
|
+
function mergeCollectorSchemas(
|
|
96
|
+
strategySchema: Record<string, unknown> | undefined,
|
|
97
|
+
collectorProperties: Record<string, unknown>
|
|
98
|
+
): Record<string, unknown> | undefined {
|
|
99
|
+
// If no collectors, return original schema
|
|
100
|
+
if (Object.keys(collectorProperties).length === 0) {
|
|
101
|
+
return strategySchema;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Build the collectors nested schema
|
|
105
|
+
const collectorsSchema = {
|
|
106
|
+
type: "object",
|
|
107
|
+
properties: collectorProperties,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// If no strategy schema, create one just with collectors
|
|
111
|
+
if (!strategySchema) {
|
|
112
|
+
return {
|
|
113
|
+
type: "object",
|
|
114
|
+
properties: {
|
|
115
|
+
collectors: collectorsSchema,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Merge: add collectors to existing properties
|
|
121
|
+
const existingProps = (strategySchema.properties ?? {}) as Record<
|
|
122
|
+
string,
|
|
123
|
+
unknown
|
|
124
|
+
>;
|
|
125
|
+
return {
|
|
126
|
+
...strategySchema,
|
|
127
|
+
properties: {
|
|
128
|
+
...existingProps,
|
|
129
|
+
collectors: collectorsSchema,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -302,15 +302,22 @@ export const AssertionBuilder: React.FC<AssertionBuilderProps> = ({
|
|
|
302
302
|
<div className="space-y-4">
|
|
303
303
|
{assertions.map((assertion, index) => {
|
|
304
304
|
const field = getFieldByPath(assertion.field);
|
|
305
|
-
|
|
305
|
+
// Safely get operators with fallback to empty array
|
|
306
|
+
const operators = field ? OPERATORS[field.type] ?? [] : [];
|
|
306
307
|
const needsValue = !VALUE_LESS_OPERATORS.has(assertion.operator);
|
|
307
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
|
+
|
|
308
315
|
return (
|
|
309
316
|
<div key={index} className="flex items-start gap-2 flex-wrap">
|
|
310
317
|
{/* Field selector */}
|
|
311
318
|
<div className="flex-1 min-w-[120px]">
|
|
312
319
|
<Select
|
|
313
|
-
value={assertion.field}
|
|
320
|
+
value={fieldValueValid ? assertion.field : undefined}
|
|
314
321
|
onValueChange={(v) => handleFieldChange(index, v)}
|
|
315
322
|
>
|
|
316
323
|
<SelectTrigger>
|
|
@@ -340,7 +347,7 @@ export const AssertionBuilder: React.FC<AssertionBuilderProps> = ({
|
|
|
340
347
|
{/* Operator selector */}
|
|
341
348
|
<div className="flex-1 min-w-[100px]">
|
|
342
349
|
<Select
|
|
343
|
-
value={assertion.operator}
|
|
350
|
+
value={operatorValueValid ? assertion.operator : undefined}
|
|
344
351
|
onValueChange={(v) => handleOperatorChange(index, v)}
|
|
345
352
|
>
|
|
346
353
|
<SelectTrigger>
|
|
@@ -100,6 +100,7 @@ export const CollectorList: React.FC<CollectorListProps> = ({
|
|
|
100
100
|
if (!collector) return;
|
|
101
101
|
|
|
102
102
|
const newEntry: CollectorConfigEntry = {
|
|
103
|
+
id: crypto.randomUUID(),
|
|
103
104
|
collectorId,
|
|
104
105
|
config: {},
|
|
105
106
|
assertions: [],
|
|
@@ -111,6 +112,11 @@ export const CollectorList: React.FC<CollectorListProps> = ({
|
|
|
111
112
|
const handleRemove = (index: number) => {
|
|
112
113
|
const updated = [...configuredCollectors];
|
|
113
114
|
updated.splice(index, 1);
|
|
115
|
+
|
|
116
|
+
// Reset validity map to prevent stale entries after index shift
|
|
117
|
+
// The DynamicForm components will re-report their validity on next render
|
|
118
|
+
setValidityMap({});
|
|
119
|
+
|
|
114
120
|
onChange(updated);
|
|
115
121
|
};
|
|
116
122
|
|
|
@@ -13,12 +13,14 @@ import {
|
|
|
13
13
|
useToast,
|
|
14
14
|
Dialog,
|
|
15
15
|
DialogContent,
|
|
16
|
+
DialogDescription,
|
|
16
17
|
DialogHeader,
|
|
17
18
|
DialogTitle,
|
|
18
19
|
DialogFooter,
|
|
19
20
|
} from "@checkstack/ui";
|
|
20
21
|
import { useCollectors } from "../hooks/useCollectors";
|
|
21
22
|
import { CollectorList } from "./CollectorList";
|
|
23
|
+
import { TeamAccessEditor } from "@checkstack/auth-frontend";
|
|
22
24
|
|
|
23
25
|
interface HealthCheckEditorProps {
|
|
24
26
|
strategies: HealthCheckStrategyDto[];
|
|
@@ -82,7 +84,7 @@ export const HealthCheckEditor: React.FC<HealthCheckEditorProps> = ({
|
|
|
82
84
|
strategyId,
|
|
83
85
|
intervalSeconds: Number.parseInt(interval, 10),
|
|
84
86
|
config,
|
|
85
|
-
collectors
|
|
87
|
+
collectors, // Always send the array, even if empty, to allow clearing
|
|
86
88
|
});
|
|
87
89
|
} catch (error) {
|
|
88
90
|
const message =
|
|
@@ -102,6 +104,11 @@ export const HealthCheckEditor: React.FC<HealthCheckEditorProps> = ({
|
|
|
102
104
|
<DialogTitle>
|
|
103
105
|
{initialData ? "Edit Health Check" : "Create Health Check"}
|
|
104
106
|
</DialogTitle>
|
|
107
|
+
<DialogDescription className="sr-only">
|
|
108
|
+
{initialData
|
|
109
|
+
? "Modify the settings for this health check configuration"
|
|
110
|
+
: "Configure a new health check to monitor your services"}
|
|
111
|
+
</DialogDescription>
|
|
105
112
|
</DialogHeader>
|
|
106
113
|
|
|
107
114
|
<div className="space-y-6 py-4 max-h-[70vh] overflow-y-auto">
|
|
@@ -148,6 +155,16 @@ export const HealthCheckEditor: React.FC<HealthCheckEditorProps> = ({
|
|
|
148
155
|
onValidChange={setCollectorsValid}
|
|
149
156
|
/>
|
|
150
157
|
)}
|
|
158
|
+
|
|
159
|
+
{/* Team Access Editor - only shown for existing configurations */}
|
|
160
|
+
{initialData?.id && (
|
|
161
|
+
<TeamAccessEditor
|
|
162
|
+
resourceType="healthcheck.configuration"
|
|
163
|
+
resourceId={initialData.id}
|
|
164
|
+
compact
|
|
165
|
+
expanded
|
|
166
|
+
/>
|
|
167
|
+
)}
|
|
151
168
|
</div>
|
|
152
169
|
|
|
153
170
|
<DialogFooter>
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
Button,
|
|
10
10
|
Dialog,
|
|
11
11
|
DialogContent,
|
|
12
|
+
DialogDescription,
|
|
12
13
|
DialogHeader,
|
|
13
14
|
DialogTitle,
|
|
14
15
|
DialogFooter,
|
|
@@ -79,10 +80,11 @@ export const SystemHealthCheckAssignment: React.FC<Props> = ({
|
|
|
79
80
|
const loadData = async () => {
|
|
80
81
|
setLoading(true);
|
|
81
82
|
try {
|
|
82
|
-
const [allConfigs, systemAssociations] =
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
const [{ configurations: allConfigs }, systemAssociations] =
|
|
84
|
+
await Promise.all([
|
|
85
|
+
api.getConfigurations(),
|
|
86
|
+
api.getSystemAssociations({ systemId }),
|
|
87
|
+
]);
|
|
86
88
|
setConfigs(allConfigs);
|
|
87
89
|
setAssociations(systemAssociations);
|
|
88
90
|
} catch (error) {
|
|
@@ -741,6 +743,9 @@ export const SystemHealthCheckAssignment: React.FC<Props> = ({
|
|
|
741
743
|
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto">
|
|
742
744
|
<DialogHeader>
|
|
743
745
|
<DialogTitle>Health Check Assignments</DialogTitle>
|
|
746
|
+
<DialogDescription className="sr-only">
|
|
747
|
+
Manage health check assignments for this system
|
|
748
|
+
</DialogDescription>
|
|
744
749
|
</DialogHeader>
|
|
745
750
|
|
|
746
751
|
{loading ? (
|
|
@@ -45,7 +45,7 @@ const HealthCheckConfigPageContent = () => {
|
|
|
45
45
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
46
46
|
|
|
47
47
|
const fetchData = async () => {
|
|
48
|
-
const [configs, strats] = await Promise.all([
|
|
48
|
+
const [{ configurations: configs }, strats] = await Promise.all([
|
|
49
49
|
api.getConfigurations(),
|
|
50
50
|
api.getStrategies(),
|
|
51
51
|
]);
|