@checkstack/healthcheck-backend 0.5.0 → 0.7.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,85 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 1f81b60: ### Clickable Run History with Deep Linking
8
+
9
+ **Backend (`healthcheck-backend`):**
10
+
11
+ - Added `getRunById` service method to fetch a single health check run by ID
12
+
13
+ **Schema (`healthcheck-common`):**
14
+
15
+ - Added `getRunById` RPC procedure for fetching individual runs
16
+ - Added `historyRun` route for deep linking to specific runs (`/history/:systemId/:configurationId/:runId`)
17
+
18
+ **Frontend (`healthcheck-frontend`):**
19
+
20
+ - Table rows in Recent Runs and Run History now navigate to detailed view instead of expanding inline
21
+ - Added "Selected Run" card that displays when navigating to a specific run
22
+ - Extracted `ExpandedResultView` into reusable component
23
+ - Fixed layout shift during table pagination by preserving previous data while loading
24
+ - Removed accordion expansion in favor of consistent navigation UX
25
+
26
+ ### Patch Changes
27
+
28
+ - 090143b: ### Health Check Aggregation & UI Fixes
29
+
30
+ **Backend (`healthcheck-backend`):**
31
+
32
+ - Fixed tail-end bucket truncation where the last aggregated bucket was cut off at the interval boundary instead of extending to the query end date
33
+ - Added `rangeEnd` parameter to `reaggregateBuckets()` to properly extend the last bucket
34
+ - Fixed cross-tier merge logic (`mergeTieredBuckets`) to prevent hourly aggregates from blocking fresh raw data
35
+
36
+ **Schema (`healthcheck-common`):**
37
+
38
+ - Added `bucketEnd` field to `AggregatedBucketBaseSchema` so frontends know the actual end time of each bucket
39
+
40
+ **Frontend (`healthcheck-frontend`):**
41
+
42
+ - Updated all components to use `bucket.bucketEnd` instead of calculating from `bucketIntervalSeconds`
43
+ - Fixed aggregation mode detection: changed `>` to `>=` so 7-day queries use aggregated data when `rawRetentionDays` is 7
44
+ - Added ref-based memoization in `useHealthCheckData` to prevent layout shift during signal-triggered refetches
45
+ - Exposed `isFetching` state to show loading spinner during background refetches
46
+ - Added debounced custom date range with Apply button to prevent fetching on every field change
47
+ - Added validation preventing start date >= end date in custom ranges
48
+ - Added sparkline downsampling: when there are 60+ data points, they are aggregated into buckets with informative tooltips
49
+
50
+ **UI (`ui`):**
51
+
52
+ - Fixed `DateRangeFilter` presets to use true sliding windows (removed `startOfDay` from 7-day and 30-day ranges)
53
+ - Added `disabled` prop to `DateRangeFilter` and `DateTimePicker` components
54
+ - Added `onCustomChange` prop to `DateRangeFilter` for debounced custom date handling
55
+ - Improved layout: custom date pickers now inline with preset buttons on desktop
56
+ - Added responsive mobile layout: date pickers stack vertically with down arrow
57
+ - Added validation error display for invalid date ranges
58
+
59
+ - Updated dependencies [1f81b60]
60
+ - Updated dependencies [090143b]
61
+ - @checkstack/healthcheck-common@0.7.0
62
+
63
+ ## 0.6.0
64
+
65
+ ### Minor Changes
66
+
67
+ - 11d2679: Add ability to pause health check configurations globally. When paused, health checks continue to be scheduled but execution is skipped for all systems using that configuration. Users with manage access can pause/resume from the Health Checks config page.
68
+ - cce5453: Add notification suppression for incidents
69
+
70
+ - Added `suppressNotifications` field to incidents, allowing active incidents to optionally suppress health check notifications
71
+ - When enabled, health status change notifications will not be sent for affected systems while the incident is active (not resolved)
72
+ - Mirrors the existing maintenance notification suppression pattern
73
+ - Added toggle UI in the IncidentEditor dialog
74
+ - Added `hasActiveIncidentWithSuppression` RPC endpoint for service-to-service queries
75
+
76
+ ### Patch Changes
77
+
78
+ - Updated dependencies [11d2679]
79
+ - Updated dependencies [cce5453]
80
+ - @checkstack/healthcheck-common@0.6.0
81
+ - @checkstack/incident-common@0.4.0
82
+
3
83
  ## 0.5.0
4
84
 
5
85
  ### Minor Changes
@@ -0,0 +1 @@
1
+ ALTER TABLE "health_check_configurations" ADD COLUMN "paused" boolean DEFAULT false NOT NULL;
@@ -0,0 +1,420 @@
1
+ {
2
+ "id": "86171dc8-efcc-4246-a95a-665fdefb1a1f",
3
+ "prevId": "bb50b71f-3f81-4cb2-aac6-7e7564060fa1",
4
+ "version": "7",
5
+ "dialect": "postgresql",
6
+ "tables": {
7
+ "public.health_check_aggregates": {
8
+ "name": "health_check_aggregates",
9
+ "schema": "",
10
+ "columns": {
11
+ "id": {
12
+ "name": "id",
13
+ "type": "uuid",
14
+ "primaryKey": true,
15
+ "notNull": true,
16
+ "default": "gen_random_uuid()"
17
+ },
18
+ "configuration_id": {
19
+ "name": "configuration_id",
20
+ "type": "uuid",
21
+ "primaryKey": false,
22
+ "notNull": true
23
+ },
24
+ "system_id": {
25
+ "name": "system_id",
26
+ "type": "text",
27
+ "primaryKey": false,
28
+ "notNull": true
29
+ },
30
+ "bucket_start": {
31
+ "name": "bucket_start",
32
+ "type": "timestamp",
33
+ "primaryKey": false,
34
+ "notNull": true
35
+ },
36
+ "bucket_size": {
37
+ "name": "bucket_size",
38
+ "type": "bucket_size",
39
+ "typeSchema": "public",
40
+ "primaryKey": false,
41
+ "notNull": true
42
+ },
43
+ "run_count": {
44
+ "name": "run_count",
45
+ "type": "integer",
46
+ "primaryKey": false,
47
+ "notNull": true
48
+ },
49
+ "healthy_count": {
50
+ "name": "healthy_count",
51
+ "type": "integer",
52
+ "primaryKey": false,
53
+ "notNull": true
54
+ },
55
+ "degraded_count": {
56
+ "name": "degraded_count",
57
+ "type": "integer",
58
+ "primaryKey": false,
59
+ "notNull": true
60
+ },
61
+ "unhealthy_count": {
62
+ "name": "unhealthy_count",
63
+ "type": "integer",
64
+ "primaryKey": false,
65
+ "notNull": true
66
+ },
67
+ "latency_sum_ms": {
68
+ "name": "latency_sum_ms",
69
+ "type": "integer",
70
+ "primaryKey": false,
71
+ "notNull": false
72
+ },
73
+ "avg_latency_ms": {
74
+ "name": "avg_latency_ms",
75
+ "type": "integer",
76
+ "primaryKey": false,
77
+ "notNull": false
78
+ },
79
+ "min_latency_ms": {
80
+ "name": "min_latency_ms",
81
+ "type": "integer",
82
+ "primaryKey": false,
83
+ "notNull": false
84
+ },
85
+ "max_latency_ms": {
86
+ "name": "max_latency_ms",
87
+ "type": "integer",
88
+ "primaryKey": false,
89
+ "notNull": false
90
+ },
91
+ "p95_latency_ms": {
92
+ "name": "p95_latency_ms",
93
+ "type": "integer",
94
+ "primaryKey": false,
95
+ "notNull": false
96
+ },
97
+ "aggregated_result": {
98
+ "name": "aggregated_result",
99
+ "type": "jsonb",
100
+ "primaryKey": false,
101
+ "notNull": false
102
+ }
103
+ },
104
+ "indexes": {
105
+ "health_check_aggregates_bucket_unique": {
106
+ "name": "health_check_aggregates_bucket_unique",
107
+ "columns": [
108
+ {
109
+ "expression": "configuration_id",
110
+ "isExpression": false,
111
+ "asc": true,
112
+ "nulls": "last"
113
+ },
114
+ {
115
+ "expression": "system_id",
116
+ "isExpression": false,
117
+ "asc": true,
118
+ "nulls": "last"
119
+ },
120
+ {
121
+ "expression": "bucket_start",
122
+ "isExpression": false,
123
+ "asc": true,
124
+ "nulls": "last"
125
+ },
126
+ {
127
+ "expression": "bucket_size",
128
+ "isExpression": false,
129
+ "asc": true,
130
+ "nulls": "last"
131
+ }
132
+ ],
133
+ "isUnique": true,
134
+ "concurrently": false,
135
+ "method": "btree",
136
+ "with": {}
137
+ }
138
+ },
139
+ "foreignKeys": {
140
+ "health_check_aggregates_configuration_id_health_check_configurations_id_fk": {
141
+ "name": "health_check_aggregates_configuration_id_health_check_configurations_id_fk",
142
+ "tableFrom": "health_check_aggregates",
143
+ "tableTo": "health_check_configurations",
144
+ "columnsFrom": [
145
+ "configuration_id"
146
+ ],
147
+ "columnsTo": [
148
+ "id"
149
+ ],
150
+ "onDelete": "cascade",
151
+ "onUpdate": "no action"
152
+ }
153
+ },
154
+ "compositePrimaryKeys": {},
155
+ "uniqueConstraints": {},
156
+ "policies": {},
157
+ "checkConstraints": {},
158
+ "isRLSEnabled": false
159
+ },
160
+ "public.health_check_configurations": {
161
+ "name": "health_check_configurations",
162
+ "schema": "",
163
+ "columns": {
164
+ "id": {
165
+ "name": "id",
166
+ "type": "uuid",
167
+ "primaryKey": true,
168
+ "notNull": true,
169
+ "default": "gen_random_uuid()"
170
+ },
171
+ "name": {
172
+ "name": "name",
173
+ "type": "text",
174
+ "primaryKey": false,
175
+ "notNull": true
176
+ },
177
+ "strategy_id": {
178
+ "name": "strategy_id",
179
+ "type": "text",
180
+ "primaryKey": false,
181
+ "notNull": true
182
+ },
183
+ "config": {
184
+ "name": "config",
185
+ "type": "jsonb",
186
+ "primaryKey": false,
187
+ "notNull": true
188
+ },
189
+ "collectors": {
190
+ "name": "collectors",
191
+ "type": "jsonb",
192
+ "primaryKey": false,
193
+ "notNull": false
194
+ },
195
+ "interval_seconds": {
196
+ "name": "interval_seconds",
197
+ "type": "integer",
198
+ "primaryKey": false,
199
+ "notNull": true
200
+ },
201
+ "is_template": {
202
+ "name": "is_template",
203
+ "type": "boolean",
204
+ "primaryKey": false,
205
+ "notNull": false,
206
+ "default": false
207
+ },
208
+ "paused": {
209
+ "name": "paused",
210
+ "type": "boolean",
211
+ "primaryKey": false,
212
+ "notNull": true,
213
+ "default": false
214
+ },
215
+ "created_at": {
216
+ "name": "created_at",
217
+ "type": "timestamp",
218
+ "primaryKey": false,
219
+ "notNull": true,
220
+ "default": "now()"
221
+ },
222
+ "updated_at": {
223
+ "name": "updated_at",
224
+ "type": "timestamp",
225
+ "primaryKey": false,
226
+ "notNull": true,
227
+ "default": "now()"
228
+ }
229
+ },
230
+ "indexes": {},
231
+ "foreignKeys": {},
232
+ "compositePrimaryKeys": {},
233
+ "uniqueConstraints": {},
234
+ "policies": {},
235
+ "checkConstraints": {},
236
+ "isRLSEnabled": false
237
+ },
238
+ "public.health_check_runs": {
239
+ "name": "health_check_runs",
240
+ "schema": "",
241
+ "columns": {
242
+ "id": {
243
+ "name": "id",
244
+ "type": "uuid",
245
+ "primaryKey": true,
246
+ "notNull": true,
247
+ "default": "gen_random_uuid()"
248
+ },
249
+ "configuration_id": {
250
+ "name": "configuration_id",
251
+ "type": "uuid",
252
+ "primaryKey": false,
253
+ "notNull": true
254
+ },
255
+ "system_id": {
256
+ "name": "system_id",
257
+ "type": "text",
258
+ "primaryKey": false,
259
+ "notNull": true
260
+ },
261
+ "status": {
262
+ "name": "status",
263
+ "type": "health_check_status",
264
+ "typeSchema": "public",
265
+ "primaryKey": false,
266
+ "notNull": true
267
+ },
268
+ "latency_ms": {
269
+ "name": "latency_ms",
270
+ "type": "integer",
271
+ "primaryKey": false,
272
+ "notNull": false
273
+ },
274
+ "result": {
275
+ "name": "result",
276
+ "type": "jsonb",
277
+ "primaryKey": false,
278
+ "notNull": false
279
+ },
280
+ "timestamp": {
281
+ "name": "timestamp",
282
+ "type": "timestamp",
283
+ "primaryKey": false,
284
+ "notNull": true,
285
+ "default": "now()"
286
+ }
287
+ },
288
+ "indexes": {},
289
+ "foreignKeys": {
290
+ "health_check_runs_configuration_id_health_check_configurations_id_fk": {
291
+ "name": "health_check_runs_configuration_id_health_check_configurations_id_fk",
292
+ "tableFrom": "health_check_runs",
293
+ "tableTo": "health_check_configurations",
294
+ "columnsFrom": [
295
+ "configuration_id"
296
+ ],
297
+ "columnsTo": [
298
+ "id"
299
+ ],
300
+ "onDelete": "cascade",
301
+ "onUpdate": "no action"
302
+ }
303
+ },
304
+ "compositePrimaryKeys": {},
305
+ "uniqueConstraints": {},
306
+ "policies": {},
307
+ "checkConstraints": {},
308
+ "isRLSEnabled": false
309
+ },
310
+ "public.system_health_checks": {
311
+ "name": "system_health_checks",
312
+ "schema": "",
313
+ "columns": {
314
+ "system_id": {
315
+ "name": "system_id",
316
+ "type": "text",
317
+ "primaryKey": false,
318
+ "notNull": true
319
+ },
320
+ "configuration_id": {
321
+ "name": "configuration_id",
322
+ "type": "uuid",
323
+ "primaryKey": false,
324
+ "notNull": true
325
+ },
326
+ "enabled": {
327
+ "name": "enabled",
328
+ "type": "boolean",
329
+ "primaryKey": false,
330
+ "notNull": true,
331
+ "default": true
332
+ },
333
+ "state_thresholds": {
334
+ "name": "state_thresholds",
335
+ "type": "jsonb",
336
+ "primaryKey": false,
337
+ "notNull": false
338
+ },
339
+ "retention_config": {
340
+ "name": "retention_config",
341
+ "type": "jsonb",
342
+ "primaryKey": false,
343
+ "notNull": false
344
+ },
345
+ "created_at": {
346
+ "name": "created_at",
347
+ "type": "timestamp",
348
+ "primaryKey": false,
349
+ "notNull": true,
350
+ "default": "now()"
351
+ },
352
+ "updated_at": {
353
+ "name": "updated_at",
354
+ "type": "timestamp",
355
+ "primaryKey": false,
356
+ "notNull": true,
357
+ "default": "now()"
358
+ }
359
+ },
360
+ "indexes": {},
361
+ "foreignKeys": {
362
+ "system_health_checks_configuration_id_health_check_configurations_id_fk": {
363
+ "name": "system_health_checks_configuration_id_health_check_configurations_id_fk",
364
+ "tableFrom": "system_health_checks",
365
+ "tableTo": "health_check_configurations",
366
+ "columnsFrom": [
367
+ "configuration_id"
368
+ ],
369
+ "columnsTo": [
370
+ "id"
371
+ ],
372
+ "onDelete": "cascade",
373
+ "onUpdate": "no action"
374
+ }
375
+ },
376
+ "compositePrimaryKeys": {
377
+ "system_health_checks_system_id_configuration_id_pk": {
378
+ "name": "system_health_checks_system_id_configuration_id_pk",
379
+ "columns": [
380
+ "system_id",
381
+ "configuration_id"
382
+ ]
383
+ }
384
+ },
385
+ "uniqueConstraints": {},
386
+ "policies": {},
387
+ "checkConstraints": {},
388
+ "isRLSEnabled": false
389
+ }
390
+ },
391
+ "enums": {
392
+ "public.bucket_size": {
393
+ "name": "bucket_size",
394
+ "schema": "public",
395
+ "values": [
396
+ "hourly",
397
+ "daily"
398
+ ]
399
+ },
400
+ "public.health_check_status": {
401
+ "name": "health_check_status",
402
+ "schema": "public",
403
+ "values": [
404
+ "healthy",
405
+ "unhealthy",
406
+ "degraded"
407
+ ]
408
+ }
409
+ },
410
+ "schemas": {},
411
+ "sequences": {},
412
+ "roles": {},
413
+ "policies": {},
414
+ "views": {},
415
+ "_meta": {
416
+ "columns": {},
417
+ "schemas": {},
418
+ "tables": {}
419
+ }
420
+ }
@@ -57,6 +57,13 @@
57
57
  "when": 1768921130785,
58
58
  "tag": "0007_tense_misty_knight",
59
59
  "breakpoints": true
60
+ },
61
+ {
62
+ "idx": 8,
63
+ "version": "7",
64
+ "when": 1768934529918,
65
+ "tag": "0008_broad_black_tom",
66
+ "breakpoints": true
60
67
  }
61
68
  ]
62
69
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
@@ -16,6 +16,7 @@
16
16
  "@checkstack/healthcheck-common": "workspace:*",
17
17
  "@checkstack/integration-backend": "workspace:*",
18
18
  "@checkstack/maintenance-common": "workspace:*",
19
+ "@checkstack/incident-common": "workspace:*",
19
20
  "@checkstack/queue-api": "workspace:*",
20
21
  "@checkstack/signal-common": "workspace:*",
21
22
  "@checkstack/command-backend": "workspace:*",
@@ -342,6 +342,66 @@ describe("aggregation-utils", () => {
342
342
  expect(result[1].sourceTier).toBe("hourly");
343
343
  expect(result[2].sourceTier).toBe("raw");
344
344
  });
345
+
346
+ it("raw buckets take precedence even when hourly starts earlier (regression test)", () => {
347
+ /**
348
+ * Regression test for the "Tail-End Stale" bug:
349
+ * When an hourly aggregate (e.g., 21:00-22:00) exists and raw data
350
+ * arrives mid-hour (e.g., 21:48), the raw data should take precedence,
351
+ * not be blocked by the hourly aggregate.
352
+ *
353
+ * Bug scenario:
354
+ * - Hourly aggregate: 21:00 to 22:00
355
+ * - Raw buckets: 21:48 to 22:11 (fresh data)
356
+ * - Old buggy behavior: hourly was processed first (earlier start time),
357
+ * set coveredUntil=22:00, and raw was skipped
358
+ * - Correct behavior: raw always takes precedence, hourly is excluded
359
+ */
360
+ const baseTime = 21 * HOUR; // 21:00
361
+
362
+ // Hourly bucket covering 21:00-22:00 (stale aggregate)
363
+ const hourlyBuckets = [
364
+ createBucket({
365
+ startMs: baseTime,
366
+ durationMs: HOUR,
367
+ runCount: 60, // Old stale data
368
+ sourceTier: "hourly",
369
+ }),
370
+ ];
371
+
372
+ // Raw buckets at 21:48 and 22:00 (fresh data that should NOT be blocked)
373
+ const rawBuckets = [
374
+ createBucket({
375
+ startMs: baseTime + 48 * MINUTE, // 21:48
376
+ durationMs: 12 * MINUTE,
377
+ runCount: 12, // Fresh data
378
+ sourceTier: "raw",
379
+ }),
380
+ createBucket({
381
+ startMs: baseTime + HOUR, // 22:00
382
+ durationMs: 11 * MINUTE,
383
+ runCount: 11, // Fresh data
384
+ sourceTier: "raw",
385
+ }),
386
+ ];
387
+
388
+ const result = mergeTieredBuckets({
389
+ rawBuckets,
390
+ hourlyBuckets,
391
+ dailyBuckets: [],
392
+ });
393
+
394
+ // CRITICAL: Both raw buckets should be included
395
+ expect(result).toHaveLength(2);
396
+ expect(result[0].sourceTier).toBe("raw");
397
+ expect(result[1].sourceTier).toBe("raw");
398
+ expect(result[0].runCount).toBe(12); // 21:48 bucket
399
+ expect(result[1].runCount).toBe(11); // 22:00 bucket
400
+
401
+ // Hourly bucket should be excluded because raw data covers its range
402
+ const hourlyInResult = result.find((b) => b.sourceTier === "hourly");
403
+ expect(hourlyInResult).toBeUndefined();
404
+ });
345
405
  });
346
406
 
347
407
  describe("combineBuckets", () => {
@@ -520,6 +580,7 @@ describe("aggregation-utils", () => {
520
580
  sourceBuckets: [],
521
581
  targetIntervalMs: HOUR,
522
582
  rangeStart: new Date(0),
583
+ rangeEnd: new Date(HOUR),
523
584
  });
524
585
 
525
586
  expect(result).toEqual([]);
@@ -552,6 +613,7 @@ describe("aggregation-utils", () => {
552
613
  sourceBuckets,
553
614
  targetIntervalMs: HOUR,
554
615
  rangeStart: new Date(0),
616
+ rangeEnd: new Date(HOUR),
555
617
  });
556
618
 
557
619
  expect(result).toHaveLength(1);
@@ -585,6 +647,7 @@ describe("aggregation-utils", () => {
585
647
  sourceBuckets,
586
648
  targetIntervalMs: HOUR,
587
649
  rangeStart: new Date(0),
650
+ rangeEnd: new Date(2 * HOUR),
588
651
  });
589
652
 
590
653
  expect(result).toHaveLength(2);
@@ -611,6 +674,7 @@ describe("aggregation-utils", () => {
611
674
  sourceBuckets,
612
675
  targetIntervalMs: HOUR,
613
676
  rangeStart,
677
+ rangeEnd: new Date(rangeStart.getTime() + HOUR),
614
678
  });
615
679
 
616
680
  expect(result).toHaveLength(1);
@@ -633,6 +697,7 @@ describe("aggregation-utils", () => {
633
697
  sourceBuckets,
634
698
  targetIntervalMs: HOUR,
635
699
  rangeStart: new Date(0),
700
+ rangeEnd: new Date(3 * HOUR),
636
701
  });
637
702
 
638
703
  expect(result).toHaveLength(3);