@checkstack/healthcheck-backend 1.18.0 → 1.19.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 +294 -0
- package/package.json +22 -20
- package/src/health-notification-content.test.ts +89 -0
- package/src/health-notification-content.ts +138 -0
- package/src/queue-executor.ts +31 -68
- package/src/router.ts +36 -0
- package/src/service-batching.test.ts +8 -0
- package/src/service-bulk-counts.it.test.ts +144 -0
- package/src/service-bulk-run-stats.it.test.ts +197 -0
- package/src/service-ordering.test.ts +6 -2
- package/src/service-paused-filter.test.ts +13 -0
- package/src/service-rollup-worst-wins.test.ts +209 -145
- package/src/service.ts +366 -185
- package/src/status-page/rollup.test.ts +40 -0
- package/src/status-page/rollup.ts +27 -0
- package/src/status-page/widgets.test.ts +303 -0
- package/src/status-page/widgets.ts +155 -39
|
@@ -4,96 +4,143 @@ import { HealthCheckService } from "./service";
|
|
|
4
4
|
import { evaluateHealthStatus } from "./state-evaluator";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Regression coverage for the system-rollup
|
|
8
|
-
*
|
|
9
|
-
* branch).
|
|
7
|
+
* Regression coverage for the system-rollup derivation in
|
|
8
|
+
* `getSystemHealthStatus(systemId)` (the `environmentId === undefined` branch):
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
* `timestamp DESC` list and
|
|
13
|
-
*
|
|
14
|
-
* newest-first and breaks the
|
|
15
|
-
*
|
|
16
|
-
* permanently-failing sibling env ("the healthy env
|
|
17
|
-
* and flapping whenever env insertion order drifted
|
|
10
|
+
* 1. Worst-wins ACROSS environments within an association. The original branch
|
|
11
|
+
* flattened every environment's runs into one `timestamp DESC` list and
|
|
12
|
+
* handed the interleaved list to the threshold evaluator (default
|
|
13
|
+
* `consecutive` mode). Consecutive mode walks newest-first and breaks the
|
|
14
|
+
* streak on the first interleaving env, so the rollup collapsed to whichever
|
|
15
|
+
* env ran last — masking a permanently-failing sibling env ("the healthy env
|
|
16
|
+
* wins" / latest-wins) and flapping whenever env insertion order drifted.
|
|
17
|
+
* The fix evaluates a FULL per-env window and takes worst-wins across envs.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
19
|
+
* 2. Currently-effective-slice filtering. A per-env slice whose environment was
|
|
20
|
+
* DISABLED for the assignment (removed from `environmentIds`) must STOP
|
|
21
|
+
* contributing immediately - its stale unhealthy runs must not keep dragging
|
|
22
|
+
* the rollup until they age out of the window.
|
|
23
|
+
*
|
|
24
|
+
* Each environment is now windowed by its OWN query (per-env `LIMIT`), so the
|
|
25
|
+
* mock resolves each per-env runs query against the env bound in its predicate.
|
|
24
26
|
*/
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const STAGE_RUN = { status: "healthy" as const, environmentId: "staging" };
|
|
37
|
-
|
|
38
|
-
function buildMixedPool(ticksPerEnv = 5): { status: "unhealthy" | "healthy"; timestamp: Date; environmentId: string }[] {
|
|
39
|
-
const pool: { status: "unhealthy" | "healthy"; timestamp: Date; environmentId: string }[] = [];
|
|
40
|
-
for (let i = 0; i < ticksPerEnv; i++) {
|
|
41
|
-
pool.push({ ...PROD_RUN, timestamp: new Date(2025, 0, 1, 0, 0, i) });
|
|
42
|
-
pool.push({ ...STAGE_RUN, timestamp: new Date(2025, 0, 1, 0, 0, i + 0.5) });
|
|
27
|
+
|
|
28
|
+
/** Walk a drizzle predicate object and collect every bound literal value. */
|
|
29
|
+
function collectPredicateValues(predicate: unknown): string[] {
|
|
30
|
+
const values: string[] = [];
|
|
31
|
+
const seen = new Set<unknown>();
|
|
32
|
+
const walk = (node: unknown) => {
|
|
33
|
+
if (node == null || seen.has(node) || typeof node !== "object") return;
|
|
34
|
+
seen.add(node);
|
|
35
|
+
if ("value" in (node as Record<string, unknown>)) {
|
|
36
|
+
const v = (node as { value: unknown }).value;
|
|
37
|
+
if (typeof v === "string") values.push(v);
|
|
43
38
|
}
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
for (const child of Object.values(node as Record<string, unknown>)) {
|
|
40
|
+
walk(child);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
walk(predicate);
|
|
44
|
+
return values;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type Run = { status: "healthy" | "degraded" | "unhealthy"; timestamp: Date };
|
|
46
48
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Build a mock db for the rollup path. `runsByEnv` maps each environment key
|
|
51
|
+
* (`null` = env-less) to that env's runs (DESC). `environmentIds` is the
|
|
52
|
+
* assignment's selector under test. The per-env runs query resolves against the
|
|
53
|
+
* concrete env id bound in its predicate (or the env-less slice when none of the
|
|
54
|
+
* known env ids appear, i.e. the `isNull` clause).
|
|
55
|
+
*/
|
|
56
|
+
function createRollupMockDb(props: {
|
|
57
|
+
runsByEnv: Map<string | null, Run[]>;
|
|
58
|
+
environmentIds: string[] | null;
|
|
59
|
+
}) {
|
|
60
|
+
const { runsByEnv, environmentIds } = props;
|
|
61
|
+
const knownEnvIds = new Set(
|
|
62
|
+
[...runsByEnv.keys()].filter((k): k is string => k !== null),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const assocWhere = mock(() =>
|
|
66
|
+
Promise.resolve([
|
|
49
67
|
{
|
|
50
68
|
configurationId: "config-1",
|
|
51
69
|
configName: "HTTP probe",
|
|
52
70
|
enabled: true,
|
|
53
71
|
paused: false,
|
|
54
72
|
stateThresholds: null,
|
|
73
|
+
environmentIds,
|
|
55
74
|
},
|
|
56
|
-
])
|
|
57
|
-
|
|
58
|
-
|
|
75
|
+
]),
|
|
76
|
+
);
|
|
77
|
+
const assocInnerJoin = Object.assign(Promise.resolve([]), {
|
|
78
|
+
where: assocWhere,
|
|
79
|
+
});
|
|
80
|
+
const assocFrom = Object.assign(Promise.resolve([]), {
|
|
81
|
+
innerJoin: mock(() => assocInnerJoin),
|
|
82
|
+
});
|
|
59
83
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
84
|
+
// Per-env runs query: pick the slice named by the predicate's env value.
|
|
85
|
+
const resolvePerEnv = (predicate: unknown): Run[] => {
|
|
86
|
+
const values = collectPredicateValues(predicate);
|
|
87
|
+
const envId = values.find((v) => knownEnvIds.has(v)) ?? null;
|
|
88
|
+
return runsByEnv.get(envId) ?? [];
|
|
89
|
+
};
|
|
90
|
+
const runsFromFor = () => {
|
|
91
|
+
const runsWhere = mock((predicate: unknown) => {
|
|
92
|
+
const rows = resolvePerEnv(predicate);
|
|
93
|
+
const limit = mock(() => Promise.resolve(rows));
|
|
94
|
+
return { orderBy: mock(() => ({ limit })), limit };
|
|
66
95
|
});
|
|
96
|
+
return Object.assign(Promise.resolve([]), { where: runsWhere });
|
|
97
|
+
};
|
|
67
98
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
99
|
+
// Distinct env keys query: select({environmentId}).from().where().
|
|
100
|
+
const distinctFrom = Object.assign(Promise.resolve([]), {
|
|
101
|
+
where: mock(() =>
|
|
102
|
+
Promise.resolve([...runsByEnv.keys()].map((k) => ({ environmentId: k }))),
|
|
103
|
+
),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
let selectCallCount = 0;
|
|
107
|
+
return withTransactionMock({
|
|
108
|
+
select: mock(() => {
|
|
109
|
+
selectCallCount += 1;
|
|
110
|
+
// #1 associations; every subsequent select is a per-env runs window.
|
|
111
|
+
if (selectCallCount === 1) return { from: mock(() => assocFrom) };
|
|
112
|
+
return { from: mock(() => runsFromFor()) };
|
|
113
|
+
}),
|
|
114
|
+
selectDistinct: mock(() => ({ from: mock(() => distinctFrom) })),
|
|
115
|
+
insert: mock(() => ({
|
|
116
|
+
values: mock(() => ({
|
|
117
|
+
onConflictDoUpdate: mock(() => Promise.resolve()),
|
|
118
|
+
onConflictDoNothing: mock(() => Promise.resolve()),
|
|
119
|
+
returning: mock(() => Promise.resolve([])),
|
|
81
120
|
})),
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
121
|
+
})),
|
|
122
|
+
update: mock(() => ({
|
|
123
|
+
set: mock(() => ({ where: mock(() => Promise.resolve()) })),
|
|
124
|
+
})),
|
|
125
|
+
delete: mock(() => ({ where: mock(() => Promise.resolve()) })),
|
|
126
|
+
execute: mock(() => Promise.resolve()),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function runs(status: Run["status"], count: number, envSecondOffset = 0): Run[] {
|
|
131
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
132
|
+
status,
|
|
133
|
+
timestamp: new Date(2025, 0, 1, 0, 0, i, envSecondOffset),
|
|
134
|
+
})).toReversed(); // DESC (newest first)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
describe("HealthCheckService - system rollup worst-wins across environments", () => {
|
|
138
|
+
it("reports unhealthy when ONE env is permanently unhealthy, the other healthy", async () => {
|
|
139
|
+
const runsByEnv = new Map<string | null, Run[]>([
|
|
140
|
+
["prod", runs("unhealthy", 5)],
|
|
141
|
+
["staging", runs("healthy", 5)],
|
|
142
|
+
]);
|
|
143
|
+
const mockDb = createRollupMockDb({ runsByEnv, environmentIds: null });
|
|
97
144
|
const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
|
|
98
145
|
|
|
99
146
|
const result = await service.getSystemHealthStatus("system-1");
|
|
@@ -101,57 +148,46 @@ describe("HealthCheckService - system rollup worst-wins across environments", ()
|
|
|
101
148
|
expect(result.status).toBe("unhealthy");
|
|
102
149
|
expect(result.checkStatuses).toHaveLength(1);
|
|
103
150
|
expect(result.checkStatuses[0].status).toBe("unhealthy");
|
|
104
|
-
expect(result.checkStatuses[0].runsConsidered).toBe(
|
|
151
|
+
expect(result.checkStatuses[0].runsConsidered).toBe(10);
|
|
105
152
|
// Fan-out accounting: two environment slices (prod + staging), one failing.
|
|
106
153
|
expect(result.checkStatuses[0].sliceCount).toBe(2);
|
|
107
154
|
expect(result.checkStatuses[0].failingSliceCount).toBe(1);
|
|
108
155
|
});
|
|
109
156
|
|
|
110
|
-
it("counts every failing environment slice for the fan-out denominator (3 envs, 2 failing)", () => {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
environmentId: string;
|
|
118
|
-
}[] = [];
|
|
119
|
-
for (let i = 0; i < 5; i++) {
|
|
120
|
-
pool.push({ status: "unhealthy", timestamp: new Date(2025, 0, 1, 0, 0, i), environmentId: "prod" });
|
|
121
|
-
pool.push({ status: "unhealthy", timestamp: new Date(2025, 0, 1, 0, 0, i, 250), environmentId: "eu" });
|
|
122
|
-
pool.push({ status: "healthy", timestamp: new Date(2025, 0, 1, 0, 0, i, 500), environmentId: "staging" });
|
|
123
|
-
}
|
|
124
|
-
const runsDesc = pool.toReversed();
|
|
125
|
-
const mockDb = createMockDb(runsDesc as never);
|
|
157
|
+
it("counts every failing environment slice for the fan-out denominator (3 envs, 2 failing)", async () => {
|
|
158
|
+
const runsByEnv = new Map<string | null, Run[]>([
|
|
159
|
+
["prod", runs("unhealthy", 5)],
|
|
160
|
+
["eu", runs("unhealthy", 5)],
|
|
161
|
+
["staging", runs("healthy", 5)],
|
|
162
|
+
]);
|
|
163
|
+
const mockDb = createRollupMockDb({ runsByEnv, environmentIds: null });
|
|
126
164
|
const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
|
|
127
165
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
});
|
|
166
|
+
const result = await service.getSystemHealthStatus("system-1");
|
|
167
|
+
expect(result.status).toBe("unhealthy");
|
|
168
|
+
expect(result.checkStatuses[0].sliceCount).toBe(3);
|
|
169
|
+
expect(result.checkStatuses[0].failingSliceCount).toBe(2);
|
|
133
170
|
});
|
|
134
171
|
|
|
135
|
-
it("flattening the same mixed pool through the evaluator (the pre-fix derivation) would have returned `healthy`",
|
|
172
|
+
it("flattening the same mixed pool through the evaluator (the pre-fix derivation) would have returned `healthy`", () => {
|
|
136
173
|
// Sanity check: the very data the rollup branch reads, fed directly to
|
|
137
174
|
// `evaluateHealthStatus` as one flat interleaved list, collapses to
|
|
138
|
-
// "healthy" — the precise regression
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
});
|
|
175
|
+
// "healthy" — the precise regression per-env evaluation replaces.
|
|
176
|
+
const pool: Run[] = [];
|
|
177
|
+
for (let i = 0; i < 5; i++) {
|
|
178
|
+
pool.push({ status: "unhealthy", timestamp: new Date(2025, 0, 1, 0, 0, i) });
|
|
179
|
+
pool.push({ status: "healthy", timestamp: new Date(2025, 0, 1, 0, 0, i, 500) });
|
|
180
|
+
}
|
|
181
|
+
const flatStatus = evaluateHealthStatus({ runs: pool.toReversed() as never });
|
|
145
182
|
expect(flatStatus).toBe("healthy");
|
|
146
183
|
});
|
|
147
184
|
|
|
148
185
|
it("reports healthy only when EVERY env is healthy", async () => {
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
})
|
|
154
|
-
const mockDb = createMockDb(allHealthy as never);
|
|
186
|
+
const runsByEnv = new Map<string | null, Run[]>([
|
|
187
|
+
["prod", runs("healthy", 5)],
|
|
188
|
+
["staging", runs("healthy", 5)],
|
|
189
|
+
]);
|
|
190
|
+
const mockDb = createRollupMockDb({ runsByEnv, environmentIds: null });
|
|
155
191
|
const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
|
|
156
192
|
|
|
157
193
|
const result = await service.getSystemHealthStatus("system-1");
|
|
@@ -159,46 +195,76 @@ describe("HealthCheckService - system rollup worst-wins across environments", ()
|
|
|
159
195
|
});
|
|
160
196
|
|
|
161
197
|
it("degrades (not flaps) when one env is degraded and the other healthy", async () => {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
// interleave and return `healthy` (the masked bug); per-env gives a
|
|
168
|
-
// stable `degraded`.
|
|
169
|
-
const pool: { status: "healthy" | "degraded"; timestamp: Date; environmentId: string }[] = [];
|
|
170
|
-
for (let i = 0; i < 2; i++) {
|
|
171
|
-
pool.push({ status: "degraded", timestamp: new Date(2025, 0, 1, 0, 0, i), environmentId: "prod" });
|
|
172
|
-
pool.push({ status: "healthy", timestamp: new Date(2025, 0, 1, 0, 0, i + 0.5), environmentId: "staging" });
|
|
173
|
-
}
|
|
174
|
-
const runsDesc = pool.toReversed();
|
|
175
|
-
const mockDb = createMockDb(runsDesc as never);
|
|
198
|
+
const runsByEnv = new Map<string | null, Run[]>([
|
|
199
|
+
["prod", runs("degraded", 2)],
|
|
200
|
+
["staging", runs("healthy", 2)],
|
|
201
|
+
]);
|
|
202
|
+
const mockDb = createRollupMockDb({ runsByEnv, environmentIds: null });
|
|
176
203
|
const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
|
|
177
204
|
|
|
178
205
|
const result = await service.getSystemHealthStatus("system-1");
|
|
179
206
|
expect(result.status).toBe("degraded");
|
|
180
207
|
});
|
|
181
208
|
|
|
209
|
+
it("drops a DISABLED environment's stale unhealthy runs from the rollup (regression)", async () => {
|
|
210
|
+
// prod was DISABLED for the assignment (environmentIds now ['staging']) but
|
|
211
|
+
// its historical unhealthy runs still exist. The rollup must ignore prod and
|
|
212
|
+
// read healthy from the sole effective env (staging), immediately - not after
|
|
213
|
+
// prod's runs age out of the window.
|
|
214
|
+
const runsByEnv = new Map<string | null, Run[]>([
|
|
215
|
+
["prod", runs("unhealthy", 5)],
|
|
216
|
+
["staging", runs("healthy", 5)],
|
|
217
|
+
]);
|
|
218
|
+
const mockDb = createRollupMockDb({
|
|
219
|
+
runsByEnv,
|
|
220
|
+
environmentIds: ["staging"],
|
|
221
|
+
});
|
|
222
|
+
const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
|
|
223
|
+
|
|
224
|
+
const result = await service.getSystemHealthStatus("system-1");
|
|
225
|
+
|
|
226
|
+
expect(result.status).toBe("healthy");
|
|
227
|
+
expect(result.checkStatuses[0].status).toBe("healthy");
|
|
228
|
+
// Only the effective (staging) slice counts now.
|
|
229
|
+
expect(result.checkStatuses[0].sliceCount).toBe(1);
|
|
230
|
+
expect(result.checkStatuses[0].failingSliceCount).toBe(0);
|
|
231
|
+
expect(result.checkStatuses[0].runsConsidered).toBe(5);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("opting out ([]) drops all concrete-env runs and keeps only the env-less slice", async () => {
|
|
235
|
+
const runsByEnv = new Map<string | null, Run[]>([
|
|
236
|
+
["prod", runs("unhealthy", 5)],
|
|
237
|
+
[null, runs("healthy", 3)],
|
|
238
|
+
]);
|
|
239
|
+
const mockDb = createRollupMockDb({ runsByEnv, environmentIds: [] });
|
|
240
|
+
const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
|
|
241
|
+
|
|
242
|
+
const result = await service.getSystemHealthStatus("system-1");
|
|
243
|
+
expect(result.status).toBe("healthy");
|
|
244
|
+
expect(result.checkStatuses[0].sliceCount).toBe(1);
|
|
245
|
+
expect(result.checkStatuses[0].runsConsidered).toBe(3);
|
|
246
|
+
});
|
|
247
|
+
|
|
182
248
|
it("the per-env slice (concrete environmentId) is unaffected — only the rollup branch changed", async () => {
|
|
183
|
-
// Pass an explicit environmentId; the
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
},
|
|
199
|
-
]));
|
|
249
|
+
// Pass an explicit environmentId; the per-env branch (string envId) still
|
|
250
|
+
// filters to that env's slice via a single windowed query and reads unhealthy.
|
|
251
|
+
const prodOnly = runs("unhealthy", 5);
|
|
252
|
+
const assocWhere = mock(() =>
|
|
253
|
+
Promise.resolve([
|
|
254
|
+
{
|
|
255
|
+
configurationId: "config-1",
|
|
256
|
+
configName: "HTTP probe",
|
|
257
|
+
enabled: true,
|
|
258
|
+
paused: false,
|
|
259
|
+
stateThresholds: null,
|
|
260
|
+
environmentIds: null,
|
|
261
|
+
},
|
|
262
|
+
]),
|
|
263
|
+
);
|
|
200
264
|
const assocInnerJoin = Object.assign(Promise.resolve([]), { where: assocWhere });
|
|
201
|
-
const assocFrom = Object.assign(Promise.resolve([]), {
|
|
265
|
+
const assocFrom = Object.assign(Promise.resolve([]), {
|
|
266
|
+
innerJoin: mock(() => assocInnerJoin),
|
|
267
|
+
});
|
|
202
268
|
|
|
203
269
|
const runsLimit = mock(() => Promise.resolve(prodOnly));
|
|
204
270
|
const runsOrderBy = mock(() => ({ limit: runsLimit }));
|
|
@@ -230,9 +296,7 @@ describe("HealthCheckService - system rollup worst-wins across environments", ()
|
|
|
230
296
|
const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
|
|
231
297
|
const result = await service.getSystemHealthStatus("system-1", "prod");
|
|
232
298
|
expect(result.status).toBe("unhealthy");
|
|
233
|
-
// A single-env evaluation is always one slice; failing here since prod is
|
|
234
|
-
// unhealthy.
|
|
235
299
|
expect(result.checkStatuses[0].sliceCount).toBe(1);
|
|
236
300
|
expect(result.checkStatuses[0].failingSliceCount).toBe(1);
|
|
237
301
|
});
|
|
238
|
-
});
|
|
302
|
+
});
|