@oneuptime/common 14.0.6 → 14.0.7

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.
Files changed (127) hide show
  1. package/Models/DatabaseModels/InventoryItem.ts +23 -12
  2. package/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.ts +217 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  4. package/Server/Services/AlertService.ts +32 -36
  5. package/Server/Services/IncidentAlertService.ts +609 -2
  6. package/Server/Services/IncidentService.ts +134 -44
  7. package/Server/Services/InventoryItemService.ts +38 -0
  8. package/Server/Services/IoTDeviceCredentialService.ts +13 -4
  9. package/Server/Services/ScheduledMaintenanceService.ts +50 -46
  10. package/Server/Services/UserNotificationRuleService.ts +55 -53
  11. package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
  12. package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
  13. package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
  14. package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
  15. package/Server/Utils/Response.ts +31 -0
  16. package/Server/Utils/Topology/TopologyConcurrencyLimiter.ts +229 -0
  17. package/Server/Utils/Topology/TopologyQueries.ts +1350 -0
  18. package/Server/Utils/Topology/TopologyRequest.ts +463 -0
  19. package/Server/Utils/Topology/TopologyResponseCache.ts +183 -0
  20. package/Server/Utils/Topology/TopologySql.ts +1024 -0
  21. package/Tests/App/Dashboard/AcknowledgeAlertsOnDeclare.test.ts +369 -0
  22. package/Tests/App/Dashboard/AffectedResourcesSlo.test.tsx +1 -1
  23. package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +510 -123
  24. package/Tests/App/Dashboard/AlertsTableAffectedResources.test.tsx +490 -0
  25. package/Tests/App/Dashboard/ChangeAlertStateDeclareIncident.test.tsx +886 -0
  26. package/Tests/App/Dashboard/CreateReviewStepRoleSummary.test.tsx +1250 -0
  27. package/Tests/App/Dashboard/CreateReviewStepSelectionSummary.test.tsx +738 -0
  28. package/Tests/App/Dashboard/DeclareIncidentFromAlert.test.ts +417 -0
  29. package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +1948 -229
  30. package/Tests/App/Dashboard/EntityDetailPanelLifecycle.test.tsx +493 -0
  31. package/Tests/App/Dashboard/EntityDetailPanelNavigation.test.tsx +439 -0
  32. package/Tests/App/Dashboard/EventOverviewPages.test.tsx +292 -1
  33. package/Tests/App/Dashboard/EventStatusPanel.test.tsx +798 -2
  34. package/Tests/App/Dashboard/FetchStateAndSeverityElements.test.tsx +669 -0
  35. package/Tests/App/Dashboard/FetchUsers.test.tsx +230 -0
  36. package/Tests/App/Dashboard/IncidentCreateAcknowledgeAlerts.test.tsx +1611 -0
  37. package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +1365 -57
  38. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +964 -23
  39. package/Tests/App/Dashboard/InventoryRelationships.test.tsx +885 -0
  40. package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +201 -15
  41. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
  42. package/Tests/App/Dashboard/TopologyDataLifecycle.test.tsx +583 -0
  43. package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +982 -218
  44. package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +818 -114
  45. package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +90 -21
  46. package/Tests/Server/Services/DomainService.test.ts +525 -0
  47. package/Tests/Server/Services/IncidentAlertAcknowledgeOnDeclare.test.ts +2630 -0
  48. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +1625 -1
  49. package/Tests/Server/Services/IncidentDeclareAcknowledgeAlerts.test.ts +2189 -0
  50. package/Tests/Server/Services/InventoryItemServiceOverviewCounts.test.ts +345 -0
  51. package/Tests/Server/Services/InventoryOverviewPostgres.test.ts +254 -0
  52. package/Tests/Server/Services/IoTDeviceCredentialService.test.ts +819 -0
  53. package/Tests/Server/Services/LinkedAffectedResourcesCreatedFeed.test.ts +420 -0
  54. package/Tests/Server/Services/SloAffectedResourceCreatedFeed.test.ts +92 -36
  55. package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
  56. package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -0
  57. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigration.test.ts +386 -0
  58. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigrationPostgres.test.ts +430 -0
  59. package/Tests/Server/Utils/Topology/TopologyActivityParityPostgres.test.ts +279 -0
  60. package/Tests/Server/Utils/Topology/TopologyConcurrencyLimiter.test.ts +534 -0
  61. package/Tests/Server/Utils/Topology/TopologyEstateGenerator.ts +624 -0
  62. package/Tests/Server/Utils/Topology/TopologyJsonResponse.test.ts +90 -0
  63. package/Tests/Server/Utils/Topology/TopologyQueries.test.ts +1634 -0
  64. package/Tests/Server/Utils/Topology/TopologyQueriesDifferentialPostgres.test.ts +683 -0
  65. package/Tests/Server/Utils/Topology/TopologyQueriesPostgres.test.ts +2624 -0
  66. package/Tests/Server/Utils/Topology/TopologyReference.ts +1018 -0
  67. package/Tests/Server/Utils/Topology/TopologyRequest.test.ts +643 -0
  68. package/Tests/Server/Utils/Topology/TopologyResponseCache.test.ts +305 -0
  69. package/Tests/Server/Utils/Topology/TopologySql.test.ts +973 -0
  70. package/Tests/Types/Topology/TopologyTypeRules.test.ts +176 -0
  71. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
  72. package/Types/Incident/IncidentAlertLink.ts +19 -0
  73. package/Types/Telemetry/InventoryLiveness.ts +26 -0
  74. package/Types/Topology/TopologyApi.ts +428 -0
  75. package/Types/Topology/TopologyTypeRules.ts +191 -0
  76. package/Utils/Incident/IncidentFromAlerts.ts +78 -0
  77. package/build/dist/Models/DatabaseModels/InventoryItem.js +20 -11
  78. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
  79. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js +165 -0
  80. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js.map +1 -0
  81. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  82. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  83. package/build/dist/Server/Services/AlertService.js +21 -33
  84. package/build/dist/Server/Services/AlertService.js.map +1 -1
  85. package/build/dist/Server/Services/IncidentAlertService.js +457 -3
  86. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -1
  87. package/build/dist/Server/Services/IncidentService.js +96 -42
  88. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  89. package/build/dist/Server/Services/InventoryItemService.js +31 -0
  90. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  91. package/build/dist/Server/Services/IoTDeviceCredentialService.js +12 -5
  92. package/build/dist/Server/Services/IoTDeviceCredentialService.js.map +1 -1
  93. package/build/dist/Server/Services/ScheduledMaintenanceService.js +37 -40
  94. package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
  95. package/build/dist/Server/Services/UserNotificationRuleService.js +55 -51
  96. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  97. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +42 -25
  98. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -1
  99. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js +405 -0
  100. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js.map +1 -0
  101. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js +112 -0
  102. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js.map +1 -0
  103. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js +81 -0
  104. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
  105. package/build/dist/Server/Utils/Response.js +30 -0
  106. package/build/dist/Server/Utils/Response.js.map +1 -1
  107. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js +178 -0
  108. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js.map +1 -0
  109. package/build/dist/Server/Utils/Topology/TopologyQueries.js +884 -0
  110. package/build/dist/Server/Utils/Topology/TopologyQueries.js.map +1 -0
  111. package/build/dist/Server/Utils/Topology/TopologyRequest.js +275 -0
  112. package/build/dist/Server/Utils/Topology/TopologyRequest.js.map +1 -0
  113. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js +146 -0
  114. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js.map +1 -0
  115. package/build/dist/Server/Utils/Topology/TopologySql.js +688 -0
  116. package/build/dist/Server/Utils/Topology/TopologySql.js.map +1 -0
  117. package/build/dist/Types/Incident/IncidentAlertLink.js +17 -0
  118. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -1
  119. package/build/dist/Types/Telemetry/InventoryLiveness.js +23 -0
  120. package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -1
  121. package/build/dist/Types/Topology/TopologyApi.js +55 -0
  122. package/build/dist/Types/Topology/TopologyApi.js.map +1 -0
  123. package/build/dist/Types/Topology/TopologyTypeRules.js +165 -0
  124. package/build/dist/Types/Topology/TopologyTypeRules.js.map +1 -0
  125. package/build/dist/Utils/Incident/IncidentFromAlerts.js +38 -0
  126. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
  127. package/package.json +1 -1
@@ -20,6 +20,7 @@ import ProjectService from "./ProjectService";
20
20
  import { IsBillingEnabled } from "../EnvironmentConfig";
21
21
  import { applyAlertRelatedRecordPrivacyFilter } from "../Utils/Alert/AlertPrivacyFilter";
22
22
  import { applyIncidentRelatedRecordPrivacyFilter } from "../Utils/Incident/IncidentPrivacyFilter";
23
+ import AlertStateChangeAuthorization from "../Utils/Alert/AlertStateChangeAuthorization";
23
24
  import PostgresErrorTranslator from "../Utils/Database/PostgresErrorTranslator";
24
25
  import ProjectScopedReferenceValidator from "../Utils/Database/ProjectScopedReferenceValidator";
25
26
  import RelationIdUtil from "../Utils/Database/RelationIdUtil";
@@ -44,6 +45,7 @@ import ForbiddenException from "../../Types/Exception/ForbiddenException";
44
45
  import NotAuthorizedException from "../../Types/Exception/NotAuthorizedException";
45
46
  import NotFoundException from "../../Types/Exception/NotFoundException";
46
47
  import {
48
+ INCIDENT_ACKNOWLEDGE_ALERTS_TO_LINK_KEY,
47
49
  INCIDENT_ALERT_ALREADY_LINKED_MESSAGE,
48
50
  INCIDENT_ALERT_IDS_TO_LINK_KEY,
49
51
  MAX_ALERTS_PER_INCIDENT_LINK_ACTION,
@@ -145,6 +147,54 @@ export interface LinkAlertsToIncidentResult {
145
147
  failed: Array<{ alertId: ObjectID; message: string }>;
146
148
  }
147
149
 
150
+ export interface AcknowledgeDeclaredAlertsResult {
151
+ acknowledgedAlertIds: Array<ObjectID>;
152
+ // Already acknowledged, resolved or in a later state: left as they were.
153
+ alreadyAcknowledgedAlertIds: Array<ObjectID>;
154
+ /*
155
+ * Linked alerts the project's linked-alert sync moves on its own (the
156
+ * incident was declared straight into a state its switches act on).
157
+ */
158
+ leftToLinkedAlertSyncAlertIds: Array<ObjectID>;
159
+ failed: Array<{ alertId: ObjectID; message: string }>;
160
+ }
161
+
162
+ /*
163
+ * What validateAcknowledgeAlertsForNewIncident settled for a declaration
164
+ * that asked to acknowledge its alerts: the project's Acknowledged state,
165
+ * and the alerts not acknowledged yet - the ones the caller was checked for,
166
+ * and the only ones acknowledgeAlertsDeclaredWithIncident may write.
167
+ */
168
+ export interface AlertsToAcknowledgeOnDeclare {
169
+ acknowledgedAlertStateId: ObjectID;
170
+ alertIdsToAcknowledge: Array<ObjectID>;
171
+ }
172
+
173
+ /*
174
+ * Alerts acknowledged at once when an incident is declared from many. Each
175
+ * acknowledgement is a full state change (and waits on its Slack / Microsoft
176
+ * Teams post), so one at a time could leave the last of 50 alerts escalating
177
+ * for minutes. Distinct alerts never share a timeline lock.
178
+ */
179
+ const DECLARED_ALERT_ACKNOWLEDGE_CONCURRENCY: number = 5;
180
+
181
+ /*
182
+ * The "why" on an alert acknowledged because an incident was declared from
183
+ * it. It goes to the alert's feed, the alert's Slack / Microsoft Teams
184
+ * channels and its owners' notifications - places that must not name a
185
+ * private incident - so a private incident is not named at all.
186
+ */
187
+ export function getDeclaredAlertAcknowledgementCause(data: {
188
+ incidentNumber: string;
189
+ isIncidentPrivate: boolean;
190
+ }): string {
191
+ if (data.isIncidentPrivate) {
192
+ return "Acknowledged because a private incident was declared from this alert.";
193
+ }
194
+
195
+ return `Acknowledged because ${withNumber("Incident", data.incidentNumber)} was declared from this alert.`;
196
+ }
197
+
148
198
  /*
149
199
  * miscDataProps key on an IncidentAlert create: the link is one of the links
150
200
  * written while an incident is being declared from alerts. The incident then
@@ -1257,6 +1307,560 @@ export class Service extends DatabaseService<Model> {
1257
1307
  return result;
1258
1308
  }
1259
1309
 
1310
+ /*
1311
+ * Reads miscDataProps[INCIDENT_ACKNOWLEDGE_ALERTS_TO_LINK_KEY] on an
1312
+ * incident create: whether to acknowledge the alerts the incident is
1313
+ * declared from once they are linked, and which. Null when not asked to
1314
+ * (absent, null or false). Otherwise the project's Acknowledged alert
1315
+ * state and the alerts that are not acknowledged yet - the only ones that
1316
+ * will be written, and so the only ones the caller must be allowed to
1317
+ * change: an alert that is already acknowledged or resolved is left alone,
1318
+ * so being unable to change it must not refuse the declaration.
1319
+ *
1320
+ * Called from IncidentService's onBeforeCreate after the alert ids were
1321
+ * validated - before the incident number is taken - so an impossible
1322
+ * request is refused instead of the incident being declared while its
1323
+ * alerts keep paging: the project has no Acknowledged alert state, or the
1324
+ * caller may not change the state of every alert that would be
1325
+ * acknowledged (see AlertStateChangeAuthorization; the acknowledgements
1326
+ * themselves are written as root, for exactly these alerts).
1327
+ */
1328
+ @CaptureSpan()
1329
+ public async validateAcknowledgeAlertsForNewIncident(data: {
1330
+ projectId: ObjectID | undefined;
1331
+ acknowledgeAlerts: unknown;
1332
+ alertIds: Array<ObjectID>;
1333
+ props: DatabaseCommonInteractionProps;
1334
+ }): Promise<AlertsToAcknowledgeOnDeclare | null> {
1335
+ if (
1336
+ data.acknowledgeAlerts === undefined ||
1337
+ data.acknowledgeAlerts === null ||
1338
+ data.acknowledgeAlerts === false
1339
+ ) {
1340
+ return null;
1341
+ }
1342
+
1343
+ if (data.acknowledgeAlerts !== true) {
1344
+ throw new BadDataException(
1345
+ `${INCIDENT_ACKNOWLEDGE_ALERTS_TO_LINK_KEY} must be true or false.`,
1346
+ );
1347
+ }
1348
+
1349
+ if (data.alertIds.length === 0) {
1350
+ throw new BadDataException(
1351
+ `${INCIDENT_ACKNOWLEDGE_ALERTS_TO_LINK_KEY} only applies when the incident is declared from alerts: send the alert ids in ${INCIDENT_ALERT_IDS_TO_LINK_KEY}.`,
1352
+ );
1353
+ }
1354
+
1355
+ if (!data.projectId) {
1356
+ throw new BadDataException(
1357
+ "projectId is required to acknowledge alerts.",
1358
+ );
1359
+ }
1360
+
1361
+ const projectId: ObjectID = data.projectId;
1362
+
1363
+ const acknowledgedState: AlertState | null =
1364
+ await AlertStateService.findOneBy({
1365
+ query: {
1366
+ projectId: projectId,
1367
+ isAcknowledgedState: true,
1368
+ },
1369
+ select: {
1370
+ _id: true,
1371
+ order: true,
1372
+ },
1373
+ props: {
1374
+ isRoot: true,
1375
+ },
1376
+ });
1377
+
1378
+ if (
1379
+ !acknowledgedState ||
1380
+ !acknowledgedState._id ||
1381
+ acknowledgedState.order === undefined ||
1382
+ acknowledgedState.order === null
1383
+ ) {
1384
+ throw new BadDataException(
1385
+ "This project has no Acknowledged alert state, so the alerts cannot be acknowledged. Declare the incident without acknowledging them, or add an Acknowledged state in the alert settings.",
1386
+ );
1387
+ }
1388
+
1389
+ const acknowledgedOrder: number = acknowledgedState.order;
1390
+
1391
+ const alerts: Array<Alert> = await AlertService.findBy({
1392
+ query: {
1393
+ _id: QueryHelper.any(data.alertIds),
1394
+ projectId: projectId,
1395
+ },
1396
+ select: {
1397
+ _id: true,
1398
+ currentAlertState: {
1399
+ order: true,
1400
+ },
1401
+ },
1402
+ limit: LIMIT_PER_PROJECT,
1403
+ skip: 0,
1404
+ props: {
1405
+ isRoot: true,
1406
+ },
1407
+ });
1408
+
1409
+ const stateOrderByAlertId: Map<string, number | undefined> = new Map();
1410
+
1411
+ for (const alert of alerts) {
1412
+ if (alert._id) {
1413
+ stateOrderByAlertId.set(
1414
+ normalizeId(alert._id),
1415
+ alert.currentAlertState?.order ?? undefined,
1416
+ );
1417
+ }
1418
+ }
1419
+
1420
+ // Same rule as acknowledgeAlertsDeclaredWithIncident: by order.
1421
+ const alertIdsToAcknowledge: Array<ObjectID> = data.alertIds.filter(
1422
+ (alertId: ObjectID): boolean => {
1423
+ const key: string = normalizeId(alertId);
1424
+
1425
+ if (!stateOrderByAlertId.has(key)) {
1426
+ return false;
1427
+ }
1428
+
1429
+ const order: number | undefined = stateOrderByAlertId.get(key);
1430
+
1431
+ return order === undefined || order < acknowledgedOrder;
1432
+ },
1433
+ );
1434
+
1435
+ if (
1436
+ alertIdsToAcknowledge.length > 0 &&
1437
+ !data.props.isRoot &&
1438
+ !data.props.isMasterAdmin
1439
+ ) {
1440
+ try {
1441
+ await AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
1442
+ projectId: projectId,
1443
+ alertIds: alertIdsToAcknowledge,
1444
+ props: data.props,
1445
+ });
1446
+ } catch (error) {
1447
+ // A lapsed session or an unpaid project keeps its own answer.
1448
+ if (
1449
+ error instanceof NotAuthorizedException ||
1450
+ error instanceof BadDataException
1451
+ ) {
1452
+ throw new BadDataException(
1453
+ "You do not have permission to acknowledge one or more of these alerts. Declare the incident without acknowledging them, or ask a project admin for permission.",
1454
+ );
1455
+ }
1456
+
1457
+ throw error;
1458
+ }
1459
+ }
1460
+
1461
+ return {
1462
+ acknowledgedAlertStateId: new ObjectID(acknowledgedState._id.toString()),
1463
+ alertIdsToAcknowledge: alertIdsToAcknowledge,
1464
+ };
1465
+ }
1466
+
1467
+ /*
1468
+ * Acknowledges the alerts an incident was just declared from, when the
1469
+ * declaration asked for it (INCIDENT_ACKNOWLEDGE_ALERTS_TO_LINK_KEY). An
1470
+ * alert's own on-call escalation and its per-user notifications stop once
1471
+ * the alert is acknowledged (the workers check at their next run), so this
1472
+ * is what makes declaring an incident stop the alerts paging.
1473
+ *
1474
+ * Written as root - the caller's right to change the state of every one of
1475
+ * these alerts was checked before the incident was created - and credited
1476
+ * to the declaring user, as if they had pressed Acknowledge on each alert
1477
+ * themselves: the alert's owners are told, and the alert's feed says who
1478
+ * and why. A private incident is not named in that "why", because the
1479
+ * alert's feed entry is posted to the alert's Slack / Microsoft Teams
1480
+ * channels and its owners' notifications.
1481
+ *
1482
+ * Left as they are:
1483
+ * - alerts already acknowledged, resolved or in any later state - nothing
1484
+ * is ever moved backwards;
1485
+ * - linked alerts the project's linked-alert sync is about to move anyway
1486
+ * (the incident was declared straight into an acknowledged or resolved
1487
+ * state with the project switches on): one writer per alert, so the two
1488
+ * never race on its timeline.
1489
+ *
1490
+ * IncidentService runs this after the links are written and does not wait
1491
+ * for it. It never throws: one alert failing never stops the others.
1492
+ */
1493
+ @CaptureSpan()
1494
+ public async acknowledgeAlertsDeclaredWithIncident(data: {
1495
+ projectId: ObjectID;
1496
+ incidentId: ObjectID;
1497
+ alertIds: Array<ObjectID>;
1498
+ // The alerts whose link to the incident exists (the sync runs on those).
1499
+ linkedAlertIds: Array<ObjectID>;
1500
+ acknowledgedByUserId: ObjectID | undefined;
1501
+ }): Promise<AcknowledgeDeclaredAlertsResult> {
1502
+ const result: AcknowledgeDeclaredAlertsResult = {
1503
+ acknowledgedAlertIds: [],
1504
+ alreadyAcknowledgedAlertIds: [],
1505
+ leftToLinkedAlertSyncAlertIds: [],
1506
+ failed: [],
1507
+ };
1508
+
1509
+ const alertIds: Array<ObjectID> = uniqueIds(data.alertIds);
1510
+
1511
+ if (alertIds.length === 0) {
1512
+ return result;
1513
+ }
1514
+
1515
+ const logAttributes: LogAttributes = {
1516
+ projectId: data.projectId.toString(),
1517
+ incidentId: data.incidentId.toString(),
1518
+ } as LogAttributes;
1519
+
1520
+ try {
1521
+ const acknowledgedState: AlertState | null =
1522
+ await AlertStateService.findOneBy({
1523
+ query: {
1524
+ projectId: data.projectId,
1525
+ isAcknowledgedState: true,
1526
+ },
1527
+ select: {
1528
+ _id: true,
1529
+ order: true,
1530
+ },
1531
+ props: {
1532
+ isRoot: true,
1533
+ },
1534
+ });
1535
+
1536
+ if (
1537
+ !acknowledgedState ||
1538
+ !acknowledgedState._id ||
1539
+ acknowledgedState.order === undefined ||
1540
+ acknowledgedState.order === null
1541
+ ) {
1542
+ const message: string =
1543
+ "This project has no Acknowledged alert state, so the alerts could not be acknowledged.";
1544
+
1545
+ logger.error(`IncidentAlertService: ${message}`, logAttributes);
1546
+
1547
+ for (const alertId of alertIds) {
1548
+ result.failed.push({ alertId: alertId, message: message });
1549
+ }
1550
+
1551
+ return result;
1552
+ }
1553
+
1554
+ const acknowledgedStateId: ObjectID = new ObjectID(
1555
+ acknowledgedState._id.toString(),
1556
+ );
1557
+ const acknowledgedOrder: number = acknowledgedState.order;
1558
+
1559
+ const incident: Incident | null = await IncidentService.findOneById({
1560
+ id: data.incidentId,
1561
+ select: {
1562
+ incidentNumber: true,
1563
+ incidentNumberWithPrefix: true,
1564
+ isPrivate: true,
1565
+ currentIncidentStateId: true,
1566
+ },
1567
+ props: {
1568
+ isRoot: true,
1569
+ },
1570
+ });
1571
+
1572
+ const rootCause: string = getDeclaredAlertAcknowledgementCause({
1573
+ incidentNumber: formatNumber(
1574
+ incident?.incidentNumberWithPrefix,
1575
+ incident?.incidentNumber,
1576
+ ),
1577
+ isIncidentPrivate: incident?.isPrivate === true,
1578
+ });
1579
+
1580
+ const alerts: Array<Alert> = await AlertService.findBy({
1581
+ query: {
1582
+ _id: QueryHelper.any(alertIds),
1583
+ projectId: data.projectId,
1584
+ },
1585
+ select: {
1586
+ _id: true,
1587
+ currentAlertStateId: true,
1588
+ currentAlertState: {
1589
+ order: true,
1590
+ },
1591
+ },
1592
+ limit: LIMIT_PER_PROJECT,
1593
+ skip: 0,
1594
+ props: {
1595
+ isRoot: true,
1596
+ },
1597
+ });
1598
+
1599
+ const alertById: Map<string, Alert> = new Map();
1600
+
1601
+ for (const alert of alerts) {
1602
+ if (alert._id) {
1603
+ alertById.set(normalizeId(alert._id), alert);
1604
+ }
1605
+ }
1606
+
1607
+ const ownedBySync: Set<string> =
1608
+ await this.getLinkedAlertsTheSyncWillMove({
1609
+ projectId: data.projectId,
1610
+ incidentId: data.incidentId,
1611
+ incidentStateId: incident?.currentIncidentStateId || undefined,
1612
+ linkedAlerts: uniqueIds(data.linkedAlertIds)
1613
+ .map((alertId: ObjectID): Alert | undefined => {
1614
+ return alertById.get(normalizeId(alertId));
1615
+ })
1616
+ .filter((alert: Alert | undefined): boolean => {
1617
+ return Boolean(alert);
1618
+ }) as Array<Alert>,
1619
+ });
1620
+
1621
+ const alertIdsToWrite: Array<ObjectID> = [];
1622
+
1623
+ for (const alertId of alertIds) {
1624
+ const key: string = normalizeId(alertId);
1625
+ const alert: Alert | undefined = alertById.get(key);
1626
+
1627
+ if (!alert) {
1628
+ result.failed.push({
1629
+ alertId: alertId,
1630
+ message: "The alert could not be found in this project.",
1631
+ });
1632
+ continue;
1633
+ }
1634
+
1635
+ const stateOrder: number | undefined =
1636
+ alert.currentAlertState?.order ?? undefined;
1637
+
1638
+ if (stateOrder !== undefined && stateOrder >= acknowledgedOrder) {
1639
+ result.alreadyAcknowledgedAlertIds.push(alertId);
1640
+ continue;
1641
+ }
1642
+
1643
+ if (ownedBySync.has(key)) {
1644
+ result.leftToLinkedAlertSyncAlertIds.push(alertId);
1645
+ continue;
1646
+ }
1647
+
1648
+ alertIdsToWrite.push(alertId);
1649
+ }
1650
+
1651
+ for (
1652
+ let index: number = 0;
1653
+ index < alertIdsToWrite.length;
1654
+ index += DECLARED_ALERT_ACKNOWLEDGE_CONCURRENCY
1655
+ ) {
1656
+ const batch: Array<ObjectID> = alertIdsToWrite.slice(
1657
+ index,
1658
+ index + DECLARED_ALERT_ACKNOWLEDGE_CONCURRENCY,
1659
+ );
1660
+
1661
+ const outcomes: Array<string | null> = await Promise.all(
1662
+ batch.map((alertId: ObjectID): Promise<string | null> => {
1663
+ return this.acknowledgeDeclaredAlert({
1664
+ projectId: data.projectId,
1665
+ alertId: alertId,
1666
+ acknowledgedStateId: acknowledgedStateId,
1667
+ acknowledgedOrder: acknowledgedOrder,
1668
+ rootCause: rootCause,
1669
+ acknowledgedByUserId: data.acknowledgedByUserId,
1670
+ });
1671
+ }),
1672
+ );
1673
+
1674
+ batch.forEach((alertId: ObjectID, batchIndex: number) => {
1675
+ const failure: string | null = outcomes[batchIndex] ?? null;
1676
+
1677
+ if (failure === null) {
1678
+ result.acknowledgedAlertIds.push(alertId);
1679
+ return;
1680
+ }
1681
+
1682
+ result.failed.push({ alertId: alertId, message: failure });
1683
+
1684
+ logger.error(
1685
+ `IncidentAlertService could not acknowledge alert ${alertId.toString()} for the incident it was declared with: ${failure}`,
1686
+ {
1687
+ ...logAttributes,
1688
+ alertId: alertId.toString(),
1689
+ } as LogAttributes,
1690
+ );
1691
+ });
1692
+ }
1693
+ } catch (error) {
1694
+ logger.error(
1695
+ `IncidentAlertService could not acknowledge the alerts an incident was declared from: ${error}`,
1696
+ logAttributes,
1697
+ );
1698
+
1699
+ const settled: Set<string> = new Set(
1700
+ [
1701
+ ...result.acknowledgedAlertIds,
1702
+ ...result.alreadyAcknowledgedAlertIds,
1703
+ ...result.leftToLinkedAlertSyncAlertIds,
1704
+ ...result.failed.map((failure: { alertId: ObjectID }) => {
1705
+ return failure.alertId;
1706
+ }),
1707
+ ].map((alertId: ObjectID): string => {
1708
+ return normalizeId(alertId);
1709
+ }),
1710
+ );
1711
+
1712
+ for (const alertId of alertIds) {
1713
+ if (!settled.has(normalizeId(alertId))) {
1714
+ result.failed.push({
1715
+ alertId: alertId,
1716
+ message: error instanceof Error ? error.message : String(error),
1717
+ });
1718
+ }
1719
+ }
1720
+ }
1721
+
1722
+ return result;
1723
+ }
1724
+
1725
+ /*
1726
+ * The linked alerts the project's linked-alert sync (and the incident
1727
+ * state cascade) will move by themselves, because the incident is already
1728
+ * in a state their switches act on: the same plan, the same per-alert
1729
+ * choice. Empty when both switches are off - the default - or the incident
1730
+ * is before Acknowledged, which is the usual declaration.
1731
+ */
1732
+ @CaptureSpan()
1733
+ private async getLinkedAlertsTheSyncWillMove(data: {
1734
+ projectId: ObjectID;
1735
+ incidentId: ObjectID;
1736
+ incidentStateId: ObjectID | undefined;
1737
+ linkedAlerts: Array<Alert>;
1738
+ }): Promise<Set<string>> {
1739
+ const willMove: Set<string> = new Set();
1740
+
1741
+ if (!data.incidentStateId || data.linkedAlerts.length === 0) {
1742
+ return willMove;
1743
+ }
1744
+
1745
+ const switches: LinkedAlertSwitches | null =
1746
+ await this.getLinkedAlertSwitches(data.projectId);
1747
+
1748
+ if (!switches) {
1749
+ return willMove;
1750
+ }
1751
+
1752
+ const plan: LinkedAlertStatePlan | null =
1753
+ await this.buildLinkedAlertStatePlan({
1754
+ projectId: data.projectId,
1755
+ incidentStateId: data.incidentStateId,
1756
+ switches: switches,
1757
+ });
1758
+
1759
+ if (!plan) {
1760
+ return willMove;
1761
+ }
1762
+
1763
+ const blockedFromResolve: Set<string> = plan.resolvedAlertState
1764
+ ? await this.getAlertsHeldOpenByAnotherIncident({
1765
+ plan: plan,
1766
+ projectId: data.projectId,
1767
+ incidentId: data.incidentId,
1768
+ alerts: data.linkedAlerts,
1769
+ })
1770
+ : new Set();
1771
+
1772
+ for (const alert of data.linkedAlerts) {
1773
+ const alertId: string = alert._id ? normalizeId(alert._id) : "";
1774
+
1775
+ const alertStateOrder: number | undefined = alert.currentAlertStateId
1776
+ ? plan.alertStateOrderById.get(normalizeId(alert.currentAlertStateId))
1777
+ : undefined;
1778
+
1779
+ if (!alertId || alertStateOrder === undefined) {
1780
+ continue;
1781
+ }
1782
+
1783
+ const target: AlertState | null = chooseLinkedAlertTargetState({
1784
+ alertStateOrder: alertStateOrder,
1785
+ acknowledgedAlertState: plan.acknowledgedAlertState,
1786
+ resolvedAlertState: plan.resolvedAlertState,
1787
+ isBlockedFromResolve: blockedFromResolve.has(alertId),
1788
+ });
1789
+
1790
+ if (target) {
1791
+ willMove.add(alertId);
1792
+ }
1793
+ }
1794
+
1795
+ return willMove;
1796
+ }
1797
+
1798
+ /*
1799
+ * Acknowledges one declared alert. Null when the alert ended up
1800
+ * acknowledged (or past it), otherwise why not. Never throws.
1801
+ *
1802
+ * The outcome is read back from the alert rather than taken from the
1803
+ * write: the write may be refused because somebody acknowledged or
1804
+ * resolved the alert in the meantime (the outcome asked for), and a write
1805
+ * that "succeeded" only counts if the alert's current state moved.
1806
+ */
1807
+ @CaptureSpan()
1808
+ private async acknowledgeDeclaredAlert(data: {
1809
+ projectId: ObjectID;
1810
+ alertId: ObjectID;
1811
+ acknowledgedStateId: ObjectID;
1812
+ acknowledgedOrder: number;
1813
+ rootCause: string;
1814
+ acknowledgedByUserId: ObjectID | undefined;
1815
+ }): Promise<string | null> {
1816
+ let failure: string = "The alert's state did not change to Acknowledged.";
1817
+
1818
+ try {
1819
+ await AlertService.changeAlertState({
1820
+ projectId: data.projectId,
1821
+ alertId: data.alertId,
1822
+ alertStateId: data.acknowledgedStateId,
1823
+ notifyOwners: true,
1824
+ rootCause: data.rootCause,
1825
+ stateChangeLog: undefined,
1826
+ createdByUserId: data.acknowledgedByUserId,
1827
+ props: {
1828
+ isRoot: true,
1829
+ },
1830
+ });
1831
+ } catch (error) {
1832
+ failure = error instanceof Error ? error.message : String(error);
1833
+ }
1834
+
1835
+ try {
1836
+ const alert: Alert | null = await AlertService.findOneBy({
1837
+ query: {
1838
+ _id: data.alertId,
1839
+ projectId: data.projectId,
1840
+ },
1841
+ select: {
1842
+ currentAlertState: {
1843
+ order: true,
1844
+ },
1845
+ },
1846
+ props: {
1847
+ isRoot: true,
1848
+ },
1849
+ });
1850
+
1851
+ const order: number | undefined =
1852
+ alert?.currentAlertState?.order ?? undefined;
1853
+
1854
+ if (order !== undefined && order >= data.acknowledgedOrder) {
1855
+ return null;
1856
+ }
1857
+ } catch {
1858
+ // The alert cannot be read back, so it cannot be counted as done.
1859
+ }
1860
+
1861
+ return failure;
1862
+ }
1863
+
1260
1864
  /*
1261
1865
  * An incident changed state: bring its linked alerts along, when the
1262
1866
  * project has opted in. Called fire-and-forget from
@@ -1355,8 +1959,11 @@ export class Service extends DatabaseService<Model> {
1355
1959
  /*
1356
1960
  * The link-time half: an alert was just linked to an incident that may
1357
1961
  * already be acknowledged or resolved. Same rules as the cascade, applied to
1358
- * the one alert. A newly declared incident is in its created state, so
1359
- * this is a no-op for "declare incident from alerts". Never throws.
1962
+ * the one alert. An incident declared from alerts usually starts in its
1963
+ * created state, which calls for nothing; one declared straight into an
1964
+ * acknowledged or resolved state brings its alerts along here (and
1965
+ * acknowledgeAlertsDeclaredWithIncident leaves those alerts to this).
1966
+ * Never throws.
1360
1967
  */
1361
1968
  @CaptureSpan()
1362
1969
  public async syncAlertWithLinkedIncidentState(data: {