@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.
@@ -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
- React.useEffect(() => {
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-4 py-4">
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={(id) => {
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
- <div className="space-y-2">
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] = await Promise.all([
83
- api.getConfigurations(),
84
- api.getSystemAssociations({ systemId }),
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
+ }