@checkstack/healthcheck-frontend 0.17.0 → 0.18.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 +65 -0
- package/package.json +12 -12
- package/src/auto-charts/AutoChartGrid.tsx +308 -101
- package/src/auto-charts/SingleRunChartGrid.tsx +16 -13
- package/src/auto-charts/schema-parser.ts +1 -4
- 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
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import type { ChartField } from "./schema-parser";
|
|
9
9
|
import { extractChartFields, getFieldValue } from "./schema-parser";
|
|
10
10
|
import { useStrategySchemas } from "./useStrategySchemas";
|
|
11
|
-
import { Card, CardContent, CardHeader, CardTitle } from "@checkstack/ui";
|
|
11
|
+
import { Badge, Card, CardContent, CardHeader, CardTitle } from "@checkstack/ui";
|
|
12
12
|
import { RadialBarChart, RadialBar, ResponsiveContainer } from "recharts";
|
|
13
13
|
|
|
14
14
|
interface SingleRunChartGridProps {
|
|
@@ -62,7 +62,7 @@ export function SingleRunChartGrid({
|
|
|
62
62
|
<div className="space-y-6">
|
|
63
63
|
{/* Strategy-level fields */}
|
|
64
64
|
{strategyFields.length > 0 && (
|
|
65
|
-
<div className="
|
|
65
|
+
<div className="space-y-4">
|
|
66
66
|
{strategyFields.map((field) => (
|
|
67
67
|
<SingleValueCard
|
|
68
68
|
key={field.name}
|
|
@@ -124,12 +124,13 @@ function CollectorSection({
|
|
|
124
124
|
|
|
125
125
|
return (
|
|
126
126
|
<div className="space-y-4">
|
|
127
|
-
<div className="flex items-center gap-2">
|
|
128
|
-
<
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
127
|
+
<div className="flex items-center gap-2 flex-wrap border-b pb-2">
|
|
128
|
+
<h3 className="text-lg font-semibold capitalize">{displayName}</h3>
|
|
129
|
+
<Badge variant="outline" className="font-mono">
|
|
130
|
+
{collectorId}
|
|
131
|
+
</Badge>
|
|
132
|
+
<span className="text-xs text-muted-foreground font-mono">
|
|
133
|
+
{instanceId.slice(0, 8)}
|
|
133
134
|
</span>
|
|
134
135
|
</div>
|
|
135
136
|
|
|
@@ -165,7 +166,7 @@ function CollectorSection({
|
|
|
165
166
|
</Card>
|
|
166
167
|
)}
|
|
167
168
|
|
|
168
|
-
<div className="
|
|
169
|
+
<div className="space-y-4">
|
|
169
170
|
{fields.map((field) => (
|
|
170
171
|
<SingleValueCard
|
|
171
172
|
key={field.name}
|
|
@@ -190,9 +191,11 @@ function SingleValueCard({ field, value }: SingleValueCardProps) {
|
|
|
190
191
|
return (
|
|
191
192
|
<Card>
|
|
192
193
|
<CardHeader className="pb-2">
|
|
193
|
-
<CardTitle className="text-sm font-medium">
|
|
194
|
+
<CardTitle className="text-sm font-medium text-center">
|
|
195
|
+
{field.label}
|
|
196
|
+
</CardTitle>
|
|
194
197
|
</CardHeader>
|
|
195
|
-
<CardContent>
|
|
198
|
+
<CardContent className="flex flex-col items-center text-center [&>*]:w-full">
|
|
196
199
|
<SingleValueRenderer field={field} value={value} />
|
|
197
200
|
</CardContent>
|
|
198
201
|
</Card>
|
|
@@ -292,7 +295,7 @@ function GaugeRenderer({ value, unit }: { value: unknown; unit?: string }) {
|
|
|
292
295
|
const data = [{ name: "value", value: clampedValue, fill: fillColor }];
|
|
293
296
|
|
|
294
297
|
return (
|
|
295
|
-
<div className="flex items-center gap-3">
|
|
298
|
+
<div className="flex items-center justify-center gap-3">
|
|
296
299
|
<ResponsiveContainer width={80} height={80}>
|
|
297
300
|
<RadialBarChart
|
|
298
301
|
cx="50%"
|
|
@@ -330,7 +333,7 @@ function BooleanRenderer({ value }: { value: unknown }) {
|
|
|
330
333
|
const boolValue = Boolean(value);
|
|
331
334
|
|
|
332
335
|
return (
|
|
333
|
-
<div className="flex items-center gap-2">
|
|
336
|
+
<div className="flex items-center justify-center gap-2">
|
|
334
337
|
<div
|
|
335
338
|
className={`w-3 h-3 rounded-full ${
|
|
336
339
|
boolValue ? "bg-green-500" : "bg-red-500"
|
|
@@ -108,12 +108,9 @@ function extractFieldsFromProperties(
|
|
|
108
108
|
if (!chartType) continue;
|
|
109
109
|
|
|
110
110
|
// Use just field name - collectorId is stored separately for data lookup
|
|
111
|
+
// and surfaced via a separate badge in the group header
|
|
111
112
|
const field = extractSingleField(fieldName, prop);
|
|
112
113
|
field.collectorId = collectorId;
|
|
113
|
-
// Prefix label with collector ID for clarity
|
|
114
|
-
if (!prop["x-chart-label"]?.includes(collectorId)) {
|
|
115
|
-
field.label = `${collectorId}: ${field.label}`;
|
|
116
|
-
}
|
|
117
114
|
fields.push(field);
|
|
118
115
|
}
|
|
119
116
|
|
|
@@ -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
|
|