@checkstack/healthcheck-frontend 0.11.7 → 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.
- package/CHANGELOG.md +60 -0
- package/package.json +5 -5
- package/src/components/SystemHealthBadge.tsx +3 -3
- 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/index.tsx +14 -0
- package/src/pages/HealthCheckConfigPage.tsx +12 -59
- package/src/pages/HealthCheckIDEPage.tsx +382 -0
- package/src/pages/StrategyPickerPage.tsx +157 -0
- package/src/components/HealthCheckEditor.tsx +0 -190
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
setIsEditorOpen(true);
|
|
96
|
+
navigate(resolveRoute(healthcheckRoutes.routes.create));
|
|
124
97
|
};
|
|
125
98
|
|
|
126
99
|
const handleEdit = (config: HealthCheckConfiguration) => {
|
|
127
|
-
|
|
128
|
-
|
|
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);
|