@checkstack/healthcheck-frontend 0.22.0 → 0.23.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 +157 -0
- package/package.json +21 -20
- package/src/auto-charts/extension.tsx +15 -2
- package/src/components/CatalogBrowseHealthFiller.tsx +60 -0
- package/src/components/CollectorList.tsx +10 -0
- package/src/components/HealthCheckDrawer.tsx +35 -4
- package/src/components/HealthCheckRunsTable.tsx +38 -4
- package/src/components/HealthCheckSystemOverview.tsx +19 -11
- package/src/components/HealthSignalsFiller.tsx +76 -0
- package/src/components/SystemHealthBadge.tsx +7 -2
- package/src/components/assignments/ExecutionPanel.tsx +91 -1
- package/src/components/assignments/environment-selector.logic.test.ts +68 -0
- package/src/components/assignments/environment-selector.logic.ts +69 -0
- package/src/components/editor/CollectorSection.tsx +48 -6
- package/src/components/editor/EditorPanel.tsx +26 -0
- package/src/components/editor/collector-preview-context.logic.test.ts +101 -0
- package/src/components/editor/collector-preview-context.logic.ts +88 -0
- package/src/index.tsx +65 -23
- package/src/pages/AssignmentIDEPage.tsx +88 -1
- package/src/pages/HealthCheckIDEPage.tsx +74 -0
- package/tsconfig.json +3 -0
- package/src/components/HealthCheckMenuItems.tsx +0 -30
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import React, { useEffect, useMemo } from "react";
|
|
2
|
+
import { usePluginClient, type SlotContext } from "@checkstack/frontend-api";
|
|
3
|
+
import { resolveRoute } from "@checkstack/common";
|
|
4
|
+
import {
|
|
5
|
+
SystemSignalsSlot,
|
|
6
|
+
type SystemSignal,
|
|
7
|
+
type SystemSignalsMap,
|
|
8
|
+
} from "@checkstack/catalog-common";
|
|
9
|
+
import { healthcheckRoutes } from "@checkstack/healthcheck-common";
|
|
10
|
+
import { HealthCheckApi } from "../api";
|
|
11
|
+
|
|
12
|
+
type Props = SlotContext<typeof SystemSignalsSlot>;
|
|
13
|
+
|
|
14
|
+
const SOURCE_ID = "healthcheck";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Reports per-system health as dashboard signals. Bulk-fetches health for all
|
|
18
|
+
* overview systems in one request and contributes a signal for every system
|
|
19
|
+
* that is degraded or unhealthy, deep-linking to the failing check's history
|
|
20
|
+
* (or the system's check assignments when no specific check is failing).
|
|
21
|
+
* Renders nothing — it is a headless filler for {@link SystemSignalsSlot}.
|
|
22
|
+
*/
|
|
23
|
+
export const HealthSignalsFiller: React.FC<Props> = ({
|
|
24
|
+
systemIds,
|
|
25
|
+
onSignals,
|
|
26
|
+
}) => {
|
|
27
|
+
const healthCheckClient = usePluginClient(HealthCheckApi);
|
|
28
|
+
|
|
29
|
+
const { data } = healthCheckClient.getBulkSystemHealthStatus.useQuery(
|
|
30
|
+
{ systemIds },
|
|
31
|
+
{ enabled: systemIds.length > 0, staleTime: 30_000 },
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const signals = useMemo<SystemSignalsMap>(() => {
|
|
35
|
+
const result: SystemSignalsMap = {};
|
|
36
|
+
if (!data) return result;
|
|
37
|
+
|
|
38
|
+
for (const systemId of systemIds) {
|
|
39
|
+
const status = data.statuses[systemId];
|
|
40
|
+
if (!status || status.status === "healthy") continue;
|
|
41
|
+
|
|
42
|
+
const failing = status.checkStatuses.filter(
|
|
43
|
+
(c) => c.status !== "healthy",
|
|
44
|
+
);
|
|
45
|
+
const failingCheck = failing[0];
|
|
46
|
+
const href = failingCheck
|
|
47
|
+
? resolveRoute(healthcheckRoutes.routes.historyDetail, {
|
|
48
|
+
systemId,
|
|
49
|
+
configurationId: failingCheck.configurationId,
|
|
50
|
+
})
|
|
51
|
+
: resolveRoute(healthcheckRoutes.routes.assignments, { systemId });
|
|
52
|
+
|
|
53
|
+
const detail =
|
|
54
|
+
status.checkStatuses.length > 0
|
|
55
|
+
? `${failing.length} of ${status.checkStatuses.length} checks failing`
|
|
56
|
+
: undefined;
|
|
57
|
+
|
|
58
|
+
const signal: SystemSignal = {
|
|
59
|
+
source: SOURCE_ID,
|
|
60
|
+
tone: status.status === "unhealthy" ? "error" : "warn",
|
|
61
|
+
label: status.status === "unhealthy" ? "Unhealthy" : "Degraded",
|
|
62
|
+
detail,
|
|
63
|
+
href,
|
|
64
|
+
iconName: "Activity",
|
|
65
|
+
};
|
|
66
|
+
result[systemId] = [signal];
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}, [data, systemIds]);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
onSignals(SOURCE_ID, signals);
|
|
73
|
+
}, [signals, onSignals]);
|
|
74
|
+
|
|
75
|
+
return null;
|
|
76
|
+
};
|
|
@@ -2,7 +2,8 @@ import React from "react";
|
|
|
2
2
|
import { usePluginClient, type SlotContext } from "@checkstack/frontend-api";
|
|
3
3
|
import { SystemStateBadgesSlot } from "@checkstack/catalog-common";
|
|
4
4
|
import { HealthCheckApi } from "../api";
|
|
5
|
-
import {
|
|
5
|
+
import { StatusBadge } from "@checkstack/ui";
|
|
6
|
+
import { Activity } from "lucide-react";
|
|
6
7
|
import { useSystemBadgeDataOptional } from "@checkstack/dashboard-frontend";
|
|
7
8
|
|
|
8
9
|
type Props = SlotContext<typeof SystemStateBadgesSlot>;
|
|
@@ -37,5 +38,9 @@ export const SystemHealthBadge: React.FC<Props> = ({ system }) => {
|
|
|
37
38
|
const status = providerStatus ?? localStatus;
|
|
38
39
|
|
|
39
40
|
if (!status || status === "healthy") return <></>;
|
|
40
|
-
return
|
|
41
|
+
return status === "unhealthy" ? (
|
|
42
|
+
<StatusBadge tone="error" icon={Activity} label="Unhealthy" />
|
|
43
|
+
) : (
|
|
44
|
+
<StatusBadge tone="warn" icon={Activity} label="Degraded" />
|
|
45
|
+
);
|
|
41
46
|
};
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Checkbox, Label, Tooltip } from "@checkstack/ui";
|
|
3
|
-
import { Satellite } from "lucide-react";
|
|
3
|
+
import { Satellite, Layers } from "lucide-react";
|
|
4
|
+
import {
|
|
5
|
+
modeFromEnvironmentIds,
|
|
6
|
+
type EnvironmentSelectorMode,
|
|
7
|
+
} from "./environment-selector.logic";
|
|
4
8
|
|
|
5
9
|
interface SatelliteDto {
|
|
6
10
|
id: string;
|
|
@@ -9,12 +13,26 @@ interface SatelliteDto {
|
|
|
9
13
|
status: "online" | "offline";
|
|
10
14
|
}
|
|
11
15
|
|
|
16
|
+
interface EnvironmentDto {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
interface ExecutionPanelProps {
|
|
13
22
|
includeLocal: boolean;
|
|
14
23
|
satelliteIds: string[];
|
|
15
24
|
satellites: SatelliteDto[];
|
|
16
25
|
onToggleLocal: () => void;
|
|
17
26
|
onToggleSatellite: (satelliteId: string) => void;
|
|
27
|
+
/**
|
|
28
|
+
* Per-assignment environment selector value. null = all current
|
|
29
|
+
* environments; [] = opt out (env-less); non-empty = those env ids.
|
|
30
|
+
*/
|
|
31
|
+
environmentIds: string[] | null;
|
|
32
|
+
/** Environments the system currently belongs to. */
|
|
33
|
+
environments: EnvironmentDto[];
|
|
34
|
+
onSetEnvironmentMode: (mode: EnvironmentSelectorMode) => void;
|
|
35
|
+
onToggleEnvironment: (environmentId: string) => void;
|
|
18
36
|
saving: boolean;
|
|
19
37
|
isLocked?: boolean;
|
|
20
38
|
}
|
|
@@ -29,11 +47,24 @@ export const ExecutionPanel: React.FC<ExecutionPanelProps> = ({
|
|
|
29
47
|
satellites,
|
|
30
48
|
onToggleLocal,
|
|
31
49
|
onToggleSatellite,
|
|
50
|
+
environmentIds,
|
|
51
|
+
environments,
|
|
52
|
+
onSetEnvironmentMode,
|
|
53
|
+
onToggleEnvironment,
|
|
32
54
|
saving,
|
|
33
55
|
isLocked,
|
|
34
56
|
}) => {
|
|
35
57
|
const hasSatellites = satelliteIds.length > 0;
|
|
36
58
|
const willRunAnywhere = includeLocal || hasSatellites;
|
|
59
|
+
const envMode = modeFromEnvironmentIds(environmentIds);
|
|
60
|
+
const selectedEnvIds = new Set<string>(
|
|
61
|
+
environmentIds === null ? [] : environmentIds,
|
|
62
|
+
);
|
|
63
|
+
const envModes: { value: EnvironmentSelectorMode; label: string; hint: string }[] = [
|
|
64
|
+
{ value: "all", label: "All environments", hint: "Run once per environment the system belongs to" },
|
|
65
|
+
{ value: "specific", label: "Specific", hint: "Run only for the selected environments" },
|
|
66
|
+
{ value: "none", label: "None", hint: "Run once with no environment" },
|
|
67
|
+
];
|
|
37
68
|
|
|
38
69
|
return (
|
|
39
70
|
<div className="p-6 space-y-4">
|
|
@@ -118,6 +149,65 @@ export const ExecutionPanel: React.FC<ExecutionPanelProps> = ({
|
|
|
118
149
|
)}
|
|
119
150
|
</div>
|
|
120
151
|
|
|
152
|
+
{/* Environment Selector */}
|
|
153
|
+
<div className="space-y-2">
|
|
154
|
+
<div className="flex items-center gap-2">
|
|
155
|
+
<Layers className="h-3.5 w-3.5 text-muted-foreground" />
|
|
156
|
+
<Label className="text-sm font-medium">Environments</Label>
|
|
157
|
+
<Tooltip content="Fan this check out into one run per environment. The custom fields of each environment are exposed to scripts and templating." />
|
|
158
|
+
</div>
|
|
159
|
+
<p className="text-xs text-muted-foreground">
|
|
160
|
+
Choose how this check fans out across the system's environments.
|
|
161
|
+
</p>
|
|
162
|
+
|
|
163
|
+
<div className="space-y-1.5">
|
|
164
|
+
{envModes.map((m) => (
|
|
165
|
+
<label
|
|
166
|
+
key={m.value}
|
|
167
|
+
className="flex items-start gap-3 p-2.5 rounded-md border hover:bg-muted/30 transition-colors cursor-pointer"
|
|
168
|
+
>
|
|
169
|
+
<input
|
|
170
|
+
type="radio"
|
|
171
|
+
name="environment-mode"
|
|
172
|
+
className="mt-1"
|
|
173
|
+
checked={envMode === m.value}
|
|
174
|
+
disabled={saving || isLocked}
|
|
175
|
+
onChange={() => onSetEnvironmentMode(m.value)}
|
|
176
|
+
/>
|
|
177
|
+
<div className="flex-1 min-w-0">
|
|
178
|
+
<span className="text-sm font-medium">{m.label}</span>
|
|
179
|
+
<p className="text-xs text-muted-foreground mt-0.5">{m.hint}</p>
|
|
180
|
+
</div>
|
|
181
|
+
</label>
|
|
182
|
+
))}
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
{envMode === "specific" && (
|
|
186
|
+
<div className="space-y-1.5 pl-6">
|
|
187
|
+
{environments.length === 0 ? (
|
|
188
|
+
<p className="text-sm text-muted-foreground italic py-2">
|
|
189
|
+
This system has no environments. Attach environments to the
|
|
190
|
+
system in the catalog first.
|
|
191
|
+
</p>
|
|
192
|
+
) : (
|
|
193
|
+
environments.map((env) => (
|
|
194
|
+
<div
|
|
195
|
+
key={env.id}
|
|
196
|
+
className="flex items-center gap-3 p-2 rounded-md border hover:bg-muted/30 transition-colors"
|
|
197
|
+
>
|
|
198
|
+
<Checkbox
|
|
199
|
+
checked={selectedEnvIds.has(env.id)}
|
|
200
|
+
onCheckedChange={() => onToggleEnvironment(env.id)}
|
|
201
|
+
disabled={saving || isLocked}
|
|
202
|
+
/>
|
|
203
|
+
<span className="text-sm truncate">{env.name}</span>
|
|
204
|
+
</div>
|
|
205
|
+
))
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
|
|
121
211
|
{/* Execution Summary */}
|
|
122
212
|
<div className="p-3 bg-muted/30 rounded-lg border text-xs text-muted-foreground">
|
|
123
213
|
<span className="font-medium">Execution: </span>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
modeFromEnvironmentIds,
|
|
4
|
+
environmentIdsForMode,
|
|
5
|
+
toggleEnvironmentId,
|
|
6
|
+
} from "./environment-selector.logic";
|
|
7
|
+
|
|
8
|
+
describe("modeFromEnvironmentIds", () => {
|
|
9
|
+
it("null => all", () => {
|
|
10
|
+
expect(modeFromEnvironmentIds(null)).toBe("all");
|
|
11
|
+
});
|
|
12
|
+
it("undefined => all", () => {
|
|
13
|
+
expect(modeFromEnvironmentIds(undefined)).toBe("all");
|
|
14
|
+
});
|
|
15
|
+
it("[] => none", () => {
|
|
16
|
+
expect(modeFromEnvironmentIds([])).toBe("none");
|
|
17
|
+
});
|
|
18
|
+
it("non-empty => specific", () => {
|
|
19
|
+
expect(modeFromEnvironmentIds(["prod"])).toBe("specific");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("environmentIdsForMode", () => {
|
|
24
|
+
it("all => null", () => {
|
|
25
|
+
expect(environmentIdsForMode({ mode: "all", selectedIds: ["x"] })).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
it("none => []", () => {
|
|
28
|
+
expect(environmentIdsForMode({ mode: "none", selectedIds: ["x"] })).toEqual(
|
|
29
|
+
[],
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
it("specific => the selected ids verbatim", () => {
|
|
33
|
+
expect(
|
|
34
|
+
environmentIdsForMode({ mode: "specific", selectedIds: ["prod", "qa"] }),
|
|
35
|
+
).toEqual(["prod", "qa"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("round-trips through modeFromEnvironmentIds for all three modes", () => {
|
|
39
|
+
expect(
|
|
40
|
+
modeFromEnvironmentIds(
|
|
41
|
+
environmentIdsForMode({ mode: "all", selectedIds: [] }),
|
|
42
|
+
),
|
|
43
|
+
).toBe("all");
|
|
44
|
+
expect(
|
|
45
|
+
modeFromEnvironmentIds(
|
|
46
|
+
environmentIdsForMode({ mode: "none", selectedIds: [] }),
|
|
47
|
+
),
|
|
48
|
+
).toBe("none");
|
|
49
|
+
expect(
|
|
50
|
+
modeFromEnvironmentIds(
|
|
51
|
+
environmentIdsForMode({ mode: "specific", selectedIds: ["e1"] }),
|
|
52
|
+
),
|
|
53
|
+
).toBe("specific");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("toggleEnvironmentId", () => {
|
|
58
|
+
it("adds an id not present, preserving order", () => {
|
|
59
|
+
expect(
|
|
60
|
+
toggleEnvironmentId({ selectedIds: ["a"], environmentId: "b" }),
|
|
61
|
+
).toEqual(["a", "b"]);
|
|
62
|
+
});
|
|
63
|
+
it("removes an id already present", () => {
|
|
64
|
+
expect(
|
|
65
|
+
toggleEnvironmentId({ selectedIds: ["a", "b"], environmentId: "a" }),
|
|
66
|
+
).toEqual(["b"]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, DOM-free logic for the per-assignment environment selector. Kept
|
|
3
|
+
* separate from the React component so it can be unit-tested in the repo-root
|
|
4
|
+
* `bun test` run (which has no happy-dom).
|
|
5
|
+
*
|
|
6
|
+
* The selector has three modes that map to the `environmentIds` wire value
|
|
7
|
+
* (semantics locked in the environments plan, §2/§7.1):
|
|
8
|
+
* - "all" => `null` : run for ALL environments the system belongs to.
|
|
9
|
+
* - "specific" => `string[]` (non-empty): run for exactly those env ids.
|
|
10
|
+
* - "none" => `[]` : opt out, run ONCE with no environment.
|
|
11
|
+
*/
|
|
12
|
+
export type EnvironmentSelectorMode = "all" | "specific" | "none";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Derive the UI mode from the stored `environmentIds` wire value.
|
|
16
|
+
* `null`/`undefined` = all; `[]` = none; non-empty = specific.
|
|
17
|
+
*/
|
|
18
|
+
export function modeFromEnvironmentIds(
|
|
19
|
+
environmentIds: string[] | null | undefined,
|
|
20
|
+
): EnvironmentSelectorMode {
|
|
21
|
+
if (environmentIds === null || environmentIds === undefined) return "all";
|
|
22
|
+
if (environmentIds.length === 0) return "none";
|
|
23
|
+
return "specific";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Compute the `environmentIds` wire value to persist for a chosen mode.
|
|
28
|
+
*
|
|
29
|
+
* For "specific", `selectedIds` is used verbatim (empty selection under
|
|
30
|
+
* "specific" is NOT auto-coerced to "none" here — the component keeps the
|
|
31
|
+
* mode explicit and `selectedIds` empty; callers/UI should require at least
|
|
32
|
+
* one selection before this is treated as a meaningful "specific" set).
|
|
33
|
+
*/
|
|
34
|
+
export function environmentIdsForMode({
|
|
35
|
+
mode,
|
|
36
|
+
selectedIds,
|
|
37
|
+
}: {
|
|
38
|
+
mode: EnvironmentSelectorMode;
|
|
39
|
+
selectedIds: string[];
|
|
40
|
+
}): string[] | null {
|
|
41
|
+
switch (mode) {
|
|
42
|
+
case "all": {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
case "none": {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
case "specific": {
|
|
49
|
+
return selectedIds;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Toggle one environment id in a "specific" selection, returning the new
|
|
56
|
+
* sorted-stable list (membership order is the caller's concern; this just
|
|
57
|
+
* adds/removes preserving existing order).
|
|
58
|
+
*/
|
|
59
|
+
export function toggleEnvironmentId({
|
|
60
|
+
selectedIds,
|
|
61
|
+
environmentId,
|
|
62
|
+
}: {
|
|
63
|
+
selectedIds: string[];
|
|
64
|
+
environmentId: string;
|
|
65
|
+
}): string[] {
|
|
66
|
+
return selectedIds.includes(environmentId)
|
|
67
|
+
? selectedIds.filter((id) => id !== environmentId)
|
|
68
|
+
: [...selectedIds, environmentId];
|
|
69
|
+
}
|
|
@@ -10,10 +10,18 @@ import {
|
|
|
10
10
|
healthcheckScriptContext,
|
|
11
11
|
} from "@checkstack/ui";
|
|
12
12
|
import { Trash2 } from "lucide-react";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
useScriptPackageTypeAcquisition,
|
|
15
|
+
useSdkTypeInjection,
|
|
16
|
+
} from "@checkstack/script-packages-frontend";
|
|
14
17
|
import { useSecretNames } from "@checkstack/secrets-frontend";
|
|
18
|
+
import {
|
|
19
|
+
EnvironmentPreviewPicker,
|
|
20
|
+
type Environment,
|
|
21
|
+
} from "@checkstack/catalog-frontend";
|
|
15
22
|
import { AssertionBuilder, type Assertion } from "../AssertionBuilder";
|
|
16
23
|
import { createCollectorScriptTestRenderer } from "./CollectorScriptTestRenderer";
|
|
24
|
+
import { schemaHasTemplatableFields } from "./collector-preview-context.logic";
|
|
17
25
|
|
|
18
26
|
interface CollectorSectionProps {
|
|
19
27
|
entry: CollectorConfigEntry;
|
|
@@ -22,6 +30,21 @@ interface CollectorSectionProps {
|
|
|
22
30
|
onAssertionsChange: (assertions: CollectorConfigEntry["assertions"]) => void;
|
|
23
31
|
onValidChange: (isValid: boolean) => void;
|
|
24
32
|
onRemove: () => void;
|
|
33
|
+
/**
|
|
34
|
+
* Environments offered in the "Preview as" picker (the system's when one is
|
|
35
|
+
* in context, else all). Empty disables the picker.
|
|
36
|
+
*/
|
|
37
|
+
previewEnvironments: ReadonlyArray<Environment>;
|
|
38
|
+
/** Currently selected preview environment id (shared across collectors). */
|
|
39
|
+
previewEnvironmentId: string | null;
|
|
40
|
+
/** Called when the author picks (or clears) a preview environment. */
|
|
41
|
+
onPreviewEnvironmentChange: (environmentId: string | null) => void;
|
|
42
|
+
/**
|
|
43
|
+
* Sample context for previewing `x-templatable` fields, built from the
|
|
44
|
+
* selected environment's custom fields plus curated check/system metadata.
|
|
45
|
+
* `undefined` when no environment is selected (preview line stays hidden).
|
|
46
|
+
*/
|
|
47
|
+
templatePreviewContext?: Record<string, unknown>;
|
|
25
48
|
}
|
|
26
49
|
|
|
27
50
|
export const CollectorSection: React.FC<CollectorSectionProps> = ({
|
|
@@ -31,6 +54,10 @@ export const CollectorSection: React.FC<CollectorSectionProps> = ({
|
|
|
31
54
|
onAssertionsChange,
|
|
32
55
|
onValidChange,
|
|
33
56
|
onRemove,
|
|
57
|
+
previewEnvironments,
|
|
58
|
+
previewEnvironmentId,
|
|
59
|
+
onPreviewEnvironmentChange,
|
|
60
|
+
templatePreviewContext,
|
|
34
61
|
}) => {
|
|
35
62
|
const scriptTestRenderer = React.useMemo(
|
|
36
63
|
() => createCollectorScriptTestRenderer(entry.config),
|
|
@@ -41,6 +68,8 @@ export const CollectorSection: React.FC<CollectorSectionProps> = ({
|
|
|
41
68
|
// drives import-specifier name completion before any module is registered.
|
|
42
69
|
const { acquireTypes, acquireResetKey, importablePackages } =
|
|
43
70
|
useScriptPackageTypeAcquisition();
|
|
71
|
+
// SDK editor types so `@checkstack/sdk/healthcheck` imports resolve.
|
|
72
|
+
const { sdkTypes, sdkTypesResetKey } = useSdkTypeInjection();
|
|
44
73
|
// Secret names (never values) for the secret -> env mapping editor.
|
|
45
74
|
const { secretNames } = useSecretNames();
|
|
46
75
|
|
|
@@ -72,11 +101,21 @@ export const CollectorSection: React.FC<CollectorSectionProps> = ({
|
|
|
72
101
|
{/* Configuration */}
|
|
73
102
|
{collectorDef?.configSchema && (
|
|
74
103
|
<div className="space-y-3">
|
|
75
|
-
<div>
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
104
|
+
<div className="flex items-start justify-between gap-4">
|
|
105
|
+
<div>
|
|
106
|
+
<Label className="text-sm font-semibold">Configuration</Label>
|
|
107
|
+
<p className="text-xs text-muted-foreground">
|
|
108
|
+
Configure how this check item behaves.
|
|
109
|
+
</p>
|
|
110
|
+
</div>
|
|
111
|
+
{/* Only offer the preview picker when a templatable field exists. */}
|
|
112
|
+
{schemaHasTemplatableFields(collectorDef.configSchema) && (
|
|
113
|
+
<EnvironmentPreviewPicker
|
|
114
|
+
environments={previewEnvironments}
|
|
115
|
+
selectedId={previewEnvironmentId}
|
|
116
|
+
onSelect={onPreviewEnvironmentChange}
|
|
117
|
+
/>
|
|
118
|
+
)}
|
|
80
119
|
</div>
|
|
81
120
|
{(() => {
|
|
82
121
|
const ctx = healthcheckScriptContext({
|
|
@@ -90,11 +129,14 @@ export const CollectorSection: React.FC<CollectorSectionProps> = ({
|
|
|
90
129
|
value={entry.config}
|
|
91
130
|
onChange={onConfigChange}
|
|
92
131
|
onValidChange={onValidChange}
|
|
132
|
+
templatePreviewContext={templatePreviewContext}
|
|
93
133
|
{...ctx}
|
|
94
134
|
scriptTestRenderer={scriptTestRenderer}
|
|
95
135
|
secretNames={secretNames}
|
|
96
136
|
acquireTypes={acquireTypes}
|
|
97
137
|
acquireResetKey={acquireResetKey}
|
|
138
|
+
sdkTypes={sdkTypes}
|
|
139
|
+
sdkTypesResetKey={sdkTypesResetKey}
|
|
98
140
|
importablePackages={importablePackages}
|
|
99
141
|
/>
|
|
100
142
|
);
|
|
@@ -10,6 +10,7 @@ import { CollectorSection } from "./CollectorSection";
|
|
|
10
10
|
import { CollectorPicker } from "./CollectorPicker";
|
|
11
11
|
import { SystemsSection } from "./SystemsSection";
|
|
12
12
|
import { TeamAccessEditor } from "@checkstack/auth-frontend";
|
|
13
|
+
import type { Environment } from "@checkstack/catalog-frontend";
|
|
13
14
|
|
|
14
15
|
// =============================================================================
|
|
15
16
|
// TYPES
|
|
@@ -49,6 +50,21 @@ interface EditorPanelProps {
|
|
|
49
50
|
systemsLoading?: boolean;
|
|
50
51
|
selectedSystemIds?: string[];
|
|
51
52
|
onSystemsChange?: (systemIds: string[]) => void;
|
|
53
|
+
/**
|
|
54
|
+
* Environments offered in the collector "Preview as" picker (the system's
|
|
55
|
+
* environments when a single system is in context, else all). Empty hides
|
|
56
|
+
* the picker.
|
|
57
|
+
*/
|
|
58
|
+
previewEnvironments?: ReadonlyArray<Environment>;
|
|
59
|
+
/** Selected preview environment id (shared across collectors). */
|
|
60
|
+
previewEnvironmentId?: string | null;
|
|
61
|
+
/** Called when the author picks (or clears) a preview environment. */
|
|
62
|
+
onPreviewEnvironmentChange?: (environmentId: string | null) => void;
|
|
63
|
+
/**
|
|
64
|
+
* Sample context for previewing the selected collector's `x-templatable`
|
|
65
|
+
* fields, built from the chosen environment. `undefined` when none chosen.
|
|
66
|
+
*/
|
|
67
|
+
templatePreviewContext?: Record<string, unknown>;
|
|
52
68
|
}
|
|
53
69
|
|
|
54
70
|
// =============================================================================
|
|
@@ -78,6 +94,10 @@ export const EditorPanel: React.FC<EditorPanelProps> = ({
|
|
|
78
94
|
systemsLoading = false,
|
|
79
95
|
selectedSystemIds = [],
|
|
80
96
|
onSystemsChange,
|
|
97
|
+
previewEnvironments = [],
|
|
98
|
+
previewEnvironmentId = null,
|
|
99
|
+
onPreviewEnvironmentChange,
|
|
100
|
+
templatePreviewContext,
|
|
81
101
|
}) => {
|
|
82
102
|
// --- General Section ---
|
|
83
103
|
if (selectedNode === "general") {
|
|
@@ -180,6 +200,12 @@ export const EditorPanel: React.FC<EditorPanelProps> = ({
|
|
|
180
200
|
}
|
|
181
201
|
onValidChange={(isValid) => onCollectorValidChange(entryId, isValid)}
|
|
182
202
|
onRemove={() => onCollectorRemove(entryId)}
|
|
203
|
+
previewEnvironments={previewEnvironments}
|
|
204
|
+
previewEnvironmentId={previewEnvironmentId}
|
|
205
|
+
onPreviewEnvironmentChange={(id) =>
|
|
206
|
+
onPreviewEnvironmentChange?.(id)
|
|
207
|
+
}
|
|
208
|
+
templatePreviewContext={templatePreviewContext}
|
|
183
209
|
/>
|
|
184
210
|
</div>
|
|
185
211
|
);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
schemaHasTemplatableFields,
|
|
4
|
+
buildTemplatePreviewContext,
|
|
5
|
+
} from "./collector-preview-context.logic";
|
|
6
|
+
import type { JsonSchema } from "@checkstack/ui";
|
|
7
|
+
|
|
8
|
+
describe("schemaHasTemplatableFields", () => {
|
|
9
|
+
it("returns false for empty/missing schema", () => {
|
|
10
|
+
expect(schemaHasTemplatableFields(undefined)).toBe(false);
|
|
11
|
+
expect(schemaHasTemplatableFields(null)).toBe(false);
|
|
12
|
+
expect(schemaHasTemplatableFields({ type: "object" })).toBe(false);
|
|
13
|
+
expect(
|
|
14
|
+
schemaHasTemplatableFields({ type: "object", properties: {} }),
|
|
15
|
+
).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("detects a top-level templatable field", () => {
|
|
19
|
+
const schema: JsonSchema = {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
url: { type: "string", "x-templatable": true },
|
|
23
|
+
method: { type: "string" },
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
expect(schemaHasTemplatableFields(schema)).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns false when no field is templatable", () => {
|
|
30
|
+
const schema: JsonSchema = {
|
|
31
|
+
type: "object",
|
|
32
|
+
properties: {
|
|
33
|
+
url: { type: "string" },
|
|
34
|
+
timeout: { type: "number" },
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
expect(schemaHasTemplatableFields(schema)).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("detects a nested templatable field in an object property", () => {
|
|
41
|
+
const schema: JsonSchema = {
|
|
42
|
+
type: "object",
|
|
43
|
+
properties: {
|
|
44
|
+
auth: {
|
|
45
|
+
type: "object",
|
|
46
|
+
properties: {
|
|
47
|
+
token: { type: "string", "x-templatable": true },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
expect(schemaHasTemplatableFields(schema)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("detects a templatable field inside array items", () => {
|
|
56
|
+
const schema: JsonSchema = {
|
|
57
|
+
type: "object",
|
|
58
|
+
properties: {
|
|
59
|
+
headers: {
|
|
60
|
+
type: "array",
|
|
61
|
+
items: {
|
|
62
|
+
type: "object",
|
|
63
|
+
properties: {
|
|
64
|
+
value: { type: "string", "x-templatable": true },
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
expect(schemaHasTemplatableFields(schema)).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("buildTemplatePreviewContext", () => {
|
|
75
|
+
it("places environment fields under `environment`", () => {
|
|
76
|
+
const ctx = buildTemplatePreviewContext({
|
|
77
|
+
environmentFields: { baseUrl: "https://staging.example.com" },
|
|
78
|
+
});
|
|
79
|
+
expect(ctx.environment).toEqual({
|
|
80
|
+
baseUrl: "https://staging.example.com",
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("defaults check/system to empty objects when absent", () => {
|
|
85
|
+
const ctx = buildTemplatePreviewContext({ environmentFields: {} });
|
|
86
|
+
expect(ctx).toEqual({ environment: {}, check: {}, system: {} });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("includes curated check/system metadata when provided", () => {
|
|
90
|
+
const ctx = buildTemplatePreviewContext({
|
|
91
|
+
environmentFields: { baseUrl: "https://x" },
|
|
92
|
+
check: { id: "c1", name: "API health", intervalSeconds: 60 },
|
|
93
|
+
system: { id: "s1", name: "Payments" },
|
|
94
|
+
});
|
|
95
|
+
expect(ctx).toEqual({
|
|
96
|
+
environment: { baseUrl: "https://x" },
|
|
97
|
+
check: { id: "c1", name: "API health", intervalSeconds: 60 },
|
|
98
|
+
system: { id: "s1", name: "Payments" },
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|