@checkstack/healthcheck-frontend 0.0.3 → 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,149 @@
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
+
101
+ ## 0.1.0
102
+
103
+ ### Minor Changes
104
+
105
+ - f5b1f49: Added support for nested collector result display in auto-charts and history table.
106
+
107
+ - Updated `schema-parser.ts` to traverse `collectors.*` nested schemas and extract chart fields with dot-notation paths
108
+ - Added `getFieldValue()` support for dot-notation paths like `collectors.request.responseTimeMs`
109
+ - Added `ExpandedResultView` component to `HealthCheckRunsTable.tsx` that displays:
110
+ - Connection info (status, latency, connection time)
111
+ - Per-collector results as structured cards with key-value pairs
112
+
113
+ - f5b1f49: Added JSONPath assertions for response body validation and fully qualified strategy IDs.
114
+
115
+ **JSONPath Assertions:**
116
+
117
+ - Added `healthResultJSONPath()` factory in healthcheck-common for fields supporting JSONPath queries
118
+ - Extended AssertionBuilder with jsonpath field type showing path input (e.g., `$.data.status`)
119
+ - Added `jsonPath` field to `CollectorAssertionSchema` for persistence
120
+ - HTTP Request collector body field now supports JSONPath assertions
121
+
122
+ **Fully Qualified Strategy IDs:**
123
+
124
+ - HealthCheckRegistry now uses scoped factories like CollectorRegistry
125
+ - Strategies are stored with `pluginId.strategyId` format
126
+ - Added `getStrategiesWithMeta()` method to HealthCheckRegistry interface
127
+ - Router returns qualified IDs so frontend can correctly fetch collectors
128
+
129
+ **UI Improvements:**
130
+
131
+ - Save button disabled when collector configs have invalid required fields
132
+ - Fixed nested button warning in CollectorList accordion
133
+
134
+ ### Patch Changes
135
+
136
+ - Updated dependencies [f5b1f49]
137
+ - Updated dependencies [f5b1f49]
138
+ - Updated dependencies [f5b1f49]
139
+ - Updated dependencies [f5b1f49]
140
+ - @checkstack/healthcheck-common@0.1.0
141
+ - @checkstack/common@0.0.3
142
+ - @checkstack/ui@0.0.4
143
+ - @checkstack/catalog-common@0.0.3
144
+ - @checkstack/frontend-api@0.0.3
145
+ - @checkstack/signal-frontend@0.0.4
146
+
3
147
  ## 0.0.3
4
148
 
5
149
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-frontend",
3
- "version": "0.0.3",
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");