@checkstack/healthcheck-frontend 0.11.8 → 0.12.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,98 @@
1
+ import React from "react";
2
+ import type { HealthCheckStrategyDto } from "@checkstack/healthcheck-common";
3
+ import { Input, Label, DynamicForm } from "@checkstack/ui";
4
+ import { AlertTriangle } from "lucide-react";
5
+
6
+ interface GeneralSectionProps {
7
+ name: string;
8
+ intervalSeconds: number;
9
+ strategyConfig: Record<string, unknown>;
10
+ strategy: HealthCheckStrategyDto | undefined;
11
+ onNameChange: (name: string) => void;
12
+ onIntervalChange: (interval: number) => void;
13
+ onStrategyConfigChange: (config: Record<string, unknown>) => void;
14
+ onStrategyConfigValidChange: (isValid: boolean) => void;
15
+ }
16
+
17
+ export const GeneralSection: React.FC<GeneralSectionProps> = ({
18
+ name,
19
+ intervalSeconds,
20
+ strategyConfig,
21
+ strategy,
22
+ onNameChange,
23
+ onIntervalChange,
24
+ onStrategyConfigChange,
25
+ onStrategyConfigValidChange,
26
+ }) => {
27
+ return (
28
+ <div className="space-y-6">
29
+ <div>
30
+ <h2 className="text-lg font-semibold">General</h2>
31
+ <p className="text-sm text-muted-foreground">
32
+ Basic configuration for this health check.
33
+ </p>
34
+ </div>
35
+
36
+ {/* Strategy Display */}
37
+ {strategy && (
38
+ <div className="flex items-center gap-2 rounded-md border border-border/50 bg-muted/30 px-3 py-2">
39
+ <span className="text-sm font-medium">{strategy.displayName}</span>
40
+ {strategy.description && (
41
+ <span className="text-xs text-muted-foreground">
42
+ — {strategy.description}
43
+ </span>
44
+ )}
45
+ </div>
46
+ )}
47
+
48
+ {/* Name */}
49
+ <div className="space-y-2">
50
+ <Label htmlFor="hc-name">Name</Label>
51
+ <Input
52
+ id="hc-name"
53
+ value={name}
54
+ onChange={(e) => onNameChange(e.target.value)}
55
+ placeholder="e.g. Production API Health"
56
+ />
57
+ </div>
58
+
59
+ {/* Interval */}
60
+ <div className="space-y-2">
61
+ <Label htmlFor="hc-interval">Interval (seconds)</Label>
62
+ <Input
63
+ id="hc-interval"
64
+ type="number"
65
+ min={1}
66
+ value={intervalSeconds}
67
+ onChange={(e) => onIntervalChange(Number(e.target.value))}
68
+ />
69
+ {intervalSeconds > 0 && intervalSeconds < 60 && (
70
+ <div className="flex items-center gap-1.5 text-xs text-amber-500">
71
+ <AlertTriangle className="h-3 w-3" />
72
+ <span>
73
+ Sub-minute intervals may cause high load on monitored services.
74
+ </span>
75
+ </div>
76
+ )}
77
+ </div>
78
+
79
+ {/* Strategy Config */}
80
+ {strategy?.configSchema && (
81
+ <div className="space-y-3 pt-2 border-t">
82
+ <div>
83
+ <h3 className="text-sm font-semibold">Strategy Configuration</h3>
84
+ <p className="text-xs text-muted-foreground">
85
+ Settings specific to {strategy.displayName}.
86
+ </p>
87
+ </div>
88
+ <DynamicForm
89
+ schema={strategy.configSchema}
90
+ value={strategyConfig}
91
+ onChange={onStrategyConfigChange}
92
+ onValidChange={onStrategyConfigValidChange}
93
+ />
94
+ </div>
95
+ )}
96
+ </div>
97
+ );
98
+ };
@@ -0,0 +1,58 @@
1
+ import React from "react";
2
+ import type { TreeNodeId } from "./EditorTree";
3
+ import { AlertCircle, CheckCircle2 } from "lucide-react";
4
+
5
+ // =============================================================================
6
+ // TYPES
7
+ // =============================================================================
8
+
9
+ export interface ValidationIssue {
10
+ nodeId: TreeNodeId;
11
+ message: string;
12
+ }
13
+
14
+ interface IDEStatusBarProps {
15
+ issues: ValidationIssue[];
16
+ onIssueClick: (nodeId: TreeNodeId) => void;
17
+ }
18
+
19
+ // =============================================================================
20
+ // STATUS BAR
21
+ // =============================================================================
22
+
23
+ export const IDEStatusBar: React.FC<IDEStatusBarProps> = ({
24
+ issues,
25
+ onIssueClick,
26
+ }) => {
27
+ if (issues.length === 0) {
28
+ return (
29
+ <div className="flex items-center gap-2 px-4 py-2 mt-2 rounded-md border bg-card text-xs text-muted-foreground">
30
+ <CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
31
+ <span>No issues found</span>
32
+ </div>
33
+ );
34
+ }
35
+
36
+ return (
37
+ <div className="flex items-center gap-3 px-4 py-2 mt-2 rounded-md border bg-card text-xs">
38
+ <div className="flex items-center gap-1.5 text-destructive shrink-0">
39
+ <AlertCircle className="h-3.5 w-3.5" />
40
+ <span className="font-medium">
41
+ {issues.length} {issues.length === 1 ? "issue" : "issues"}
42
+ </span>
43
+ </div>
44
+ <div className="flex items-center gap-2 overflow-x-auto">
45
+ {issues.map((issue, i) => (
46
+ <button
47
+ key={`${issue.nodeId}-${i}`}
48
+ type="button"
49
+ onClick={() => onIssueClick(issue.nodeId)}
50
+ className="text-muted-foreground hover:text-foreground transition-colors whitespace-nowrap underline-offset-2 hover:underline"
51
+ >
52
+ {issue.message}
53
+ </button>
54
+ ))}
55
+ </div>
56
+ </div>
57
+ );
58
+ };
package/src/index.tsx CHANGED
@@ -4,6 +4,8 @@ import {
4
4
  UserMenuItemsSlot,
5
5
  } from "@checkstack/frontend-api";
6
6
  import { HealthCheckConfigPage } from "./pages/HealthCheckConfigPage";
7
+ import { StrategyPickerPage } from "./pages/StrategyPickerPage";
8
+ import { HealthCheckIDEPage } from "./pages/HealthCheckIDEPage";
7
9
  import { HealthCheckHistoryPage } from "./pages/HealthCheckHistoryPage";
8
10
  import { HealthCheckHistoryDetailPage } from "./pages/HealthCheckHistoryDetailPage";
9
11
  import { HealthCheckMenuItems } from "./components/HealthCheckMenuItems";
@@ -43,6 +45,18 @@ export default createFrontendPlugin({
43
45
  title: "Health Checks",
44
46
  accessRule: healthCheckAccess.configuration.manage,
45
47
  },
48
+ {
49
+ route: healthcheckRoutes.routes.create,
50
+ element: <StrategyPickerPage />,
51
+ title: "Create Health Check",
52
+ accessRule: healthCheckAccess.configuration.manage,
53
+ },
54
+ {
55
+ route: healthcheckRoutes.routes.edit,
56
+ element: <HealthCheckIDEPage />,
57
+ title: "Edit Health Check",
58
+ accessRule: healthCheckAccess.configuration.manage,
59
+ },
46
60
  {
47
61
  route: healthcheckRoutes.routes.history,
48
62
  element: <HealthCheckHistoryPage />,
@@ -1,5 +1,5 @@
1
- import { useEffect, useState } from "react";
2
- import { useSearchParams } from "react-router-dom";
1
+ import { useEffect } from "react";
2
+ import { useSearchParams, useNavigate } from "react-router-dom";
3
3
  import {
4
4
  usePluginClient,
5
5
  wrapInSuspense,
@@ -8,13 +8,11 @@ import {
8
8
  } from "@checkstack/frontend-api";
9
9
  import { HealthCheckApi } from "../api";
10
10
  import {
11
- HealthCheckConfiguration,
12
- CreateHealthCheckConfiguration,
11
+ type HealthCheckConfiguration,
13
12
  healthcheckRoutes,
14
13
  healthCheckAccess,
15
14
  } from "@checkstack/healthcheck-common";
16
15
  import { HealthCheckList } from "../components/HealthCheckList";
17
- import { HealthCheckEditor } from "../components/HealthCheckEditor";
18
16
  import {
19
17
  Button,
20
18
  ConfirmationModal,
@@ -24,11 +22,13 @@ import {
24
22
  import { Plus, History, Activity } from "lucide-react";
25
23
  import { Link } from "react-router-dom";
26
24
  import { resolveRoute } from "@checkstack/common";
25
+ import { useState } from "react";
27
26
 
28
27
  const HealthCheckConfigPageContent = () => {
29
28
  const healthCheckClient = usePluginClient(HealthCheckApi);
30
29
  const accessApi = useApi(accessApiRef);
31
30
  const toast = useToast();
31
+ const navigate = useNavigate();
32
32
  const [searchParams, setSearchParams] = useSearchParams();
33
33
  const { allowed: canRead, loading: accessLoading } = accessApi.useAccess(
34
34
  healthCheckAccess.configuration.read,
@@ -37,11 +37,6 @@ const HealthCheckConfigPageContent = () => {
37
37
  healthCheckAccess.configuration.manage,
38
38
  );
39
39
 
40
- const [isEditorOpen, setIsEditorOpen] = useState(false);
41
- const [editingConfig, setEditingConfig] = useState<
42
- HealthCheckConfiguration | undefined
43
- >();
44
-
45
40
  // Delete modal state
46
41
  const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
47
42
  const [idToDelete, setIdToDelete] = useState<string | undefined>();
@@ -60,35 +55,14 @@ const HealthCheckConfigPageContent = () => {
60
55
  // Handle ?action=create URL parameter (from command palette)
61
56
  useEffect(() => {
62
57
  if (searchParams.get("action") === "create" && canManage) {
63
- setEditingConfig(undefined);
64
- setIsEditorOpen(true);
65
- // Clear the URL param after opening
58
+ // Clear the URL param and navigate to the create flow
66
59
  searchParams.delete("action");
67
60
  setSearchParams(searchParams, { replace: true });
61
+ navigate(resolveRoute(healthcheckRoutes.routes.create));
68
62
  }
69
- }, [searchParams, canManage, setSearchParams]);
63
+ }, [searchParams, canManage, setSearchParams, navigate]);
70
64
 
71
65
  // Mutations
72
- const createMutation = healthCheckClient.createConfiguration.useMutation({
73
- onSuccess: () => {
74
- setIsEditorOpen(false);
75
- void refetchConfigurations();
76
- },
77
- onError: (error) => {
78
- toast.error(error instanceof Error ? error.message : "Failed to create");
79
- },
80
- });
81
-
82
- const updateMutation = healthCheckClient.updateConfiguration.useMutation({
83
- onSuccess: () => {
84
- setIsEditorOpen(false);
85
- void refetchConfigurations();
86
- },
87
- onError: (error) => {
88
- toast.error(error instanceof Error ? error.message : "Failed to update");
89
- },
90
- });
91
-
92
66
  const deleteMutation = healthCheckClient.deleteConfiguration.useMutation({
93
67
  onSuccess: () => {
94
68
  setIsDeleteModalOpen(false);
@@ -119,13 +93,13 @@ const HealthCheckConfigPageContent = () => {
119
93
  });
120
94
 
121
95
  const handleCreate = () => {
122
- setEditingConfig(undefined);
123
- setIsEditorOpen(true);
96
+ navigate(resolveRoute(healthcheckRoutes.routes.create));
124
97
  };
125
98
 
126
99
  const handleEdit = (config: HealthCheckConfiguration) => {
127
- setEditingConfig(config);
128
- setIsEditorOpen(true);
100
+ navigate(
101
+ resolveRoute(healthcheckRoutes.routes.edit, { configId: config.id }),
102
+ );
129
103
  };
130
104
 
131
105
  const handleDelete = (id: string) => {
@@ -138,19 +112,6 @@ const HealthCheckConfigPageContent = () => {
138
112
  deleteMutation.mutate(idToDelete);
139
113
  };
140
114
 
141
- const handleSave = async (data: CreateHealthCheckConfiguration) => {
142
- if (editingConfig) {
143
- updateMutation.mutate({ id: editingConfig.id, body: data });
144
- } else {
145
- createMutation.mutate(data);
146
- }
147
- };
148
-
149
- const handleEditorClose = () => {
150
- setIsEditorOpen(false);
151
- setEditingConfig(undefined);
152
- };
153
-
154
115
  return (
155
116
  <PageLayout
156
117
  title="Health Checks"
@@ -183,14 +144,6 @@ const HealthCheckConfigPageContent = () => {
183
144
  canManage={canManage}
184
145
  />
185
146
 
186
- <HealthCheckEditor
187
- open={isEditorOpen}
188
- strategies={strategies}
189
- initialData={editingConfig}
190
- onSave={handleSave}
191
- onCancel={handleEditorClose}
192
- />
193
-
194
147
  <ConfirmationModal
195
148
  isOpen={isDeleteModalOpen}
196
149
  onClose={() => setIsDeleteModalOpen(false)}
@@ -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 } 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(error instanceof Error ? error.message : "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(error instanceof Error ? error.message : "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);