@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,309 @@
|
|
|
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
|
+
id: crypto.randomUUID(),
|
|
104
|
+
collectorId,
|
|
105
|
+
config: {},
|
|
106
|
+
assertions: [],
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
onChange([...configuredCollectors, newEntry]);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const handleRemove = (index: number) => {
|
|
113
|
+
const updated = [...configuredCollectors];
|
|
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
|
+
|
|
120
|
+
onChange(updated);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const handleConfigChange = (
|
|
124
|
+
index: number,
|
|
125
|
+
config: Record<string, unknown>
|
|
126
|
+
) => {
|
|
127
|
+
const updated = [...configuredCollectors];
|
|
128
|
+
updated[index] = { ...updated[index], config };
|
|
129
|
+
onChange(updated);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const handleAssertionsChange = (index: number, assertions: Assertion[]) => {
|
|
133
|
+
const updated = [...configuredCollectors];
|
|
134
|
+
updated[index] = { ...updated[index], assertions };
|
|
135
|
+
onChange(updated);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const getCollectorDetails = (collectorId: string) => {
|
|
139
|
+
return availableCollectors.find((c) => c.id === collectorId);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
if (loading) {
|
|
143
|
+
return (
|
|
144
|
+
<Card>
|
|
145
|
+
<CardHeader>
|
|
146
|
+
<CardTitle className="text-base">Check Items</CardTitle>
|
|
147
|
+
</CardHeader>
|
|
148
|
+
<CardContent>
|
|
149
|
+
<div className="text-muted-foreground text-sm">
|
|
150
|
+
Loading collectors...
|
|
151
|
+
</div>
|
|
152
|
+
</CardContent>
|
|
153
|
+
</Card>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<Card>
|
|
159
|
+
<CardHeader className="flex flex-row items-center justify-between">
|
|
160
|
+
<CardTitle className="text-base">Check Items</CardTitle>
|
|
161
|
+
{addableCollectors.length > 0 && (
|
|
162
|
+
<Select value="" onValueChange={handleAdd}>
|
|
163
|
+
<SelectTrigger className="w-[200px]">
|
|
164
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
165
|
+
<SelectValue placeholder="Add collector..." />
|
|
166
|
+
</SelectTrigger>
|
|
167
|
+
<SelectContent>
|
|
168
|
+
{/* Built-in collectors first */}
|
|
169
|
+
{builtInCollectors.length > 0 && (
|
|
170
|
+
<>
|
|
171
|
+
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
|
172
|
+
Built-in
|
|
173
|
+
</div>
|
|
174
|
+
{builtInCollectors
|
|
175
|
+
.filter((c) => addableCollectors.some((a) => a.id === c.id))
|
|
176
|
+
.map((collector) => (
|
|
177
|
+
<SelectItem key={collector.id} value={collector.id}>
|
|
178
|
+
<div className="flex items-center gap-2">
|
|
179
|
+
<span>{collector.displayName}</span>
|
|
180
|
+
{collector.allowMultiple && (
|
|
181
|
+
<Badge variant="outline" className="text-xs">
|
|
182
|
+
Multiple
|
|
183
|
+
</Badge>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
</SelectItem>
|
|
187
|
+
))}
|
|
188
|
+
</>
|
|
189
|
+
)}
|
|
190
|
+
{/* External collectors */}
|
|
191
|
+
{externalCollectors.length > 0 && (
|
|
192
|
+
<>
|
|
193
|
+
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
|
194
|
+
External
|
|
195
|
+
</div>
|
|
196
|
+
{externalCollectors
|
|
197
|
+
.filter((c) => addableCollectors.some((a) => a.id === c.id))
|
|
198
|
+
.map((collector) => (
|
|
199
|
+
<SelectItem key={collector.id} value={collector.id}>
|
|
200
|
+
<div className="flex items-center gap-2">
|
|
201
|
+
<span>{collector.displayName}</span>
|
|
202
|
+
{collector.allowMultiple && (
|
|
203
|
+
<Badge variant="outline" className="text-xs">
|
|
204
|
+
Multiple
|
|
205
|
+
</Badge>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
</SelectItem>
|
|
209
|
+
))}
|
|
210
|
+
</>
|
|
211
|
+
)}
|
|
212
|
+
</SelectContent>
|
|
213
|
+
</Select>
|
|
214
|
+
)}
|
|
215
|
+
</CardHeader>
|
|
216
|
+
<CardContent>
|
|
217
|
+
{configuredCollectors.length === 0 ? (
|
|
218
|
+
<div className="text-muted-foreground text-sm text-center py-4">
|
|
219
|
+
No check items configured. Add a collector to define what to check.
|
|
220
|
+
</div>
|
|
221
|
+
) : (
|
|
222
|
+
<Accordion type="multiple" className="w-full">
|
|
223
|
+
{configuredCollectors.map((entry, index) => {
|
|
224
|
+
const collector = getCollectorDetails(entry.collectorId);
|
|
225
|
+
const isBuiltIn = isBuiltInCollector(
|
|
226
|
+
entry.collectorId,
|
|
227
|
+
strategyId
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<AccordionItem key={index} value={`item-${index}`}>
|
|
232
|
+
<AccordionTrigger className="hover:no-underline">
|
|
233
|
+
<div className="flex items-center gap-2 flex-1">
|
|
234
|
+
<span className="font-medium">
|
|
235
|
+
{collector?.displayName || entry.collectorId}
|
|
236
|
+
</span>
|
|
237
|
+
{isBuiltIn && (
|
|
238
|
+
<Badge variant="secondary" className="text-xs">
|
|
239
|
+
Built-in
|
|
240
|
+
</Badge>
|
|
241
|
+
)}
|
|
242
|
+
</div>
|
|
243
|
+
<div
|
|
244
|
+
role="button"
|
|
245
|
+
tabIndex={0}
|
|
246
|
+
className="inline-flex items-center justify-center rounded-md h-8 w-8 text-destructive hover:text-destructive hover:bg-accent cursor-pointer"
|
|
247
|
+
onClick={(e) => {
|
|
248
|
+
e.stopPropagation();
|
|
249
|
+
handleRemove(index);
|
|
250
|
+
}}
|
|
251
|
+
onKeyDown={(e) => {
|
|
252
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
253
|
+
e.stopPropagation();
|
|
254
|
+
handleRemove(index);
|
|
255
|
+
}
|
|
256
|
+
}}
|
|
257
|
+
>
|
|
258
|
+
<Trash2 className="h-4 w-4" />
|
|
259
|
+
</div>
|
|
260
|
+
</AccordionTrigger>
|
|
261
|
+
<AccordionContent>
|
|
262
|
+
<div className="space-y-6 pt-4">
|
|
263
|
+
{/* Configuration Section */}
|
|
264
|
+
{collector?.configSchema && (
|
|
265
|
+
<div className="space-y-4">
|
|
266
|
+
<Label className="text-sm font-medium">
|
|
267
|
+
Configuration
|
|
268
|
+
</Label>
|
|
269
|
+
<DynamicForm
|
|
270
|
+
schema={collector.configSchema}
|
|
271
|
+
value={entry.config}
|
|
272
|
+
onChange={(config) =>
|
|
273
|
+
handleConfigChange(index, config)
|
|
274
|
+
}
|
|
275
|
+
onValidChange={(isValid) =>
|
|
276
|
+
handleCollectorValidChange(index, isValid)
|
|
277
|
+
}
|
|
278
|
+
/>
|
|
279
|
+
</div>
|
|
280
|
+
)}
|
|
281
|
+
|
|
282
|
+
{/* Assertion Builder Section */}
|
|
283
|
+
{collector?.resultSchema && (
|
|
284
|
+
<div className="space-y-4">
|
|
285
|
+
<Label className="text-sm font-medium">
|
|
286
|
+
Assertions
|
|
287
|
+
</Label>
|
|
288
|
+
<AssertionBuilder
|
|
289
|
+
resultSchema={collector.resultSchema}
|
|
290
|
+
assertions={
|
|
291
|
+
(entry.assertions as unknown as Assertion[]) ?? []
|
|
292
|
+
}
|
|
293
|
+
onChange={(assertions) =>
|
|
294
|
+
handleAssertionsChange(index, assertions)
|
|
295
|
+
}
|
|
296
|
+
/>
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
300
|
+
</AccordionContent>
|
|
301
|
+
</AccordionItem>
|
|
302
|
+
);
|
|
303
|
+
})}
|
|
304
|
+
</Accordion>
|
|
305
|
+
)}
|
|
306
|
+
</CardContent>
|
|
307
|
+
</Card>
|
|
308
|
+
);
|
|
309
|
+
};
|
|
@@ -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,
|
|
@@ -12,10 +13,14 @@ import {
|
|
|
12
13
|
useToast,
|
|
13
14
|
Dialog,
|
|
14
15
|
DialogContent,
|
|
16
|
+
DialogDescription,
|
|
15
17
|
DialogHeader,
|
|
16
18
|
DialogTitle,
|
|
17
19
|
DialogFooter,
|
|
18
20
|
} from "@checkstack/ui";
|
|
21
|
+
import { useCollectors } from "../hooks/useCollectors";
|
|
22
|
+
import { CollectorList } from "./CollectorList";
|
|
23
|
+
import { TeamAccessEditor } from "@checkstack/auth-frontend";
|
|
19
24
|
|
|
20
25
|
interface HealthCheckEditorProps {
|
|
21
26
|
strategies: HealthCheckStrategyDto[];
|
|
@@ -40,20 +45,36 @@ export const HealthCheckEditor: React.FC<HealthCheckEditorProps> = ({
|
|
|
40
45
|
const [config, setConfig] = useState<Record<string, unknown>>(
|
|
41
46
|
(initialData?.config as Record<string, unknown>) || {}
|
|
42
47
|
);
|
|
48
|
+
const [collectors, setCollectors] = useState<CollectorConfigEntry[]>(
|
|
49
|
+
initialData?.collectors || []
|
|
50
|
+
);
|
|
43
51
|
|
|
44
52
|
const toast = useToast();
|
|
45
53
|
const [loading, setLoading] = useState(false);
|
|
54
|
+
const [collectorsValid, setCollectorsValid] = useState(true);
|
|
55
|
+
|
|
56
|
+
// Fetch available collectors for the selected strategy
|
|
57
|
+
const { collectors: availableCollectors, loading: collectorsLoading } =
|
|
58
|
+
useCollectors(strategyId);
|
|
46
59
|
|
|
47
60
|
// Reset form when dialog opens with new data
|
|
48
|
-
|
|
61
|
+
useEffect(() => {
|
|
49
62
|
if (open) {
|
|
50
63
|
setName(initialData?.name || "");
|
|
51
64
|
setStrategyId(initialData?.strategyId || "");
|
|
52
65
|
setInterval(initialData?.intervalSeconds?.toString() || "60");
|
|
53
66
|
setConfig((initialData?.config as Record<string, unknown>) || {});
|
|
67
|
+
setCollectors(initialData?.collectors || []);
|
|
54
68
|
}
|
|
55
69
|
}, [open, initialData]);
|
|
56
70
|
|
|
71
|
+
// Clear collectors when strategy changes (new strategy = different collectors)
|
|
72
|
+
const handleStrategyChange = (id: string) => {
|
|
73
|
+
setStrategyId(id);
|
|
74
|
+
setConfig({});
|
|
75
|
+
setCollectors([]);
|
|
76
|
+
};
|
|
77
|
+
|
|
57
78
|
const handleSave = async (e: React.FormEvent) => {
|
|
58
79
|
e.preventDefault();
|
|
59
80
|
setLoading(true);
|
|
@@ -63,6 +84,7 @@ export const HealthCheckEditor: React.FC<HealthCheckEditorProps> = ({
|
|
|
63
84
|
strategyId,
|
|
64
85
|
intervalSeconds: Number.parseInt(interval, 10),
|
|
65
86
|
config,
|
|
87
|
+
collectors, // Always send the array, even if empty, to allow clearing
|
|
66
88
|
});
|
|
67
89
|
} catch (error) {
|
|
68
90
|
const message =
|
|
@@ -82,9 +104,14 @@ export const HealthCheckEditor: React.FC<HealthCheckEditorProps> = ({
|
|
|
82
104
|
<DialogTitle>
|
|
83
105
|
{initialData ? "Edit Health Check" : "Create Health Check"}
|
|
84
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>
|
|
85
112
|
</DialogHeader>
|
|
86
113
|
|
|
87
|
-
<div className="space-y-
|
|
114
|
+
<div className="space-y-6 py-4 max-h-[70vh] overflow-y-auto">
|
|
88
115
|
<div className="space-y-2">
|
|
89
116
|
<Label htmlFor="name">Name</Label>
|
|
90
117
|
<Input
|
|
@@ -111,21 +138,40 @@ export const HealthCheckEditor: React.FC<HealthCheckEditorProps> = ({
|
|
|
111
138
|
label="Strategy"
|
|
112
139
|
plugins={strategies}
|
|
113
140
|
selectedPluginId={strategyId}
|
|
114
|
-
onPluginChange={
|
|
115
|
-
setStrategyId(id);
|
|
116
|
-
setConfig({});
|
|
117
|
-
}}
|
|
141
|
+
onPluginChange={handleStrategyChange}
|
|
118
142
|
config={config}
|
|
119
143
|
onConfigChange={setConfig}
|
|
120
144
|
disabled={!!initialData}
|
|
121
145
|
/>
|
|
146
|
+
|
|
147
|
+
{/* Collector Configuration Section */}
|
|
148
|
+
{strategyId && (
|
|
149
|
+
<CollectorList
|
|
150
|
+
strategyId={strategyId}
|
|
151
|
+
availableCollectors={availableCollectors}
|
|
152
|
+
configuredCollectors={collectors}
|
|
153
|
+
onChange={setCollectors}
|
|
154
|
+
loading={collectorsLoading}
|
|
155
|
+
onValidChange={setCollectorsValid}
|
|
156
|
+
/>
|
|
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
|
+
)}
|
|
122
168
|
</div>
|
|
123
169
|
|
|
124
170
|
<DialogFooter>
|
|
125
171
|
<Button type="button" variant="outline" onClick={onCancel}>
|
|
126
172
|
Cancel
|
|
127
173
|
</Button>
|
|
128
|
-
<Button type="submit" disabled={loading}>
|
|
174
|
+
<Button type="submit" disabled={loading || !collectorsValid}>
|
|
129
175
|
{loading ? "Saving..." : "Save"}
|
|
130
176
|
</Button>
|
|
131
177
|
</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
|
+
.replace(/^./, (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
|
+
}
|
|
@@ -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 ? (
|
|
@@ -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
|
+
}
|