@checkstack/healthcheck-frontend 0.12.0 → 0.13.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,477 @@
1
+ import { useState, useEffect, useMemo } from "react";
2
+ import { useParams, useNavigate } from "react-router-dom";
3
+ import { usePluginClient, wrapInSuspense } from "@checkstack/frontend-api";
4
+ import { HealthCheckApi } from "../api";
5
+ import { SatelliteApi } from "@checkstack/satellite-common";
6
+ import {
7
+ DEFAULT_STATE_THRESHOLDS,
8
+ DEFAULT_RETENTION_CONFIG,
9
+ } from "@checkstack/healthcheck-common";
10
+ import type { StateThresholds } from "@checkstack/healthcheck-common";
11
+ import { PageLayout, IDELayout, useToast, BackLink } from "@checkstack/ui";
12
+ import { Settings } from "lucide-react";
13
+ import { extractErrorMessage, resolveRoute } from "@checkstack/common";
14
+ import { catalogRoutes } from "@checkstack/catalog-common";
15
+ import {
16
+ AssignmentTree,
17
+ type AssignmentNodeId,
18
+ } from "../components/assignments/AssignmentTree";
19
+ import { GeneralPanel } from "../components/assignments/GeneralPanel";
20
+ import { ThresholdsPanel } from "../components/assignments/ThresholdsPanel";
21
+ import {
22
+ RetentionPanel,
23
+ type RetentionData,
24
+ } from "../components/assignments/RetentionPanel";
25
+ import { ExecutionPanel } from "../components/assignments/ExecutionPanel";
26
+
27
+ // =============================================================================
28
+ // HELPERS
29
+ // =============================================================================
30
+
31
+ function parseNodeId(nodeId: AssignmentNodeId): {
32
+ panel: "general" | "thresholds" | "retention" | "execution";
33
+ configId: string;
34
+ } {
35
+ const [panel, ...rest] = nodeId.split(":") as [string, ...string[]];
36
+ return {
37
+ panel: panel as "general" | "thresholds" | "retention" | "execution",
38
+ configId: rest.join(":"),
39
+ };
40
+ }
41
+
42
+ // =============================================================================
43
+ // PAGE
44
+ // =============================================================================
45
+
46
+ const AssignmentIDEPageContent = () => {
47
+ const { systemId } = useParams<{ systemId: string }>();
48
+ const navigate = useNavigate();
49
+ const toast = useToast();
50
+ const healthCheckClient = usePluginClient(HealthCheckApi);
51
+ const satelliteClient = usePluginClient(SatelliteApi);
52
+
53
+ // --- Data Fetching ---
54
+
55
+ const { data: configurationsData, isLoading: configsLoading } =
56
+ healthCheckClient.getConfigurations.useQuery({});
57
+
58
+ const { data: associations = [], refetch: refetchAssociations } =
59
+ healthCheckClient.getSystemAssociations.useQuery(
60
+ { systemId: systemId ?? "" },
61
+ { enabled: !!systemId },
62
+ );
63
+
64
+ const { data: satellitesData } = satelliteClient.listSatellites.useQuery({});
65
+
66
+ // --- UI State ---
67
+
68
+ const [selectedNode, setSelectedNode] = useState<AssignmentNodeId>();
69
+ const [localThresholds, setLocalThresholds] = useState<
70
+ Record<string, StateThresholds>
71
+ >({});
72
+ const [retentionData, setRetentionData] = useState<
73
+ Record<string, RetentionData>
74
+ >({});
75
+
76
+ const configs = useMemo(
77
+ () => configurationsData?.configurations ?? [],
78
+ [configurationsData],
79
+ );
80
+ const satellites = satellitesData?.satellites ?? [];
81
+ const assignedIds = useMemo(
82
+ () => new Set(associations.map((a) => a.configurationId)),
83
+ [associations],
84
+ );
85
+
86
+ // Fetch retention for selected config
87
+ const selectedConfigId = selectedNode
88
+ ? parseNodeId(selectedNode).configId
89
+ : undefined;
90
+ const isRetentionPanel = selectedNode?.startsWith("retention:");
91
+
92
+ const { data: retentionConfigData } =
93
+ healthCheckClient.getRetentionConfig.useQuery(
94
+ {
95
+ systemId: systemId ?? "",
96
+ configurationId: selectedConfigId ?? "",
97
+ },
98
+ {
99
+ enabled:
100
+ !!isRetentionPanel &&
101
+ !!selectedConfigId &&
102
+ !retentionData[selectedConfigId],
103
+ },
104
+ );
105
+
106
+ useEffect(() => {
107
+ if (
108
+ retentionConfigData &&
109
+ selectedConfigId &&
110
+ !retentionData[selectedConfigId]
111
+ ) {
112
+ setRetentionData((prev) => ({
113
+ ...prev,
114
+ [selectedConfigId]: {
115
+ rawRetentionDays:
116
+ retentionConfigData.retentionConfig?.rawRetentionDays ??
117
+ DEFAULT_RETENTION_CONFIG.rawRetentionDays,
118
+ hourlyRetentionDays:
119
+ retentionConfigData.retentionConfig?.hourlyRetentionDays ??
120
+ DEFAULT_RETENTION_CONFIG.hourlyRetentionDays,
121
+ dailyRetentionDays:
122
+ retentionConfigData.retentionConfig?.dailyRetentionDays ??
123
+ DEFAULT_RETENTION_CONFIG.dailyRetentionDays,
124
+ isCustom: !!retentionConfigData.retentionConfig,
125
+ },
126
+ }));
127
+ }
128
+ }, [retentionConfigData, selectedConfigId, retentionData]);
129
+
130
+ // --- Auto-select first node ---
131
+
132
+ useEffect(() => {
133
+ if (!selectedNode && associations.length > 0) {
134
+ setSelectedNode(`general:${associations[0].configurationId}`);
135
+ }
136
+ }, [selectedNode, associations]);
137
+
138
+ // --- Mutations ---
139
+
140
+ const associateMutation = healthCheckClient.associateSystem.useMutation({
141
+ onSuccess: () => void refetchAssociations(),
142
+ onError: (error) =>
143
+ toast.error(extractErrorMessage(error, "Failed to update")),
144
+ });
145
+
146
+ const disassociateMutation = healthCheckClient.disassociateSystem.useMutation(
147
+ {
148
+ onSuccess: () => {
149
+ toast.success("Health check unassigned");
150
+ void refetchAssociations();
151
+ },
152
+ onError: (error) =>
153
+ toast.error(extractErrorMessage(error, "Failed to update")),
154
+ },
155
+ );
156
+
157
+ const updateRetentionMutation =
158
+ healthCheckClient.updateRetentionConfig.useMutation({
159
+ onSuccess: () => toast.success("Retention settings saved"),
160
+ onError: (error) =>
161
+ toast.error(extractErrorMessage(error, "Failed to save")),
162
+ });
163
+
164
+ const saving =
165
+ associateMutation.isPending ||
166
+ disassociateMutation.isPending ||
167
+ updateRetentionMutation.isPending;
168
+
169
+ // --- Handlers ---
170
+
171
+ const handleToggleAssignment = (configId: string, isAssigned: boolean) => {
172
+ if (!systemId) return;
173
+
174
+ if (isAssigned) {
175
+ disassociateMutation.mutate({ systemId, configId });
176
+ if (selectedNode && parseNodeId(selectedNode).configId === configId) {
177
+ setSelectedNode(undefined);
178
+ }
179
+ } else {
180
+ associateMutation.mutate({
181
+ systemId,
182
+ body: {
183
+ configurationId: configId,
184
+ enabled: true,
185
+ stateThresholds: DEFAULT_STATE_THRESHOLDS,
186
+ includeLocal: true,
187
+ },
188
+ });
189
+ }
190
+ };
191
+
192
+ const handleToggleEnabled = (configId: string, currentEnabled: boolean) => {
193
+ if (!systemId) return;
194
+ const assoc = associations.find((a) => a.configurationId === configId);
195
+ if (!assoc) return;
196
+
197
+ associateMutation.mutate({
198
+ systemId,
199
+ body: {
200
+ configurationId: configId,
201
+ enabled: !currentEnabled,
202
+ stateThresholds: assoc.stateThresholds,
203
+ satelliteIds: assoc.satelliteIds,
204
+ includeLocal: assoc.includeLocal,
205
+ },
206
+ });
207
+ };
208
+
209
+ const handleThresholdChange = (
210
+ configId: string,
211
+ thresholds: StateThresholds,
212
+ ) => {
213
+ setLocalThresholds((prev) => ({ ...prev, [configId]: thresholds }));
214
+ };
215
+
216
+ const handleSaveThresholds = (configId: string) => {
217
+ if (!systemId) return;
218
+ const assoc = associations.find((a) => a.configurationId === configId);
219
+ const thresholds = localThresholds[configId] ?? assoc?.stateThresholds;
220
+ if (!assoc) return;
221
+
222
+ associateMutation.mutate(
223
+ {
224
+ systemId,
225
+ body: {
226
+ configurationId: configId,
227
+ enabled: assoc.enabled,
228
+ stateThresholds: thresholds,
229
+ },
230
+ },
231
+ {
232
+ onSuccess: () => {
233
+ toast.success("Thresholds saved");
234
+ setLocalThresholds((prev) => {
235
+ const next = { ...prev };
236
+ delete next[configId];
237
+ return next;
238
+ });
239
+ },
240
+ },
241
+ );
242
+ };
243
+
244
+ const handleToggleSatellite = (configId: string, satelliteId: string) => {
245
+ if (!systemId) return;
246
+ const assoc = associations.find((a) => a.configurationId === configId);
247
+ if (!assoc) return;
248
+
249
+ const currentIds = assoc.satelliteIds ?? [];
250
+ const isAssigned = currentIds.includes(satelliteId);
251
+ const newIds = isAssigned
252
+ ? currentIds.filter((id) => id !== satelliteId)
253
+ : [...currentIds, satelliteId];
254
+
255
+ associateMutation.mutate({
256
+ systemId,
257
+ body: {
258
+ configurationId: configId,
259
+ enabled: assoc.enabled,
260
+ stateThresholds: assoc.stateThresholds,
261
+ satelliteIds: newIds,
262
+ includeLocal: assoc.includeLocal,
263
+ },
264
+ });
265
+ };
266
+
267
+ const handleToggleLocal = (configId: string) => {
268
+ if (!systemId) return;
269
+ const assoc = associations.find((a) => a.configurationId === configId);
270
+ if (!assoc) return;
271
+
272
+ associateMutation.mutate({
273
+ systemId,
274
+ body: {
275
+ configurationId: configId,
276
+ enabled: assoc.enabled,
277
+ stateThresholds: assoc.stateThresholds,
278
+ satelliteIds: assoc.satelliteIds,
279
+ includeLocal: !assoc.includeLocal,
280
+ },
281
+ });
282
+ };
283
+
284
+ const handleSaveRetention = (configId: string) => {
285
+ if (!systemId) return;
286
+ const data = retentionData[configId];
287
+ if (!data) return;
288
+
289
+ updateRetentionMutation.mutate({
290
+ systemId,
291
+ configurationId: configId,
292
+ retentionConfig: {
293
+ rawRetentionDays: data.rawRetentionDays,
294
+ hourlyRetentionDays: data.hourlyRetentionDays,
295
+ dailyRetentionDays: data.dailyRetentionDays,
296
+ },
297
+ });
298
+ };
299
+
300
+ const handleResetRetention = (configId: string) => {
301
+ if (!systemId) return;
302
+ updateRetentionMutation.mutate(
303
+ {
304
+ systemId,
305
+ configurationId: configId,
306
+ // eslint-disable-next-line unicorn/no-null -- RPC contract uses nullable()
307
+ retentionConfig: null,
308
+ },
309
+ {
310
+ onSuccess: () => {
311
+ setRetentionData((prev) => ({
312
+ ...prev,
313
+ [configId]: {
314
+ rawRetentionDays: DEFAULT_RETENTION_CONFIG.rawRetentionDays,
315
+ hourlyRetentionDays: DEFAULT_RETENTION_CONFIG.hourlyRetentionDays,
316
+ dailyRetentionDays: DEFAULT_RETENTION_CONFIG.dailyRetentionDays,
317
+ isCustom: false,
318
+ },
319
+ }));
320
+ toast.success("Reset to defaults");
321
+ },
322
+ },
323
+ );
324
+ };
325
+
326
+ const updateRetentionField = (
327
+ configId: string,
328
+ field: string,
329
+ value: number,
330
+ ) => {
331
+ setRetentionData((prev) => ({
332
+ ...prev,
333
+ [configId]: { ...prev[configId], [field]: value, isCustom: true },
334
+ }));
335
+ };
336
+
337
+ // --- Derived Data ---
338
+
339
+ const assignedConfigs = useMemo(
340
+ () =>
341
+ associations
342
+ .map((assoc) => ({
343
+ configurationId: assoc.configurationId,
344
+ configurationName: assoc.configurationName,
345
+ enabled: assoc.enabled,
346
+ satelliteCount: assoc.satelliteIds?.length ?? 0,
347
+ }))
348
+ .toSorted((a, b) =>
349
+ a.configurationName.localeCompare(b.configurationName),
350
+ ),
351
+ [associations],
352
+ );
353
+
354
+ const availableConfigs = useMemo(
355
+ () =>
356
+ configs
357
+ .filter((c) => !assignedIds.has(c.id))
358
+ .map((c) => ({ id: c.id, name: c.name, strategyId: c.strategyId }))
359
+ .toSorted((a, b) => a.name.localeCompare(b.name)),
360
+ [configs, assignedIds],
361
+ );
362
+
363
+ // --- Render Panel ---
364
+
365
+ const renderPanel = () => {
366
+ if (!selectedNode) {
367
+ return (
368
+ <div className="flex items-center justify-center h-full text-sm text-muted-foreground p-12">
369
+ {associations.length === 0
370
+ ? "Add a health check from the left panel to get started."
371
+ : "Select an item from the left panel to configure it."}
372
+ </div>
373
+ );
374
+ }
375
+
376
+ const { panel, configId } = parseNodeId(selectedNode);
377
+ const assoc = associations.find((a) => a.configurationId === configId);
378
+ if (!assoc) return;
379
+
380
+ switch (panel) {
381
+ case "general": {
382
+ return (
383
+ <GeneralPanel
384
+ configurationName={assoc.configurationName}
385
+ strategyId={
386
+ configs.find((c) => c.id === configId)?.strategyId ?? ""
387
+ }
388
+ configurationId={configId}
389
+ enabled={assoc.enabled}
390
+ onToggleEnabled={() => handleToggleEnabled(configId, assoc.enabled)}
391
+ onUnassign={() => handleToggleAssignment(configId, true)}
392
+ saving={saving}
393
+ />
394
+ );
395
+ }
396
+ case "thresholds": {
397
+ const thresholds =
398
+ localThresholds[configId] ??
399
+ assoc.stateThresholds ??
400
+ DEFAULT_STATE_THRESHOLDS;
401
+ return (
402
+ <ThresholdsPanel
403
+ thresholds={thresholds}
404
+ onChange={(t) => handleThresholdChange(configId, t)}
405
+ onSave={() => handleSaveThresholds(configId)}
406
+ saving={saving}
407
+ />
408
+ );
409
+ }
410
+ case "retention": {
411
+ return (
412
+ <RetentionPanel
413
+ data={retentionData[configId]}
414
+ onFieldChange={(field, value) =>
415
+ updateRetentionField(configId, field, value)
416
+ }
417
+ onSave={() => handleSaveRetention(configId)}
418
+ onReset={() => handleResetRetention(configId)}
419
+ saving={saving}
420
+ />
421
+ );
422
+ }
423
+ case "execution": {
424
+ return (
425
+ <ExecutionPanel
426
+ includeLocal={assoc.includeLocal}
427
+ satelliteIds={assoc.satelliteIds ?? []}
428
+ satellites={satellites}
429
+ onToggleLocal={() => handleToggleLocal(configId)}
430
+ onToggleSatellite={(satId) =>
431
+ handleToggleSatellite(configId, satId)
432
+ }
433
+ saving={saving}
434
+ />
435
+ );
436
+ }
437
+ }
438
+ };
439
+
440
+ if (configsLoading) {
441
+ return (
442
+ <PageLayout title="Health Check Assignments" icon={Settings} loading>
443
+ <div />
444
+ </PageLayout>
445
+ );
446
+ }
447
+
448
+ return (
449
+ <PageLayout
450
+ title="Health Check Assignments"
451
+ icon={Settings}
452
+ maxWidth="full"
453
+ actions={
454
+ <BackLink
455
+ onClick={() => navigate(resolveRoute(catalogRoutes.routes.config))}
456
+ >
457
+ Back to Systems
458
+ </BackLink>
459
+ }
460
+ >
461
+ <IDELayout
462
+ tree={
463
+ <AssignmentTree
464
+ assigned={assignedConfigs}
465
+ available={availableConfigs}
466
+ selectedNode={selectedNode}
467
+ onSelectNode={setSelectedNode}
468
+ onToggleAssignment={handleToggleAssignment}
469
+ />
470
+ }
471
+ panel={renderPanel()}
472
+ />
473
+ </PageLayout>
474
+ );
475
+ };
476
+
477
+ export const AssignmentIDEPage = wrapInSuspense(AssignmentIDEPageContent);
@@ -21,7 +21,7 @@ import {
21
21
  } from "@checkstack/ui";
22
22
  import { Plus, History, Activity } from "lucide-react";
23
23
  import { Link } from "react-router-dom";
24
- import { resolveRoute } from "@checkstack/common";
24
+ import { resolveRoute, extractErrorMessage} from "@checkstack/common";
25
25
  import { useState } from "react";
26
26
 
27
27
  const HealthCheckConfigPageContent = () => {
@@ -70,7 +70,7 @@ const HealthCheckConfigPageContent = () => {
70
70
  void refetchConfigurations();
71
71
  },
72
72
  onError: (error) => {
73
- toast.error(error instanceof Error ? error.message : "Failed to delete");
73
+ toast.error(extractErrorMessage(error, "Failed to delete"));
74
74
  },
75
75
  });
76
76
 
@@ -79,7 +79,7 @@ const HealthCheckConfigPageContent = () => {
79
79
  void refetchConfigurations();
80
80
  },
81
81
  onError: (error) => {
82
- toast.error(error instanceof Error ? error.message : "Failed to pause");
82
+ toast.error(extractErrorMessage(error, "Failed to pause"));
83
83
  },
84
84
  });
85
85
 
@@ -88,7 +88,7 @@ const HealthCheckConfigPageContent = () => {
88
88
  void refetchConfigurations();
89
89
  },
90
90
  onError: (error) => {
91
- toast.error(error instanceof Error ? error.message : "Failed to resume");
91
+ toast.error(extractErrorMessage(error, "Failed to resume"));
92
92
  },
93
93
  });
94
94
 
@@ -10,6 +10,7 @@ import {
10
10
  healthCheckAccess,
11
11
  HealthCheckApi,
12
12
  } from "@checkstack/healthcheck-common";
13
+ import { SatelliteApi } from "@checkstack/satellite-common";
13
14
  import { resolveRoute } from "@checkstack/common";
14
15
  import {
15
16
  PageLayout,
@@ -26,7 +27,7 @@ import {
26
27
  type DateRange,
27
28
  } from "@checkstack/ui";
28
29
  import { useParams, useNavigate } from "react-router-dom";
29
- import { History, X } from "lucide-react";
30
+ import { History, X, Server, Satellite } from "lucide-react";
30
31
  import { format } from "date-fns";
31
32
  import {
32
33
  HealthCheckRunsTable,
@@ -44,16 +45,22 @@ const HealthCheckHistoryDetailPageContent = () => {
44
45
 
45
46
  const navigate = useNavigate();
46
47
  const healthCheckClient = usePluginClient(HealthCheckApi);
48
+ const satelliteClient = usePluginClient(SatelliteApi);
47
49
  const accessApi = useApi(accessApiRef);
48
50
  const { allowed: canManage, loading: accessLoading } = accessApi.useAccess(
49
51
  healthCheckAccess.configuration.manage,
50
52
  );
51
53
 
52
54
  const [dateRange, setDateRange] = useState<DateRange>(getDefaultDateRange);
55
+ const [sourceFilter, setSourceFilter] = useState<string | undefined>();
53
56
 
54
57
  // Pagination state
55
58
  const pagination = usePagination({ defaultLimit: 20 });
56
59
 
60
+ // Fetch satellites for the source filter dropdown
61
+ const { data: satellitesData } = satelliteClient.listSatellites.useQuery({});
62
+ const satellites = satellitesData?.satellites ?? [];
63
+
57
64
  // Fetch specific run if runId is provided
58
65
  const { data: specificRun } = healthCheckClient.getRunById.useQuery(
59
66
  {
@@ -81,6 +88,7 @@ const HealthCheckHistoryDetailPageContent = () => {
81
88
  configurationId,
82
89
  startDate: dateRange.startDate,
83
90
  endDate: dateRange.endDate,
91
+ sourceFilter,
84
92
  limit: pagination.limit,
85
93
  offset: pagination.offset,
86
94
  sortOrder: "desc",
@@ -156,11 +164,50 @@ const HealthCheckHistoryDetailPageContent = () => {
156
164
  <CardTitle>Run History</CardTitle>
157
165
  </CardHeader>
158
166
  <CardContent>
159
- <DateRangeFilter
160
- value={dateRange}
161
- onChange={setDateRange}
162
- className="mb-4"
163
- />
167
+ <div className="flex flex-wrap items-center gap-3 mb-4">
168
+ <DateRangeFilter value={dateRange} onChange={setDateRange} />
169
+ {/* Source filter */}
170
+ <div className="flex items-center gap-2">
171
+ <span className="text-sm text-muted-foreground">Source:</span>
172
+ <div className="flex items-center gap-1">
173
+ <button
174
+ onClick={() => setSourceFilter(undefined)}
175
+ className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full transition-colors ${
176
+ sourceFilter === undefined
177
+ ? "bg-primary text-primary-foreground"
178
+ : "bg-muted text-muted-foreground hover:bg-muted/80"
179
+ }`}
180
+ >
181
+ All
182
+ </button>
183
+ <button
184
+ onClick={() => setSourceFilter("local")}
185
+ className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full transition-colors ${
186
+ sourceFilter === "local"
187
+ ? "bg-primary text-primary-foreground"
188
+ : "bg-muted text-muted-foreground hover:bg-muted/80"
189
+ }`}
190
+ >
191
+ <Server className="h-3 w-3" />
192
+ Local
193
+ </button>
194
+ {satellites.map((sat) => (
195
+ <button
196
+ key={sat.id}
197
+ onClick={() => setSourceFilter(sat.id)}
198
+ className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full transition-colors ${
199
+ sourceFilter === sat.id
200
+ ? "bg-orange-500 text-white"
201
+ : "bg-orange-500/10 text-orange-600 hover:bg-orange-500/20"
202
+ }`}
203
+ >
204
+ <Satellite className="h-3 w-3" />
205
+ {sat.name}
206
+ </button>
207
+ ))}
208
+ </div>
209
+ </div>
210
+ </div>
164
211
  <HealthCheckRunsTable
165
212
  runs={runs}
166
213
  loading={isLoading}
@@ -9,13 +9,12 @@ import {
9
9
  healthcheckRoutes,
10
10
  type CollectorConfigEntry,
11
11
  } from "@checkstack/healthcheck-common";
12
- import { PageLayout, Button, useToast } from "@checkstack/ui";
12
+ import { PageLayout, Button, useToast, IDELayout, type ValidationIssue } from "@checkstack/ui";
13
13
  import { Save, Settings } from "lucide-react";
14
- import { resolveRoute } from "@checkstack/common";
14
+ import { resolveRoute, extractErrorMessage} from "@checkstack/common";
15
15
  import { useCollectors } from "../hooks/useCollectors";
16
16
  import { EditorTree, type TreeNodeId } from "../components/editor/EditorTree";
17
17
  import { EditorPanel } from "../components/editor/EditorPanel";
18
- import { IDEStatusBar, type ValidationIssue } from "../components/editor/IDEStatusBar";
19
18
 
20
19
  // =============================================================================
21
20
  // TYPES
@@ -249,7 +248,7 @@ const HealthCheckIDEPageContent = () => {
249
248
  navigate(resolveRoute(healthcheckRoutes.routes.config));
250
249
  },
251
250
  onError: (error) => {
252
- toast.error(error instanceof Error ? error.message : "Failed to create");
251
+ toast.error(extractErrorMessage(error, "Failed to create"));
253
252
  },
254
253
  });
255
254
 
@@ -260,7 +259,7 @@ const HealthCheckIDEPageContent = () => {
260
259
  navigate(resolveRoute(healthcheckRoutes.routes.config));
261
260
  },
262
261
  onError: (error) => {
263
- toast.error(error instanceof Error ? error.message : "Failed to update");
262
+ toast.error(extractErrorMessage(error, "Failed to update"));
264
263
  },
265
264
  });
266
265
 
@@ -328,9 +327,8 @@ const HealthCheckIDEPageContent = () => {
328
327
  </Button>
329
328
  }
330
329
  >
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">
330
+ <IDELayout
331
+ tree={
334
332
  <EditorTree
335
333
  collectors={formState.collectors}
336
334
  availableCollectors={availableCollectors}
@@ -340,10 +338,8 @@ const HealthCheckIDEPageContent = () => {
340
338
  validationIssues={validationIssues}
341
339
  strategyId={activeStrategyId ?? ""}
342
340
  />
343
- </div>
344
-
345
- {/* Editor Panel — Right Panel */}
346
- <div className="flex-1 min-w-0">
341
+ }
342
+ panel={
347
343
  <EditorPanel
348
344
  selectedNode={selectedNode}
349
345
  formState={formState}
@@ -367,13 +363,9 @@ const HealthCheckIDEPageContent = () => {
367
363
  onCollectorAdd={handleCollectorAdd}
368
364
  strategyId={activeStrategyId ?? ""}
369
365
  />
370
- </div>
371
- </div>
372
-
373
- {/* Status Bar */}
374
- <IDEStatusBar
366
+ }
375
367
  issues={validationIssues}
376
- onIssueClick={(nodeId) => setSelectedNode(nodeId)}
368
+ onIssueClick={(nodeId) => setSelectedNode(nodeId as TreeNodeId)}
377
369
  />
378
370
  </PageLayout>
379
371
  );