@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.
- package/Models/DatabaseModels/InventoryItem.ts +23 -12
- package/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.ts +217 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Services/AlertService.ts +32 -36
- package/Server/Services/IncidentAlertService.ts +609 -2
- package/Server/Services/IncidentService.ts +134 -44
- package/Server/Services/InventoryItemService.ts +38 -0
- package/Server/Services/IoTDeviceCredentialService.ts +13 -4
- package/Server/Services/ScheduledMaintenanceService.ts +50 -46
- package/Server/Services/UserNotificationRuleService.ts +55 -53
- package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
- package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
- package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
- package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
- package/Server/Utils/Response.ts +31 -0
- package/Server/Utils/Topology/TopologyConcurrencyLimiter.ts +229 -0
- package/Server/Utils/Topology/TopologyQueries.ts +1350 -0
- package/Server/Utils/Topology/TopologyRequest.ts +463 -0
- package/Server/Utils/Topology/TopologyResponseCache.ts +183 -0
- package/Server/Utils/Topology/TopologySql.ts +1024 -0
- package/Tests/App/Dashboard/AcknowledgeAlertsOnDeclare.test.ts +369 -0
- package/Tests/App/Dashboard/AffectedResourcesSlo.test.tsx +1 -1
- package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +510 -123
- package/Tests/App/Dashboard/AlertsTableAffectedResources.test.tsx +490 -0
- package/Tests/App/Dashboard/ChangeAlertStateDeclareIncident.test.tsx +886 -0
- package/Tests/App/Dashboard/CreateReviewStepRoleSummary.test.tsx +1250 -0
- package/Tests/App/Dashboard/CreateReviewStepSelectionSummary.test.tsx +738 -0
- package/Tests/App/Dashboard/DeclareIncidentFromAlert.test.ts +417 -0
- package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +1948 -229
- package/Tests/App/Dashboard/EntityDetailPanelLifecycle.test.tsx +493 -0
- package/Tests/App/Dashboard/EntityDetailPanelNavigation.test.tsx +439 -0
- package/Tests/App/Dashboard/EventOverviewPages.test.tsx +292 -1
- package/Tests/App/Dashboard/EventStatusPanel.test.tsx +798 -2
- package/Tests/App/Dashboard/FetchStateAndSeverityElements.test.tsx +669 -0
- package/Tests/App/Dashboard/FetchUsers.test.tsx +230 -0
- package/Tests/App/Dashboard/IncidentCreateAcknowledgeAlerts.test.tsx +1611 -0
- package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +1365 -57
- package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +964 -23
- package/Tests/App/Dashboard/InventoryRelationships.test.tsx +885 -0
- package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +201 -15
- package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
- package/Tests/App/Dashboard/TopologyDataLifecycle.test.tsx +583 -0
- package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +982 -218
- package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +818 -114
- package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +90 -21
- package/Tests/Server/Services/DomainService.test.ts +525 -0
- package/Tests/Server/Services/IncidentAlertAcknowledgeOnDeclare.test.ts +2630 -0
- package/Tests/Server/Services/IncidentAlertPostgres.test.ts +1625 -1
- package/Tests/Server/Services/IncidentDeclareAcknowledgeAlerts.test.ts +2189 -0
- package/Tests/Server/Services/InventoryItemServiceOverviewCounts.test.ts +345 -0
- package/Tests/Server/Services/InventoryOverviewPostgres.test.ts +254 -0
- package/Tests/Server/Services/IoTDeviceCredentialService.test.ts +819 -0
- package/Tests/Server/Services/LinkedAffectedResourcesCreatedFeed.test.ts +420 -0
- package/Tests/Server/Services/SloAffectedResourceCreatedFeed.test.ts +92 -36
- package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
- package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -0
- package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigration.test.ts +386 -0
- package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigrationPostgres.test.ts +430 -0
- package/Tests/Server/Utils/Topology/TopologyActivityParityPostgres.test.ts +279 -0
- package/Tests/Server/Utils/Topology/TopologyConcurrencyLimiter.test.ts +534 -0
- package/Tests/Server/Utils/Topology/TopologyEstateGenerator.ts +624 -0
- package/Tests/Server/Utils/Topology/TopologyJsonResponse.test.ts +90 -0
- package/Tests/Server/Utils/Topology/TopologyQueries.test.ts +1634 -0
- package/Tests/Server/Utils/Topology/TopologyQueriesDifferentialPostgres.test.ts +683 -0
- package/Tests/Server/Utils/Topology/TopologyQueriesPostgres.test.ts +2624 -0
- package/Tests/Server/Utils/Topology/TopologyReference.ts +1018 -0
- package/Tests/Server/Utils/Topology/TopologyRequest.test.ts +643 -0
- package/Tests/Server/Utils/Topology/TopologyResponseCache.test.ts +305 -0
- package/Tests/Server/Utils/Topology/TopologySql.test.ts +973 -0
- package/Tests/Types/Topology/TopologyTypeRules.test.ts +176 -0
- package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
- package/Types/Incident/IncidentAlertLink.ts +19 -0
- package/Types/Telemetry/InventoryLiveness.ts +26 -0
- package/Types/Topology/TopologyApi.ts +428 -0
- package/Types/Topology/TopologyTypeRules.ts +191 -0
- package/Utils/Incident/IncidentFromAlerts.ts +78 -0
- package/build/dist/Models/DatabaseModels/InventoryItem.js +20 -11
- package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js +165 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/AlertService.js +21 -33
- package/build/dist/Server/Services/AlertService.js.map +1 -1
- package/build/dist/Server/Services/IncidentAlertService.js +457 -3
- package/build/dist/Server/Services/IncidentAlertService.js.map +1 -1
- package/build/dist/Server/Services/IncidentService.js +96 -42
- package/build/dist/Server/Services/IncidentService.js.map +1 -1
- package/build/dist/Server/Services/InventoryItemService.js +31 -0
- package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
- package/build/dist/Server/Services/IoTDeviceCredentialService.js +12 -5
- package/build/dist/Server/Services/IoTDeviceCredentialService.js.map +1 -1
- package/build/dist/Server/Services/ScheduledMaintenanceService.js +37 -40
- package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
- package/build/dist/Server/Services/UserNotificationRuleService.js +55 -51
- package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
- package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +42 -25
- package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -1
- package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js +405 -0
- package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js.map +1 -0
- package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js +112 -0
- package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js.map +1 -0
- package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js +81 -0
- package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
- package/build/dist/Server/Utils/Response.js +30 -0
- package/build/dist/Server/Utils/Response.js.map +1 -1
- package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js +178 -0
- package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologyQueries.js +884 -0
- package/build/dist/Server/Utils/Topology/TopologyQueries.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologyRequest.js +275 -0
- package/build/dist/Server/Utils/Topology/TopologyRequest.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologyResponseCache.js +146 -0
- package/build/dist/Server/Utils/Topology/TopologyResponseCache.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologySql.js +688 -0
- package/build/dist/Server/Utils/Topology/TopologySql.js.map +1 -0
- package/build/dist/Types/Incident/IncidentAlertLink.js +17 -0
- package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -1
- package/build/dist/Types/Telemetry/InventoryLiveness.js +23 -0
- package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -1
- package/build/dist/Types/Topology/TopologyApi.js +55 -0
- package/build/dist/Types/Topology/TopologyApi.js.map +1 -0
- package/build/dist/Types/Topology/TopologyTypeRules.js +165 -0
- package/build/dist/Types/Topology/TopologyTypeRules.js.map +1 -0
- package/build/dist/Utils/Incident/IncidentFromAlerts.js +38 -0
- package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import Alert from "../../../Models/DatabaseModels/Alert";
|
|
2
|
+
import AlertStateTimeline from "../../../Models/DatabaseModels/AlertStateTimeline";
|
|
3
|
+
import DatabaseCommonInteractionProps from "../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
|
|
4
|
+
import { LIMIT_PER_PROJECT } from "../../../Types/Database/LimitMax";
|
|
5
|
+
import NotAuthorizedException from "../../../Types/Exception/NotAuthorizedException";
|
|
6
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
7
|
+
import AlertService from "../../Services/AlertService";
|
|
8
|
+
import ModelPermission from "../../Types/Database/Permissions/Index";
|
|
9
|
+
import Query from "../../Types/Database/Query";
|
|
10
|
+
import QueryHelper from "../../Types/Database/QueryHelper";
|
|
11
|
+
import { applyAlertSelfPrivacyFilter } from "./AlertPrivacyFilter";
|
|
12
|
+
import CaptureSpan from "../Telemetry/CaptureSpan";
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* Whether a caller may change the state of a set of alerts - acknowledge
|
|
16
|
+
* them, say - when that change is going to be written for them as root.
|
|
17
|
+
*
|
|
18
|
+
* Changing an alert's state from its own page takes two writes as the caller:
|
|
19
|
+
* a new AlertStateTimeline row (table and column permissions only), and then
|
|
20
|
+
* the alert's currentAlertStateId, which is an Alert UPDATE and so is narrowed
|
|
21
|
+
* by the caller's label scope, Owned scope and alert privacy. Being able to
|
|
22
|
+
* read an alert is not enough: a Viewer who is also an AlertMember for one
|
|
23
|
+
* label can read every alert but change the state of only some. A root write
|
|
24
|
+
* skips both, so this checks both, for every alert, before anything is
|
|
25
|
+
* written - the same rule the Microsoft Teams acknowledge action applies to
|
|
26
|
+
* one alert.
|
|
27
|
+
*/
|
|
28
|
+
export default class AlertStateChangeAuthorization {
|
|
29
|
+
@CaptureSpan()
|
|
30
|
+
public static async assertCanChangeStateOfAlerts(data: {
|
|
31
|
+
projectId: ObjectID;
|
|
32
|
+
alertIds: Array<ObjectID>;
|
|
33
|
+
props: DatabaseCommonInteractionProps;
|
|
34
|
+
}): Promise<void> {
|
|
35
|
+
if (data.props.isRoot || data.alertIds.length === 0) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const probe: AlertStateTimeline = new AlertStateTimeline();
|
|
40
|
+
probe.projectId = data.projectId;
|
|
41
|
+
probe.alertId = data.alertIds[0]!;
|
|
42
|
+
probe.alertStateId = ObjectID.generate();
|
|
43
|
+
|
|
44
|
+
ModelPermission.checkCreatePermissions(
|
|
45
|
+
AlertStateTimeline,
|
|
46
|
+
probe,
|
|
47
|
+
data.props,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const alertQuery: Query<Alert> = {
|
|
51
|
+
_id: QueryHelper.any(data.alertIds),
|
|
52
|
+
projectId: data.projectId,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/*
|
|
56
|
+
* Labelled block and allow rules are decided per record, from the
|
|
57
|
+
* record's labels.
|
|
58
|
+
*/
|
|
59
|
+
const alerts: Array<Alert> = await AlertService.findBy({
|
|
60
|
+
query: alertQuery,
|
|
61
|
+
select: {
|
|
62
|
+
_id: true,
|
|
63
|
+
labels: {
|
|
64
|
+
_id: true,
|
|
65
|
+
name: true,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
limit: LIMIT_PER_PROJECT,
|
|
69
|
+
skip: 0,
|
|
70
|
+
props: {
|
|
71
|
+
isRoot: true,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
for (const alert of alerts) {
|
|
76
|
+
await ModelPermission.checkUpdatePermissionByModel({
|
|
77
|
+
modelType: Alert,
|
|
78
|
+
fetchModelWithAccessControlIds: async (): Promise<Alert> => {
|
|
79
|
+
return alert;
|
|
80
|
+
},
|
|
81
|
+
props: data.props,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/*
|
|
86
|
+
* The caller's own update scope (labels, Owned, privacy) as a query:
|
|
87
|
+
* every alert must still be in it.
|
|
88
|
+
*/
|
|
89
|
+
const permittedQuery: Query<Alert> =
|
|
90
|
+
await ModelPermission.checkUpdateQueryPermissions(
|
|
91
|
+
Alert,
|
|
92
|
+
alertQuery,
|
|
93
|
+
{
|
|
94
|
+
currentAlertStateId: ObjectID.getZeroObjectID(),
|
|
95
|
+
},
|
|
96
|
+
data.props,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const permittedAlerts: Array<Alert> = await AlertService.findBy({
|
|
100
|
+
query: applyAlertSelfPrivacyFilter(permittedQuery, data.props),
|
|
101
|
+
select: {
|
|
102
|
+
_id: true,
|
|
103
|
+
},
|
|
104
|
+
limit: LIMIT_PER_PROJECT,
|
|
105
|
+
skip: 0,
|
|
106
|
+
props: {
|
|
107
|
+
isRoot: true,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const permittedIds: Set<string> = new Set(
|
|
112
|
+
permittedAlerts.map((alert: Alert): string => {
|
|
113
|
+
return alert._id?.toString().toLowerCase() || "";
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const allPermitted: boolean = data.alertIds.every(
|
|
118
|
+
(alertId: ObjectID): boolean => {
|
|
119
|
+
return permittedIds.has(alertId.toString().toLowerCase());
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
if (!allPermitted) {
|
|
124
|
+
throw new NotAuthorizedException(
|
|
125
|
+
"You do not have permission to change the state of one or more of these alerts.",
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AggregateColumn,
|
|
3
|
+
AggregateRow,
|
|
4
|
+
} from "../../Types/Database/AggregateBy";
|
|
5
|
+
import AggregateResultUtil from "../../Types/Database/AggregateResultUtil";
|
|
6
|
+
import Dictionary from "../../../Types/Dictionary";
|
|
7
|
+
import EntitySource from "../../../Types/Telemetry/EntitySource";
|
|
8
|
+
import {
|
|
9
|
+
InventoryLiveness,
|
|
10
|
+
getInventoryLivenessSql,
|
|
11
|
+
} from "../../../Types/Telemetry/InventoryLiveness";
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
* The Inventory Overview's numbers: the five summary tiles and the per-type
|
|
15
|
+
* breakdown.
|
|
16
|
+
*
|
|
17
|
+
* These used to be folded in the browser from one list read capped at
|
|
18
|
+
* LIMIT_PER_PROJECT, so a project past ten thousand items saw tiles and a
|
|
19
|
+
* breakdown describing whichever ten thousand had been seen most recently,
|
|
20
|
+
* with nothing on the page saying so. They are counted in Postgres instead,
|
|
21
|
+
* in ONE grouped statement: the tiles are sums over the same per-type groups
|
|
22
|
+
* the breakdown lists, so the two are views of one snapshot and cannot
|
|
23
|
+
* disagree.
|
|
24
|
+
*
|
|
25
|
+
* Every tile drills into the Items list, and each filter below is the exact
|
|
26
|
+
* narrowing that tile's list applies — the source facet, or the source facet
|
|
27
|
+
* plus the Stale status facet, whose SQL is the same getInventoryLivenessSql
|
|
28
|
+
* the `inventoryStatus` virtual column is built from.
|
|
29
|
+
*
|
|
30
|
+
* Kept apart from InventoryItemService so the dashboard's tile tests and the
|
|
31
|
+
* Postgres test can use the same columns without loading the service.
|
|
32
|
+
*/
|
|
33
|
+
export const INVENTORY_OVERVIEW_GROUP_BY: Array<AggregateColumn> = [
|
|
34
|
+
{ expression: `"InventoryItem"."entityType"`, alias: "entityType" },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export const INVENTORY_OVERVIEW_SELECT: Array<AggregateColumn> = [
|
|
38
|
+
{ expression: `COUNT(*)`, alias: "itemCount" },
|
|
39
|
+
{
|
|
40
|
+
expression: `COUNT(*) FILTER (WHERE "InventoryItem"."source" = '${EntitySource.Discovered}')`,
|
|
41
|
+
alias: "discoveredCount",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
expression: `COUNT(*) FILTER (WHERE "InventoryItem"."source" = '${EntitySource.Inventory}')`,
|
|
45
|
+
alias: "mirroredCount",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
expression: `COUNT(*) FILTER (WHERE "InventoryItem"."source" = '${EntitySource.Manual}')`,
|
|
49
|
+
alias: "manualCount",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
// Only discovered rows can be stale; the shared rule says NotTracked for the rest.
|
|
53
|
+
expression: `COUNT(*) FILTER (WHERE (${getInventoryLivenessSql(`"InventoryItem"`)}) = '${InventoryLiveness.Stale}')`,
|
|
54
|
+
alias: "staleCount",
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
export interface InventoryOverviewCounts {
|
|
59
|
+
total: number;
|
|
60
|
+
discovered: number;
|
|
61
|
+
mirrored: number;
|
|
62
|
+
manual: number;
|
|
63
|
+
stale: number;
|
|
64
|
+
// Items per entity type, for the category breakdown. Types that counted zero are absent.
|
|
65
|
+
countsByType: Dictionary<number>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Sum the per-type groups into the tile counts, and key the groups by type
|
|
70
|
+
* for the breakdown.
|
|
71
|
+
*
|
|
72
|
+
* `total` counts every row, including rows whose source this build does not
|
|
73
|
+
* recognise — they land in none of the three source buckets, which is why
|
|
74
|
+
* those are not expected to sum to the total. A group with no type still
|
|
75
|
+
* counts towards every tile; it is only left out of the breakdown, which has
|
|
76
|
+
* no row to put it in.
|
|
77
|
+
*/
|
|
78
|
+
export function readInventoryOverviewGroups(
|
|
79
|
+
rows: Array<AggregateRow>,
|
|
80
|
+
): InventoryOverviewCounts {
|
|
81
|
+
const counts: InventoryOverviewCounts = {
|
|
82
|
+
total: 0,
|
|
83
|
+
discovered: 0,
|
|
84
|
+
mirrored: 0,
|
|
85
|
+
manual: 0,
|
|
86
|
+
stale: 0,
|
|
87
|
+
countsByType: {},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
for (const row of rows) {
|
|
91
|
+
const itemCount: number = AggregateResultUtil.toNumber(row, "itemCount");
|
|
92
|
+
|
|
93
|
+
counts.total += itemCount;
|
|
94
|
+
counts.discovered += AggregateResultUtil.toNumber(row, "discoveredCount");
|
|
95
|
+
counts.mirrored += AggregateResultUtil.toNumber(row, "mirroredCount");
|
|
96
|
+
counts.manual += AggregateResultUtil.toNumber(row, "manualCount");
|
|
97
|
+
counts.stale += AggregateResultUtil.toNumber(row, "staleCount");
|
|
98
|
+
|
|
99
|
+
const entityType: string | null = AggregateResultUtil.toStringOrNull(
|
|
100
|
+
row,
|
|
101
|
+
"entityType",
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
if (entityType && itemCount > 0) {
|
|
105
|
+
counts.countsByType[entityType] =
|
|
106
|
+
(counts.countsByType[entityType] || 0) + itemCount;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return counts;
|
|
111
|
+
}
|
package/Server/Utils/Response.ts
CHANGED
|
@@ -451,6 +451,37 @@ export default class Response {
|
|
|
451
451
|
.send(item);
|
|
452
452
|
}
|
|
453
453
|
|
|
454
|
+
/*
|
|
455
|
+
* Send a body that is ALREADY serialized JSON, as is.
|
|
456
|
+
*
|
|
457
|
+
* For responses built once and handed out many times (the Topology maps
|
|
458
|
+
* cache their serialized payload): sendJsonObjectResponse would parse
|
|
459
|
+
* nothing but re-stringify a multi-megabyte object on every hit, and
|
|
460
|
+
* res.send would hash the whole body for an ETag nobody can use on a POST.
|
|
461
|
+
* writeHead + end skips both, the way sendHtmlResponse does.
|
|
462
|
+
*
|
|
463
|
+
* no-store: the body is one project's inventory, read under the caller's
|
|
464
|
+
* permissions. The audit log gets its size, never the payload.
|
|
465
|
+
*/
|
|
466
|
+
@CaptureSpan()
|
|
467
|
+
public static sendJsonStringResponse(
|
|
468
|
+
_req: ExpressRequest,
|
|
469
|
+
res: ExpressResponse,
|
|
470
|
+
json: string,
|
|
471
|
+
): void {
|
|
472
|
+
const oneUptimeResponse: OneUptimeResponse = res as OneUptimeResponse;
|
|
473
|
+
|
|
474
|
+
Response.setNoCacheHeaders(oneUptimeResponse);
|
|
475
|
+
oneUptimeResponse.logBody = {
|
|
476
|
+
contentType: "application/json",
|
|
477
|
+
characters: json.length,
|
|
478
|
+
};
|
|
479
|
+
oneUptimeResponse.writeHead(200, {
|
|
480
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
481
|
+
});
|
|
482
|
+
oneUptimeResponse.end(json);
|
|
483
|
+
}
|
|
484
|
+
|
|
454
485
|
@CaptureSpan()
|
|
455
486
|
public static sendTextResponse(
|
|
456
487
|
_req: ExpressRequest,
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import TooManyRequestsException from "../../../Types/Exception/TooManyRequestsException";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Bounds the Topology API's database work in one API process.
|
|
5
|
+
*
|
|
6
|
+
* Every Topology request reads in its own snapshot on one pooled connection
|
|
7
|
+
* (TopologyQueries.inReadOnlySnapshot), and a map build is among the heaviest
|
|
8
|
+
* reads in the app — a whole-inventory pass that can take seconds on a large
|
|
9
|
+
* project. The response cache shares a build between viewers of the same
|
|
10
|
+
* (project, minute), but callers choose the minute and the drawer and
|
|
11
|
+
* collection reads are not cached at all, so nothing else stops a burst of
|
|
12
|
+
* them from taking the connection pool (shared with ingest, alerting and
|
|
13
|
+
* every other request on the process) and the database CPU with it.
|
|
14
|
+
*
|
|
15
|
+
* So at most `maxRunning` of them run at once per process, and at most
|
|
16
|
+
* `maxRunningPerProject` for any one project, so one busy project cannot
|
|
17
|
+
* occupy every slot. The rest wait in arrival order WITHOUT holding a
|
|
18
|
+
* connection: a released slot goes to the first waiter whose project is
|
|
19
|
+
* under its own limit, so a project at its limit never holds up another
|
|
20
|
+
* project queued behind it. The queue is bounded too — per process and per
|
|
21
|
+
* project — and a request that finds it full, or waits longer than
|
|
22
|
+
* `maxWaitMs`, is answered 429 (TooManyRequestsException) at once: the
|
|
23
|
+
* Dashboard shows "busy, try again" rather than a request that hangs.
|
|
24
|
+
*
|
|
25
|
+
* A request whose client has gone (the drawer aborts the previous entity's
|
|
26
|
+
* load on every click) gives up its queue place at once and never starts:
|
|
27
|
+
* otherwise one person clicking through the drawer would queue dead reads
|
|
28
|
+
* ahead of the one they are waiting for and fill their project's queue.
|
|
29
|
+
*
|
|
30
|
+
* Per process, like the response cache; there is no cross-process
|
|
31
|
+
* coordination, so the cluster-wide bound is this times the API replicas.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export const TOPOLOGY_BUSY_MESSAGE: string =
|
|
35
|
+
"The topology service is busy. Try again in a moment.";
|
|
36
|
+
|
|
37
|
+
/* Sent as Retry-After with a 429 from the Topology API. */
|
|
38
|
+
export const TOPOLOGY_BUSY_RETRY_AFTER_SECONDS: number = 5;
|
|
39
|
+
|
|
40
|
+
export interface TopologyConcurrencyLimits {
|
|
41
|
+
/* Topology reads (one snapshot each) running at once in this process. */
|
|
42
|
+
maxRunning: number;
|
|
43
|
+
/* Of those, at most this many for one project. */
|
|
44
|
+
maxRunningPerProject: number;
|
|
45
|
+
/* Requests waiting for a slot in this process; one more is a 429. */
|
|
46
|
+
maxWaiting: number;
|
|
47
|
+
/* Of those, at most this many for one project, so it cannot fill the queue. */
|
|
48
|
+
maxWaitingPerProject: number;
|
|
49
|
+
/* Longest a request waits for a slot before it is answered 429. */
|
|
50
|
+
maxWaitMs: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const TOPOLOGY_CONCURRENCY_LIMITS: TopologyConcurrencyLimits = {
|
|
54
|
+
maxRunning: 4,
|
|
55
|
+
maxRunningPerProject: 2,
|
|
56
|
+
maxWaiting: 64,
|
|
57
|
+
maxWaitingPerProject: 16,
|
|
58
|
+
maxWaitMs: 30_000,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/* The caller went away before its read started; nobody is waiting for it. */
|
|
62
|
+
export class TopologyRequestAbandonedError extends Error {
|
|
63
|
+
public constructor() {
|
|
64
|
+
super("The Topology request was abandoned by its client.");
|
|
65
|
+
this.name = "TopologyRequestAbandonedError";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface Waiter {
|
|
70
|
+
projectId: string;
|
|
71
|
+
start: () => void;
|
|
72
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
73
|
+
detach: () => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class TopologyConcurrencyLimiter {
|
|
77
|
+
private running: number = 0;
|
|
78
|
+
private runningByProject: Map<string, number> = new Map<string, number>();
|
|
79
|
+
/*
|
|
80
|
+
* Arrival order. Invariant between calls: no waiter here could start (the
|
|
81
|
+
* process is full, or its project is), so a new request that can start
|
|
82
|
+
* never overtakes one that could have.
|
|
83
|
+
*/
|
|
84
|
+
private waiting: Array<Waiter> = [];
|
|
85
|
+
|
|
86
|
+
public constructor(
|
|
87
|
+
private limits: TopologyConcurrencyLimits = TOPOLOGY_CONCURRENCY_LIMITS,
|
|
88
|
+
) {}
|
|
89
|
+
|
|
90
|
+
/*
|
|
91
|
+
* Runs `work` once a slot for `projectId` is free, and frees it when the
|
|
92
|
+
* work settles, however it settles. Throws TooManyRequestsException
|
|
93
|
+
* without running `work` when the queue is full or the wait too long, and
|
|
94
|
+
* TopologyRequestAbandonedError without running it when `signal` fires
|
|
95
|
+
* first (a read that has started runs to completion).
|
|
96
|
+
*/
|
|
97
|
+
public async run<T>(
|
|
98
|
+
projectId: string,
|
|
99
|
+
work: () => Promise<T>,
|
|
100
|
+
signal?: AbortSignal | undefined,
|
|
101
|
+
): Promise<T> {
|
|
102
|
+
await this.acquire(projectId, signal);
|
|
103
|
+
try {
|
|
104
|
+
if (signal?.aborted) {
|
|
105
|
+
throw new TopologyRequestAbandonedError();
|
|
106
|
+
}
|
|
107
|
+
return await work();
|
|
108
|
+
} finally {
|
|
109
|
+
this.release(projectId);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
public runningCount(projectId?: string): number {
|
|
114
|
+
if (projectId === undefined) {
|
|
115
|
+
return this.running;
|
|
116
|
+
}
|
|
117
|
+
return this.runningByProject.get(projectId) || 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
public waitingCount(projectId?: string): number {
|
|
121
|
+
if (projectId === undefined) {
|
|
122
|
+
return this.waiting.length;
|
|
123
|
+
}
|
|
124
|
+
return this.waiting.filter((waiter: Waiter): boolean => {
|
|
125
|
+
return waiter.projectId === projectId;
|
|
126
|
+
}).length;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private canStart(projectId: string): boolean {
|
|
130
|
+
return (
|
|
131
|
+
this.running < this.limits.maxRunning &&
|
|
132
|
+
this.runningCount(projectId) < this.limits.maxRunningPerProject
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private take(projectId: string): void {
|
|
137
|
+
this.running++;
|
|
138
|
+
this.runningByProject.set(projectId, this.runningCount(projectId) + 1);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private acquire(
|
|
142
|
+
projectId: string,
|
|
143
|
+
signal: AbortSignal | undefined,
|
|
144
|
+
): Promise<void> {
|
|
145
|
+
if (signal?.aborted) {
|
|
146
|
+
return Promise.reject(new TopologyRequestAbandonedError());
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (this.canStart(projectId)) {
|
|
150
|
+
this.take(projectId);
|
|
151
|
+
return Promise.resolve();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (
|
|
155
|
+
this.waiting.length >= this.limits.maxWaiting ||
|
|
156
|
+
this.waitingCount(projectId) >= this.limits.maxWaitingPerProject
|
|
157
|
+
) {
|
|
158
|
+
return Promise.reject(
|
|
159
|
+
new TooManyRequestsException(TOPOLOGY_BUSY_MESSAGE),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return new Promise<void>(
|
|
164
|
+
(resolve: () => void, reject: (error: Error) => void): void => {
|
|
165
|
+
/* Leaves the queue (if still in it) and rejects with `error`. */
|
|
166
|
+
const leave: (error: Error) => void = (error: Error): void => {
|
|
167
|
+
const index: number = this.waiting.indexOf(waiter);
|
|
168
|
+
if (index >= 0) {
|
|
169
|
+
this.waiting.splice(index, 1);
|
|
170
|
+
waiter.detach();
|
|
171
|
+
reject(error);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
const onAbort: () => void = (): void => {
|
|
175
|
+
leave(new TopologyRequestAbandonedError());
|
|
176
|
+
};
|
|
177
|
+
const waiter: Waiter = {
|
|
178
|
+
projectId,
|
|
179
|
+
start: resolve,
|
|
180
|
+
timer: null,
|
|
181
|
+
detach: (): void => {
|
|
182
|
+
if (waiter.timer) {
|
|
183
|
+
clearTimeout(waiter.timer);
|
|
184
|
+
}
|
|
185
|
+
signal?.removeEventListener("abort", onAbort);
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
if (Number.isFinite(this.limits.maxWaitMs)) {
|
|
189
|
+
waiter.timer = setTimeout((): void => {
|
|
190
|
+
leave(new TooManyRequestsException(TOPOLOGY_BUSY_MESSAGE));
|
|
191
|
+
}, this.limits.maxWaitMs);
|
|
192
|
+
}
|
|
193
|
+
signal?.addEventListener("abort", onAbort);
|
|
194
|
+
this.waiting.push(waiter);
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private release(projectId: string): void {
|
|
200
|
+
this.running--;
|
|
201
|
+
const remaining: number = this.runningCount(projectId) - 1;
|
|
202
|
+
if (remaining > 0) {
|
|
203
|
+
this.runningByProject.set(projectId, remaining);
|
|
204
|
+
} else {
|
|
205
|
+
this.runningByProject.delete(projectId);
|
|
206
|
+
}
|
|
207
|
+
this.startWaiters();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/* Hands free slots to waiters in arrival order, skipping full projects. */
|
|
211
|
+
private startWaiters(): void {
|
|
212
|
+
if (this.waiting.length === 0) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const stillWaiting: Array<Waiter> = [];
|
|
216
|
+
for (const waiter of this.waiting) {
|
|
217
|
+
if (this.canStart(waiter.projectId)) {
|
|
218
|
+
this.take(waiter.projectId);
|
|
219
|
+
waiter.detach();
|
|
220
|
+
waiter.start();
|
|
221
|
+
} else {
|
|
222
|
+
stillWaiting.push(waiter);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
this.waiting = stillWaiting;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export default new TopologyConcurrencyLimiter();
|