@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
|
@@ -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
|
+
};
|
|
@@ -74,35 +74,38 @@ export function useHealthCheckData({
|
|
|
74
74
|
healthCheckAccess.details,
|
|
75
75
|
);
|
|
76
76
|
|
|
77
|
-
// Always use aggregated data with fixed target points
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
);
|
|
77
|
+
// Always use aggregated data with fixed target points.
|
|
78
|
+
// Realtime refetches happen via SignalAutoInvalidator (auto-invalidates
|
|
79
|
+
// `[["healthcheck"]]` on HEALTH_CHECK_RUN_COMPLETED).
|
|
80
|
+
const { data: aggregatedData, isLoading } =
|
|
81
|
+
healthCheckClient.getDetailedAggregatedHistory.useQuery(
|
|
82
|
+
{
|
|
83
|
+
systemId,
|
|
84
|
+
configurationId,
|
|
85
|
+
startDate: dateRange.startDate,
|
|
86
|
+
endDate: dateRange.endDate,
|
|
87
|
+
sourceFilter,
|
|
88
|
+
targetPoints: 500,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
enabled: !!systemId && !!configurationId && hasAccess && !accessLoading,
|
|
92
|
+
// Keep previous data visible during refetch to prevent layout shift
|
|
93
|
+
placeholderData: (prev) => prev,
|
|
94
|
+
},
|
|
95
|
+
);
|
|
97
96
|
|
|
98
|
-
//
|
|
97
|
+
// For rolling presets, we still need an explicit signal handler to advance
|
|
98
|
+
// the endDate alongside cache invalidation — this is UI state (date range),
|
|
99
|
+
// not cache, so auto-invalidation is not enough.
|
|
99
100
|
useSignal(HEALTH_CHECK_RUN_COMPLETED, ({ systemId: changedId }) => {
|
|
100
|
-
if (
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
101
|
+
if (
|
|
102
|
+
changedId === systemId &&
|
|
103
|
+
hasAccess &&
|
|
104
|
+
!accessLoading &&
|
|
105
|
+
isRollingPreset &&
|
|
106
|
+
onDateRangeRefresh
|
|
107
|
+
) {
|
|
108
|
+
onDateRangeRefresh(new Date());
|
|
106
109
|
}
|
|
107
110
|
});
|
|
108
111
|
|
|
@@ -8,10 +8,11 @@ import {
|
|
|
8
8
|
DEFAULT_RETENTION_CONFIG,
|
|
9
9
|
} from "@checkstack/healthcheck-common";
|
|
10
10
|
import type { StateThresholds } from "@checkstack/healthcheck-common";
|
|
11
|
-
import { PageLayout, IDELayout, useToast, BackLink } from "@checkstack/ui";
|
|
12
|
-
import { Settings } from "lucide-react";
|
|
11
|
+
import { PageLayout, IDELayout, useToast, BackLink, Button } from "@checkstack/ui";
|
|
12
|
+
import { Settings, Plus } from "lucide-react";
|
|
13
13
|
import { extractErrorMessage, resolveRoute } from "@checkstack/common";
|
|
14
14
|
import { catalogRoutes } from "@checkstack/catalog-common";
|
|
15
|
+
import { healthcheckRoutes } from "@checkstack/healthcheck-common";
|
|
15
16
|
import {
|
|
16
17
|
AssignmentTree,
|
|
17
18
|
type AssignmentNodeId,
|
|
@@ -480,11 +481,26 @@ const AssignmentIDEPageContent = () => {
|
|
|
480
481
|
icon={Settings}
|
|
481
482
|
maxWidth="full"
|
|
482
483
|
actions={
|
|
483
|
-
<
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
484
|
+
<div className="flex items-center gap-2">
|
|
485
|
+
{!isLocked && systemId && (
|
|
486
|
+
<Button
|
|
487
|
+
size="sm"
|
|
488
|
+
onClick={() =>
|
|
489
|
+
navigate(
|
|
490
|
+
`${resolveRoute(healthcheckRoutes.routes.create)}?systemId=${encodeURIComponent(systemId)}`,
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
>
|
|
494
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
495
|
+
Create new check
|
|
496
|
+
</Button>
|
|
497
|
+
)}
|
|
498
|
+
<BackLink
|
|
499
|
+
onClick={() => navigate(resolveRoute(catalogRoutes.routes.config))}
|
|
500
|
+
>
|
|
501
|
+
Back to Systems
|
|
502
|
+
</BackLink>
|
|
503
|
+
</div>
|
|
488
504
|
}
|
|
489
505
|
>
|
|
490
506
|
{isLocked && provenance && (
|
|
@@ -10,7 +10,9 @@ import { HealthCheckConfigIDEPanelSlot } from "../slots";
|
|
|
10
10
|
import {
|
|
11
11
|
healthcheckRoutes,
|
|
12
12
|
type CollectorConfigEntry,
|
|
13
|
+
DEFAULT_STATE_THRESHOLDS,
|
|
13
14
|
} from "@checkstack/healthcheck-common";
|
|
15
|
+
import { CatalogApi } from "@checkstack/catalog-common";
|
|
14
16
|
import { PageLayout, Button, useToast, IDELayout, type ValidationIssue } from "@checkstack/ui";
|
|
15
17
|
import { Save, Settings } from "lucide-react";
|
|
16
18
|
import { resolveRoute, extractErrorMessage} from "@checkstack/common";
|
|
@@ -44,6 +46,8 @@ const HealthCheckIDEPageContent = () => {
|
|
|
44
46
|
|
|
45
47
|
const isEditMode = !!configId && configId !== "new";
|
|
46
48
|
const strategyIdFromUrl = searchParams.get("strategy") ?? undefined;
|
|
49
|
+
const systemIdFromUrl = searchParams.get("systemId") ?? undefined;
|
|
50
|
+
const catalogClient = usePluginClient(CatalogApi);
|
|
47
51
|
|
|
48
52
|
// --- GitOps Provenance Lock ---
|
|
49
53
|
const { isLocked, provenance } = useProvenanceLock({
|
|
@@ -79,6 +83,19 @@ const HealthCheckIDEPageContent = () => {
|
|
|
79
83
|
const { collectors: availableCollectors, loading: collectorsLoading } =
|
|
80
84
|
useCollectors(activeStrategyId ?? "");
|
|
81
85
|
|
|
86
|
+
// Fetch systems for assignment (only in create mode)
|
|
87
|
+
const { data: systemsData, isLoading: systemsLoading } =
|
|
88
|
+
catalogClient.getSystems.useQuery({}, { enabled: !isEditMode });
|
|
89
|
+
const systems = useMemo(
|
|
90
|
+
() =>
|
|
91
|
+
(systemsData?.systems ?? []).map((s) => ({
|
|
92
|
+
id: s.id,
|
|
93
|
+
name: s.name,
|
|
94
|
+
description: s.description,
|
|
95
|
+
})),
|
|
96
|
+
[systemsData],
|
|
97
|
+
);
|
|
98
|
+
|
|
82
99
|
// --- Form State ---
|
|
83
100
|
|
|
84
101
|
const [formState, setFormState] = useState<EditorFormState>({
|
|
@@ -94,6 +111,9 @@ const HealthCheckIDEPageContent = () => {
|
|
|
94
111
|
Record<string, boolean>
|
|
95
112
|
>({});
|
|
96
113
|
const [isDirty, setIsDirty] = useState(false);
|
|
114
|
+
const [selectedSystemIds, setSelectedSystemIds] = useState<string[]>(
|
|
115
|
+
systemIdFromUrl ? [systemIdFromUrl] : [],
|
|
116
|
+
);
|
|
97
117
|
|
|
98
118
|
// Initialize form from existing configuration (edit mode)
|
|
99
119
|
useEffect(() => {
|
|
@@ -250,11 +270,55 @@ const HealthCheckIDEPageContent = () => {
|
|
|
250
270
|
|
|
251
271
|
// --- Save ---
|
|
252
272
|
|
|
273
|
+
const associateMutation = healthCheckClient.associateSystem.useMutation();
|
|
274
|
+
|
|
253
275
|
const createMutation = healthCheckClient.createConfiguration.useMutation({
|
|
254
|
-
onSuccess: () => {
|
|
276
|
+
onSuccess: async (created) => {
|
|
255
277
|
setIsDirty(false);
|
|
256
|
-
|
|
257
|
-
|
|
278
|
+
|
|
279
|
+
// Fan-out: assign the new config to each selected system.
|
|
280
|
+
if (selectedSystemIds.length > 0 && created?.id) {
|
|
281
|
+
const results = await Promise.allSettled(
|
|
282
|
+
selectedSystemIds.map((systemId) =>
|
|
283
|
+
associateMutation.mutateAsync({
|
|
284
|
+
systemId,
|
|
285
|
+
body: {
|
|
286
|
+
configurationId: created.id,
|
|
287
|
+
enabled: true,
|
|
288
|
+
stateThresholds: DEFAULT_STATE_THRESHOLDS,
|
|
289
|
+
includeLocal: true,
|
|
290
|
+
},
|
|
291
|
+
}),
|
|
292
|
+
),
|
|
293
|
+
);
|
|
294
|
+
const failed = results.filter((r) => r.status === "rejected").length;
|
|
295
|
+
if (failed > 0) {
|
|
296
|
+
toast.error(
|
|
297
|
+
`Health check created, but ${failed} of ${selectedSystemIds.length} system assignment${selectedSystemIds.length === 1 ? "" : "s"} failed.`,
|
|
298
|
+
);
|
|
299
|
+
} else {
|
|
300
|
+
toast.success(
|
|
301
|
+
`Health check created and assigned to ${selectedSystemIds.length} system${selectedSystemIds.length === 1 ? "" : "s"}`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
} else {
|
|
305
|
+
toast.success("Health check created");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Where to land: prefer back to the originating system's assignment IDE
|
|
309
|
+
// when the user came from there, otherwise the config list.
|
|
310
|
+
if (
|
|
311
|
+
systemIdFromUrl &&
|
|
312
|
+
selectedSystemIds.includes(systemIdFromUrl)
|
|
313
|
+
) {
|
|
314
|
+
navigate(
|
|
315
|
+
resolveRoute(healthcheckRoutes.routes.assignments, {
|
|
316
|
+
systemId: systemIdFromUrl,
|
|
317
|
+
}),
|
|
318
|
+
);
|
|
319
|
+
} else {
|
|
320
|
+
navigate(resolveRoute(healthcheckRoutes.routes.config));
|
|
321
|
+
}
|
|
258
322
|
},
|
|
259
323
|
onError: (error) => {
|
|
260
324
|
toast.error(extractErrorMessage(error, "Failed to create"));
|
|
@@ -352,6 +416,8 @@ const HealthCheckIDEPageContent = () => {
|
|
|
352
416
|
validationIssues={validationIssues}
|
|
353
417
|
strategyId={activeStrategyId ?? ""}
|
|
354
418
|
configId={configId}
|
|
419
|
+
showSystemsNode={!isEditMode}
|
|
420
|
+
selectedSystemCount={selectedSystemIds.length}
|
|
355
421
|
/>
|
|
356
422
|
}
|
|
357
423
|
panel={
|
|
@@ -378,6 +444,14 @@ const HealthCheckIDEPageContent = () => {
|
|
|
378
444
|
onCollectorRemove={handleCollectorRemove}
|
|
379
445
|
onCollectorAdd={handleCollectorAdd}
|
|
380
446
|
strategyId={activeStrategyId ?? ""}
|
|
447
|
+
showSystemsSection={!isEditMode}
|
|
448
|
+
systems={systems}
|
|
449
|
+
systemsLoading={systemsLoading}
|
|
450
|
+
selectedSystemIds={selectedSystemIds}
|
|
451
|
+
onSystemsChange={(ids) => {
|
|
452
|
+
setSelectedSystemIds(ids);
|
|
453
|
+
setIsDirty(true);
|
|
454
|
+
}}
|
|
381
455
|
/>
|
|
382
456
|
{configId && (
|
|
383
457
|
<ExtensionSlot
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
Badge,
|
|
17
17
|
} from "@checkstack/ui";
|
|
18
18
|
import { Search, Zap } from "lucide-react";
|
|
19
|
-
import { useNavigate } from "react-router-dom";
|
|
19
|
+
import { useNavigate, useSearchParams } from "react-router-dom";
|
|
20
20
|
import { resolveRoute } from "@checkstack/common";
|
|
21
21
|
|
|
22
22
|
/**
|
|
@@ -61,6 +61,8 @@ function StrategyCard({
|
|
|
61
61
|
const StrategyPickerPageContent = () => {
|
|
62
62
|
const healthCheckClient = usePluginClient(HealthCheckApi);
|
|
63
63
|
const navigate = useNavigate();
|
|
64
|
+
const [urlParams] = useSearchParams();
|
|
65
|
+
const systemIdFromUrl = urlParams.get("systemId");
|
|
64
66
|
const [searchQuery, setSearchQuery] = useState("");
|
|
65
67
|
|
|
66
68
|
const { data: strategies = [] } = healthCheckClient.getStrategies.useQuery(
|
|
@@ -96,8 +98,13 @@ const StrategyPickerPageContent = () => {
|
|
|
96
98
|
}, [strategies, searchQuery]);
|
|
97
99
|
|
|
98
100
|
const handleSelectStrategy = (strategy: HealthCheckStrategyDto) => {
|
|
101
|
+
const params = new URLSearchParams();
|
|
102
|
+
params.set("strategy", strategy.id);
|
|
103
|
+
if (systemIdFromUrl) {
|
|
104
|
+
params.set("systemId", systemIdFromUrl);
|
|
105
|
+
}
|
|
99
106
|
navigate(
|
|
100
|
-
`${resolveRoute(healthcheckRoutes.routes.edit, { configId: "new" })}
|
|
107
|
+
`${resolveRoute(healthcheckRoutes.routes.edit, { configId: "new" })}?${params.toString()}`,
|
|
101
108
|
);
|
|
102
109
|
};
|
|
103
110
|
|