@checkstack/healthcheck-backend 1.11.1 → 1.13.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 +327 -0
- package/package.json +29 -29
- package/src/automations.test.ts +43 -1
- package/src/automations.ts +22 -3
- package/src/history-access.test.ts +283 -0
- package/src/history-access.ts +203 -0
- package/src/hooks.ts +7 -0
- package/src/index.ts +21 -4
- package/src/queue-executor.test.ts +108 -16
- package/src/queue-executor.ts +116 -9
- package/src/router-pause-recompute.test.ts +142 -0
- package/src/router.ts +138 -6
- package/src/service-env-filter.test.ts +299 -0
- package/src/service-paused-filter.test.ts +391 -0
- package/src/service-rollup-worst-wins.test.ts +205 -0
- package/src/service.ts +320 -28
package/src/service.ts
CHANGED
|
@@ -33,6 +33,7 @@ import * as schema from "./schema";
|
|
|
33
33
|
import {
|
|
34
34
|
eq,
|
|
35
35
|
and,
|
|
36
|
+
or,
|
|
36
37
|
InferSelectModel,
|
|
37
38
|
desc,
|
|
38
39
|
gte,
|
|
@@ -531,6 +532,33 @@ export class HealthCheckService {
|
|
|
531
532
|
return results;
|
|
532
533
|
}
|
|
533
534
|
|
|
535
|
+
/**
|
|
536
|
+
* List the IDs of every system an ENABLED assignment of `configurationId`
|
|
537
|
+
* targets. Used by the pause/resume RPC handlers to know which systems'
|
|
538
|
+
* rollup `health` entity must be recomputed when a configuration's `paused`
|
|
539
|
+
* flag flips (because `getSystemHealthStatus` excludes paused configs, the
|
|
540
|
+
* recomputed rollup may transition healthy → degraded or vice-versa, and
|
|
541
|
+
* that transition drives downstream SLO downtime open/close).
|
|
542
|
+
*
|
|
543
|
+
* Only ENABLED assignments are returned: a disabled assignment never
|
|
544
|
+
* contributed to system health, so flipping `paused` on its config has no
|
|
545
|
+
* effect on that system's aggregate.
|
|
546
|
+
*/
|
|
547
|
+
async getSystemIdsForConfiguration(
|
|
548
|
+
configurationId: string,
|
|
549
|
+
): Promise<string[]> {
|
|
550
|
+
const rows = await this.db
|
|
551
|
+
.select({ systemId: systemHealthChecks.systemId })
|
|
552
|
+
.from(systemHealthChecks)
|
|
553
|
+
.where(
|
|
554
|
+
and(
|
|
555
|
+
eq(systemHealthChecks.configurationId, configurationId),
|
|
556
|
+
eq(systemHealthChecks.enabled, true),
|
|
557
|
+
),
|
|
558
|
+
);
|
|
559
|
+
return rows.map((r) => r.systemId);
|
|
560
|
+
}
|
|
561
|
+
|
|
534
562
|
/**
|
|
535
563
|
* Resolve the fully-defaulted notification policy for a single
|
|
536
564
|
* (system, configuration) association. Resolution order:
|
|
@@ -580,10 +608,14 @@ export class HealthCheckService {
|
|
|
580
608
|
*
|
|
581
609
|
* Environment dimension (Phase 3b, §7.4.2):
|
|
582
610
|
* - `environmentId` OMITTED (or `undefined`) ⇒ the **system rollup**: all
|
|
583
|
-
* runs for the system
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
611
|
+
* runs for the system, grouped by `environment_id` per association and
|
|
612
|
+
* evaluated per-env, then worst-wins ACROSS environments within each
|
|
613
|
+
* association (unhealthy > degraded > healthy). This is stable regardless
|
|
614
|
+
* of env insertion order or multi-pod racing; flattening envs into one
|
|
615
|
+
* list feeds interleaved statuses to the consecutive evaluator and breaks
|
|
616
|
+
* the streak on the first interleaving env (masking / flapping). For an
|
|
617
|
+
* assignment with a single env (or env-less only) this reduces to the
|
|
618
|
+
* pre-existing flat-window behavior.
|
|
587
619
|
* - `environmentId` a STRING ⇒ the per-environment slice: only runs whose
|
|
588
620
|
* `environment_id` equals that id.
|
|
589
621
|
* - `environmentId` `null` ⇒ the ENV-LESS slice: only runs with
|
|
@@ -614,6 +646,13 @@ export class HealthCheckService {
|
|
|
614
646
|
and(
|
|
615
647
|
eq(systemHealthChecks.systemId, systemId),
|
|
616
648
|
eq(systemHealthChecks.enabled, true),
|
|
649
|
+
// A paused configuration contributes no signal to the system's
|
|
650
|
+
// health: its execution is skipped (see queue-executor pause gate)
|
|
651
|
+
// and its historical runs MUST NOT keep the aggregate degraded
|
|
652
|
+
// while it is paused. Excluding it here makes the rollup reflect
|
|
653
|
+
// only the actively-running checks, so pausing the sole failing
|
|
654
|
+
// check clears the system's status and downstream SLO downtime.
|
|
655
|
+
eq(healthCheckConfigurations.paused, false),
|
|
617
656
|
),
|
|
618
657
|
);
|
|
619
658
|
|
|
@@ -634,6 +673,9 @@ export class HealthCheckService {
|
|
|
634
673
|
// adds no predicate; `null` filters to the env-less slice; a string
|
|
635
674
|
// filters to that environment. The lookup index leads with
|
|
636
675
|
// (system_id, environment_id, …) so the env-scoped query is index-efficient.
|
|
676
|
+
//
|
|
677
|
+
// For the rollup, we deliberately do NOT apply a single envFilter to one
|
|
678
|
+
// flat run list — see the per-association branch below for why.
|
|
637
679
|
const envFilter =
|
|
638
680
|
environmentId === undefined
|
|
639
681
|
? undefined
|
|
@@ -642,36 +684,102 @@ export class HealthCheckService {
|
|
|
642
684
|
: eq(healthCheckRuns.environmentId, environmentId);
|
|
643
685
|
|
|
644
686
|
for (const assoc of associations) {
|
|
645
|
-
const runs = await this.db
|
|
646
|
-
.select({
|
|
647
|
-
status: healthCheckRuns.status,
|
|
648
|
-
timestamp: healthCheckRuns.timestamp,
|
|
649
|
-
})
|
|
650
|
-
.from(healthCheckRuns)
|
|
651
|
-
.where(
|
|
652
|
-
and(
|
|
653
|
-
eq(healthCheckRuns.systemId, systemId),
|
|
654
|
-
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
655
|
-
...(envFilter ? [envFilter] : []),
|
|
656
|
-
),
|
|
657
|
-
)
|
|
658
|
-
.orderBy(desc(healthCheckRuns.timestamp))
|
|
659
|
-
.limit(maxWindowSize);
|
|
660
|
-
|
|
661
687
|
// Extract and migrate thresholds from versioned config
|
|
662
688
|
let thresholds: StateThresholds | undefined;
|
|
663
689
|
if (assoc.stateThresholds) {
|
|
664
690
|
thresholds = await stateThresholds.parse(assoc.stateThresholds);
|
|
665
691
|
}
|
|
666
692
|
|
|
667
|
-
|
|
693
|
+
let status: HealthCheckStatus;
|
|
694
|
+
let runsConsidered: number;
|
|
695
|
+
let lastRunAt: Date | undefined;
|
|
696
|
+
|
|
697
|
+
if (environmentId === undefined) {
|
|
698
|
+
// System rollup: evaluate the threshold window PER ENVIRONMENT within
|
|
699
|
+
// the association, then take worst-wins ACROSS envs. Flattening every
|
|
700
|
+
// env's runs into one list feeds interleaved statuses to
|
|
701
|
+
// `evaluateConsecutive` (the default mode): the streak breaks on the
|
|
702
|
+
// first interleaving env, so the evaluator collapses to whatever
|
|
703
|
+
// single env's status the most recent run landed on. That masks any
|
|
704
|
+
// permanently-failing sibling env in the default mode ("the healthy
|
|
705
|
+
// env wins"), and flaps healthy↔degraded whenever env insertion
|
|
706
|
+
// order drifts across ticks (see the regression test
|
|
707
|
+
// `rollup — worst-wins across environments within an association`).
|
|
708
|
+
// Per-env evaluation makes the rollup worst-wins stable regardless of
|
|
709
|
+
// insertion order or multi-pod racing.
|
|
710
|
+
const runs = await this.db
|
|
711
|
+
.select({
|
|
712
|
+
status: healthCheckRuns.status,
|
|
713
|
+
timestamp: healthCheckRuns.timestamp,
|
|
714
|
+
environmentId: healthCheckRuns.environmentId,
|
|
715
|
+
})
|
|
716
|
+
.from(healthCheckRuns)
|
|
717
|
+
.where(
|
|
718
|
+
and(
|
|
719
|
+
eq(healthCheckRuns.systemId, systemId),
|
|
720
|
+
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
721
|
+
),
|
|
722
|
+
)
|
|
723
|
+
.orderBy(desc(healthCheckRuns.timestamp))
|
|
724
|
+
.limit(maxWindowSize);
|
|
725
|
+
|
|
726
|
+
// Group by environmentId. `null` is its own group (the env-less slice
|
|
727
|
+
// of an assignment that has opted out, plus any pre-3b env-less runs).
|
|
728
|
+
const byEnv = new Map<string | null, { status: HealthCheckStatus; timestamp: Date }[]>();
|
|
729
|
+
for (const r of runs) {
|
|
730
|
+
const key = r.environmentId ?? null;
|
|
731
|
+
const bucket = byEnv.get(key);
|
|
732
|
+
if (bucket) {
|
|
733
|
+
bucket.push(r);
|
|
734
|
+
} else {
|
|
735
|
+
byEnv.set(key, [r]);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
status = "healthy";
|
|
740
|
+
runsConsidered = runs.length;
|
|
741
|
+
lastRunAt = runs[0]?.timestamp;
|
|
742
|
+
for (const envRuns of byEnv.values()) {
|
|
743
|
+
const envStatus = evaluateHealthStatus({ runs: envRuns, thresholds });
|
|
744
|
+
if (envStatus === "unhealthy") {
|
|
745
|
+
status = "unhealthy";
|
|
746
|
+
break; // worst: stop
|
|
747
|
+
}
|
|
748
|
+
if (envStatus === "degraded" && status === "healthy") {
|
|
749
|
+
status = "degraded";
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
} else {
|
|
753
|
+
// Per-env (string) or env-less (null) slice: that slice's flat run
|
|
754
|
+
// window is monotonic per-env, so the threshold evaluator sees no
|
|
755
|
+
// interleaving — the consecutive streak is well-defined.
|
|
756
|
+
const runs = await this.db
|
|
757
|
+
.select({
|
|
758
|
+
status: healthCheckRuns.status,
|
|
759
|
+
timestamp: healthCheckRuns.timestamp,
|
|
760
|
+
})
|
|
761
|
+
.from(healthCheckRuns)
|
|
762
|
+
.where(
|
|
763
|
+
and(
|
|
764
|
+
eq(healthCheckRuns.systemId, systemId),
|
|
765
|
+
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
766
|
+
...(envFilter ? [envFilter] : []),
|
|
767
|
+
),
|
|
768
|
+
)
|
|
769
|
+
.orderBy(desc(healthCheckRuns.timestamp))
|
|
770
|
+
.limit(maxWindowSize);
|
|
771
|
+
|
|
772
|
+
status = evaluateHealthStatus({ runs, thresholds });
|
|
773
|
+
runsConsidered = runs.length;
|
|
774
|
+
lastRunAt = runs[0]?.timestamp;
|
|
775
|
+
}
|
|
668
776
|
|
|
669
777
|
checkStatuses.push({
|
|
670
778
|
configurationId: assoc.configurationId,
|
|
671
779
|
configurationName: assoc.configName,
|
|
672
780
|
status,
|
|
673
|
-
runsConsidered
|
|
674
|
-
lastRunAt
|
|
781
|
+
runsConsidered,
|
|
782
|
+
lastRunAt,
|
|
675
783
|
});
|
|
676
784
|
}
|
|
677
785
|
|
|
@@ -846,6 +954,7 @@ export class HealthCheckService {
|
|
|
846
954
|
strategyId: healthCheckConfigurations.strategyId,
|
|
847
955
|
intervalSeconds: healthCheckConfigurations.intervalSeconds,
|
|
848
956
|
enabled: systemHealthChecks.enabled,
|
|
957
|
+
paused: healthCheckConfigurations.paused,
|
|
849
958
|
stateThresholds: systemHealthChecks.stateThresholds,
|
|
850
959
|
})
|
|
851
960
|
.from(systemHealthChecks)
|
|
@@ -865,6 +974,7 @@ export class HealthCheckService {
|
|
|
865
974
|
id: healthCheckRuns.id,
|
|
866
975
|
status: healthCheckRuns.status,
|
|
867
976
|
timestamp: healthCheckRuns.timestamp,
|
|
977
|
+
environmentId: healthCheckRuns.environmentId,
|
|
868
978
|
})
|
|
869
979
|
.from(healthCheckRuns)
|
|
870
980
|
.where(
|
|
@@ -885,11 +995,89 @@ export class HealthCheckService {
|
|
|
885
995
|
thresholds = await stateThresholds.parse(assoc.stateThresholds);
|
|
886
996
|
}
|
|
887
997
|
|
|
888
|
-
//
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
998
|
+
// Group the fetched runs by environmentId (null = env-less slice). We
|
|
999
|
+
// query each env's slice separately below to evaluate it on its own
|
|
1000
|
+
// monotonic run window and worst-wins across envs — this is the same
|
|
1001
|
+
// derivation `getSystemHealthStatus(systemId)` uses for the rollup; see
|
|
1002
|
+
// that method for the rationale (flattening envs feeds interleaved
|
|
1003
|
+
// statuses to the consecutive evaluator and masks sibling outages).
|
|
1004
|
+
const perEnvironment: {
|
|
1005
|
+
environmentId: string | null;
|
|
1006
|
+
status: HealthCheckStatus;
|
|
1007
|
+
recentRuns: { id: string; status: HealthCheckStatus; timestamp: Date }[];
|
|
1008
|
+
}[] = [];
|
|
1009
|
+
|
|
1010
|
+
// Stable ordering of env keys: env-less (`null`) first, then env ids in
|
|
1011
|
+
// the order they were first encountered in the mixed pool (membership
|
|
1012
|
+
// order is otherwise unobservable here without a catalog read; recent
|
|
1013
|
+
// runs surface stable, recent order).
|
|
1014
|
+
const envKeys: (string | null)[] = [];
|
|
1015
|
+
const seenEnv = new Set<string | null>();
|
|
1016
|
+
for (const r of runs) {
|
|
1017
|
+
const key = r.environmentId ?? null;
|
|
1018
|
+
if (!seenEnv.has(key)) {
|
|
1019
|
+
seenEnv.add(key);
|
|
1020
|
+
envKeys.push(key);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
// If no runs at all, surface a single env-less entry so UI can render
|
|
1024
|
+
// an empty row rather than nothing.
|
|
1025
|
+
if (envKeys.length === 0) envKeys.push(null);
|
|
1026
|
+
|
|
1027
|
+
let aggregateStatus: HealthCheckStatus = "healthy";
|
|
1028
|
+
for (const envId of envKeys) {
|
|
1029
|
+
const envRuns = await this.db
|
|
1030
|
+
.select({
|
|
1031
|
+
id: healthCheckRuns.id,
|
|
1032
|
+
status: healthCheckRuns.status,
|
|
1033
|
+
timestamp: healthCheckRuns.timestamp,
|
|
1034
|
+
})
|
|
1035
|
+
.from(healthCheckRuns)
|
|
1036
|
+
.where(
|
|
1037
|
+
and(
|
|
1038
|
+
eq(healthCheckRuns.systemId, systemId),
|
|
1039
|
+
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
1040
|
+
envId === null
|
|
1041
|
+
? isNull(healthCheckRuns.environmentId)
|
|
1042
|
+
: eq(healthCheckRuns.environmentId, envId),
|
|
1043
|
+
),
|
|
1044
|
+
)
|
|
1045
|
+
.orderBy(desc(healthCheckRuns.timestamp))
|
|
1046
|
+
.limit(sparklineLimit);
|
|
1047
|
+
|
|
1048
|
+
const envStatus = evaluateHealthStatus({
|
|
1049
|
+
runs: envRuns,
|
|
1050
|
+
thresholds,
|
|
1051
|
+
});
|
|
1052
|
+
// Worst-wins across envs (unhealthy > degraded > healthy).
|
|
1053
|
+
if (envStatus === "unhealthy") {
|
|
1054
|
+
aggregateStatus = "unhealthy";
|
|
1055
|
+
} else if (envStatus === "degraded" && aggregateStatus === "healthy") {
|
|
1056
|
+
aggregateStatus = "degraded";
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
perEnvironment.push({
|
|
1060
|
+
environmentId: envId,
|
|
1061
|
+
status: envStatus,
|
|
1062
|
+
recentRuns: envRuns.toReversed().map((r) => ({
|
|
1063
|
+
id: r.id,
|
|
1064
|
+
status: r.status,
|
|
1065
|
+
timestamp: r.timestamp,
|
|
1066
|
+
})),
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// Evaluate current status (runs are in DESC order - newest first - as evaluateHealthStatus expects).
|
|
1071
|
+
// For a paused configuration the runs are stale (execution is skipped),
|
|
1072
|
+
// so the evaluated `status` is NOT a meaningful current verdict — the
|
|
1073
|
+
// frontend renders a "Paused" pill from the `paused` flag instead.
|
|
1074
|
+
// We still compute it so the historical/sparkline path stays uniform,
|
|
1075
|
+
// and so a non-paused consumer that ignores `paused` sees a best-
|
|
1076
|
+
// effort status rather than a hard null. `aggregateStatus` is the
|
|
1077
|
+
// worst-wins-across-envs rollup derived above (it equals what
|
|
1078
|
+
// evaluateHealthStatus would return on the flat pool if only ONE env is
|
|
1079
|
+
// present, preserving per-check single-env behavior).
|
|
1080
|
+
const status = aggregateStatus;
|
|
893
1081
|
|
|
894
1082
|
checks.push({
|
|
895
1083
|
configurationId: assoc.configurationId,
|
|
@@ -897,13 +1085,16 @@ export class HealthCheckService {
|
|
|
897
1085
|
strategyId: assoc.strategyId,
|
|
898
1086
|
intervalSeconds: assoc.intervalSeconds,
|
|
899
1087
|
enabled: assoc.enabled,
|
|
1088
|
+
paused: assoc.paused,
|
|
900
1089
|
status,
|
|
901
1090
|
stateThresholds: thresholds,
|
|
902
1091
|
recentRuns: chronologicalRuns.map((r) => ({
|
|
903
1092
|
id: r.id,
|
|
904
1093
|
status: r.status,
|
|
905
1094
|
timestamp: r.timestamp,
|
|
1095
|
+
environmentId: r.environmentId,
|
|
906
1096
|
})),
|
|
1097
|
+
perEnvironment,
|
|
907
1098
|
});
|
|
908
1099
|
}
|
|
909
1100
|
|
|
@@ -921,6 +1112,7 @@ export class HealthCheckService {
|
|
|
921
1112
|
endDate?: Date;
|
|
922
1113
|
sourceFilter?: string;
|
|
923
1114
|
statusFilter?: HealthCheckStatus[];
|
|
1115
|
+
environmentId?: string | null;
|
|
924
1116
|
limit?: number;
|
|
925
1117
|
offset?: number;
|
|
926
1118
|
sortOrder: "asc" | "desc";
|
|
@@ -932,6 +1124,7 @@ export class HealthCheckService {
|
|
|
932
1124
|
endDate,
|
|
933
1125
|
sourceFilter,
|
|
934
1126
|
statusFilter,
|
|
1127
|
+
environmentId,
|
|
935
1128
|
limit = 10,
|
|
936
1129
|
offset = 0,
|
|
937
1130
|
sortOrder,
|
|
@@ -956,6 +1149,17 @@ export class HealthCheckService {
|
|
|
956
1149
|
conditions.push(inArray(healthCheckRuns.status, statusFilter));
|
|
957
1150
|
}
|
|
958
1151
|
|
|
1152
|
+
// Environment filtering (server-side). `null` selects the env-less slice;
|
|
1153
|
+
// a string selects that env; `undefined` leaves all envs in the window.
|
|
1154
|
+
// The drawer relies on this to scope its Recent Runs table to the env the
|
|
1155
|
+
// operator clicked, so the total + the paginated rows reflect only the
|
|
1156
|
+
// (check, environment) pair — not the mixed-env pool.
|
|
1157
|
+
if (environmentId === null) {
|
|
1158
|
+
conditions.push(isNull(healthCheckRuns.environmentId));
|
|
1159
|
+
} else if (environmentId !== undefined) {
|
|
1160
|
+
conditions.push(eq(healthCheckRuns.environmentId, environmentId));
|
|
1161
|
+
}
|
|
1162
|
+
|
|
959
1163
|
// Build where clause
|
|
960
1164
|
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
|
961
1165
|
|
|
@@ -1006,6 +1210,7 @@ export class HealthCheckService {
|
|
|
1006
1210
|
endDate: Date;
|
|
1007
1211
|
sourceFilter?: string;
|
|
1008
1212
|
statusFilter?: HealthCheckStatus[];
|
|
1213
|
+
environmentId?: string | null;
|
|
1009
1214
|
maxBuckets?: number;
|
|
1010
1215
|
}): Promise<RunStats> {
|
|
1011
1216
|
const {
|
|
@@ -1015,6 +1220,7 @@ export class HealthCheckService {
|
|
|
1015
1220
|
endDate,
|
|
1016
1221
|
sourceFilter,
|
|
1017
1222
|
statusFilter,
|
|
1223
|
+
environmentId,
|
|
1018
1224
|
maxBuckets = 24,
|
|
1019
1225
|
} = props;
|
|
1020
1226
|
|
|
@@ -1033,6 +1239,12 @@ export class HealthCheckService {
|
|
|
1033
1239
|
if (statusFilter && statusFilter.length > 0) {
|
|
1034
1240
|
conditions.push(inArray(healthCheckRuns.status, statusFilter));
|
|
1035
1241
|
}
|
|
1242
|
+
// Server-side env filter; same semantics as `getHistory`.
|
|
1243
|
+
if (environmentId === null) {
|
|
1244
|
+
conditions.push(isNull(healthCheckRuns.environmentId));
|
|
1245
|
+
} else if (environmentId !== undefined) {
|
|
1246
|
+
conditions.push(eq(healthCheckRuns.environmentId, environmentId));
|
|
1247
|
+
}
|
|
1036
1248
|
|
|
1037
1249
|
const rows = await this.db
|
|
1038
1250
|
.select({
|
|
@@ -1057,13 +1269,36 @@ export class HealthCheckService {
|
|
|
1057
1269
|
* Restricted to users with manage access.
|
|
1058
1270
|
* @param sortOrder - 'asc' for chronological (oldest first), 'desc' for reverse (newest first)
|
|
1059
1271
|
*/
|
|
1272
|
+
/**
|
|
1273
|
+
* Distinct system ids that have at least one recorded run - the candidate
|
|
1274
|
+
* set for scoping the run-history feed by SYSTEM manage access (a system's
|
|
1275
|
+
* owning team sees every run of that system).
|
|
1276
|
+
*/
|
|
1277
|
+
async getRunSystemIds(): Promise<string[]> {
|
|
1278
|
+
const rows = await this.db
|
|
1279
|
+
.selectDistinct({ systemId: healthCheckRuns.systemId })
|
|
1280
|
+
.from(healthCheckRuns);
|
|
1281
|
+
return rows.map((r) => r.systemId);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1060
1284
|
async getDetailedHistory(props: {
|
|
1061
1285
|
systemId?: string;
|
|
1062
1286
|
configurationId?: string;
|
|
1287
|
+
/**
|
|
1288
|
+
* Restrict the feed to a team-scoped caller's slice: runs of their
|
|
1289
|
+
* configurations OR runs belonging to their systems (a system's owning
|
|
1290
|
+
* team sees every run of that system). Applied on TOP of the other
|
|
1291
|
+
* filters; both `total` and the page respect it, so pagination stays
|
|
1292
|
+
* correct for a filtered feed. At least one array must be non-empty
|
|
1293
|
+
* (an entirely-empty scope is the caller's "forbidden" case, decided
|
|
1294
|
+
* before the query).
|
|
1295
|
+
*/
|
|
1296
|
+
teamScope?: { configurationIds: string[]; systemIds: string[] };
|
|
1063
1297
|
startDate?: Date;
|
|
1064
1298
|
endDate?: Date;
|
|
1065
1299
|
sourceFilter?: string;
|
|
1066
1300
|
statusFilter?: HealthCheckStatus[];
|
|
1301
|
+
environmentId?: string | null;
|
|
1067
1302
|
limit?: number;
|
|
1068
1303
|
offset?: number;
|
|
1069
1304
|
sortOrder: "asc" | "desc";
|
|
@@ -1071,10 +1306,12 @@ export class HealthCheckService {
|
|
|
1071
1306
|
const {
|
|
1072
1307
|
systemId,
|
|
1073
1308
|
configurationId,
|
|
1309
|
+
teamScope,
|
|
1074
1310
|
startDate,
|
|
1075
1311
|
endDate,
|
|
1076
1312
|
sourceFilter,
|
|
1077
1313
|
statusFilter,
|
|
1314
|
+
environmentId,
|
|
1078
1315
|
limit = 10,
|
|
1079
1316
|
offset = 0,
|
|
1080
1317
|
sortOrder,
|
|
@@ -1084,6 +1321,28 @@ export class HealthCheckService {
|
|
|
1084
1321
|
if (systemId) conditions.push(eq(healthCheckRuns.systemId, systemId));
|
|
1085
1322
|
if (configurationId)
|
|
1086
1323
|
conditions.push(eq(healthCheckRuns.configurationId, configurationId));
|
|
1324
|
+
if (teamScope) {
|
|
1325
|
+
// drizzle's inArray rejects empty arrays, so only include non-empty
|
|
1326
|
+
// branches of the OR.
|
|
1327
|
+
const scopeBranches = [];
|
|
1328
|
+
if (teamScope.configurationIds.length > 0) {
|
|
1329
|
+
scopeBranches.push(
|
|
1330
|
+
inArray(healthCheckRuns.configurationId, teamScope.configurationIds),
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
if (teamScope.systemIds.length > 0) {
|
|
1334
|
+
scopeBranches.push(
|
|
1335
|
+
inArray(healthCheckRuns.systemId, teamScope.systemIds),
|
|
1336
|
+
);
|
|
1337
|
+
}
|
|
1338
|
+
if (scopeBranches.length === 0) {
|
|
1339
|
+
// Defensive: an empty scope must never widen to the full feed.
|
|
1340
|
+
return { runs: [], total: 0 };
|
|
1341
|
+
}
|
|
1342
|
+
conditions.push(
|
|
1343
|
+
scopeBranches.length === 1 ? scopeBranches[0] : or(...scopeBranches),
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1087
1346
|
if (startDate) conditions.push(gte(healthCheckRuns.timestamp, startDate));
|
|
1088
1347
|
if (endDate) conditions.push(lte(healthCheckRuns.timestamp, endDate));
|
|
1089
1348
|
|
|
@@ -1099,6 +1358,13 @@ export class HealthCheckService {
|
|
|
1099
1358
|
conditions.push(inArray(healthCheckRuns.status, statusFilter));
|
|
1100
1359
|
}
|
|
1101
1360
|
|
|
1361
|
+
// Server-side env filter; same semantics as `getHistory`.
|
|
1362
|
+
if (environmentId === null) {
|
|
1363
|
+
conditions.push(isNull(healthCheckRuns.environmentId));
|
|
1364
|
+
} else if (environmentId !== undefined) {
|
|
1365
|
+
conditions.push(eq(healthCheckRuns.environmentId, environmentId));
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1102
1368
|
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
|
1103
1369
|
const total = await this.db.$count(healthCheckRuns, whereClause);
|
|
1104
1370
|
|
|
@@ -1173,6 +1439,7 @@ export class HealthCheckService {
|
|
|
1173
1439
|
endDate: Date;
|
|
1174
1440
|
sourceFilter?: string;
|
|
1175
1441
|
targetPoints?: number;
|
|
1442
|
+
environmentId?: string | null;
|
|
1176
1443
|
},
|
|
1177
1444
|
options: { includeAggregatedResult: boolean },
|
|
1178
1445
|
) {
|
|
@@ -1183,6 +1450,7 @@ export class HealthCheckService {
|
|
|
1183
1450
|
endDate,
|
|
1184
1451
|
sourceFilter,
|
|
1185
1452
|
targetPoints = 500,
|
|
1453
|
+
environmentId,
|
|
1186
1454
|
} = props;
|
|
1187
1455
|
|
|
1188
1456
|
// Calculate dynamic bucket interval
|
|
@@ -1204,6 +1472,25 @@ export class HealthCheckService {
|
|
|
1204
1472
|
? this.registry.getStrategy(config.strategyId)
|
|
1205
1473
|
: undefined;
|
|
1206
1474
|
|
|
1475
|
+
// Server-side env filter applied to ALL three tiers (raw runs, hourly
|
|
1476
|
+
// and daily aggregates), since `health_check_runs.environment_id` and
|
|
1477
|
+
// `health_check_aggregates.environment_id` are the same env-id domain.
|
|
1478
|
+
// The bucket uniqueness on `health_check_aggregates` includes `environmentId`
|
|
1479
|
+
// (with NULLS NOT DISTINCT), so `isNull(...)` selects the env-less buckets
|
|
1480
|
+
// and `eq(...)` selects that env's buckets.
|
|
1481
|
+
const envRunCondition =
|
|
1482
|
+
environmentId === undefined
|
|
1483
|
+
? undefined
|
|
1484
|
+
: environmentId === null
|
|
1485
|
+
? isNull(healthCheckRuns.environmentId)
|
|
1486
|
+
: eq(healthCheckRuns.environmentId, environmentId);
|
|
1487
|
+
const envAggCondition =
|
|
1488
|
+
environmentId === undefined
|
|
1489
|
+
? undefined
|
|
1490
|
+
: environmentId === null
|
|
1491
|
+
? isNull(healthCheckAggregates.environmentId)
|
|
1492
|
+
: eq(healthCheckAggregates.environmentId, environmentId);
|
|
1493
|
+
|
|
1207
1494
|
// Build source condition for raw runs
|
|
1208
1495
|
const rawConditions = [
|
|
1209
1496
|
eq(healthCheckRuns.systemId, systemId),
|
|
@@ -1215,6 +1502,7 @@ export class HealthCheckService {
|
|
|
1215
1502
|
: sourceFilter
|
|
1216
1503
|
? [eq(healthCheckRuns.sourceId, sourceFilter)]
|
|
1217
1504
|
: []),
|
|
1505
|
+
...(envRunCondition ? [envRunCondition] : []),
|
|
1218
1506
|
];
|
|
1219
1507
|
|
|
1220
1508
|
// Build source condition for hourly aggregates
|
|
@@ -1229,6 +1517,7 @@ export class HealthCheckService {
|
|
|
1229
1517
|
: sourceFilter
|
|
1230
1518
|
? [eq(healthCheckAggregates.sourceId, sourceFilter)]
|
|
1231
1519
|
: []),
|
|
1520
|
+
...(envAggCondition ? [envAggCondition] : []),
|
|
1232
1521
|
];
|
|
1233
1522
|
|
|
1234
1523
|
// Build source condition for daily aggregates
|
|
@@ -1243,6 +1532,7 @@ export class HealthCheckService {
|
|
|
1243
1532
|
: sourceFilter
|
|
1244
1533
|
? [eq(healthCheckAggregates.sourceId, sourceFilter)]
|
|
1245
1534
|
: []),
|
|
1535
|
+
...(envAggCondition ? [envAggCondition] : []),
|
|
1246
1536
|
];
|
|
1247
1537
|
|
|
1248
1538
|
// Query all three tiers in parallel
|
|
@@ -1705,6 +1995,7 @@ export class HealthCheckService {
|
|
|
1705
1995
|
const runs = await this.db
|
|
1706
1996
|
.select({
|
|
1707
1997
|
result: healthCheckRuns.result,
|
|
1998
|
+
environmentId: healthCheckRuns.environmentId,
|
|
1708
1999
|
})
|
|
1709
2000
|
.from(healthCheckRuns)
|
|
1710
2001
|
.where(
|
|
@@ -1722,6 +2013,7 @@ export class HealthCheckService {
|
|
|
1722
2013
|
configurationId: assignment.configurationId,
|
|
1723
2014
|
runs: runs.map((r) => ({
|
|
1724
2015
|
result: r.result,
|
|
2016
|
+
environmentId: r.environmentId,
|
|
1725
2017
|
})),
|
|
1726
2018
|
});
|
|
1727
2019
|
}
|