@checkstack/healthcheck-frontend 0.1.0 → 0.2.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 CHANGED
@@ -1,5 +1,103 @@
1
1
  # @checkstack/healthcheck-frontend
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8e43507: # Teams and Resource-Level Access Control
8
+
9
+ This release introduces a comprehensive Teams system for organizing users and controlling access to resources at a granular level.
10
+
11
+ ## Features
12
+
13
+ ### Team Management
14
+
15
+ - Create, update, and delete teams with name and description
16
+ - Add/remove users from teams
17
+ - Designate team managers with elevated privileges
18
+ - View team membership and manager status
19
+
20
+ ### Resource-Level Access Control
21
+
22
+ - Grant teams access to specific resources (systems, health checks, incidents, maintenances)
23
+ - Configure read-only or manage permissions per team
24
+ - Resource-level "Team Only" mode that restricts access exclusively to team members
25
+ - Separate `resourceAccessSettings` table for resource-level settings (not per-grant)
26
+ - Automatic cleanup of grants when teams are deleted (database cascade)
27
+
28
+ ### Middleware Integration
29
+
30
+ - Extended `autoAuthMiddleware` to support resource access checks
31
+ - Single-resource pre-handler validation for detail endpoints
32
+ - Automatic list filtering for collection endpoints
33
+ - S2S endpoints for access verification
34
+
35
+ ### Frontend Components
36
+
37
+ - `TeamsTab` component for managing teams in Auth Settings
38
+ - `TeamAccessEditor` component for assigning team access to resources
39
+ - Resource-level "Team Only" toggle in `TeamAccessEditor`
40
+ - Integration into System, Health Check, Incident, and Maintenance editors
41
+
42
+ ## Breaking Changes
43
+
44
+ ### API Response Format Changes
45
+
46
+ List endpoints now return objects with named keys instead of arrays directly:
47
+
48
+ ```typescript
49
+ // Before
50
+ const systems = await catalogApi.getSystems();
51
+
52
+ // After
53
+ const { systems } = await catalogApi.getSystems();
54
+ ```
55
+
56
+ Affected endpoints:
57
+
58
+ - `catalog.getSystems` → `{ systems: [...] }`
59
+ - `healthcheck.getConfigurations` → `{ configurations: [...] }`
60
+ - `incident.listIncidents` → `{ incidents: [...] }`
61
+ - `maintenance.listMaintenances` → `{ maintenances: [...] }`
62
+
63
+ ### User Identity Enrichment
64
+
65
+ `RealUser` and `ApplicationUser` types now include `teamIds: string[]` field with team memberships.
66
+
67
+ ## Documentation
68
+
69
+ See `docs/backend/teams.md` for complete API reference and integration guide.
70
+
71
+ - 97c5a6b: Add UUID-based collector identification for better multiple collector support
72
+
73
+ **Breaking Change**: Existing health check configurations with collectors need to be recreated.
74
+
75
+ - Each collector instance now has a unique UUID assigned on creation
76
+ - Collector results are stored under the UUID key with `_collectorId` and `_assertionFailed` metadata
77
+ - Auto-charts correctly display separate charts for each collector instance
78
+ - Charts are now grouped by collector instance with clear headings
79
+ - Assertion status card shows pass/fail for each collector
80
+ - Renamed "Success" to "HTTP Success" to clarify it's about HTTP request success
81
+ - Fixed deletion of collectors not persisting to database
82
+ - Fixed duplicate React key warnings in auto-chart grid
83
+
84
+ ### Patch Changes
85
+
86
+ - 97c5a6b: Fix Radix UI accessibility warning in dialog components by adding visually hidden DialogDescription components
87
+ - Updated dependencies [8e43507]
88
+ - Updated dependencies [97c5a6b]
89
+ - Updated dependencies [97c5a6b]
90
+ - Updated dependencies [8e43507]
91
+ - Updated dependencies [8e43507]
92
+ - Updated dependencies [97c5a6b]
93
+ - @checkstack/ui@0.1.0
94
+ - @checkstack/auth-frontend@0.1.0
95
+ - @checkstack/catalog-common@1.0.0
96
+ - @checkstack/common@0.1.0
97
+ - @checkstack/healthcheck-common@0.2.0
98
+ - @checkstack/frontend-api@0.0.4
99
+ - @checkstack/signal-frontend@0.0.5
100
+
3
101
  ## 0.1.0
4
102
 
5
103
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-frontend",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "src/index.tsx",
6
6
  "scripts": {
@@ -9,13 +9,13 @@
9
9
  "lint:code": "eslint . --max-warnings 0"
10
10
  },
11
11
  "dependencies": {
12
+ "@checkstack/auth-frontend": "workspace:*",
12
13
  "@checkstack/catalog-common": "workspace:*",
13
14
  "@checkstack/common": "workspace:*",
14
15
  "@checkstack/frontend-api": "workspace:*",
15
16
  "@checkstack/healthcheck-common": "workspace:*",
16
17
  "@checkstack/signal-frontend": "workspace:*",
17
18
  "@checkstack/ui": "workspace:*",
18
- "@types/prismjs": "^1.26.5",
19
19
  "ajv": "^8.17.1",
20
20
  "ajv-formats": "^3.0.1",
21
21
  "date-fns": "^4.1.0",
@@ -28,6 +28,7 @@
28
28
  "zod": "^4.2.1"
29
29
  },
30
30
  "devDependencies": {
31
+ "@types/prismjs": "^1.26.5",
31
32
  "typescript": "^5.0.0",
32
33
  "@types/react": "^18.2.0",
33
34
  "@checkstack/tsconfig": "workspace:*",
@@ -33,6 +33,9 @@ interface AutoChartGridProps {
33
33
 
34
34
  /**
35
35
  * Main component that renders a grid of auto-generated charts.
36
+ *
37
+ * Discovers actual collector instances from run data and creates
38
+ * separate charts for each instance, grouped with headings.
36
39
  */
37
40
  export function AutoChartGrid({ context }: AutoChartGridProps) {
38
41
  const { schemas, loading } = useStrategySchemas(context.strategyId);
@@ -55,22 +58,247 @@ export function AutoChartGrid({ context }: AutoChartGridProps) {
55
58
  return;
56
59
  }
57
60
 
58
- const fields = extractChartFields(schema);
59
- if (fields.length === 0) {
61
+ const schemaFields = extractChartFields(schema);
62
+ if (schemaFields.length === 0) {
60
63
  return;
61
64
  }
62
65
 
66
+ // Discover actual collector instances from run data
67
+ const instanceMap = discoverCollectorInstances(context);
68
+
69
+ // Separate strategy-level fields from collector fields
70
+ const strategyFields = schemaFields.filter((f) => !f.collectorId);
71
+
72
+ // Build grouped collector fields
73
+ const collectorGroups = buildCollectorGroups(schemaFields, instanceMap);
74
+
63
75
  return (
64
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 mt-4">
65
- {fields.map((field) => (
66
- <AutoChartCard key={field.name} field={field} context={context} />
76
+ <div className="space-y-6 mt-4">
77
+ {/* Strategy-level fields */}
78
+ {strategyFields.length > 0 && (
79
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
80
+ {strategyFields.map((field) => (
81
+ <AutoChartCard key={field.name} field={field} context={context} />
82
+ ))}
83
+ </div>
84
+ )}
85
+
86
+ {/* Collector groups */}
87
+ {collectorGroups.map((group) => (
88
+ <CollectorGroup
89
+ key={group.instanceKey}
90
+ group={group}
91
+ context={context}
92
+ />
67
93
  ))}
68
94
  </div>
69
95
  );
70
96
  }
71
97
 
98
+ /**
99
+ * A group of fields for a single collector instance.
100
+ */
101
+ interface CollectorGroupData {
102
+ instanceKey: string;
103
+ collectorId: string;
104
+ displayName: string;
105
+ fields: ExpandedChartField[];
106
+ }
107
+
108
+ /**
109
+ * Build grouped collector data from schema fields and instance map.
110
+ */
111
+ function buildCollectorGroups(
112
+ schemaFields: ChartField[],
113
+ instanceMap: Record<string, string[]>
114
+ ): CollectorGroupData[] {
115
+ const groups: CollectorGroupData[] = [];
116
+
117
+ // Process each collector type
118
+ for (const [collectorId, instanceKeys] of Object.entries(instanceMap)) {
119
+ // Get fields for this collector type
120
+ const collectorFields = schemaFields.filter(
121
+ (f) => f.collectorId === collectorId
122
+ );
123
+ if (collectorFields.length === 0) continue;
124
+
125
+ // Create a group for each instance
126
+ for (const [index, instanceKey] of instanceKeys.entries()) {
127
+ const displayName =
128
+ instanceKeys.length === 1
129
+ ? collectorId.split(".").pop() || collectorId
130
+ : `${collectorId.split(".").pop() || collectorId} #${index + 1}`;
131
+
132
+ groups.push({
133
+ instanceKey,
134
+ collectorId,
135
+ displayName,
136
+ fields: collectorFields.map((field) => ({
137
+ ...field,
138
+ instanceKey,
139
+ })),
140
+ });
141
+ }
142
+ }
143
+
144
+ return groups;
145
+ }
146
+
147
+ /**
148
+ * Renders a collector group with heading, assertion status, and field cards.
149
+ */
150
+ function CollectorGroup({
151
+ group,
152
+ context,
153
+ }: {
154
+ group: CollectorGroupData;
155
+ context: HealthCheckDiagramSlotContext;
156
+ }) {
157
+ // Get assertion status for this collector instance
158
+ const assertionFailed = getAssertionFailed(context, group.instanceKey);
159
+
160
+ return (
161
+ <div>
162
+ <h4 className="text-sm font-medium text-muted-foreground mb-3 uppercase tracking-wide">
163
+ {group.displayName}
164
+ </h4>
165
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
166
+ {/* Assertion status card */}
167
+ <AssertionStatusCard assertionFailed={assertionFailed} />
168
+
169
+ {/* Field cards */}
170
+ {group.fields.map((field) => (
171
+ <AutoChartCard
172
+ key={`${field.instanceKey}-${field.name}`}
173
+ field={field}
174
+ context={context}
175
+ />
176
+ ))}
177
+ </div>
178
+ </div>
179
+ );
180
+ }
181
+
182
+ /**
183
+ * Get the _assertionFailed value for a specific collector instance.
184
+ */
185
+ function getAssertionFailed(
186
+ context: HealthCheckDiagramSlotContext,
187
+ instanceKey: string
188
+ ): string | undefined {
189
+ if (context.type === "raw" && context.runs.length > 0) {
190
+ const latestRun = context.runs[0];
191
+ const result = latestRun.result as StoredHealthCheckResult | undefined;
192
+ const collectors = result?.metadata?.collectors as
193
+ | Record<string, Record<string, unknown>>
194
+ | undefined;
195
+ const collectorData = collectors?.[instanceKey];
196
+ return collectorData?._assertionFailed as string | undefined;
197
+ }
198
+ return undefined;
199
+ }
200
+
201
+ /**
202
+ * Card showing assertion pass/fail status.
203
+ */
204
+ function AssertionStatusCard({
205
+ assertionFailed,
206
+ }: {
207
+ assertionFailed: string | undefined;
208
+ }) {
209
+ if (!assertionFailed) {
210
+ return (
211
+ <Card>
212
+ <CardHeader className="pb-2">
213
+ <CardTitle className="text-sm font-medium">Assertion</CardTitle>
214
+ </CardHeader>
215
+ <CardContent>
216
+ <div className="flex items-center gap-2 text-green-600">
217
+ <div className="w-3 h-3 rounded-full bg-green-500" />
218
+ <span>Passed</span>
219
+ </div>
220
+ </CardContent>
221
+ </Card>
222
+ );
223
+ }
224
+
225
+ return (
226
+ <Card className="border-red-200 dark:border-red-900">
227
+ <CardHeader className="pb-2">
228
+ <CardTitle className="text-sm font-medium text-red-600">
229
+ Assertion Failed
230
+ </CardTitle>
231
+ </CardHeader>
232
+ <CardContent>
233
+ <div className="text-sm text-red-600 bg-red-50 dark:bg-red-950 px-2 py-1 rounded">
234
+ {assertionFailed}
235
+ </div>
236
+ </CardContent>
237
+ </Card>
238
+ );
239
+ }
240
+
241
+ /**
242
+ * Discover collector instances from actual run data.
243
+ * Returns a map from base collector ID (type) to array of instance UUIDs.
244
+ */
245
+ function discoverCollectorInstances(
246
+ context: HealthCheckDiagramSlotContext
247
+ ): Record<string, string[]> {
248
+ const instanceMap: Record<string, Set<string>> = {};
249
+
250
+ const addInstances = (collectors: Record<string, unknown> | undefined) => {
251
+ if (!collectors || typeof collectors !== "object") return;
252
+
253
+ for (const [uuid, data] of Object.entries(collectors)) {
254
+ // Read the collector type from the stored _collectorId metadata
255
+ const collectorData = data as Record<string, unknown> | undefined;
256
+ const collectorId = collectorData?._collectorId as string | undefined;
257
+
258
+ if (collectorId) {
259
+ if (!instanceMap[collectorId]) {
260
+ instanceMap[collectorId] = new Set();
261
+ }
262
+ instanceMap[collectorId].add(uuid);
263
+ }
264
+ }
265
+ };
266
+
267
+ if (context.type === "raw") {
268
+ for (const run of context.runs) {
269
+ const result = run.result as StoredHealthCheckResult | undefined;
270
+ const collectors = result?.metadata?.collectors as
271
+ | Record<string, unknown>
272
+ | undefined;
273
+ addInstances(collectors);
274
+ }
275
+ } else {
276
+ for (const bucket of context.buckets) {
277
+ const collectors = (
278
+ bucket.aggregatedResult as Record<string, unknown> | undefined
279
+ )?.collectors as Record<string, unknown> | undefined;
280
+ addInstances(collectors);
281
+ }
282
+ }
283
+
284
+ // Convert sets to arrays
285
+ const result: Record<string, string[]> = {};
286
+ for (const [collectorId, uuids] of Object.entries(instanceMap)) {
287
+ result[collectorId] = [...uuids].toSorted();
288
+ }
289
+ return result;
290
+ }
291
+
292
+ /**
293
+ * Extended ChartField with instance UUID for data lookup.
294
+ */
295
+ interface ExpandedChartField extends ChartField {
296
+ /** Instance UUID for data lookup */
297
+ instanceKey?: string;
298
+ }
299
+
72
300
  interface AutoChartCardProps {
73
- field: ChartField;
301
+ field: ExpandedChartField;
74
302
  context: HealthCheckDiagramSlotContext;
75
303
  }
76
304
 
@@ -91,7 +319,7 @@ function AutoChartCard({ field, context }: AutoChartCardProps) {
91
319
  }
92
320
 
93
321
  interface ChartRendererProps {
94
- field: ChartField;
322
+ field: ExpandedChartField;
95
323
  context: HealthCheckDiagramSlotContext;
96
324
  }
97
325
 
@@ -139,7 +367,7 @@ function ChartRenderer({ field, context }: ChartRendererProps) {
139
367
  * Counts how many times each value appears across all runs/buckets.
140
368
  */
141
369
  function CounterRenderer({ field, context }: ChartRendererProps) {
142
- const counts = getValueCounts(field.name, context);
370
+ const counts = getValueCounts(field.name, context, field.instanceKey);
143
371
  const entries = Object.entries(counts);
144
372
 
145
373
  if (entries.length === 0) {
@@ -184,7 +412,7 @@ function CounterRenderer({ field, context }: ChartRendererProps) {
184
412
  * Renders a percentage gauge visualization using Recharts RadialBarChart.
185
413
  */
186
414
  function GaugeRenderer({ field, context }: ChartRendererProps) {
187
- const value = getLatestValue(field.name, context);
415
+ const value = getLatestValue(field.name, context, field.instanceKey);
188
416
  const numValue =
189
417
  typeof value === "number" ? Math.min(100, Math.max(0, value)) : 0;
190
418
  const unit = field.unit ?? "%";
@@ -231,7 +459,7 @@ function GaugeRenderer({ field, context }: ChartRendererProps) {
231
459
  * Renders a boolean indicator (success/failure).
232
460
  */
233
461
  function BooleanRenderer({ field, context }: ChartRendererProps) {
234
- const value = getLatestValue(field.name, context);
462
+ const value = getLatestValue(field.name, context, field.instanceKey);
235
463
  const isTrue = value === true;
236
464
 
237
465
  return (
@@ -252,7 +480,7 @@ function BooleanRenderer({ field, context }: ChartRendererProps) {
252
480
  * Renders text value.
253
481
  */
254
482
  function TextRenderer({ field, context }: ChartRendererProps) {
255
- const value = getLatestValue(field.name, context);
483
+ const value = getLatestValue(field.name, context, field.instanceKey);
256
484
  const displayValue = formatTextValue(value);
257
485
 
258
486
  return (
@@ -269,7 +497,7 @@ function TextRenderer({ field, context }: ChartRendererProps) {
269
497
  * Renders error/status badge.
270
498
  */
271
499
  function StatusRenderer({ field, context }: ChartRendererProps) {
272
- const value = getLatestValue(field.name, context);
500
+ const value = getLatestValue(field.name, context, field.instanceKey);
273
501
  const hasValue = value !== undefined && value !== null && value !== "";
274
502
 
275
503
  if (!hasValue) {
@@ -287,7 +515,7 @@ function StatusRenderer({ field, context }: ChartRendererProps) {
287
515
  * Renders an area chart for time series data using Recharts AreaChart.
288
516
  */
289
517
  function LineChartRenderer({ field, context }: ChartRendererProps) {
290
- const values = getAllValues(field.name, context);
518
+ const values = getAllValues(field.name, context, field.instanceKey);
291
519
  const unit = field.unit ?? "";
292
520
 
293
521
  if (values.length === 0) {
@@ -443,7 +671,7 @@ const CHART_COLORS = [
443
671
  */
444
672
  function PieChartRenderer({ field, context }: ChartRendererProps) {
445
673
  // First, try to get a pre-aggregated object value
446
- const value = getLatestValue(field.name, context);
674
+ const value = getLatestValue(field.name, context, field.instanceKey);
447
675
 
448
676
  // Determine the data source: use pre-aggregated object or count simple values
449
677
  let dataRecord: Record<string, number>;
@@ -453,7 +681,7 @@ function PieChartRenderer({ field, context }: ChartRendererProps) {
453
681
  dataRecord = value as Record<string, number>;
454
682
  } else {
455
683
  // Simple values (like statusCode) - count occurrences
456
- dataRecord = getValueCounts(field.name, context);
684
+ dataRecord = getValueCounts(field.name, context, field.instanceKey);
457
685
  }
458
686
 
459
687
  const entries = Object.entries(dataRecord).slice(0, 8);
@@ -542,7 +770,8 @@ function PieChartRenderer({ field, context }: ChartRendererProps) {
542
770
  */
543
771
  function getLatestValue(
544
772
  fieldName: string,
545
- context: HealthCheckDiagramSlotContext
773
+ context: HealthCheckDiagramSlotContext,
774
+ collectorId?: string
546
775
  ): unknown {
547
776
  if (context.type === "raw") {
548
777
  const runs = context.runs;
@@ -550,7 +779,7 @@ function getLatestValue(
550
779
  // For raw runs, aggregate across all runs for record types
551
780
  const allValues = runs.map((run) => {
552
781
  const result = run.result as StoredHealthCheckResult | undefined;
553
- return getFieldValue(result?.metadata, fieldName);
782
+ return getFieldValue(result?.metadata, fieldName, collectorId);
554
783
  });
555
784
 
556
785
  // If the values are record types (like statusCodeCounts), combine them
@@ -613,14 +842,15 @@ function combineRecordValues(
613
842
  */
614
843
  function getValueCounts(
615
844
  fieldName: string,
616
- context: HealthCheckDiagramSlotContext
845
+ context: HealthCheckDiagramSlotContext,
846
+ collectorId?: string
617
847
  ): Record<string, number> {
618
848
  const counts: Record<string, number> = {};
619
849
 
620
850
  if (context.type === "raw") {
621
851
  for (const run of context.runs) {
622
852
  const result = run.result as StoredHealthCheckResult | undefined;
623
- const value = getFieldValue(result?.metadata, fieldName);
853
+ const value = getFieldValue(result?.metadata, fieldName, collectorId);
624
854
  if (value !== undefined && value !== null) {
625
855
  const key = String(value);
626
856
  counts[key] = (counts[key] || 0) + 1;
@@ -631,7 +861,8 @@ function getValueCounts(
631
861
  for (const bucket of context.buckets) {
632
862
  const value = getFieldValue(
633
863
  bucket.aggregatedResult as Record<string, unknown>,
634
- fieldName
864
+ fieldName,
865
+ collectorId
635
866
  );
636
867
  if (value !== undefined && value !== null) {
637
868
  const key = String(value);
@@ -651,14 +882,15 @@ function getValueCounts(
651
882
  */
652
883
  function getAllValues(
653
884
  fieldName: string,
654
- context: HealthCheckDiagramSlotContext
885
+ context: HealthCheckDiagramSlotContext,
886
+ collectorId?: string
655
887
  ): number[] {
656
888
  if (context.type === "raw") {
657
889
  return context.runs
658
890
  .map((run) => {
659
891
  // result is typed as StoredHealthCheckResult with { status, latencyMs, message, metadata }
660
892
  const result = run.result as StoredHealthCheckResult;
661
- return getFieldValue(result?.metadata, fieldName);
893
+ return getFieldValue(result?.metadata, fieldName, collectorId);
662
894
  })
663
895
  .filter((v): v is number => typeof v === "number");
664
896
  }
@@ -666,7 +898,8 @@ function getAllValues(
666
898
  .map((bucket) =>
667
899
  getFieldValue(
668
900
  bucket.aggregatedResult as Record<string, unknown>,
669
- fieldName
901
+ fieldName,
902
+ collectorId
670
903
  )
671
904
  )
672
905
  .filter((v): v is number => typeof v === "number");
@@ -34,7 +34,7 @@ export type ResultSchema = JsonSchemaBase<ResultSchemaProperty>;
34
34
  * Chart field information extracted from JSON Schema.
35
35
  */
36
36
  export interface ChartField {
37
- /** Field path (supports dot notation for nested fields like "collectors.request.responseTimeMs") */
37
+ /** Field name (simple name for collector fields, path for others) */
38
38
  name: string;
39
39
  /** Chart type to render */
40
40
  chartType: ChartType;
@@ -44,7 +44,7 @@ export interface ChartField {
44
44
  unit?: string;
45
45
  /** JSON Schema type (number, string, boolean, etc.) */
46
46
  schemaType: string;
47
- /** Collector ID if this field is from a collector */
47
+ /** Collector ID if this field is from a collector (used for data lookup) */
48
48
  collectorId?: string;
49
49
  }
50
50
 
@@ -78,7 +78,6 @@ export function extractChartFields(
78
78
  // Extract fields from the collector's result schema
79
79
  const collectorFields = extractFieldsFromProperties(
80
80
  collectorProp.properties,
81
- `collectors.${collectorId}`,
82
81
  collectorId
83
82
  );
84
83
  fields.push(...collectorFields);
@@ -101,7 +100,6 @@ export function extractChartFields(
101
100
  */
102
101
  function extractFieldsFromProperties(
103
102
  properties: Record<string, ResultSchemaProperty>,
104
- pathPrefix: string,
105
103
  collectorId: string
106
104
  ): ChartField[] {
107
105
  const fields: ChartField[] = [];
@@ -110,8 +108,8 @@ function extractFieldsFromProperties(
110
108
  const chartType = prop["x-chart-type"];
111
109
  if (!chartType) continue;
112
110
 
113
- const fullPath = `${pathPrefix}.${fieldName}`;
114
- const field = extractSingleField(fullPath, prop);
111
+ // Use just field name - collectorId is stored separately for data lookup
112
+ const field = extractSingleField(fieldName, prop);
115
113
  field.collectorId = collectorId;
116
114
  // Prefix label with collector ID for clarity
117
115
  if (!prop["x-chart-label"]?.includes(collectorId)) {
@@ -165,28 +163,53 @@ function formatFieldName(name: string): string {
165
163
 
166
164
  /**
167
165
  * Get the value for a field from a data object.
168
- * Supports dot-notation paths like "collectors.request.responseTimeMs".
166
+ * For strategy-level fields, also searches inside collectors as fallback.
167
+ *
168
+ * @param data - The metadata object
169
+ * @param fieldName - Simple field name (no dot notation for collector fields)
170
+ * @param collectorInstanceId - Optional: if provided, looks in collectors[collectorInstanceId]
169
171
  */
170
172
  export function getFieldValue(
171
173
  data: Record<string, unknown> | undefined,
172
- fieldName: string
174
+ fieldName: string,
175
+ collectorInstanceId?: string
173
176
  ): unknown {
174
177
  if (!data) return undefined;
175
178
 
176
- // Simple case: no dot notation
177
- if (!fieldName.includes(".")) {
178
- return data[fieldName];
179
+ // If collectorInstanceId is provided, look in that specific collector's data
180
+ if (collectorInstanceId) {
181
+ const collectors = data.collectors as
182
+ | Record<string, Record<string, unknown>>
183
+ | undefined;
184
+ if (collectors && typeof collectors === "object") {
185
+ const collectorData = collectors[collectorInstanceId];
186
+ if (collectorData && typeof collectorData === "object") {
187
+ return collectorData[fieldName];
188
+ }
189
+ }
190
+ return undefined;
179
191
  }
180
192
 
181
- // Dot notation: traverse the path
182
- const parts = fieldName.split(".");
183
- let current: unknown = data;
193
+ // For non-collector fields, try direct lookup first
194
+ const directValue = data[fieldName];
195
+ if (directValue !== undefined) {
196
+ return directValue;
197
+ }
184
198
 
185
- for (const part of parts) {
186
- if (current === null || current === undefined) return undefined;
187
- if (typeof current !== "object") return undefined;
188
- current = (current as Record<string, unknown>)[part];
199
+ // Fallback: search all collectors for the field (for strategy schema fields)
200
+ const collectors = data.collectors as
201
+ | Record<string, Record<string, unknown>>
202
+ | undefined;
203
+ if (collectors && typeof collectors === "object") {
204
+ for (const collectorData of Object.values(collectors)) {
205
+ if (collectorData && typeof collectorData === "object") {
206
+ const value = collectorData[fieldName];
207
+ if (value !== undefined) {
208
+ return value;
209
+ }
210
+ }
211
+ }
189
212
  }
190
213
 
191
- return current;
214
+ return undefined;
192
215
  }
@@ -1,5 +1,9 @@
1
1
  /**
2
2
  * Hook to fetch and cache strategy schemas.
3
+ *
4
+ * Fetches both strategy result schemas AND collector result schemas,
5
+ * merging them into a unified schema where collector schemas are nested
6
+ * under `properties.collectors.<collectorId>`.
3
7
  */
4
8
 
5
9
  import { useEffect, useState } from "react";
@@ -14,6 +18,9 @@ interface StrategySchemas {
14
18
  /**
15
19
  * Fetch and cache strategy schemas for auto-chart rendering.
16
20
  *
21
+ * Also fetches collector schemas and merges them into the result schema
22
+ * so that chart fields from collectors are properly extracted.
23
+ *
17
24
  * @param strategyId - The strategy ID to fetch schemas for
18
25
  * @returns Schemas for the strategy, or undefined if not found
19
26
  */
@@ -30,13 +37,30 @@ export function useStrategySchemas(strategyId: string): {
30
37
 
31
38
  async function fetchSchemas() {
32
39
  try {
33
- const strategies = await api.getStrategies();
40
+ // Fetch strategy and collectors in parallel
41
+ const [strategies, collectors] = await Promise.all([
42
+ api.getStrategies(),
43
+ api.getCollectors({ strategyId }),
44
+ ]);
45
+
34
46
  const strategy = strategies.find((s) => s.id === strategyId);
35
47
 
36
48
  if (!cancelled && strategy) {
49
+ // Build collector schemas object for nesting under resultSchema.properties.collectors
50
+ const collectorProperties: Record<string, unknown> = {};
51
+ for (const collector of collectors) {
52
+ // Use full ID so it matches stored data keys like "healthcheck-http.request"
53
+ collectorProperties[collector.id] = collector.resultSchema;
54
+ }
55
+
56
+ // Merge collector schemas into strategy result schema
57
+ const mergedResultSchema = mergeCollectorSchemas(
58
+ strategy.resultSchema as Record<string, unknown> | undefined,
59
+ collectorProperties
60
+ );
61
+
37
62
  setSchemas({
38
- resultSchema:
39
- (strategy.resultSchema as Record<string, unknown>) ?? undefined,
63
+ resultSchema: mergedResultSchema,
40
64
  aggregatedResultSchema:
41
65
  (strategy.aggregatedResultSchema as Record<string, unknown>) ??
42
66
  undefined,
@@ -60,3 +84,49 @@ export function useStrategySchemas(strategyId: string): {
60
84
 
61
85
  return { schemas, loading };
62
86
  }
87
+
88
+ /**
89
+ * Merge collector result schemas into a strategy result schema.
90
+ *
91
+ * Creates a schema structure where collectors are nested under
92
+ * `properties.collectors.<collectorId>`, matching the actual data structure
93
+ * stored by the health check executor.
94
+ */
95
+ function mergeCollectorSchemas(
96
+ strategySchema: Record<string, unknown> | undefined,
97
+ collectorProperties: Record<string, unknown>
98
+ ): Record<string, unknown> | undefined {
99
+ // If no collectors, return original schema
100
+ if (Object.keys(collectorProperties).length === 0) {
101
+ return strategySchema;
102
+ }
103
+
104
+ // Build the collectors nested schema
105
+ const collectorsSchema = {
106
+ type: "object",
107
+ properties: collectorProperties,
108
+ };
109
+
110
+ // If no strategy schema, create one just with collectors
111
+ if (!strategySchema) {
112
+ return {
113
+ type: "object",
114
+ properties: {
115
+ collectors: collectorsSchema,
116
+ },
117
+ };
118
+ }
119
+
120
+ // Merge: add collectors to existing properties
121
+ const existingProps = (strategySchema.properties ?? {}) as Record<
122
+ string,
123
+ unknown
124
+ >;
125
+ return {
126
+ ...strategySchema,
127
+ properties: {
128
+ ...existingProps,
129
+ collectors: collectorsSchema,
130
+ },
131
+ };
132
+ }
@@ -302,15 +302,22 @@ export const AssertionBuilder: React.FC<AssertionBuilderProps> = ({
302
302
  <div className="space-y-4">
303
303
  {assertions.map((assertion, index) => {
304
304
  const field = getFieldByPath(assertion.field);
305
- const operators = field ? OPERATORS[field.type] : [];
305
+ // Safely get operators with fallback to empty array
306
+ const operators = field ? OPERATORS[field.type] ?? [] : [];
306
307
  const needsValue = !VALUE_LESS_OPERATORS.has(assertion.operator);
307
308
 
309
+ // Check if current values match available options (prevents Radix UI crash)
310
+ const fieldValueValid = fields.some((f) => f.path === assertion.field);
311
+ const operatorValueValid = operators.some(
312
+ (op) => op.value === assertion.operator
313
+ );
314
+
308
315
  return (
309
316
  <div key={index} className="flex items-start gap-2 flex-wrap">
310
317
  {/* Field selector */}
311
318
  <div className="flex-1 min-w-[120px]">
312
319
  <Select
313
- value={assertion.field}
320
+ value={fieldValueValid ? assertion.field : undefined}
314
321
  onValueChange={(v) => handleFieldChange(index, v)}
315
322
  >
316
323
  <SelectTrigger>
@@ -340,7 +347,7 @@ export const AssertionBuilder: React.FC<AssertionBuilderProps> = ({
340
347
  {/* Operator selector */}
341
348
  <div className="flex-1 min-w-[100px]">
342
349
  <Select
343
- value={assertion.operator}
350
+ value={operatorValueValid ? assertion.operator : undefined}
344
351
  onValueChange={(v) => handleOperatorChange(index, v)}
345
352
  >
346
353
  <SelectTrigger>
@@ -100,6 +100,7 @@ export const CollectorList: React.FC<CollectorListProps> = ({
100
100
  if (!collector) return;
101
101
 
102
102
  const newEntry: CollectorConfigEntry = {
103
+ id: crypto.randomUUID(),
103
104
  collectorId,
104
105
  config: {},
105
106
  assertions: [],
@@ -111,6 +112,11 @@ export const CollectorList: React.FC<CollectorListProps> = ({
111
112
  const handleRemove = (index: number) => {
112
113
  const updated = [...configuredCollectors];
113
114
  updated.splice(index, 1);
115
+
116
+ // Reset validity map to prevent stale entries after index shift
117
+ // The DynamicForm components will re-report their validity on next render
118
+ setValidityMap({});
119
+
114
120
  onChange(updated);
115
121
  };
116
122
 
@@ -13,12 +13,14 @@ import {
13
13
  useToast,
14
14
  Dialog,
15
15
  DialogContent,
16
+ DialogDescription,
16
17
  DialogHeader,
17
18
  DialogTitle,
18
19
  DialogFooter,
19
20
  } from "@checkstack/ui";
20
21
  import { useCollectors } from "../hooks/useCollectors";
21
22
  import { CollectorList } from "./CollectorList";
23
+ import { TeamAccessEditor } from "@checkstack/auth-frontend";
22
24
 
23
25
  interface HealthCheckEditorProps {
24
26
  strategies: HealthCheckStrategyDto[];
@@ -82,7 +84,7 @@ export const HealthCheckEditor: React.FC<HealthCheckEditorProps> = ({
82
84
  strategyId,
83
85
  intervalSeconds: Number.parseInt(interval, 10),
84
86
  config,
85
- collectors: collectors.length > 0 ? collectors : undefined,
87
+ collectors, // Always send the array, even if empty, to allow clearing
86
88
  });
87
89
  } catch (error) {
88
90
  const message =
@@ -102,6 +104,11 @@ export const HealthCheckEditor: React.FC<HealthCheckEditorProps> = ({
102
104
  <DialogTitle>
103
105
  {initialData ? "Edit Health Check" : "Create Health Check"}
104
106
  </DialogTitle>
107
+ <DialogDescription className="sr-only">
108
+ {initialData
109
+ ? "Modify the settings for this health check configuration"
110
+ : "Configure a new health check to monitor your services"}
111
+ </DialogDescription>
105
112
  </DialogHeader>
106
113
 
107
114
  <div className="space-y-6 py-4 max-h-[70vh] overflow-y-auto">
@@ -148,6 +155,16 @@ export const HealthCheckEditor: React.FC<HealthCheckEditorProps> = ({
148
155
  onValidChange={setCollectorsValid}
149
156
  />
150
157
  )}
158
+
159
+ {/* Team Access Editor - only shown for existing configurations */}
160
+ {initialData?.id && (
161
+ <TeamAccessEditor
162
+ resourceType="healthcheck.configuration"
163
+ resourceId={initialData.id}
164
+ compact
165
+ expanded
166
+ />
167
+ )}
151
168
  </div>
152
169
 
153
170
  <DialogFooter>
@@ -300,7 +300,7 @@ function CollectorResultCard({
300
300
  function formatKey(key: string): string {
301
301
  return key
302
302
  .replaceAll(/([a-z])([A-Z])/g, "$1 $2")
303
- .replaceAll(/^./, (c) => c.toUpperCase());
303
+ .replace(/^./, (c) => c.toUpperCase());
304
304
  }
305
305
 
306
306
  /**
@@ -9,6 +9,7 @@ import {
9
9
  Button,
10
10
  Dialog,
11
11
  DialogContent,
12
+ DialogDescription,
12
13
  DialogHeader,
13
14
  DialogTitle,
14
15
  DialogFooter,
@@ -79,10 +80,11 @@ export const SystemHealthCheckAssignment: React.FC<Props> = ({
79
80
  const loadData = async () => {
80
81
  setLoading(true);
81
82
  try {
82
- const [allConfigs, systemAssociations] = await Promise.all([
83
- api.getConfigurations(),
84
- api.getSystemAssociations({ systemId }),
85
- ]);
83
+ const [{ configurations: allConfigs }, systemAssociations] =
84
+ await Promise.all([
85
+ api.getConfigurations(),
86
+ api.getSystemAssociations({ systemId }),
87
+ ]);
86
88
  setConfigs(allConfigs);
87
89
  setAssociations(systemAssociations);
88
90
  } catch (error) {
@@ -741,6 +743,9 @@ export const SystemHealthCheckAssignment: React.FC<Props> = ({
741
743
  <DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto">
742
744
  <DialogHeader>
743
745
  <DialogTitle>Health Check Assignments</DialogTitle>
746
+ <DialogDescription className="sr-only">
747
+ Manage health check assignments for this system
748
+ </DialogDescription>
744
749
  </DialogHeader>
745
750
 
746
751
  {loading ? (
@@ -45,7 +45,7 @@ const HealthCheckConfigPageContent = () => {
45
45
  const [isDeleting, setIsDeleting] = useState(false);
46
46
 
47
47
  const fetchData = async () => {
48
- const [configs, strats] = await Promise.all([
48
+ const [{ configurations: configs }, strats] = await Promise.all([
49
49
  api.getConfigurations(),
50
50
  api.getStrategies(),
51
51
  ]);