@checkstack/healthcheck-backend 0.6.0 → 0.8.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,82 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d6f7449: Add availability statistics display to HealthCheckSystemOverview
8
+
9
+ - New `getAvailabilityStats` RPC endpoint that calculates availability percentages for 31-day and 365-day periods
10
+ - Availability is calculated as `(healthyRuns / totalRuns) * 100`
11
+ - Data is sourced from both daily aggregates and recent raw runs to include the most up-to-date information
12
+ - Frontend displays availability stats with color-coded badges (green ≥99.9%, yellow ≥99%, red <99%)
13
+ - Shows total run counts for each period
14
+
15
+ ### Patch Changes
16
+
17
+ - Updated dependencies [d6f7449]
18
+ - @checkstack/healthcheck-common@0.8.0
19
+
20
+ ## 0.7.0
21
+
22
+ ### Minor Changes
23
+
24
+ - 1f81b60: ### Clickable Run History with Deep Linking
25
+
26
+ **Backend (`healthcheck-backend`):**
27
+
28
+ - Added `getRunById` service method to fetch a single health check run by ID
29
+
30
+ **Schema (`healthcheck-common`):**
31
+
32
+ - Added `getRunById` RPC procedure for fetching individual runs
33
+ - Added `historyRun` route for deep linking to specific runs (`/history/:systemId/:configurationId/:runId`)
34
+
35
+ **Frontend (`healthcheck-frontend`):**
36
+
37
+ - Table rows in Recent Runs and Run History now navigate to detailed view instead of expanding inline
38
+ - Added "Selected Run" card that displays when navigating to a specific run
39
+ - Extracted `ExpandedResultView` into reusable component
40
+ - Fixed layout shift during table pagination by preserving previous data while loading
41
+ - Removed accordion expansion in favor of consistent navigation UX
42
+
43
+ ### Patch Changes
44
+
45
+ - 090143b: ### Health Check Aggregation & UI Fixes
46
+
47
+ **Backend (`healthcheck-backend`):**
48
+
49
+ - 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
50
+ - Added `rangeEnd` parameter to `reaggregateBuckets()` to properly extend the last bucket
51
+ - Fixed cross-tier merge logic (`mergeTieredBuckets`) to prevent hourly aggregates from blocking fresh raw data
52
+
53
+ **Schema (`healthcheck-common`):**
54
+
55
+ - Added `bucketEnd` field to `AggregatedBucketBaseSchema` so frontends know the actual end time of each bucket
56
+
57
+ **Frontend (`healthcheck-frontend`):**
58
+
59
+ - Updated all components to use `bucket.bucketEnd` instead of calculating from `bucketIntervalSeconds`
60
+ - Fixed aggregation mode detection: changed `>` to `>=` so 7-day queries use aggregated data when `rawRetentionDays` is 7
61
+ - Added ref-based memoization in `useHealthCheckData` to prevent layout shift during signal-triggered refetches
62
+ - Exposed `isFetching` state to show loading spinner during background refetches
63
+ - Added debounced custom date range with Apply button to prevent fetching on every field change
64
+ - Added validation preventing start date >= end date in custom ranges
65
+ - Added sparkline downsampling: when there are 60+ data points, they are aggregated into buckets with informative tooltips
66
+
67
+ **UI (`ui`):**
68
+
69
+ - Fixed `DateRangeFilter` presets to use true sliding windows (removed `startOfDay` from 7-day and 30-day ranges)
70
+ - Added `disabled` prop to `DateRangeFilter` and `DateTimePicker` components
71
+ - Added `onCustomChange` prop to `DateRangeFilter` for debounced custom date handling
72
+ - Improved layout: custom date pickers now inline with preset buttons on desktop
73
+ - Added responsive mobile layout: date pickers stack vertically with down arrow
74
+ - Added validation error display for invalid date ranges
75
+
76
+ - Updated dependencies [1f81b60]
77
+ - Updated dependencies [090143b]
78
+ - @checkstack/healthcheck-common@0.7.0
79
+
3
80
  ## 0.6.0
4
81
 
5
82
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
@@ -33,6 +33,7 @@
33
33
  "@checkstack/tsconfig": "workspace:*",
34
34
  "@orpc/server": "^1.13.2",
35
35
  "@types/bun": "^1.0.0",
36
+ "date-fns": "^4.1.0",
36
37
  "drizzle-kit": "^0.31.8",
37
38
  "typescript": "^5.0.0"
38
39
  }
@@ -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);
@@ -193,6 +193,10 @@ const TIER_PRIORITY: Record<NormalizedBucket["sourceTier"], number> = {
193
193
  /**
194
194
  * Merge buckets from different tiers, preferring most granular data.
195
195
  * For overlapping time periods, uses priority: raw > hourly > daily.
196
+ *
197
+ * IMPORTANT: Raw buckets always take precedence over hourly/daily aggregates,
198
+ * even when the aggregate bucket starts earlier. This ensures fresh raw data
199
+ * is never blocked by stale pre-computed aggregates.
196
200
  */
197
201
  export function mergeTieredBuckets(params: {
198
202
  rawBuckets: NormalizedBucket[];
@@ -201,42 +205,108 @@ export function mergeTieredBuckets(params: {
201
205
  }): NormalizedBucket[] {
202
206
  const { rawBuckets, hourlyBuckets, dailyBuckets } = params;
203
207
 
204
- // Combine all buckets
205
- const allBuckets = [...rawBuckets, ...hourlyBuckets, ...dailyBuckets];
206
-
207
- if (allBuckets.length === 0) {
208
+ if (
209
+ rawBuckets.length === 0 &&
210
+ hourlyBuckets.length === 0 &&
211
+ dailyBuckets.length === 0
212
+ ) {
208
213
  return [];
209
214
  }
210
215
 
211
- // Sort by start time, then by tier priority (most granular first)
212
- allBuckets.sort((a, b) => {
213
- const timeDiff = a.bucketStart.getTime() - b.bucketStart.getTime();
214
- if (timeDiff !== 0) return timeDiff;
215
- return TIER_PRIORITY[a.sourceTier] - TIER_PRIORITY[b.sourceTier];
216
- });
216
+ // Two-pass approach:
217
+ // 1. First, collect all time ranges covered by raw data (highest priority)
218
+ // 2. Then, add hourly/daily buckets only for gaps not covered by raw data
219
+
220
+ // Build a sorted list of raw bucket time ranges for efficient lookup
221
+ const rawTimeRanges = rawBuckets
222
+ .map((b) => ({
223
+ start: b.bucketStart.getTime(),
224
+ end: b.bucketEndMs,
225
+ }))
226
+ .toSorted((a, b) => a.start - b.start);
227
+
228
+ // Merge overlapping raw time ranges into continuous coverage
229
+ const rawCoverage: Array<{ start: number; end: number }> = [];
230
+ for (const range of rawTimeRanges) {
231
+ if (rawCoverage.length === 0) {
232
+ rawCoverage.push({ ...range });
233
+ } else {
234
+ const last = rawCoverage.at(-1)!;
235
+ // If this range overlaps or is adjacent to the last, extend it
236
+ if (range.start <= last.end) {
237
+ last.end = Math.max(last.end, range.end);
238
+ } else {
239
+ rawCoverage.push({ ...range });
240
+ }
241
+ }
242
+ }
217
243
 
218
- // Merge overlapping buckets, keeping the most granular tier
219
- const result: NormalizedBucket[] = [];
220
- let coveredUntil = 0; // Timestamp up to which we have data
221
-
222
- for (const bucket of allBuckets) {
223
- const bucketStartMs = bucket.bucketStart.getTime();
224
-
225
- // Skip if this bucket's time range is already covered by higher-priority data
226
- if (bucketStartMs < coveredUntil) {
227
- // Check if this bucket extends beyond current coverage
228
- if (bucket.bucketEndMs > coveredUntil) {
229
- // Partial overlap - for simplicity, we skip partially overlapping lower-priority buckets
230
- // This is acceptable because we prefer raw data which is more granular
231
- continue;
244
+ // Helper: check if a bucket has ANY overlap with raw data
245
+ // Two ranges overlap if: start1 < end2 AND start2 < end1
246
+ const doesBucketOverlapWithRaw = (bucket: NormalizedBucket): boolean => {
247
+ const bucketStart = bucket.bucketStart.getTime();
248
+ const bucketEnd = bucket.bucketEndMs;
249
+
250
+ for (const range of rawCoverage) {
251
+ // Check for overlap: ranges overlap if they intersect
252
+ if (bucketStart < range.end && range.start < bucketEnd) {
253
+ return true;
254
+ }
255
+ // Optimization: if raw range starts after bucket ends, no more overlaps possible
256
+ if (range.start >= bucketEnd) {
257
+ break;
232
258
  }
233
- continue;
234
259
  }
260
+ return false;
261
+ };
235
262
 
236
- result.push(bucket);
237
- coveredUntil = bucket.bucketEndMs;
263
+ // Start with all raw buckets (they always take precedence)
264
+ const result: NormalizedBucket[] = [...rawBuckets];
265
+
266
+ // Add hourly buckets that don't overlap with raw data
267
+ for (const bucket of hourlyBuckets) {
268
+ if (!doesBucketOverlapWithRaw(bucket)) {
269
+ result.push(bucket);
270
+ }
238
271
  }
239
272
 
273
+ // Add daily buckets that don't overlap with raw or hourly data
274
+ // Build hourly coverage to check against
275
+ const hourlyTimeRanges = hourlyBuckets
276
+ .map((b) => ({
277
+ start: b.bucketStart.getTime(),
278
+ end: b.bucketEndMs,
279
+ }))
280
+ .toSorted((a, b) => a.start - b.start);
281
+
282
+ // Helper: check if a bucket has ANY overlap with hourly data
283
+ const doesBucketOverlapWithHourly = (bucket: NormalizedBucket): boolean => {
284
+ const bucketStart = bucket.bucketStart.getTime();
285
+ const bucketEnd = bucket.bucketEndMs;
286
+
287
+ for (const range of hourlyTimeRanges) {
288
+ if (bucketStart < range.end && range.start < bucketEnd) {
289
+ return true;
290
+ }
291
+ if (range.start >= bucketEnd) {
292
+ break;
293
+ }
294
+ }
295
+ return false;
296
+ };
297
+
298
+ for (const bucket of dailyBuckets) {
299
+ if (
300
+ !doesBucketOverlapWithRaw(bucket) &&
301
+ !doesBucketOverlapWithHourly(bucket)
302
+ ) {
303
+ result.push(bucket);
304
+ }
305
+ }
306
+
307
+ // Sort final result by bucket start time
308
+ result.sort((a, b) => a.bucketStart.getTime() - b.bucketStart.getTime());
309
+
240
310
  return result;
241
311
  }
242
312
 
@@ -349,19 +419,24 @@ export function combineBuckets(params: {
349
419
  /**
350
420
  * Re-aggregate a list of normalized buckets into target-sized buckets.
351
421
  * Groups source buckets by target bucket boundaries and combines them.
422
+ *
423
+ * @param rangeEnd - The end of the query range. The last bucket will extend
424
+ * to this time to ensure data is visually represented up to the query end.
352
425
  */
353
426
  export function reaggregateBuckets(params: {
354
427
  sourceBuckets: NormalizedBucket[];
355
428
  targetIntervalMs: number;
356
429
  rangeStart: Date;
430
+ rangeEnd: Date;
357
431
  }): NormalizedBucket[] {
358
- const { sourceBuckets, targetIntervalMs, rangeStart } = params;
432
+ const { sourceBuckets, targetIntervalMs, rangeStart, rangeEnd } = params;
359
433
 
360
434
  if (sourceBuckets.length === 0) {
361
435
  return [];
362
436
  }
363
437
 
364
438
  const rangeStartMs = rangeStart.getTime();
439
+ const rangeEndMs = rangeEnd.getTime();
365
440
 
366
441
  // Group source buckets by target bucket index
367
442
  const bucketGroups = new Map<number, NormalizedBucket[]>();
@@ -379,9 +454,16 @@ export function reaggregateBuckets(params: {
379
454
  // Combine each group into a single target bucket
380
455
  const result: NormalizedBucket[] = [];
381
456
 
457
+ // Find the maximum bucket index to identify the last bucket
458
+ const maxIndex = Math.max(...bucketGroups.keys());
459
+
382
460
  for (const [index, buckets] of bucketGroups) {
383
461
  const targetBucketStart = new Date(rangeStartMs + index * targetIntervalMs);
384
- const targetBucketEndMs = targetBucketStart.getTime() + targetIntervalMs;
462
+ const intervalEndMs = targetBucketStart.getTime() + targetIntervalMs;
463
+
464
+ // For the last bucket, extend to rangeEnd to capture all trailing data
465
+ const targetBucketEndMs =
466
+ index === maxIndex ? Math.max(intervalEndMs, rangeEndMs) : intervalEndMs;
385
467
 
386
468
  result.push(
387
469
  combineBuckets({