@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.
@@ -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);