@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.
@@ -0,0 +1,88 @@
1
+ /**
2
+ * DOM-free helpers backing the collector config "preview-as-environment" UX.
3
+ *
4
+ * Two concerns, both kept pure so they run in the repo-root `bun test` (no
5
+ * happy-dom):
6
+ *
7
+ * 1. Detect whether a collector's config schema has any `x-templatable` field
8
+ * (recursively), so the editor only shows the environment picker when a
9
+ * preview would actually be meaningful.
10
+ * 2. Build the `templatePreviewContext` fed to `DynamicForm` from a chosen
11
+ * environment's custom fields plus curated check/system metadata. The shape
12
+ * `{ environment, check, system }` mirrors the backend's run-time render
13
+ * context exactly (see `queue-executor.ts`), so the preview never diverges
14
+ * from what runs.
15
+ */
16
+
17
+ import type { JsonSchema, JsonSchemaProperty } from "@checkstack/ui";
18
+
19
+ /**
20
+ * True when `schema` (or any nested object/array property) declares at least
21
+ * one `x-templatable` field. Used to gate the preview picker: no templatable
22
+ * field means no `{{ … }}` to resolve, so the picker stays hidden.
23
+ */
24
+ export function schemaHasTemplatableFields(
25
+ schema: JsonSchema | undefined | null,
26
+ ): boolean {
27
+ if (!schema?.properties) return false;
28
+ return Object.values(schema.properties).some((property) =>
29
+ propertyHasTemplatable(property),
30
+ );
31
+ }
32
+
33
+ function propertyHasTemplatable(property: JsonSchemaProperty): boolean {
34
+ if (property["x-templatable"]) return true;
35
+ if (
36
+ property.properties &&
37
+ Object.values(property.properties).some((nested) =>
38
+ propertyHasTemplatable(nested),
39
+ )
40
+ ) {
41
+ return true;
42
+ }
43
+ if (property.items && propertyHasTemplatable(property.items)) {
44
+ return true;
45
+ }
46
+ return false;
47
+ }
48
+
49
+ /**
50
+ * Curated check/system metadata exposed to the preview, matching the run-time
51
+ * `CollectorRunContext` subset that templating sees. Optional because the
52
+ * editor may not have a concrete system in context (the author writes a config
53
+ * that several systems may later use).
54
+ */
55
+ export interface PreviewCheckMeta {
56
+ id: string;
57
+ name: string;
58
+ intervalSeconds: number;
59
+ }
60
+
61
+ export interface PreviewSystemMeta {
62
+ id: string;
63
+ name: string;
64
+ }
65
+
66
+ /**
67
+ * Build the sample `templatePreviewContext` for `DynamicForm`. `environment`
68
+ * carries the chosen environment's verbatim custom fields (its catalog
69
+ * `metadata`); `check`/`system` carry curated metadata when available. The
70
+ * resulting shape `{ environment, check, system }` matches the backend
71
+ * `templateContext` so `{{ environment.baseUrl }}` previews exactly as it
72
+ * renders at run time.
73
+ */
74
+ export function buildTemplatePreviewContext({
75
+ environmentFields,
76
+ check,
77
+ system,
78
+ }: {
79
+ environmentFields: Record<string, unknown>;
80
+ check?: PreviewCheckMeta;
81
+ system?: PreviewSystemMeta;
82
+ }): Record<string, unknown> {
83
+ return {
84
+ environment: environmentFields,
85
+ check: check ?? {},
86
+ system: system ?? {},
87
+ };
88
+ }
package/src/index.tsx CHANGED
@@ -1,25 +1,24 @@
1
1
  import {
2
2
  createFrontendPlugin,
3
3
  createSlotExtension,
4
- UserMenuItemsSlot,
5
4
  } from "@checkstack/frontend-api";
6
- import { HealthCheckConfigPage } from "./pages/HealthCheckConfigPage";
7
- import { StrategyPickerPage } from "./pages/StrategyPickerPage";
8
- import { HealthCheckIDEPage } from "./pages/HealthCheckIDEPage";
9
- import { AssignmentIDEPage } from "./pages/AssignmentIDEPage";
10
- import { HealthCheckHistoryPage } from "./pages/HealthCheckHistoryPage";
11
- import { HealthCheckHistoryDetailPage } from "./pages/HealthCheckHistoryDetailPage";
12
- import { HealthCheckMenuItems } from "./components/HealthCheckMenuItems";
13
- import { HealthCheckSystemOverview } from "./components/HealthCheckSystemOverview";
5
+ import { Activity } from "lucide-react";
14
6
  import { SystemHealthCheckAssignment } from "./components/SystemHealthCheckAssignment";
15
7
  import { SystemHealthBadge } from "./components/SystemHealthBadge";
16
8
  import { healthCheckAccess } from "@checkstack/healthcheck-common";
17
- import { autoChartExtension } from "./auto-charts";
9
+ // Import directly from the extension module, NOT the `./auto-charts` barrel:
10
+ // the barrel statically re-exports AutoChartGrid + SingleRunChartGrid (both
11
+ // pull in recharts), so importing the extension through it would drag recharts
12
+ // into this eagerly-loaded plugin entry. The extension itself lazy-loads the
13
+ // chart grid.
14
+ import { autoChartExtension } from "./auto-charts/extension";
18
15
 
19
16
  import {
20
17
  SystemDetailsSlot,
21
18
  CatalogSystemActionsSlot,
22
19
  SystemStateBadgesSlot,
20
+ CatalogBrowseHealthSlot,
21
+ SystemSignalsSlot,
23
22
  } from "@checkstack/catalog-common";
24
23
  import {
25
24
  healthcheckRoutes,
@@ -49,43 +48,70 @@ export default createFrontendPlugin({
49
48
  routes: [
50
49
  {
51
50
  route: healthcheckRoutes.routes.config,
52
- element: <HealthCheckConfigPage />,
51
+ load: () =>
52
+ import("./pages/HealthCheckConfigPage").then((m) => ({
53
+ default: m.HealthCheckConfigPage,
54
+ })),
53
55
  title: "Health Checks",
54
56
  accessRule: healthCheckAccess.configuration.manage,
57
+ nav: {
58
+ group: "Reliability",
59
+ icon: Activity,
60
+ // Visible to read-only users (the page itself still gates on manage).
61
+ accessRule: healthCheckAccess.configuration.read,
62
+ },
55
63
  },
56
64
  {
57
65
  route: healthcheckRoutes.routes.create,
58
- element: <StrategyPickerPage />,
66
+ load: () =>
67
+ import("./pages/StrategyPickerPage").then((m) => ({
68
+ default: m.StrategyPickerPage,
69
+ })),
59
70
  title: "Create Health Check",
60
71
  accessRule: healthCheckAccess.configuration.manage,
61
72
  },
62
73
  {
63
74
  route: healthcheckRoutes.routes.edit,
64
- element: <HealthCheckIDEPage />,
75
+ load: () =>
76
+ import("./pages/HealthCheckIDEPage").then((m) => ({
77
+ default: m.HealthCheckIDEPage,
78
+ })),
65
79
  title: "Edit Health Check",
66
80
  accessRule: healthCheckAccess.configuration.manage,
67
81
  },
68
82
  {
69
83
  route: healthcheckRoutes.routes.assignments,
70
- element: <AssignmentIDEPage />,
84
+ load: () =>
85
+ import("./pages/AssignmentIDEPage").then((m) => ({
86
+ default: m.AssignmentIDEPage,
87
+ })),
71
88
  title: "Health Check Assignments",
72
89
  accessRule: healthCheckAccess.configuration.manage,
73
90
  },
74
91
  {
75
92
  route: healthcheckRoutes.routes.history,
76
- element: <HealthCheckHistoryPage />,
93
+ load: () =>
94
+ import("./pages/HealthCheckHistoryPage").then((m) => ({
95
+ default: m.HealthCheckHistoryPage,
96
+ })),
77
97
  title: "Health Check History",
78
98
  accessRule: healthCheckAccess.configuration.read,
79
99
  },
80
100
  {
81
101
  route: healthcheckRoutes.routes.historyDetail,
82
- element: <HealthCheckHistoryDetailPage />,
102
+ load: () =>
103
+ import("./pages/HealthCheckHistoryDetailPage").then((m) => ({
104
+ default: m.HealthCheckHistoryDetailPage,
105
+ })),
83
106
  title: "Health Check Detail",
84
107
  accessRule: healthCheckAccess.details,
85
108
  },
86
109
  {
87
110
  route: healthcheckRoutes.routes.historyRun,
88
- element: <HealthCheckHistoryDetailPage />,
111
+ load: () =>
112
+ import("./pages/HealthCheckHistoryDetailPage").then((m) => ({
113
+ default: m.HealthCheckHistoryDetailPage,
114
+ })),
89
115
  title: "Health Check Run",
90
116
  accessRule: healthCheckAccess.details,
91
117
  },
@@ -93,18 +119,34 @@ export default createFrontendPlugin({
93
119
  // No APIs needed - components use usePluginClient() directly
94
120
  apis: [],
95
121
  extensions: [
96
- createSlotExtension(UserMenuItemsSlot, {
97
- id: "healthcheck.user-menu.items",
98
- component: HealthCheckMenuItems,
99
- metadata: { group: "Reliability" },
100
- }),
101
122
  createSlotExtension(SystemStateBadgesSlot, {
102
123
  id: "healthcheck.system-health-badge",
103
124
  component: SystemHealthBadge,
104
125
  }),
126
+ createSlotExtension(CatalogBrowseHealthSlot, {
127
+ id: "healthcheck.catalog.browse-health",
128
+ // Lazy: only loads when the catalog browse/manage view mounts the slot.
129
+ load: () =>
130
+ import("./components/CatalogBrowseHealthFiller").then((m) => ({
131
+ default: m.CatalogBrowseHealthFiller,
132
+ })),
133
+ }),
134
+ createSlotExtension(SystemSignalsSlot, {
135
+ id: "healthcheck.dashboard.signals",
136
+ // Lazy: only loads when the dashboard overview mounts the slot.
137
+ load: () =>
138
+ import("./components/HealthSignalsFiller").then((m) => ({
139
+ default: m.HealthSignalsFiller,
140
+ })),
141
+ }),
105
142
  createSlotExtension(SystemDetailsSlot, {
106
143
  id: "healthcheck.system-details.overview",
107
- component: HealthCheckSystemOverview,
144
+ // Heavier overview (drawer pulls recharts) — lazy so it stays out of the
145
+ // initial bundle and loads when a system-detail page renders.
146
+ load: () =>
147
+ import("./components/HealthCheckSystemOverview").then((m) => ({
148
+ default: m.HealthCheckSystemOverview,
149
+ })),
108
150
  }),
109
151
  createSlotExtension(CatalogSystemActionsSlot, {
110
152
  id: "healthcheck.catalog.system-actions",
@@ -15,7 +15,12 @@ import type {
15
15
  import { PageLayout, IDELayout, useToast, BackLink, Button } from "@checkstack/ui";
16
16
  import { Settings, Plus, Bell } from "lucide-react";
17
17
  import { extractErrorMessage, resolveRoute } from "@checkstack/common";
18
- import { catalogRoutes } from "@checkstack/catalog-common";
18
+ import { catalogRoutes, CatalogApi } from "@checkstack/catalog-common";
19
+ import {
20
+ environmentIdsForMode,
21
+ toggleEnvironmentId,
22
+ type EnvironmentSelectorMode,
23
+ } from "../components/assignments/environment-selector.logic";
19
24
  import { healthcheckRoutes } from "@checkstack/healthcheck-common";
20
25
  import {
21
26
  AssignmentTree,
@@ -68,6 +73,7 @@ const AssignmentIDEPageContent = () => {
68
73
  const toast = useToast();
69
74
  const healthCheckClient = usePluginClient(HealthCheckApi);
70
75
  const satelliteClient = usePluginClient(SatelliteApi);
76
+ const catalogClient = usePluginClient(CatalogApi);
71
77
 
72
78
  // --- Data Fetching ---
73
79
 
@@ -87,6 +93,14 @@ const AssignmentIDEPageContent = () => {
87
93
 
88
94
  const { data: satellitesData } = satelliteClient.listSatellites.useQuery({});
89
95
 
96
+ // Environments the system currently belongs to — drives the per-assignment
97
+ // environment selector (the fan-out set is a subset of these).
98
+ const { data: systemEnvironments = [] } =
99
+ catalogClient.getSystemEnvironments.useQuery(
100
+ { systemId: systemId ?? "" },
101
+ { enabled: !!systemId },
102
+ );
103
+
90
104
  // --- UI State ---
91
105
 
92
106
  const [selectedNode, setSelectedNode] = useState<AssignmentNodeId>();
@@ -239,6 +253,7 @@ const AssignmentIDEPageContent = () => {
239
253
  enabled: !currentEnabled,
240
254
  stateThresholds: assoc.stateThresholds,
241
255
  satelliteIds: assoc.satelliteIds,
256
+ environmentIds: assoc.environmentIds,
242
257
  includeLocal: assoc.includeLocal,
243
258
  notificationPolicy: assoc.notificationPolicy,
244
259
  },
@@ -266,6 +281,7 @@ const AssignmentIDEPageContent = () => {
266
281
  enabled: assoc.enabled,
267
282
  stateThresholds: thresholds,
268
283
  satelliteIds: assoc.satelliteIds,
284
+ environmentIds: assoc.environmentIds,
269
285
  includeLocal: assoc.includeLocal,
270
286
  notificationPolicy: assoc.notificationPolicy,
271
287
  },
@@ -308,6 +324,7 @@ const AssignmentIDEPageContent = () => {
308
324
  enabled: assoc.enabled,
309
325
  stateThresholds: assoc.stateThresholds,
310
326
  satelliteIds: assoc.satelliteIds,
327
+ environmentIds: assoc.environmentIds,
311
328
  includeLocal: assoc.includeLocal,
312
329
  notificationPolicy: policy,
313
330
  },
@@ -343,6 +360,7 @@ const AssignmentIDEPageContent = () => {
343
360
  enabled: assoc.enabled,
344
361
  stateThresholds: assoc.stateThresholds,
345
362
  satelliteIds: assoc.satelliteIds,
363
+ environmentIds: assoc.environmentIds,
346
364
  includeLocal: assoc.includeLocal,
347
365
  notificationPolicy: undefined,
348
366
  },
@@ -391,6 +409,7 @@ const AssignmentIDEPageContent = () => {
391
409
  enabled: assoc.enabled,
392
410
  stateThresholds: assoc.stateThresholds,
393
411
  satelliteIds: newIds,
412
+ environmentIds: assoc.environmentIds,
394
413
  includeLocal: assoc.includeLocal,
395
414
  notificationPolicy: assoc.notificationPolicy,
396
415
  },
@@ -409,12 +428,69 @@ const AssignmentIDEPageContent = () => {
409
428
  enabled: assoc.enabled,
410
429
  stateThresholds: assoc.stateThresholds,
411
430
  satelliteIds: assoc.satelliteIds,
431
+ environmentIds: assoc.environmentIds,
412
432
  includeLocal: !assoc.includeLocal,
413
433
  notificationPolicy: assoc.notificationPolicy,
414
434
  },
415
435
  });
416
436
  };
417
437
 
438
+ /**
439
+ * Persist `environmentIds` for an assignment, preserving every other
440
+ * operator-managed field. Used by both the mode switch and the per-env
441
+ * toggle in the "specific" mode.
442
+ */
443
+ const persistEnvironmentIds = (
444
+ configId: string,
445
+ environmentIds: string[] | null,
446
+ ) => {
447
+ if (!systemId) return;
448
+ const assoc = associations.find((a) => a.configurationId === configId);
449
+ if (!assoc) return;
450
+
451
+ associateMutation.mutate({
452
+ systemId,
453
+ body: {
454
+ configurationId: configId,
455
+ enabled: assoc.enabled,
456
+ stateThresholds: assoc.stateThresholds,
457
+ satelliteIds: assoc.satelliteIds,
458
+ environmentIds,
459
+ includeLocal: assoc.includeLocal,
460
+ notificationPolicy: assoc.notificationPolicy,
461
+ },
462
+ });
463
+ };
464
+
465
+ const handleSetEnvironmentMode = (
466
+ configId: string,
467
+ mode: EnvironmentSelectorMode,
468
+ ) => {
469
+ const assoc = associations.find((a) => a.configurationId === configId);
470
+ // Switching to "specific" seeds with the current explicit set if any,
471
+ // otherwise all current environment ids (a sensible starting point).
472
+ const currentSpecific =
473
+ assoc?.environmentIds && assoc.environmentIds.length > 0
474
+ ? assoc.environmentIds
475
+ : systemEnvironments.map((e) => e.id);
476
+ persistEnvironmentIds(
477
+ configId,
478
+ environmentIdsForMode({ mode, selectedIds: currentSpecific }),
479
+ );
480
+ };
481
+
482
+ const handleToggleEnvironment = (configId: string, environmentId: string) => {
483
+ const assoc = associations.find((a) => a.configurationId === configId);
484
+ const currentSpecific =
485
+ assoc?.environmentIds && assoc.environmentIds.length > 0
486
+ ? assoc.environmentIds
487
+ : [];
488
+ persistEnvironmentIds(
489
+ configId,
490
+ toggleEnvironmentId({ selectedIds: currentSpecific, environmentId }),
491
+ );
492
+ };
493
+
418
494
  const handleSaveRetention = (configId: string) => {
419
495
  if (!systemId) return;
420
496
  const data = retentionData[configId];
@@ -567,6 +643,17 @@ const AssignmentIDEPageContent = () => {
567
643
  onToggleSatellite={(satId) =>
568
644
  handleToggleSatellite(configId, satId)
569
645
  }
646
+ environmentIds={assoc.environmentIds ?? null}
647
+ environments={systemEnvironments.map((e) => ({
648
+ id: e.id,
649
+ name: e.name,
650
+ }))}
651
+ onSetEnvironmentMode={(mode) =>
652
+ handleSetEnvironmentMode(configId, mode)
653
+ }
654
+ onToggleEnvironment={(envId) =>
655
+ handleToggleEnvironment(configId, envId)
656
+ }
570
657
  saving={saving}
571
658
  isLocked={isLocked}
572
659
  />
@@ -20,6 +20,11 @@ import { useCollectors } from "../hooks/useCollectors";
20
20
  import { EditorTree, type TreeNodeId } from "../components/editor/EditorTree";
21
21
  import { EditorPanel } from "../components/editor/EditorPanel";
22
22
  import { useProvenanceLock, GitOpsLockBanner } from "@checkstack/gitops-frontend";
23
+ import {
24
+ environmentToPreviewFields,
25
+ findSelectedEnvironment,
26
+ } from "@checkstack/catalog-frontend";
27
+ import { buildTemplatePreviewContext } from "../components/editor/collector-preview-context.logic";
23
28
 
24
29
 
25
30
  // =============================================================================
@@ -104,6 +109,23 @@ const HealthCheckIDEPageContent = () => {
104
109
  [systemsData],
105
110
  );
106
111
 
112
+ // Environments for the collector "Preview as" picker. When the editor was
113
+ // opened from a single system (create-from-system), use that system's
114
+ // environments; otherwise the config may later be assigned to many systems,
115
+ // so a global picker over all environments is acceptable.
116
+ const { data: systemEnvironments = [] } =
117
+ catalogClient.getSystemEnvironments.useQuery(
118
+ { systemId: systemIdFromUrl ?? "" },
119
+ { enabled: !!systemIdFromUrl },
120
+ );
121
+ const { data: allEnvironments = [] } = catalogClient.listEnvironments.useQuery(
122
+ {},
123
+ { enabled: !systemIdFromUrl },
124
+ );
125
+ const previewEnvironments = systemIdFromUrl
126
+ ? systemEnvironments
127
+ : allEnvironments;
128
+
107
129
  // --- Form State ---
108
130
 
109
131
  const [formState, setFormState] = useState<EditorFormState>({
@@ -122,6 +144,11 @@ const HealthCheckIDEPageContent = () => {
122
144
  const [selectedSystemIds, setSelectedSystemIds] = useState<string[]>(
123
145
  systemIdFromUrl ? [systemIdFromUrl] : [],
124
146
  );
147
+ // Selected "Preview as" environment id for collector config templating.
148
+ // Pure editor-local UI state (never persisted) — shared across collectors.
149
+ const [previewEnvironmentId, setPreviewEnvironmentId] = useState<
150
+ string | null
151
+ >(null);
125
152
 
126
153
  // Initialize form from existing configuration (edit mode).
127
154
  //
@@ -282,6 +309,49 @@ const HealthCheckIDEPageContent = () => {
282
309
 
283
310
  const isValid = validationIssues.length === 0;
284
311
 
312
+ // --- Template preview context ---
313
+
314
+ // Build the sample `{ environment, check, system }` context fed to the
315
+ // collector config form so `{{ environment.* }}` previews live. `undefined`
316
+ // until an environment is picked, so the preview line stays hidden. Shape
317
+ // mirrors the backend run-time render context (see `queue-executor.ts`).
318
+ const templatePreviewContext = useMemo<
319
+ Record<string, unknown> | undefined
320
+ >(() => {
321
+ const selectedEnv = findSelectedEnvironment({
322
+ environments: previewEnvironments,
323
+ selectedId: previewEnvironmentId,
324
+ });
325
+ if (!selectedEnv) return;
326
+
327
+ const systemMeta = systemIdFromUrl
328
+ ? {
329
+ id: systemIdFromUrl,
330
+ name:
331
+ systems.find((s) => s.id === systemIdFromUrl)?.name ??
332
+ systemIdFromUrl,
333
+ }
334
+ : undefined;
335
+
336
+ return buildTemplatePreviewContext({
337
+ environmentFields: environmentToPreviewFields(selectedEnv),
338
+ check: {
339
+ id: configId ?? "new",
340
+ name: formState.name || "Health check",
341
+ intervalSeconds: formState.intervalSeconds,
342
+ },
343
+ system: systemMeta,
344
+ });
345
+ }, [
346
+ previewEnvironments,
347
+ previewEnvironmentId,
348
+ systemIdFromUrl,
349
+ systems,
350
+ configId,
351
+ formState.name,
352
+ formState.intervalSeconds,
353
+ ]);
354
+
285
355
  // --- Save ---
286
356
 
287
357
  const associateMutation = healthCheckClient.associateSystem.useMutation();
@@ -466,6 +536,10 @@ const HealthCheckIDEPageContent = () => {
466
536
  setSelectedSystemIds(ids);
467
537
  setIsDirty(true);
468
538
  }}
539
+ previewEnvironments={previewEnvironments}
540
+ previewEnvironmentId={previewEnvironmentId}
541
+ onPreviewEnvironmentChange={setPreviewEnvironmentId}
542
+ templatePreviewContext={templatePreviewContext}
469
543
  />
470
544
  {configId && (
471
545
  <ExtensionSlot
package/tsconfig.json CHANGED
@@ -13,6 +13,9 @@
13
13
  {
14
14
  "path": "../catalog-common"
15
15
  },
16
+ {
17
+ "path": "../catalog-frontend"
18
+ },
16
19
  {
17
20
  "path": "../common"
18
21
  },
@@ -1,30 +0,0 @@
1
- import React from "react";
2
- import { Link } from "react-router-dom";
3
- import { Activity } from "lucide-react";
4
- import type { UserMenuItemsContext } from "@checkstack/frontend-api";
5
- import { DropdownMenuItem } from "@checkstack/ui";
6
- import { resolveRoute } from "@checkstack/common";
7
- import {
8
- healthcheckRoutes,
9
- healthCheckAccess,
10
- pluginMetadata,
11
- } from "@checkstack/healthcheck-common";
12
-
13
- export const HealthCheckMenuItems = ({
14
- accessRules: userPerms,
15
- }: UserMenuItemsContext) => {
16
- const qualifiedId = `${pluginMetadata.pluginId}.${healthCheckAccess.configuration.read.id}`;
17
- const canRead = userPerms.includes("*") || userPerms.includes(qualifiedId);
18
-
19
- if (!canRead) {
20
- return <React.Fragment />;
21
- }
22
-
23
- return (
24
- <Link to={resolveRoute(healthcheckRoutes.routes.config)}>
25
- <DropdownMenuItem icon={<Activity className="w-4 h-4" />}>
26
- Health Checks
27
- </DropdownMenuItem>
28
- </Link>
29
- );
30
- };