@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 +77 -0
- package/package.json +2 -1
- package/src/aggregation-utils.test.ts +65 -0
- package/src/aggregation-utils.ts +111 -29
- package/src/aggregation.test.ts +382 -0
- package/src/availability.test.ts +225 -0
- package/src/router.ts +8 -0
- package/src/service-ordering.test.ts +316 -0
- package/src/service.ts +166 -13
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.
|
|
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);
|
package/src/aggregation-utils.ts
CHANGED
|
@@ -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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
+
if (
|
|
209
|
+
rawBuckets.length === 0 &&
|
|
210
|
+
hourlyBuckets.length === 0 &&
|
|
211
|
+
dailyBuckets.length === 0
|
|
212
|
+
) {
|
|
208
213
|
return [];
|
|
209
214
|
}
|
|
210
215
|
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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
|
|
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({
|