@checkstack/healthcheck-frontend 0.11.8 → 0.12.1
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 +64 -0
- package/package.json +5 -5
- package/src/auto-charts/schema-parser.ts +0 -7
- package/src/components/HealthCheckSystemOverview.tsx +2 -66
- package/src/components/SystemHealthCheckAssignment.tsx +4 -4
- package/src/components/editor/CollectorPicker.tsx +129 -0
- package/src/components/editor/CollectorSection.tsx +94 -0
- package/src/components/editor/EditorPanel.tsx +164 -0
- package/src/components/editor/EditorTree.tsx +185 -0
- package/src/components/editor/GeneralSection.tsx +98 -0
- package/src/components/editor/IDEStatusBar.tsx +58 -0
- package/src/hooks/useCollectors.ts +1 -1
- package/src/index.tsx +14 -0
- package/src/pages/HealthCheckConfigPage.tsx +16 -63
- package/src/pages/HealthCheckIDEPage.tsx +382 -0
- package/src/pages/StrategyPickerPage.tsx +157 -0
- package/src/components/HealthCheckEditor.tsx +0 -190
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useMemo } from "react";
|
|
2
|
+
import { useParams, useSearchParams, useNavigate } from "react-router-dom";
|
|
3
|
+
import {
|
|
4
|
+
usePluginClient,
|
|
5
|
+
wrapInSuspense,
|
|
6
|
+
} from "@checkstack/frontend-api";
|
|
7
|
+
import { HealthCheckApi } from "../api";
|
|
8
|
+
import {
|
|
9
|
+
healthcheckRoutes,
|
|
10
|
+
type CollectorConfigEntry,
|
|
11
|
+
} from "@checkstack/healthcheck-common";
|
|
12
|
+
import { PageLayout, Button, useToast } from "@checkstack/ui";
|
|
13
|
+
import { Save, Settings } from "lucide-react";
|
|
14
|
+
import { resolveRoute, extractErrorMessage} from "@checkstack/common";
|
|
15
|
+
import { useCollectors } from "../hooks/useCollectors";
|
|
16
|
+
import { EditorTree, type TreeNodeId } from "../components/editor/EditorTree";
|
|
17
|
+
import { EditorPanel } from "../components/editor/EditorPanel";
|
|
18
|
+
import { IDEStatusBar, type ValidationIssue } from "../components/editor/IDEStatusBar";
|
|
19
|
+
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// TYPES
|
|
22
|
+
// =============================================================================
|
|
23
|
+
|
|
24
|
+
interface EditorFormState {
|
|
25
|
+
name: string;
|
|
26
|
+
intervalSeconds: number;
|
|
27
|
+
strategyConfig: Record<string, unknown>;
|
|
28
|
+
collectors: CollectorConfigEntry[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// =============================================================================
|
|
32
|
+
// PAGE COMPONENT
|
|
33
|
+
// =============================================================================
|
|
34
|
+
|
|
35
|
+
const HealthCheckIDEPageContent = () => {
|
|
36
|
+
const { configId } = useParams<{ configId: string }>();
|
|
37
|
+
const [searchParams] = useSearchParams();
|
|
38
|
+
const navigate = useNavigate();
|
|
39
|
+
const toast = useToast();
|
|
40
|
+
const healthCheckClient = usePluginClient(HealthCheckApi);
|
|
41
|
+
|
|
42
|
+
// "new" is a sentinel value used by the create flow
|
|
43
|
+
const isEditMode = !!configId && configId !== "new";
|
|
44
|
+
const strategyIdFromUrl = searchParams.get("strategy") ?? undefined;
|
|
45
|
+
|
|
46
|
+
// --- Data Fetching ---
|
|
47
|
+
|
|
48
|
+
// Fetch all strategies (needed for both modes)
|
|
49
|
+
const { data: strategies = [] } = healthCheckClient.getStrategies.useQuery(
|
|
50
|
+
{},
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// Fetch single configuration for edit mode
|
|
54
|
+
const { data: existingConfig, isLoading: configLoading } =
|
|
55
|
+
healthCheckClient.getConfiguration.useQuery(
|
|
56
|
+
{ id: configId ?? "" },
|
|
57
|
+
{ enabled: isEditMode },
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// Determine the active strategy ID
|
|
61
|
+
const activeStrategyId = isEditMode
|
|
62
|
+
? existingConfig?.strategyId
|
|
63
|
+
: strategyIdFromUrl;
|
|
64
|
+
|
|
65
|
+
const activeStrategy = useMemo(
|
|
66
|
+
() => strategies.find((s) => s.id === activeStrategyId),
|
|
67
|
+
[strategies, activeStrategyId],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Fetch collectors for the active strategy
|
|
71
|
+
const { collectors: availableCollectors, loading: collectorsLoading } =
|
|
72
|
+
useCollectors(activeStrategyId ?? "");
|
|
73
|
+
|
|
74
|
+
// --- Form State ---
|
|
75
|
+
|
|
76
|
+
const [formState, setFormState] = useState<EditorFormState>({
|
|
77
|
+
name: "",
|
|
78
|
+
intervalSeconds: 60,
|
|
79
|
+
strategyConfig: {},
|
|
80
|
+
collectors: [],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const [selectedNode, setSelectedNode] = useState<TreeNodeId>("general");
|
|
84
|
+
const [strategyConfigValid, setStrategyConfigValid] = useState(true);
|
|
85
|
+
const [collectorsValidity, setCollectorsValidity] = useState<
|
|
86
|
+
Record<string, boolean>
|
|
87
|
+
>({});
|
|
88
|
+
const [isDirty, setIsDirty] = useState(false);
|
|
89
|
+
|
|
90
|
+
// Initialize form from existing configuration (edit mode)
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (existingConfig) {
|
|
93
|
+
setFormState({
|
|
94
|
+
name: existingConfig.name,
|
|
95
|
+
intervalSeconds: existingConfig.intervalSeconds,
|
|
96
|
+
strategyConfig: existingConfig.config,
|
|
97
|
+
collectors: existingConfig.collectors ?? [],
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}, [existingConfig]);
|
|
101
|
+
|
|
102
|
+
// Unsaved changes guard
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (!isDirty) return;
|
|
105
|
+
const handler = (e: BeforeUnloadEvent) => {
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
};
|
|
108
|
+
window.addEventListener("beforeunload", handler);
|
|
109
|
+
return () => window.removeEventListener("beforeunload", handler);
|
|
110
|
+
}, [isDirty]);
|
|
111
|
+
|
|
112
|
+
// --- Update Handlers ---
|
|
113
|
+
|
|
114
|
+
const updateField = useCallback(
|
|
115
|
+
<K extends keyof EditorFormState>(field: K, value: EditorFormState[K]) => {
|
|
116
|
+
setFormState((prev) => ({ ...prev, [field]: value }));
|
|
117
|
+
setIsDirty(true);
|
|
118
|
+
},
|
|
119
|
+
[],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const handleCollectorAdd = useCallback(
|
|
123
|
+
(collectorId: string) => {
|
|
124
|
+
const collector = availableCollectors.find((c) => c.id === collectorId);
|
|
125
|
+
if (!collector) return;
|
|
126
|
+
|
|
127
|
+
const newEntry: CollectorConfigEntry = {
|
|
128
|
+
id: crypto.randomUUID(),
|
|
129
|
+
collectorId,
|
|
130
|
+
config: {},
|
|
131
|
+
assertions: [],
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
setFormState((prev) => ({
|
|
135
|
+
...prev,
|
|
136
|
+
collectors: [...prev.collectors, newEntry],
|
|
137
|
+
}));
|
|
138
|
+
setIsDirty(true);
|
|
139
|
+
|
|
140
|
+
// Select the new collector in the tree
|
|
141
|
+
setSelectedNode(`collector:${newEntry.id}`);
|
|
142
|
+
},
|
|
143
|
+
[availableCollectors],
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const handleCollectorRemove = useCallback(
|
|
147
|
+
(collectorEntryId: string) => {
|
|
148
|
+
setFormState((prev) => ({
|
|
149
|
+
...prev,
|
|
150
|
+
collectors: prev.collectors.filter((c) => c.id !== collectorEntryId),
|
|
151
|
+
}));
|
|
152
|
+
setIsDirty(true);
|
|
153
|
+
|
|
154
|
+
// Navigate back to general if removing the selected collector
|
|
155
|
+
if (selectedNode === `collector:${collectorEntryId}`) {
|
|
156
|
+
setSelectedNode("general");
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
[selectedNode],
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const handleCollectorConfigChange = useCallback(
|
|
163
|
+
(collectorEntryId: string, config: Record<string, unknown>) => {
|
|
164
|
+
setFormState((prev) => ({
|
|
165
|
+
...prev,
|
|
166
|
+
collectors: prev.collectors.map((c) =>
|
|
167
|
+
c.id === collectorEntryId ? { ...c, config } : c,
|
|
168
|
+
),
|
|
169
|
+
}));
|
|
170
|
+
setIsDirty(true);
|
|
171
|
+
},
|
|
172
|
+
[],
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const handleCollectorAssertionsChange = useCallback(
|
|
176
|
+
(collectorEntryId: string, assertions: CollectorConfigEntry["assertions"]) => {
|
|
177
|
+
setFormState((prev) => ({
|
|
178
|
+
...prev,
|
|
179
|
+
collectors: prev.collectors.map((c) =>
|
|
180
|
+
c.id === collectorEntryId ? { ...c, assertions } : c,
|
|
181
|
+
),
|
|
182
|
+
}));
|
|
183
|
+
setIsDirty(true);
|
|
184
|
+
},
|
|
185
|
+
[],
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const handleCollectorValidChange = useCallback(
|
|
189
|
+
(collectorEntryId: string, isValid: boolean) => {
|
|
190
|
+
setCollectorsValidity((prev) => ({ ...prev, [collectorEntryId]: isValid }));
|
|
191
|
+
},
|
|
192
|
+
[],
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
// --- Validation ---
|
|
196
|
+
|
|
197
|
+
const validationIssues = useMemo<ValidationIssue[]>(() => {
|
|
198
|
+
const issues: ValidationIssue[] = [];
|
|
199
|
+
|
|
200
|
+
if (!formState.name.trim()) {
|
|
201
|
+
issues.push({ nodeId: "general", message: "Name is required" });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (formState.intervalSeconds < 1) {
|
|
205
|
+
issues.push({
|
|
206
|
+
nodeId: "general",
|
|
207
|
+
message: "Interval must be at least 1 second",
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!strategyConfigValid) {
|
|
212
|
+
issues.push({
|
|
213
|
+
nodeId: "general",
|
|
214
|
+
message: "Strategy configuration is invalid",
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
for (const [entryId, isValid] of Object.entries(collectorsValidity)) {
|
|
219
|
+
if (!isValid) {
|
|
220
|
+
const collector = formState.collectors.find((c) => c.id === entryId);
|
|
221
|
+
const collectorDef = availableCollectors.find(
|
|
222
|
+
(c) => c.id === collector?.collectorId,
|
|
223
|
+
);
|
|
224
|
+
issues.push({
|
|
225
|
+
nodeId: `collector:${entryId}`,
|
|
226
|
+
message: `${collectorDef?.displayName ?? "Collector"} config is invalid`,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return issues;
|
|
232
|
+
}, [
|
|
233
|
+
formState.name,
|
|
234
|
+
formState.intervalSeconds,
|
|
235
|
+
formState.collectors,
|
|
236
|
+
strategyConfigValid,
|
|
237
|
+
collectorsValidity,
|
|
238
|
+
availableCollectors,
|
|
239
|
+
]);
|
|
240
|
+
|
|
241
|
+
const isValid = validationIssues.length === 0;
|
|
242
|
+
|
|
243
|
+
// --- Save ---
|
|
244
|
+
|
|
245
|
+
const createMutation = healthCheckClient.createConfiguration.useMutation({
|
|
246
|
+
onSuccess: () => {
|
|
247
|
+
setIsDirty(false);
|
|
248
|
+
toast.success("Health check created");
|
|
249
|
+
navigate(resolveRoute(healthcheckRoutes.routes.config));
|
|
250
|
+
},
|
|
251
|
+
onError: (error) => {
|
|
252
|
+
toast.error(extractErrorMessage(error, "Failed to create"));
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const updateMutation = healthCheckClient.updateConfiguration.useMutation({
|
|
257
|
+
onSuccess: () => {
|
|
258
|
+
setIsDirty(false);
|
|
259
|
+
toast.success("Health check updated");
|
|
260
|
+
navigate(resolveRoute(healthcheckRoutes.routes.config));
|
|
261
|
+
},
|
|
262
|
+
onError: (error) => {
|
|
263
|
+
toast.error(extractErrorMessage(error, "Failed to update"));
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const handleSave = () => {
|
|
268
|
+
if (!isValid || !activeStrategyId) return;
|
|
269
|
+
|
|
270
|
+
const payload = {
|
|
271
|
+
name: formState.name,
|
|
272
|
+
strategyId: activeStrategyId,
|
|
273
|
+
config: formState.strategyConfig,
|
|
274
|
+
intervalSeconds: formState.intervalSeconds,
|
|
275
|
+
collectors: formState.collectors,
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
if (isEditMode && configId) {
|
|
279
|
+
updateMutation.mutate({ id: configId, body: payload });
|
|
280
|
+
} else {
|
|
281
|
+
createMutation.mutate(payload);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const isSaving = createMutation.isPending || updateMutation.isPending;
|
|
286
|
+
|
|
287
|
+
// --- Loading States ---
|
|
288
|
+
|
|
289
|
+
if (isEditMode && configLoading) {
|
|
290
|
+
return (
|
|
291
|
+
<PageLayout
|
|
292
|
+
title="Edit Health Check"
|
|
293
|
+
icon={Settings}
|
|
294
|
+
loading
|
|
295
|
+
>
|
|
296
|
+
<div />
|
|
297
|
+
</PageLayout>
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (isEditMode && !existingConfig && !configLoading) {
|
|
302
|
+
return (
|
|
303
|
+
<PageLayout
|
|
304
|
+
title="Health Check Not Found"
|
|
305
|
+
subtitle="The requested configuration could not be found."
|
|
306
|
+
icon={Settings}
|
|
307
|
+
>
|
|
308
|
+
<div className="text-center py-12 text-muted-foreground">
|
|
309
|
+
<p>This configuration may have been deleted.</p>
|
|
310
|
+
</div>
|
|
311
|
+
</PageLayout>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return (
|
|
316
|
+
<PageLayout
|
|
317
|
+
title={isEditMode ? `Edit: ${formState.name || "Unnamed"}` : "New Health Check"}
|
|
318
|
+
subtitle={activeStrategy?.displayName}
|
|
319
|
+
icon={Settings}
|
|
320
|
+
maxWidth="full"
|
|
321
|
+
actions={
|
|
322
|
+
<Button
|
|
323
|
+
onClick={handleSave}
|
|
324
|
+
disabled={!isValid || isSaving}
|
|
325
|
+
>
|
|
326
|
+
<Save className="mr-2 h-4 w-4" />
|
|
327
|
+
{isSaving ? "Saving..." : "Save"}
|
|
328
|
+
</Button>
|
|
329
|
+
}
|
|
330
|
+
>
|
|
331
|
+
<div className="flex flex-col lg:flex-row gap-0 min-h-[60vh] border rounded-lg bg-card overflow-hidden">
|
|
332
|
+
{/* Explorer Tree — Left Panel */}
|
|
333
|
+
<div className="w-full lg:w-64 shrink-0 border-b lg:border-b-0 lg:border-r bg-muted/30">
|
|
334
|
+
<EditorTree
|
|
335
|
+
collectors={formState.collectors}
|
|
336
|
+
availableCollectors={availableCollectors}
|
|
337
|
+
selectedNode={selectedNode}
|
|
338
|
+
onSelectNode={setSelectedNode}
|
|
339
|
+
onAddCollector={handleCollectorAdd}
|
|
340
|
+
validationIssues={validationIssues}
|
|
341
|
+
strategyId={activeStrategyId ?? ""}
|
|
342
|
+
/>
|
|
343
|
+
</div>
|
|
344
|
+
|
|
345
|
+
{/* Editor Panel — Right Panel */}
|
|
346
|
+
<div className="flex-1 min-w-0">
|
|
347
|
+
<EditorPanel
|
|
348
|
+
selectedNode={selectedNode}
|
|
349
|
+
formState={formState}
|
|
350
|
+
strategy={activeStrategy}
|
|
351
|
+
availableCollectors={availableCollectors}
|
|
352
|
+
collectorsLoading={collectorsLoading}
|
|
353
|
+
isEditMode={isEditMode}
|
|
354
|
+
configId={configId}
|
|
355
|
+
onNameChange={(name) => updateField("name", name)}
|
|
356
|
+
onIntervalChange={(interval) =>
|
|
357
|
+
updateField("intervalSeconds", interval)
|
|
358
|
+
}
|
|
359
|
+
onStrategyConfigChange={(config) =>
|
|
360
|
+
updateField("strategyConfig", config)
|
|
361
|
+
}
|
|
362
|
+
onStrategyConfigValidChange={setStrategyConfigValid}
|
|
363
|
+
onCollectorConfigChange={handleCollectorConfigChange}
|
|
364
|
+
onCollectorAssertionsChange={handleCollectorAssertionsChange}
|
|
365
|
+
onCollectorValidChange={handleCollectorValidChange}
|
|
366
|
+
onCollectorRemove={handleCollectorRemove}
|
|
367
|
+
onCollectorAdd={handleCollectorAdd}
|
|
368
|
+
strategyId={activeStrategyId ?? ""}
|
|
369
|
+
/>
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
|
|
373
|
+
{/* Status Bar */}
|
|
374
|
+
<IDEStatusBar
|
|
375
|
+
issues={validationIssues}
|
|
376
|
+
onIssueClick={(nodeId) => setSelectedNode(nodeId)}
|
|
377
|
+
/>
|
|
378
|
+
</PageLayout>
|
|
379
|
+
);
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
export const HealthCheckIDEPage = wrapInSuspense(HealthCheckIDEPageContent);
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { useState, useMemo } from "react";
|
|
2
|
+
import {
|
|
3
|
+
usePluginClient,
|
|
4
|
+
wrapInSuspense,
|
|
5
|
+
} from "@checkstack/frontend-api";
|
|
6
|
+
import { HealthCheckApi } from "../api";
|
|
7
|
+
import {
|
|
8
|
+
healthcheckRoutes,
|
|
9
|
+
STRATEGY_CATEGORY_META,
|
|
10
|
+
type StrategyCategory,
|
|
11
|
+
type HealthCheckStrategyDto,
|
|
12
|
+
} from "@checkstack/healthcheck-common";
|
|
13
|
+
import {
|
|
14
|
+
PageLayout,
|
|
15
|
+
Input,
|
|
16
|
+
Badge,
|
|
17
|
+
} from "@checkstack/ui";
|
|
18
|
+
import { Search, Zap } from "lucide-react";
|
|
19
|
+
import { useNavigate } from "react-router-dom";
|
|
20
|
+
import { resolveRoute } from "@checkstack/common";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Card component for a single strategy in the picker grid.
|
|
24
|
+
*/
|
|
25
|
+
function StrategyCard({
|
|
26
|
+
strategy,
|
|
27
|
+
onSelect,
|
|
28
|
+
}: {
|
|
29
|
+
strategy: HealthCheckStrategyDto;
|
|
30
|
+
onSelect: (strategy: HealthCheckStrategyDto) => void;
|
|
31
|
+
}) {
|
|
32
|
+
const categoryMeta = STRATEGY_CATEGORY_META[strategy.category];
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
onClick={() => onSelect(strategy)}
|
|
38
|
+
className="group flex flex-col items-start gap-2 rounded-lg border bg-card p-4 text-left transition-all hover:border-primary/50 hover:shadow-md hover:shadow-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
39
|
+
>
|
|
40
|
+
<div className="flex w-full items-center justify-between">
|
|
41
|
+
<h3 className="font-semibold text-sm group-hover:text-primary transition-colors">
|
|
42
|
+
{strategy.displayName}
|
|
43
|
+
</h3>
|
|
44
|
+
<Badge variant="outline" className="text-[10px] shrink-0">
|
|
45
|
+
{categoryMeta?.label ?? strategy.category}
|
|
46
|
+
</Badge>
|
|
47
|
+
</div>
|
|
48
|
+
{strategy.description && (
|
|
49
|
+
<p className="text-xs text-muted-foreground line-clamp-2">
|
|
50
|
+
{strategy.description}
|
|
51
|
+
</p>
|
|
52
|
+
)}
|
|
53
|
+
</button>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Strategy Picker Page — full-page card grid grouped by category.
|
|
59
|
+
* Acts as the first step of the "Create Health Check" wizard flow.
|
|
60
|
+
*/
|
|
61
|
+
const StrategyPickerPageContent = () => {
|
|
62
|
+
const healthCheckClient = usePluginClient(HealthCheckApi);
|
|
63
|
+
const navigate = useNavigate();
|
|
64
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
65
|
+
|
|
66
|
+
const { data: strategies = [] } = healthCheckClient.getStrategies.useQuery(
|
|
67
|
+
{},
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Group strategies by category and filter by search
|
|
71
|
+
const groupedStrategies = useMemo(() => {
|
|
72
|
+
const filtered = strategies.filter((s) => {
|
|
73
|
+
const query = searchQuery.toLowerCase();
|
|
74
|
+
if (!query) return true;
|
|
75
|
+
return (
|
|
76
|
+
s.displayName.toLowerCase().includes(query) ||
|
|
77
|
+
(s.description?.toLowerCase().includes(query) ?? false)
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Group by category
|
|
82
|
+
const groups = new Map<StrategyCategory, HealthCheckStrategyDto[]>();
|
|
83
|
+
for (const strategy of filtered) {
|
|
84
|
+
const category = strategy.category;
|
|
85
|
+
const existing = groups.get(category) ?? [];
|
|
86
|
+
existing.push(strategy);
|
|
87
|
+
groups.set(category, existing);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Sort groups by category sort order
|
|
91
|
+
return [...groups.entries()].toSorted(([a], [b]) => {
|
|
92
|
+
const orderA = STRATEGY_CATEGORY_META[a]?.sortOrder ?? 99;
|
|
93
|
+
const orderB = STRATEGY_CATEGORY_META[b]?.sortOrder ?? 99;
|
|
94
|
+
return orderA - orderB;
|
|
95
|
+
});
|
|
96
|
+
}, [strategies, searchQuery]);
|
|
97
|
+
|
|
98
|
+
const handleSelectStrategy = (strategy: HealthCheckStrategyDto) => {
|
|
99
|
+
navigate(
|
|
100
|
+
`${resolveRoute(healthcheckRoutes.routes.edit, { configId: "new" })}?strategy=${encodeURIComponent(strategy.id)}`,
|
|
101
|
+
);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<PageLayout
|
|
106
|
+
title="Create Health Check"
|
|
107
|
+
subtitle="Choose a strategy to get started"
|
|
108
|
+
icon={Zap}
|
|
109
|
+
actions={
|
|
110
|
+
<div className="relative w-full max-w-sm">
|
|
111
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
112
|
+
<Input
|
|
113
|
+
placeholder="Search strategies..."
|
|
114
|
+
value={searchQuery}
|
|
115
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
116
|
+
className="pl-9"
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
}
|
|
120
|
+
>
|
|
121
|
+
{groupedStrategies.length === 0 ? (
|
|
122
|
+
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
|
123
|
+
<Search className="h-8 w-8 mb-3 opacity-40" />
|
|
124
|
+
<p className="text-sm">
|
|
125
|
+
{searchQuery
|
|
126
|
+
? "No strategies match your search."
|
|
127
|
+
: "No strategies available."}
|
|
128
|
+
</p>
|
|
129
|
+
</div>
|
|
130
|
+
) : (
|
|
131
|
+
<div className="space-y-8">
|
|
132
|
+
{groupedStrategies.map(([category, categoryStrategies]) => {
|
|
133
|
+
const meta = STRATEGY_CATEGORY_META[category];
|
|
134
|
+
return (
|
|
135
|
+
<section key={category}>
|
|
136
|
+
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3">
|
|
137
|
+
{meta?.label ?? category}
|
|
138
|
+
</h2>
|
|
139
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
140
|
+
{categoryStrategies.map((strategy) => (
|
|
141
|
+
<StrategyCard
|
|
142
|
+
key={strategy.id}
|
|
143
|
+
strategy={strategy}
|
|
144
|
+
onSelect={handleSelectStrategy}
|
|
145
|
+
/>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
</section>
|
|
149
|
+
);
|
|
150
|
+
})}
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
</PageLayout>
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export const StrategyPickerPage = wrapInSuspense(StrategyPickerPageContent);
|