@checkstack/healthcheck-frontend 0.17.1 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +80 -0
- package/package.json +12 -12
- package/src/auto-charts/AutoChartGrid.tsx +266 -69
- package/src/components/HealthCheckDrawer.tsx +3 -15
- package/src/components/HealthCheckStatusTimeline.tsx +164 -72
- package/src/components/HealthCheckSystemOverview.tsx +7 -27
- package/src/components/SystemHealthBadge.tsx +9 -23
- package/src/components/editor/EditorPanel.tsx +25 -0
- package/src/components/editor/EditorTree.tsx +23 -1
- package/src/components/editor/SystemsSection.tsx +126 -0
- package/src/hooks/useHealthCheckData.ts +30 -27
- package/src/pages/AssignmentIDEPage.tsx +23 -7
- package/src/pages/HealthCheckIDEPage.tsx +77 -3
- package/src/pages/StrategyPickerPage.tsx +9 -2
|
@@ -7,9 +7,7 @@ import {
|
|
|
7
7
|
useApi,
|
|
8
8
|
accessApiRef,
|
|
9
9
|
} from "@checkstack/frontend-api";
|
|
10
|
-
import { useSignal } from "@checkstack/signal-frontend";
|
|
11
10
|
import {
|
|
12
|
-
HEALTH_CHECK_RUN_COMPLETED,
|
|
13
11
|
HealthCheckApi,
|
|
14
12
|
healthCheckAccess,
|
|
15
13
|
healthcheckRoutes,
|
|
@@ -199,11 +197,8 @@ export const HealthCheckDrawer: React.FC<HealthCheckDrawerProps> = ({
|
|
|
199
197
|
// Pagination for history table
|
|
200
198
|
const pagination = usePagination({ defaultLimit: 5 });
|
|
201
199
|
|
|
202
|
-
const {
|
|
203
|
-
|
|
204
|
-
isLoading: historyLoading,
|
|
205
|
-
refetch,
|
|
206
|
-
} = healthCheckClient.getHistory.useQuery({
|
|
200
|
+
const { data: historyData, isLoading: historyLoading } =
|
|
201
|
+
healthCheckClient.getHistory.useQuery({
|
|
207
202
|
systemId,
|
|
208
203
|
configurationId: item.configurationId,
|
|
209
204
|
limit: pagination.limit,
|
|
@@ -226,13 +221,6 @@ export const HealthCheckDrawer: React.FC<HealthCheckDrawerProps> = ({
|
|
|
226
221
|
}
|
|
227
222
|
const runs = displayRuns;
|
|
228
223
|
|
|
229
|
-
// Realtime updates
|
|
230
|
-
useSignal(HEALTH_CHECK_RUN_COMPLETED, ({ systemId: changedId }) => {
|
|
231
|
-
if (changedId === systemId) {
|
|
232
|
-
void refetch();
|
|
233
|
-
}
|
|
234
|
-
});
|
|
235
|
-
|
|
236
224
|
return (
|
|
237
225
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
238
226
|
<SheetContent size="lg">
|
|
@@ -417,7 +405,7 @@ export const HealthCheckDrawer: React.FC<HealthCheckDrawerProps> = ({
|
|
|
417
405
|
<CardContent>
|
|
418
406
|
<HealthCheckStatusTimeline
|
|
419
407
|
context={chartContext}
|
|
420
|
-
height={
|
|
408
|
+
height={96}
|
|
421
409
|
/>
|
|
422
410
|
</CardContent>
|
|
423
411
|
</Card>
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { format } from "date-fns";
|
|
2
|
+
import { usePerformance } from "@checkstack/ui";
|
|
3
|
+
import {
|
|
4
|
+
Bar,
|
|
5
|
+
BarChart,
|
|
6
|
+
ResponsiveContainer,
|
|
7
|
+
Tooltip,
|
|
8
|
+
XAxis,
|
|
9
|
+
YAxis,
|
|
10
|
+
} from "recharts";
|
|
2
11
|
import type { HealthCheckDiagramSlotContext } from "../slots";
|
|
3
|
-
import { SparklineTooltip } from "./SparklineTooltip";
|
|
4
12
|
|
|
5
13
|
interface HealthCheckStatusTimelineProps {
|
|
6
14
|
context: HealthCheckDiagramSlotContext;
|
|
@@ -13,19 +21,114 @@ const statusColors = {
|
|
|
13
21
|
unhealthy: "hsl(var(--destructive))",
|
|
14
22
|
};
|
|
15
23
|
|
|
24
|
+
interface TimelineDatum {
|
|
25
|
+
bucketStart: number;
|
|
26
|
+
bucketEnd: number;
|
|
27
|
+
healthy: number;
|
|
28
|
+
degraded: number;
|
|
29
|
+
unhealthy: number;
|
|
30
|
+
runCount: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const MAX_TIMELINE_BARS = 60;
|
|
34
|
+
|
|
35
|
+
function downsampleTimeline(
|
|
36
|
+
buckets: HealthCheckDiagramSlotContext["buckets"],
|
|
37
|
+
): TimelineDatum[] {
|
|
38
|
+
if (buckets.length === 0) return [];
|
|
39
|
+
|
|
40
|
+
const groupSize = Math.max(1, Math.ceil(buckets.length / MAX_TIMELINE_BARS));
|
|
41
|
+
const result: TimelineDatum[] = [];
|
|
42
|
+
|
|
43
|
+
for (let i = 0; i < buckets.length; i += groupSize) {
|
|
44
|
+
const slice = buckets.slice(i, i + groupSize);
|
|
45
|
+
const first = slice[0];
|
|
46
|
+
const last = slice.at(-1) ?? first;
|
|
47
|
+
let healthy = 0;
|
|
48
|
+
let degraded = 0;
|
|
49
|
+
let unhealthy = 0;
|
|
50
|
+
let runCount = 0;
|
|
51
|
+
for (const b of slice) {
|
|
52
|
+
healthy += b.healthyCount;
|
|
53
|
+
degraded += b.degradedCount;
|
|
54
|
+
unhealthy += b.unhealthyCount;
|
|
55
|
+
runCount += b.runCount;
|
|
56
|
+
}
|
|
57
|
+
result.push({
|
|
58
|
+
bucketStart: new Date(first.bucketStart).getTime(),
|
|
59
|
+
bucketEnd: new Date(last.bucketEnd).getTime(),
|
|
60
|
+
healthy,
|
|
61
|
+
degraded,
|
|
62
|
+
unhealthy,
|
|
63
|
+
runCount,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function pickTickFormat(intervalSeconds: number): string {
|
|
71
|
+
if (intervalSeconds >= 86_400) return "MMM d";
|
|
72
|
+
if (intervalSeconds >= 3600) return "MMM d HH:mm";
|
|
73
|
+
return "HH:mm";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function StatusTimelineTooltip({
|
|
77
|
+
active,
|
|
78
|
+
payload,
|
|
79
|
+
}: {
|
|
80
|
+
active?: boolean;
|
|
81
|
+
payload?: { payload: TimelineDatum }[];
|
|
82
|
+
}) {
|
|
83
|
+
if (!active || !payload?.length) return <></>;
|
|
84
|
+
const datum = payload[0].payload;
|
|
85
|
+
const span = `${format(new Date(datum.bucketStart), "MMM d, HH:mm")} – ${format(new Date(datum.bucketEnd), "HH:mm")}`;
|
|
86
|
+
return (
|
|
87
|
+
<div
|
|
88
|
+
className="rounded-md p-2 text-xs shadow-md"
|
|
89
|
+
style={{
|
|
90
|
+
backgroundColor: "hsl(var(--popover))",
|
|
91
|
+
border: "1px solid hsl(var(--border))",
|
|
92
|
+
color: "hsl(var(--popover-foreground))",
|
|
93
|
+
}}
|
|
94
|
+
>
|
|
95
|
+
<p className="text-muted-foreground mb-1">{span}</p>
|
|
96
|
+
<div className="space-y-0.5">
|
|
97
|
+
<p>
|
|
98
|
+
<span style={{ color: statusColors.healthy }}>●</span> Healthy:{" "}
|
|
99
|
+
{datum.healthy}
|
|
100
|
+
</p>
|
|
101
|
+
{datum.degraded > 0 && (
|
|
102
|
+
<p>
|
|
103
|
+
<span style={{ color: statusColors.degraded }}>●</span> Degraded:{" "}
|
|
104
|
+
{datum.degraded}
|
|
105
|
+
</p>
|
|
106
|
+
)}
|
|
107
|
+
{datum.unhealthy > 0 && (
|
|
108
|
+
<p>
|
|
109
|
+
<span style={{ color: statusColors.unhealthy }}>●</span> Unhealthy:{" "}
|
|
110
|
+
{datum.unhealthy}
|
|
111
|
+
</p>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
16
118
|
/**
|
|
17
119
|
* Timeline bar chart showing health check status distribution over time.
|
|
18
|
-
* Each bar shows the distribution of statuses in that aggregated bucket.
|
|
120
|
+
* Each bar shows the stacked distribution of statuses in that aggregated bucket.
|
|
19
121
|
*/
|
|
20
122
|
export const HealthCheckStatusTimeline: React.FC<
|
|
21
123
|
HealthCheckStatusTimelineProps
|
|
22
|
-
> = ({ context, height =
|
|
23
|
-
const
|
|
124
|
+
> = ({ context, height = 96 }) => {
|
|
125
|
+
const { isLowPower } = usePerformance();
|
|
126
|
+
const { buckets } = context;
|
|
24
127
|
|
|
25
128
|
if (buckets.length === 0) {
|
|
26
129
|
return (
|
|
27
130
|
<div
|
|
28
|
-
className="flex items-center justify-center text-muted-foreground"
|
|
131
|
+
className="flex items-center justify-center text-muted-foreground text-sm"
|
|
29
132
|
style={{ height }}
|
|
30
133
|
>
|
|
31
134
|
No status data available
|
|
@@ -33,75 +136,64 @@ export const HealthCheckStatusTimeline: React.FC<
|
|
|
33
136
|
);
|
|
34
137
|
}
|
|
35
138
|
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
(buckets[0]?.bucketIntervalSeconds ?? 3600)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
// Calculate time range for labels
|
|
43
|
-
const firstTime = new Date(buckets[0].bucketStart).getTime();
|
|
44
|
-
const lastTime = new Date(buckets.at(-1)!.bucketStart).getTime();
|
|
139
|
+
const data = downsampleTimeline(buckets);
|
|
140
|
+
const effectiveIntervalSeconds =
|
|
141
|
+
(buckets[0]?.bucketIntervalSeconds ?? 3600) *
|
|
142
|
+
Math.max(1, Math.ceil(buckets.length / MAX_TIMELINE_BARS));
|
|
143
|
+
const tickFormat = pickTickFormat(effectiveIntervalSeconds);
|
|
45
144
|
|
|
46
145
|
return (
|
|
47
|
-
<div style={{ height
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
</div>
|
|
99
|
-
|
|
100
|
-
{/* Time axis labels */}
|
|
101
|
-
<div className="flex justify-between text-xs text-muted-foreground mt-1">
|
|
102
|
-
<span>{format(new Date(firstTime), timeFormat)}</span>
|
|
103
|
-
<span>{format(new Date(lastTime), timeFormat)}</span>
|
|
104
|
-
</div>
|
|
146
|
+
<div style={{ height, width: "100%" }}>
|
|
147
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
148
|
+
<BarChart
|
|
149
|
+
data={data}
|
|
150
|
+
margin={{ top: 4, right: 4, left: 4, bottom: 0 }}
|
|
151
|
+
barCategoryGap={1}
|
|
152
|
+
>
|
|
153
|
+
<XAxis
|
|
154
|
+
dataKey="bucketStart"
|
|
155
|
+
tickFormatter={(value: number) => format(new Date(value), tickFormat)}
|
|
156
|
+
stroke="hsl(var(--muted-foreground))"
|
|
157
|
+
fontSize={11}
|
|
158
|
+
minTickGap={48}
|
|
159
|
+
tickLine={false}
|
|
160
|
+
axisLine={false}
|
|
161
|
+
interval="preserveStartEnd"
|
|
162
|
+
/>
|
|
163
|
+
<YAxis hide domain={[0, "dataMax"]} />
|
|
164
|
+
<Tooltip
|
|
165
|
+
cursor={{ fill: "hsl(var(--muted))", fillOpacity: 0.3 }}
|
|
166
|
+
content={(props) => (
|
|
167
|
+
<StatusTimelineTooltip
|
|
168
|
+
active={props.active}
|
|
169
|
+
payload={
|
|
170
|
+
props.payload as unknown as
|
|
171
|
+
| { payload: TimelineDatum }[]
|
|
172
|
+
| undefined
|
|
173
|
+
}
|
|
174
|
+
/>
|
|
175
|
+
)}
|
|
176
|
+
/>
|
|
177
|
+
<Bar
|
|
178
|
+
dataKey="healthy"
|
|
179
|
+
stackId="status"
|
|
180
|
+
fill={statusColors.healthy}
|
|
181
|
+
isAnimationActive={!isLowPower}
|
|
182
|
+
/>
|
|
183
|
+
<Bar
|
|
184
|
+
dataKey="degraded"
|
|
185
|
+
stackId="status"
|
|
186
|
+
fill={statusColors.degraded}
|
|
187
|
+
isAnimationActive={!isLowPower}
|
|
188
|
+
/>
|
|
189
|
+
<Bar
|
|
190
|
+
dataKey="unhealthy"
|
|
191
|
+
stackId="status"
|
|
192
|
+
fill={statusColors.unhealthy}
|
|
193
|
+
isAnimationActive={!isLowPower}
|
|
194
|
+
/>
|
|
195
|
+
</BarChart>
|
|
196
|
+
</ResponsiveContainer>
|
|
105
197
|
</div>
|
|
106
198
|
);
|
|
107
199
|
};
|
|
@@ -1,14 +1,10 @@
|
|
|
1
|
-
import React, { useState
|
|
1
|
+
import React, { useState } from "react";
|
|
2
2
|
import {
|
|
3
3
|
usePluginClient,
|
|
4
4
|
type SlotContext,
|
|
5
5
|
} from "@checkstack/frontend-api";
|
|
6
|
-
import { useSignal } from "@checkstack/signal-frontend";
|
|
7
6
|
import { SystemDetailsSlot } from "@checkstack/catalog-common";
|
|
8
|
-
import {
|
|
9
|
-
HEALTH_CHECK_RUN_COMPLETED,
|
|
10
|
-
HealthCheckApi,
|
|
11
|
-
} from "@checkstack/healthcheck-common";
|
|
7
|
+
import { HealthCheckApi } from "@checkstack/healthcheck-common";
|
|
12
8
|
import {
|
|
13
9
|
HealthBadge,
|
|
14
10
|
LoadingSpinner,
|
|
@@ -63,14 +59,11 @@ export function HealthCheckSystemOverview(props: SlotProps) {
|
|
|
63
59
|
HealthCheckOverviewItem | undefined
|
|
64
60
|
>();
|
|
65
61
|
|
|
66
|
-
// Fetch health check overview using useQuery
|
|
67
|
-
const {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
} = healthCheckClient.getSystemHealthOverview.useQuery({
|
|
72
|
-
systemId,
|
|
73
|
-
});
|
|
62
|
+
// Fetch health check overview using useQuery — kept fresh via SignalAutoInvalidator.
|
|
63
|
+
const { data: overviewData, isLoading: initialLoading } =
|
|
64
|
+
healthCheckClient.getSystemHealthOverview.useQuery({
|
|
65
|
+
systemId,
|
|
66
|
+
});
|
|
74
67
|
|
|
75
68
|
// Transform API response to component format
|
|
76
69
|
const overview: HealthCheckOverviewItem[] = React.useMemo(() => {
|
|
@@ -89,19 +82,6 @@ export function HealthCheckSystemOverview(props: SlotProps) {
|
|
|
89
82
|
}));
|
|
90
83
|
}, [overviewData]);
|
|
91
84
|
|
|
92
|
-
// Listen for realtime health check updates to refresh overview
|
|
93
|
-
useSignal(
|
|
94
|
-
HEALTH_CHECK_RUN_COMPLETED,
|
|
95
|
-
useCallback(
|
|
96
|
-
({ systemId: changedId }) => {
|
|
97
|
-
if (changedId === systemId) {
|
|
98
|
-
void refetch();
|
|
99
|
-
}
|
|
100
|
-
},
|
|
101
|
-
[systemId, refetch],
|
|
102
|
-
),
|
|
103
|
-
);
|
|
104
|
-
|
|
105
85
|
if (initialLoading) {
|
|
106
86
|
return <LoadingSpinner />;
|
|
107
87
|
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { usePluginClient, type SlotContext } from "@checkstack/frontend-api";
|
|
3
|
-
import { useSignal } from "@checkstack/signal-frontend";
|
|
4
3
|
import { SystemStateBadgesSlot } from "@checkstack/catalog-common";
|
|
5
|
-
import { SYSTEM_STATUS_CHANGED } from "@checkstack/healthcheck-common";
|
|
6
4
|
import { HealthCheckApi } from "../api";
|
|
7
5
|
import { HealthBadge } from "@checkstack/ui";
|
|
8
6
|
import { useSystemBadgeDataOptional } from "@checkstack/dashboard-frontend";
|
|
@@ -17,37 +15,25 @@ type Props = SlotContext<typeof SystemStateBadgesSlot>;
|
|
|
17
15
|
* When rendered within SystemBadgeDataProvider, uses bulk-fetched data.
|
|
18
16
|
* Otherwise, falls back to individual fetch.
|
|
19
17
|
*
|
|
20
|
-
*
|
|
18
|
+
* Realtime updates arrive via the SignalAutoInvalidator (auto-invalidates
|
|
19
|
+
* `[["healthcheck"]]` queries when SYSTEM_STATUS_CHANGED fires).
|
|
21
20
|
*/
|
|
22
21
|
export const SystemHealthBadge: React.FC<Props> = ({ system }) => {
|
|
23
22
|
const healthCheckClient = usePluginClient(HealthCheckApi);
|
|
24
23
|
const badgeData = useSystemBadgeDataOptional();
|
|
25
24
|
|
|
26
|
-
// Try to get data from provider first
|
|
27
25
|
const providerData = badgeData?.getSystemBadgeData(system?.id ?? "");
|
|
28
26
|
const providerStatus = providerData?.health?.status;
|
|
29
27
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
staleTime: 30_000, // Prevent unnecessary refetches
|
|
38
|
-
},
|
|
39
|
-
);
|
|
28
|
+
const { data: healthData } = healthCheckClient.getSystemHealthStatus.useQuery(
|
|
29
|
+
{ systemId: system?.id ?? "" },
|
|
30
|
+
{
|
|
31
|
+
enabled: !badgeData && !!system?.id,
|
|
32
|
+
staleTime: 30_000,
|
|
33
|
+
},
|
|
34
|
+
);
|
|
40
35
|
|
|
41
36
|
const localStatus = healthData?.status;
|
|
42
|
-
|
|
43
|
-
// Listen for realtime system status changes (only in fallback mode)
|
|
44
|
-
useSignal(SYSTEM_STATUS_CHANGED, ({ systemId: changedId }) => {
|
|
45
|
-
if (!badgeData && changedId === system?.id) {
|
|
46
|
-
void refetch();
|
|
47
|
-
}
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
// Use provider data if available, otherwise use local state
|
|
51
37
|
const status = providerStatus ?? localStatus;
|
|
52
38
|
|
|
53
39
|
if (!status || status === "healthy") return <></>;
|
|
@@ -8,6 +8,7 @@ import type { TreeNodeId } from "./EditorTree";
|
|
|
8
8
|
import { GeneralSection } from "./GeneralSection";
|
|
9
9
|
import { CollectorSection } from "./CollectorSection";
|
|
10
10
|
import { CollectorPicker } from "./CollectorPicker";
|
|
11
|
+
import { SystemsSection } from "./SystemsSection";
|
|
11
12
|
import { TeamAccessEditor } from "@checkstack/auth-frontend";
|
|
12
13
|
|
|
13
14
|
// =============================================================================
|
|
@@ -43,6 +44,11 @@ interface EditorPanelProps {
|
|
|
43
44
|
onCollectorRemove: (entryId: string) => void;
|
|
44
45
|
onCollectorAdd: (collectorId: string) => void;
|
|
45
46
|
strategyId: string;
|
|
47
|
+
showSystemsSection?: boolean;
|
|
48
|
+
systems?: Array<{ id: string; name: string; description?: string | null }>;
|
|
49
|
+
systemsLoading?: boolean;
|
|
50
|
+
selectedSystemIds?: string[];
|
|
51
|
+
onSystemsChange?: (systemIds: string[]) => void;
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
// =============================================================================
|
|
@@ -67,6 +73,11 @@ export const EditorPanel: React.FC<EditorPanelProps> = ({
|
|
|
67
73
|
onCollectorRemove,
|
|
68
74
|
onCollectorAdd,
|
|
69
75
|
strategyId,
|
|
76
|
+
showSystemsSection = false,
|
|
77
|
+
systems = [],
|
|
78
|
+
systemsLoading = false,
|
|
79
|
+
selectedSystemIds = [],
|
|
80
|
+
onSystemsChange,
|
|
70
81
|
}) => {
|
|
71
82
|
// --- General Section ---
|
|
72
83
|
if (selectedNode === "general") {
|
|
@@ -101,6 +112,20 @@ export const EditorPanel: React.FC<EditorPanelProps> = ({
|
|
|
101
112
|
);
|
|
102
113
|
}
|
|
103
114
|
|
|
115
|
+
// --- Systems Section ---
|
|
116
|
+
if (selectedNode === "systems" && showSystemsSection) {
|
|
117
|
+
return (
|
|
118
|
+
<div className="p-6">
|
|
119
|
+
<SystemsSection
|
|
120
|
+
systems={systems}
|
|
121
|
+
selectedSystemIds={selectedSystemIds}
|
|
122
|
+
loading={systemsLoading}
|
|
123
|
+
onChange={(ids) => onSystemsChange?.(ids)}
|
|
124
|
+
/>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
104
129
|
// --- Access Control ---
|
|
105
130
|
if (selectedNode === "access") {
|
|
106
131
|
return (
|
|
@@ -3,7 +3,7 @@ import type {
|
|
|
3
3
|
CollectorConfigEntry,
|
|
4
4
|
CollectorDto,
|
|
5
5
|
} from "@checkstack/healthcheck-common";
|
|
6
|
-
import { Plus, Settings, Shield, ChevronRight } from "lucide-react";
|
|
6
|
+
import { Plus, Settings, Shield, ChevronRight, Server } from "lucide-react";
|
|
7
7
|
import { isBuiltInCollector } from "../../hooks/useCollectors";
|
|
8
8
|
import {
|
|
9
9
|
IDETreeNode,
|
|
@@ -20,6 +20,7 @@ import { HealthCheckConfigIDENodeSlot } from "../../slots";
|
|
|
20
20
|
export type TreeNodeId =
|
|
21
21
|
| "general"
|
|
22
22
|
| "access"
|
|
23
|
+
| "systems"
|
|
23
24
|
| "collector-picker"
|
|
24
25
|
| `collector:${string}`
|
|
25
26
|
| (string & {});
|
|
@@ -33,6 +34,8 @@ interface EditorTreeProps {
|
|
|
33
34
|
validationIssues: ValidationIssue[];
|
|
34
35
|
strategyId: string;
|
|
35
36
|
configId?: string;
|
|
37
|
+
showSystemsNode?: boolean;
|
|
38
|
+
selectedSystemCount?: number;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
// =============================================================================
|
|
@@ -47,6 +50,8 @@ export const EditorTree: React.FC<EditorTreeProps> = ({
|
|
|
47
50
|
validationIssues,
|
|
48
51
|
strategyId,
|
|
49
52
|
configId,
|
|
53
|
+
showSystemsNode = false,
|
|
54
|
+
selectedSystemCount = 0,
|
|
50
55
|
}) => {
|
|
51
56
|
// Check if there are addable collectors remaining
|
|
52
57
|
const hasAddableCollectors = useMemo(() => {
|
|
@@ -109,6 +114,23 @@ export const EditorTree: React.FC<EditorTreeProps> = ({
|
|
|
109
114
|
</button>
|
|
110
115
|
)}
|
|
111
116
|
|
|
117
|
+
{showSystemsNode && (
|
|
118
|
+
<>
|
|
119
|
+
<IDETreeSection label="Assignment" />
|
|
120
|
+
<IDETreeNode
|
|
121
|
+
nodeId="systems"
|
|
122
|
+
label="Systems"
|
|
123
|
+
icon={Server}
|
|
124
|
+
selected={selectedNode === "systems"}
|
|
125
|
+
onClick={() => onSelectNode("systems")}
|
|
126
|
+
issues={validationIssues}
|
|
127
|
+
badge={
|
|
128
|
+
selectedSystemCount > 0 ? String(selectedSystemCount) : undefined
|
|
129
|
+
}
|
|
130
|
+
/>
|
|
131
|
+
</>
|
|
132
|
+
)}
|
|
133
|
+
|
|
112
134
|
{/* Access Control */}
|
|
113
135
|
<IDETreeSection label="Permissions" />
|
|
114
136
|
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import React, { useMemo, useState } from "react";
|
|
2
|
+
import { Checkbox, Input, InfoBanner, InfoBannerContent } from "@checkstack/ui";
|
|
3
|
+
import { Info, Search } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
interface SystemOption {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
description?: string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface SystemsSectionProps {
|
|
12
|
+
systems: SystemOption[];
|
|
13
|
+
selectedSystemIds: string[];
|
|
14
|
+
loading: boolean;
|
|
15
|
+
onChange: (systemIds: string[]) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const SystemsSection: React.FC<SystemsSectionProps> = ({
|
|
19
|
+
systems,
|
|
20
|
+
selectedSystemIds,
|
|
21
|
+
loading,
|
|
22
|
+
onChange,
|
|
23
|
+
}) => {
|
|
24
|
+
const [query, setQuery] = useState("");
|
|
25
|
+
|
|
26
|
+
const selectedSet = useMemo(
|
|
27
|
+
() => new Set(selectedSystemIds),
|
|
28
|
+
[selectedSystemIds],
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const filteredSystems = useMemo(() => {
|
|
32
|
+
const trimmed = query.trim().toLowerCase();
|
|
33
|
+
if (!trimmed) return systems;
|
|
34
|
+
return systems.filter((s) => s.name.toLowerCase().includes(trimmed));
|
|
35
|
+
}, [systems, query]);
|
|
36
|
+
|
|
37
|
+
const toggle = ({ systemId }: { systemId: string }) => {
|
|
38
|
+
if (selectedSet.has(systemId)) {
|
|
39
|
+
onChange(selectedSystemIds.filter((id) => id !== systemId));
|
|
40
|
+
} else {
|
|
41
|
+
onChange([...selectedSystemIds, systemId]);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="space-y-6">
|
|
47
|
+
<div>
|
|
48
|
+
<h2 className="text-lg font-semibold">Assign to systems</h2>
|
|
49
|
+
<p className="text-sm text-muted-foreground">
|
|
50
|
+
Optionally pick systems this check should monitor. You can also
|
|
51
|
+
assign it later from a system's catalog page.
|
|
52
|
+
</p>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<InfoBanner variant="info">
|
|
56
|
+
<Info className="h-4 w-4 shrink-0 mt-0.5" />
|
|
57
|
+
<InfoBannerContent>
|
|
58
|
+
Health checks are reusable templates — they can be assigned to
|
|
59
|
+
additional systems at any time.
|
|
60
|
+
</InfoBannerContent>
|
|
61
|
+
</InfoBanner>
|
|
62
|
+
|
|
63
|
+
<div className="space-y-2">
|
|
64
|
+
<div className="relative">
|
|
65
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
66
|
+
<Input
|
|
67
|
+
placeholder="Search systems..."
|
|
68
|
+
value={query}
|
|
69
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
70
|
+
className="pl-9"
|
|
71
|
+
disabled={loading}
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
{loading ? (
|
|
76
|
+
<p className="text-sm text-muted-foreground italic px-1">
|
|
77
|
+
Loading systems...
|
|
78
|
+
</p>
|
|
79
|
+
) : filteredSystems.length === 0 ? (
|
|
80
|
+
<p className="text-sm text-muted-foreground italic px-1">
|
|
81
|
+
{systems.length === 0
|
|
82
|
+
? "No systems exist yet. Create one from the catalog first."
|
|
83
|
+
: "No systems match your search."}
|
|
84
|
+
</p>
|
|
85
|
+
) : (
|
|
86
|
+
<div className="max-h-[420px] overflow-y-auto rounded-md border border-border/50 divide-y divide-border/50">
|
|
87
|
+
{filteredSystems.map((system) => {
|
|
88
|
+
const isChecked = selectedSet.has(system.id);
|
|
89
|
+
return (
|
|
90
|
+
<button
|
|
91
|
+
key={system.id}
|
|
92
|
+
type="button"
|
|
93
|
+
onClick={() => toggle({ systemId: system.id })}
|
|
94
|
+
className="flex items-start gap-3 w-full px-3 py-2.5 text-left hover:bg-muted/40 transition-colors"
|
|
95
|
+
>
|
|
96
|
+
<Checkbox
|
|
97
|
+
checked={isChecked}
|
|
98
|
+
onCheckedChange={() => toggle({ systemId: system.id })}
|
|
99
|
+
className="mt-0.5"
|
|
100
|
+
/>
|
|
101
|
+
<div className="min-w-0 flex-1">
|
|
102
|
+
<div className="text-sm font-medium truncate">
|
|
103
|
+
{system.name}
|
|
104
|
+
</div>
|
|
105
|
+
{system.description && (
|
|
106
|
+
<div className="text-xs text-muted-foreground truncate">
|
|
107
|
+
{system.description}
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
</button>
|
|
112
|
+
);
|
|
113
|
+
})}
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
|
|
117
|
+
{selectedSystemIds.length > 0 && (
|
|
118
|
+
<p className="text-xs text-muted-foreground px-1">
|
|
119
|
+
{selectedSystemIds.length} system
|
|
120
|
+
{selectedSystemIds.length === 1 ? "" : "s"} selected
|
|
121
|
+
</p>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
};
|