@oneuptime/common 14.0.5 → 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 (192) 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/MetricService.ts +1 -1
  10. package/Server/Services/ScheduledMaintenanceService.ts +50 -46
  11. package/Server/Services/UserNotificationRuleService.ts +55 -53
  12. package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
  13. package/Server/Types/Database/QueryHelper.ts +54 -1
  14. package/Server/Utils/AI/Toolbox/MetricTools.ts +13 -2
  15. package/Server/Utils/AI/Toolbox/ResourceTools.ts +12 -1
  16. package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
  17. package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
  18. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +1 -1
  19. package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
  20. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +33 -0
  21. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +4 -3
  22. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +3 -1
  23. package/Server/Utils/Response.ts +31 -0
  24. package/Server/Utils/Topology/TopologyConcurrencyLimiter.ts +229 -0
  25. package/Server/Utils/Topology/TopologyQueries.ts +1350 -0
  26. package/Server/Utils/Topology/TopologyRequest.ts +463 -0
  27. package/Server/Utils/Topology/TopologyResponseCache.ts +183 -0
  28. package/Server/Utils/Topology/TopologySql.ts +1024 -0
  29. package/Tests/App/Dashboard/AcknowledgeAlertsOnDeclare.test.ts +369 -0
  30. package/Tests/App/Dashboard/AffectedResourcesSlo.test.tsx +1 -1
  31. package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +510 -123
  32. package/Tests/App/Dashboard/AlertsTableAffectedResources.test.tsx +490 -0
  33. package/Tests/App/Dashboard/ChangeAlertStateDeclareIncident.test.tsx +886 -0
  34. package/Tests/App/Dashboard/CreateReviewStepRoleSummary.test.tsx +1250 -0
  35. package/Tests/App/Dashboard/CreateReviewStepSelectionSummary.test.tsx +738 -0
  36. package/Tests/App/Dashboard/DeclareIncidentFromAlert.test.ts +417 -0
  37. package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +1948 -229
  38. package/Tests/App/Dashboard/EntityDetailPanelLifecycle.test.tsx +493 -0
  39. package/Tests/App/Dashboard/EntityDetailPanelNavigation.test.tsx +439 -0
  40. package/Tests/App/Dashboard/EventOverviewPages.test.tsx +292 -1
  41. package/Tests/App/Dashboard/EventStatusPanel.test.tsx +798 -2
  42. package/Tests/App/Dashboard/FetchStateAndSeverityElements.test.tsx +669 -0
  43. package/Tests/App/Dashboard/FetchUsers.test.tsx +230 -0
  44. package/Tests/App/Dashboard/IncidentCreateAcknowledgeAlerts.test.tsx +1611 -0
  45. package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +1365 -57
  46. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +964 -23
  47. package/Tests/App/Dashboard/InventoryRelationships.test.tsx +885 -0
  48. package/Tests/App/Dashboard/NetworkTopologyExplorerSearch.test.tsx +1267 -0
  49. package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +4 -1
  50. package/Tests/App/Dashboard/RumOverviewPage.test.tsx +127 -0
  51. package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +201 -15
  52. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
  53. package/Tests/App/Dashboard/SiteSearchBox.test.tsx +2110 -0
  54. package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +168 -8
  55. package/Tests/App/Dashboard/TopologyDataLifecycle.test.tsx +583 -0
  56. package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +982 -218
  57. package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +818 -114
  58. package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +90 -21
  59. package/Tests/Server/Services/DomainService.test.ts +525 -0
  60. package/Tests/Server/Services/IncidentAlertAcknowledgeOnDeclare.test.ts +2630 -0
  61. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +1625 -1
  62. package/Tests/Server/Services/IncidentDeclareAcknowledgeAlerts.test.ts +2189 -0
  63. package/Tests/Server/Services/InventoryItemServiceOverviewCounts.test.ts +345 -0
  64. package/Tests/Server/Services/InventoryOverviewPostgres.test.ts +254 -0
  65. package/Tests/Server/Services/IoTDeviceCredentialService.test.ts +819 -0
  66. package/Tests/Server/Services/LinkedAffectedResourcesCreatedFeed.test.ts +420 -0
  67. package/Tests/Server/Services/MetricServiceAggregate.test.ts +12 -0
  68. package/Tests/Server/Services/SavedViewDefaults.test.ts +325 -0
  69. package/Tests/Server/Services/SloAffectedResourceCreatedFeed.test.ts +92 -36
  70. package/Tests/Server/Services/StatusPageCertificateService.test.ts +169 -0
  71. package/Tests/Server/Services/TeamComplianceSettingService.test.ts +191 -0
  72. package/Tests/Server/Types/Database/QueryHelperSearchAllWords.test.ts +651 -0
  73. package/Tests/Server/Utils/AI/AlertWriteTools.test.ts +256 -0
  74. package/Tests/Server/Utils/AI/IncidentWriteTools.test.ts +482 -0
  75. package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
  76. package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -0
  77. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +54 -0
  78. package/Tests/Server/Utils/Monitor/MetricDecimalThreshold.test.ts +417 -0
  79. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorAffectedResourceEdgeCases.test.ts +96 -0
  80. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigration.test.ts +386 -0
  81. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigrationPostgres.test.ts +430 -0
  82. package/Tests/Server/Utils/Topology/TopologyActivityParityPostgres.test.ts +279 -0
  83. package/Tests/Server/Utils/Topology/TopologyConcurrencyLimiter.test.ts +534 -0
  84. package/Tests/Server/Utils/Topology/TopologyEstateGenerator.ts +624 -0
  85. package/Tests/Server/Utils/Topology/TopologyJsonResponse.test.ts +90 -0
  86. package/Tests/Server/Utils/Topology/TopologyQueries.test.ts +1634 -0
  87. package/Tests/Server/Utils/Topology/TopologyQueriesDifferentialPostgres.test.ts +683 -0
  88. package/Tests/Server/Utils/Topology/TopologyQueriesPostgres.test.ts +2624 -0
  89. package/Tests/Server/Utils/Topology/TopologyReference.ts +1018 -0
  90. package/Tests/Server/Utils/Topology/TopologyRequest.test.ts +643 -0
  91. package/Tests/Server/Utils/Topology/TopologyResponseCache.test.ts +305 -0
  92. package/Tests/Server/Utils/Topology/TopologySql.test.ts +973 -0
  93. package/Tests/Types/BaseDatabase/AggregationType.test.ts +5 -1
  94. package/Tests/Types/Dashboard/KubernetesDashboardTemplate.test.ts +184 -0
  95. package/Tests/Types/Dashboard/KubernetesDashboardTemplates.test.ts +107 -0
  96. package/Tests/Types/Monitor/KubernetesAlertTemplates.test.ts +8 -14
  97. package/Tests/Types/Monitor/KubernetesMetricCatalog.test.ts +4 -3
  98. package/Tests/Types/Monitor/Utils/KubernetesAgentEmittedMetrics.ts +27 -6
  99. package/Tests/Types/Topology/TopologyTypeRules.test.ts +176 -0
  100. package/Tests/UI/Rum/ChunkLoader.test.ts +76 -0
  101. package/Tests/UI/Rum/ReplayRailDetail.test.tsx +53 -0
  102. package/Tests/UI/Rum/ReplaySignals.test.ts +54 -0
  103. package/Tests/Utils/Dashboard/KubernetesClusterCpuTileRepair.test.ts +370 -0
  104. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
  105. package/Tests/Utils/Rules/MonitorTypeRuleCriteria.test.ts +181 -0
  106. package/Types/BaseDatabase/AggregationType.ts +7 -0
  107. package/Types/Dashboard/DashboardTemplates.ts +20 -7
  108. package/Types/Incident/IncidentAlertLink.ts +19 -0
  109. package/Types/Rum/SessionReplayCustomEvents.ts +29 -3
  110. package/Types/Rum/WebVitals.ts +13 -0
  111. package/Types/Telemetry/InventoryLiveness.ts +26 -0
  112. package/Types/Topology/TopologyApi.ts +428 -0
  113. package/Types/Topology/TopologyTypeRules.ts +191 -0
  114. package/UI/Components/Input/Input.tsx +11 -0
  115. package/Utils/Dashboard/KubernetesClusterCpuTileRepair.ts +147 -0
  116. package/Utils/Incident/IncidentFromAlerts.ts +78 -0
  117. package/build/dist/Models/DatabaseModels/InventoryItem.js +20 -11
  118. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
  119. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js +165 -0
  120. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js.map +1 -0
  121. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  122. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  123. package/build/dist/Server/Services/AlertService.js +21 -33
  124. package/build/dist/Server/Services/AlertService.js.map +1 -1
  125. package/build/dist/Server/Services/IncidentAlertService.js +457 -3
  126. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -1
  127. package/build/dist/Server/Services/IncidentService.js +96 -42
  128. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  129. package/build/dist/Server/Services/InventoryItemService.js +31 -0
  130. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  131. package/build/dist/Server/Services/IoTDeviceCredentialService.js +12 -5
  132. package/build/dist/Server/Services/IoTDeviceCredentialService.js.map +1 -1
  133. package/build/dist/Server/Services/MetricService.js +1 -1
  134. package/build/dist/Server/Services/ScheduledMaintenanceService.js +37 -40
  135. package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
  136. package/build/dist/Server/Services/UserNotificationRuleService.js +55 -51
  137. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  138. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +42 -25
  139. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -1
  140. package/build/dist/Server/Types/Database/QueryHelper.js +47 -1
  141. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  142. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js +13 -2
  143. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js.map +1 -1
  144. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js +12 -1
  145. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js.map +1 -1
  146. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js +405 -0
  147. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js.map +1 -0
  148. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js +112 -0
  149. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js.map +1 -0
  150. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +1 -1
  151. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js +81 -0
  152. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
  153. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +31 -0
  154. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  155. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +1 -1
  156. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
  157. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +1 -1
  158. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  159. package/build/dist/Server/Utils/Response.js +30 -0
  160. package/build/dist/Server/Utils/Response.js.map +1 -1
  161. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js +178 -0
  162. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js.map +1 -0
  163. package/build/dist/Server/Utils/Topology/TopologyQueries.js +884 -0
  164. package/build/dist/Server/Utils/Topology/TopologyQueries.js.map +1 -0
  165. package/build/dist/Server/Utils/Topology/TopologyRequest.js +275 -0
  166. package/build/dist/Server/Utils/Topology/TopologyRequest.js.map +1 -0
  167. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js +146 -0
  168. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js.map +1 -0
  169. package/build/dist/Server/Utils/Topology/TopologySql.js +688 -0
  170. package/build/dist/Server/Utils/Topology/TopologySql.js.map +1 -0
  171. package/build/dist/Types/BaseDatabase/AggregationType.js +7 -0
  172. package/build/dist/Types/BaseDatabase/AggregationType.js.map +1 -1
  173. package/build/dist/Types/Dashboard/DashboardTemplates.js +20 -7
  174. package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
  175. package/build/dist/Types/Incident/IncidentAlertLink.js +17 -0
  176. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -1
  177. package/build/dist/Types/Rum/SessionReplayCustomEvents.js.map +1 -1
  178. package/build/dist/Types/Rum/WebVitals.js +12 -0
  179. package/build/dist/Types/Rum/WebVitals.js.map +1 -1
  180. package/build/dist/Types/Telemetry/InventoryLiveness.js +23 -0
  181. package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -1
  182. package/build/dist/Types/Topology/TopologyApi.js +55 -0
  183. package/build/dist/Types/Topology/TopologyApi.js.map +1 -0
  184. package/build/dist/Types/Topology/TopologyTypeRules.js +165 -0
  185. package/build/dist/Types/Topology/TopologyTypeRules.js.map +1 -0
  186. package/build/dist/UI/Components/Input/Input.js +1 -1
  187. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  188. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js +110 -0
  189. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js.map +1 -0
  190. package/build/dist/Utils/Incident/IncidentFromAlerts.js +38 -0
  191. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
  192. package/package.json +1 -1
@@ -17,21 +17,28 @@ import {
17
17
  } from "@testing-library/react";
18
18
  import * as React from "react";
19
19
  import { MemoryRouter } from "react-router-dom";
20
- import InventoryItem from "../../../Models/DatabaseModels/InventoryItem";
21
- import InventoryItemRelationship from "../../../Models/DatabaseModels/InventoryItemRelationship";
22
20
  import EntityType from "../../../Types/Telemetry/EntityType";
23
21
  import EntityRelationshipType from "../../../Types/Telemetry/EntityRelationshipType";
24
22
  import EntitySource from "../../../Types/Telemetry/EntitySource";
25
23
  import TimeRange from "../../../Types/Time/TimeRange";
26
24
  import { ServiceOperationalStatus } from "../../../../App/FeatureSet/Dashboard/src/Components/Topology/OperationalOverlay";
27
25
  import ServiceMapGraph from "../../../../App/FeatureSet/Dashboard/src/Components/Topology/ServiceMapGraph";
26
+ import {
27
+ EntityDetailTarget,
28
+ TopologyEntity,
29
+ TopologyRelationship,
30
+ TopologyRunsOnCount,
31
+ TopologyRunsOnCounts,
32
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/Topology/TopologyData";
28
33
  import getJestMockFunction, { MockFunction } from "../../MockType";
29
34
 
30
35
  /*
31
36
  * The Service Map component: what a person sees first, how they narrow it
32
37
  * down, and how they get to details. Graph drawing itself (React Flow) is
33
- * replaced by a stand-in that renders nodes and edges as buttons, so these
34
- * tests exercise the component's decisions, not the canvas.
38
+ * replaced by a stand-in that renders nodes and edges as buttons, and the
39
+ * detail drawer (which fetches its own connections) by a stand-in that
40
+ * records the props it was given, so these tests exercise the component's
41
+ * decisions, not the canvas or the drawer.
35
42
  */
36
43
 
37
44
  const mockFitView: MockFunction = getJestMockFunction();
@@ -52,6 +59,14 @@ let mockStatuses: Map<string, ServiceOperationalStatus> = new Map<
52
59
  string,
53
60
  ServiceOperationalStatus
54
61
  >();
62
+ /*
63
+ * Every drawer instance, by the entity it was mounted for. The map keeps one
64
+ * drawer while the user moves between entities (a remount would replay the
65
+ * slide-in and drop keyboard focus), so a new entry means a new drawer.
66
+ */
67
+ const mockDrawerMounts: Array<string> = [];
68
+ /* The props of the drawer as last rendered. */
69
+ let mockDrawerProps: Record<string, unknown> | null = null;
55
70
 
56
71
  jest.mock("../../../UI/Utils/Translation", () => {
57
72
  return {
@@ -94,41 +109,143 @@ jest.mock(
94
109
  };
95
110
  },
96
111
  );
112
+ /*
113
+ * The drawer's props contract: a preview of the entity (it fetches the rest
114
+ * itself), the server's range start, and callbacks. Its connection rows are
115
+ * stood in for by buttons that call back the way the real rows do: a
116
+ * "runs on" row opens the Infrastructure view when it can, every other row
117
+ * asks the map to select what it points at.
118
+ */
97
119
  jest.mock(
98
120
  "../../../../App/FeatureSet/Dashboard/src/Components/Topology/EntityDetailPanel",
99
121
  () => {
122
+ type Target = {
123
+ entityKey: string;
124
+ entityType?: string;
125
+ displayName?: string;
126
+ };
100
127
  return {
101
128
  __esModule: true,
102
129
  default: (props: {
103
- entity: InventoryItem;
130
+ entity: Target;
131
+ rangeStart?: Date | null;
104
132
  traffic?: { statusLabel: string; subtitle: string };
133
+ incidentStatus?: { activeIncidentCount: number } | null;
105
134
  onClose: () => void;
106
- onFocus: (key: string) => void;
107
- onSelectEntity?: (key: string) => void;
135
+ onFocus?: (key: string) => void;
136
+ focusButtonLabel?: string;
137
+ onSelectEntity?: (target: Target) => void;
138
+ onOpenInfrastructure?: (key: string) => void;
108
139
  }) => {
140
+ mockDrawerProps = props as unknown as Record<string, unknown>;
141
+ const [mountedFor] = React.useState<string>(props.entity.entityKey);
142
+ React.useEffect(() => {
143
+ mockDrawerMounts.push(props.entity.entityKey);
144
+ }, []);
145
+ const select: (target: Target) => void = (target: Target): void => {
146
+ props.onSelectEntity?.(target);
147
+ };
109
148
  return (
110
- <div role="dialog" aria-label="Service details">
111
- <p>{props.entity.displayName}</p>
149
+ <div
150
+ role="dialog"
151
+ aria-label="Service details"
152
+ data-mounted-for={mountedFor}
153
+ data-entity-key={props.entity.entityKey}
154
+ >
155
+ <p data-testid="drawer-name">
156
+ {props.entity.displayName || props.entity.entityKey}
157
+ </p>
158
+ <p data-testid="drawer-type">{props.entity.entityType || ""}</p>
112
159
  <p data-testid="drawer-status">{props.traffic?.statusLabel}</p>
113
160
  <p data-testid="drawer-subtitle">{props.traffic?.subtitle}</p>
161
+ <p data-testid="drawer-incidents">
162
+ {String(props.incidentStatus?.activeIncidentCount ?? "none")}
163
+ </p>
114
164
  <button onClick={props.onClose}>Close service</button>
165
+ {props.onFocus ? (
166
+ <button
167
+ onClick={() => {
168
+ props.onFocus?.(props.entity.entityKey);
169
+ }}
170
+ >
171
+ {props.focusButtonLabel || "Focus"}
172
+ </button>
173
+ ) : (
174
+ <></>
175
+ )}
115
176
  <button
116
177
  onClick={() => {
117
- props.onFocus(props.entity.entityKey!);
178
+ select({
179
+ entityKey: "postgres",
180
+ entityType: "database",
181
+ displayName: "postgres",
182
+ });
118
183
  }}
119
184
  >
120
- Focus this service
185
+ View related dependency
121
186
  </button>
122
187
  <button
123
188
  onClick={() => {
124
- props.onSelectEntity?.("postgres");
189
+ select({
190
+ entityKey: "web",
191
+ entityType: "service",
192
+ displayName: "web",
193
+ });
125
194
  }}
126
195
  >
127
- View related dependency
196
+ View caller
197
+ </button>
198
+ <button
199
+ onClick={() => {
200
+ select({
201
+ entityKey: "legacy",
202
+ entityType: "service",
203
+ displayName: "legacy",
204
+ });
205
+ }}
206
+ >
207
+ View inactive caller
208
+ </button>
209
+ <button
210
+ onClick={() => {
211
+ select({
212
+ entityKey: "api-instance-1",
213
+ entityType: "service.instance",
214
+ displayName: "api-instance-1",
215
+ });
216
+ }}
217
+ >
218
+ View related instance
219
+ </button>
220
+ <button
221
+ onClick={() => {
222
+ select({ entityKey: "mystery-key" });
223
+ }}
224
+ >
225
+ View untyped resource
226
+ </button>
227
+ <button
228
+ onClick={() => {
229
+ select({
230
+ entityKey: "node-7",
231
+ entityType: "k8s.node",
232
+ displayName: "node-7",
233
+ });
234
+ }}
235
+ >
236
+ View related node
128
237
  </button>
129
238
  <button
130
239
  onClick={() => {
131
- props.onSelectEntity?.("pod-1");
240
+ if (props.onOpenInfrastructure) {
241
+ props.onOpenInfrastructure("pod-1");
242
+ } else {
243
+ select({
244
+ entityKey: "pod-1",
245
+ entityType: "k8s.pod",
246
+ displayName: "pod-1",
247
+ });
248
+ }
132
249
  }}
133
250
  >
134
251
  View placement
@@ -146,8 +263,8 @@ jest.mock(
146
263
  __esModule: true,
147
264
  default: (props: {
148
265
  onClose: () => void;
149
- fromEntity: InventoryItem;
150
- toEntity: InventoryItem;
266
+ fromEntity: TopologyEntity;
267
+ toEntity: TopologyEntity;
151
268
  }) => {
152
269
  return (
153
270
  <div role="dialog" aria-label="Connection details">
@@ -281,9 +398,9 @@ const RANGE_START: Date = new Date("2026-09-06T10:00:00Z");
281
398
 
282
399
  function item(
283
400
  key: string,
284
- type: EntityType,
285
- overrides: Partial<InventoryItem> = {},
286
- ): InventoryItem {
401
+ type: EntityType | string,
402
+ overrides: Partial<TopologyEntity> = {},
403
+ ): TopologyEntity {
287
404
  return {
288
405
  entityKey: key,
289
406
  displayName: key,
@@ -291,7 +408,7 @@ function item(
291
408
  source: EntitySource.Discovered,
292
409
  lastSeenAt: NOW,
293
410
  ...overrides,
294
- } as InventoryItem;
411
+ };
295
412
  }
296
413
 
297
414
  function edge(
@@ -300,7 +417,7 @@ function edge(
300
417
  calls?: number,
301
418
  errors?: number,
302
419
  type: EntityRelationshipType = EntityRelationshipType.DependsOn,
303
- ): InventoryItemRelationship {
420
+ ): TopologyRelationship {
304
421
  return {
305
422
  fromEntityKey: from,
306
423
  toEntityKey: to,
@@ -308,32 +425,54 @@ function edge(
308
425
  callCount: calls,
309
426
  errorCount: errors,
310
427
  avgDurationMs: calls === undefined ? undefined : 25,
311
- } as InventoryItemRelationship;
428
+ };
429
+ }
430
+
431
+ function runsOn(
432
+ entityType: string,
433
+ active: number,
434
+ total: number,
435
+ ): TopologyRunsOnCount {
436
+ return { entityType, active, total };
312
437
  }
313
438
 
314
- const ENTITIES: Array<InventoryItem> = [
439
+ /*
440
+ * What the Service Map payload carries: services, what they call and the
441
+ * depends-on rows between them — no pods or hosts. Where services run comes
442
+ * as counts.
443
+ */
444
+ const ENTITIES: Array<TopologyEntity> = [
315
445
  item("web", EntityType.Service),
316
446
  item("api", EntityType.Service),
317
447
  item("worker", EntityType.Service),
318
448
  item("postgres", EntityType.Database, {
319
449
  descriptiveAttributes: { "db.system.name": "postgresql" },
320
450
  }),
321
- item("pod-1", EntityType.KubernetesPod),
322
451
  item("legacy", EntityType.Service, {
323
452
  lastSeenAt: new Date("2026-08-01T00:00:00Z"),
324
453
  }),
325
454
  ];
326
455
 
327
- const RELATIONSHIPS: Array<InventoryItemRelationship> = [
456
+ const RELATIONSHIPS: Array<TopologyRelationship> = [
328
457
  edge("web", "api", 600, 60),
329
458
  edge("api", "postgres", 900, 0),
330
- edge("api", "pod-1", undefined, undefined, EntityRelationshipType.RunsOn),
331
459
  ];
332
460
 
461
+ const RUNS_ON_COUNTS: TopologyRunsOnCounts = new Map<
462
+ string,
463
+ Array<TopologyRunsOnCount>
464
+ >([
465
+ ["api", [runsOn(EntityType.KubernetesPod, 1, 3)]],
466
+ ["worker", [runsOn(EntityType.Host, 0, 2)]],
467
+ ["legacy", [runsOn(EntityType.Host, 0, 1)]],
468
+ ]);
469
+
333
470
  async function renderGraph(
334
471
  options: {
335
- entities?: Array<InventoryItem>;
336
- relationships?: Array<InventoryItemRelationship>;
472
+ entities?: Array<TopologyEntity>;
473
+ relationships?: Array<TopologyRelationship>;
474
+ /* null renders without counts, the way full rows are fed. */
475
+ runsOnCounts?: TopologyRunsOnCounts | null;
337
476
  includeInactive?: boolean;
338
477
  onOpenInfrastructure?: (key: string) => void;
339
478
  } = {},
@@ -344,6 +483,11 @@ async function renderGraph(
344
483
  <ServiceMapGraph
345
484
  entities={options.entities || ENTITIES}
346
485
  relationships={options.relationships || RELATIONSHIPS}
486
+ runsOnCounts={
487
+ options.runsOnCounts === undefined
488
+ ? RUNS_ON_COUNTS
489
+ : options.runsOnCounts || undefined
490
+ }
347
491
  metricsWindowSeconds={60}
348
492
  timeRange={{ range: TimeRange.PAST_ONE_DAY }}
349
493
  rangeStart={RANGE_START}
@@ -355,11 +499,21 @@ async function renderGraph(
355
499
  });
356
500
  }
357
501
 
502
+ function drawer(): HTMLElement {
503
+ return screen.getByRole("dialog", { name: "Service details" });
504
+ }
505
+
506
+ function drawerTarget(): EntityDetailTarget {
507
+ return mockDrawerProps!["entity"] as EntityDetailTarget;
508
+ }
509
+
358
510
  beforeEach(() => {
359
511
  window.history.replaceState({}, "", "/");
360
512
  mockStatuses = new Map<string, ServiceOperationalStatus>();
361
513
  mockSetQuery.mockClear();
362
514
  mockFitView.mockClear();
515
+ mockDrawerMounts.length = 0;
516
+ mockDrawerProps = null;
363
517
  });
364
518
  afterEach(() => {
365
519
  cleanup();
@@ -430,8 +584,111 @@ describe("first impression", () => {
430
584
  });
431
585
  });
432
586
 
587
+ describe("where services run", () => {
588
+ test("comes from the server's counts of what reported in the range", async () => {
589
+ await renderGraph();
590
+ // api runs on 1 active pod of 3; web has no counts at all.
591
+ expect(screen.getByTestId("map-node-api")).toHaveAttribute(
592
+ "data-footer",
593
+ "Runs on 1 pod",
594
+ );
595
+ expect(screen.getByTestId("map-node-web")).toHaveAttribute(
596
+ "data-footer",
597
+ "",
598
+ );
599
+ // worker runs on 2 hosts, neither of which reported: nothing to say.
600
+ expect(
601
+ within(screen.getByTestId("service-map-unconnected")).getByRole(
602
+ "button",
603
+ { name: /worker/ },
604
+ ),
605
+ ).toHaveTextContent(/^workerService$/);
606
+ });
607
+
608
+ test("counts every resource when inactive ones are shown", async () => {
609
+ await renderGraph({ includeInactive: true });
610
+ expect(screen.getByTestId("map-node-api")).toHaveAttribute(
611
+ "data-footer",
612
+ "Runs on 3 pods",
613
+ );
614
+ const tray: HTMLElement = screen.getByTestId("service-map-unconnected");
615
+ expect(
616
+ within(tray).getByRole("button", { name: /worker/ }),
617
+ ).toHaveTextContent("Service · 2 hosts");
618
+ expect(
619
+ within(tray).getByRole("button", { name: /legacy/ }),
620
+ ).toHaveTextContent("Service · 1 host");
621
+ });
622
+
623
+ test("reads several types most first, in the table too", async () => {
624
+ await renderGraph({
625
+ runsOnCounts: new Map<string, Array<TopologyRunsOnCount>>([
626
+ [
627
+ "api",
628
+ [
629
+ runsOn(EntityType.Host, 2, 2),
630
+ runsOn(EntityType.KubernetesPod, 12, 12),
631
+ ],
632
+ ],
633
+ ]),
634
+ });
635
+ expect(screen.getByTestId("map-node-api")).toHaveAttribute(
636
+ "data-footer",
637
+ "Runs on 12 pods · 2 hosts",
638
+ );
639
+ fireEvent.click(screen.getByTestId("service-map-view-list"));
640
+ expect(
641
+ screen.getByRole("button", { name: "api", exact: true }).closest("tr"),
642
+ ).toHaveTextContent("Service · 12 pods · 2 hosts");
643
+ });
644
+
645
+ test("without counts, is counted from runs-on rows to resources it was given", async () => {
646
+ await renderGraph({
647
+ entities: [
648
+ ...ENTITIES,
649
+ item("pod-1", EntityType.KubernetesPod),
650
+ item("pod-2", EntityType.KubernetesPod),
651
+ item("old-host", EntityType.Host, {
652
+ lastSeenAt: new Date("2026-08-01T00:00:00Z"),
653
+ }),
654
+ ],
655
+ relationships: [
656
+ ...RELATIONSHIPS,
657
+ edge(
658
+ "api",
659
+ "pod-1",
660
+ undefined,
661
+ undefined,
662
+ EntityRelationshipType.RunsOn,
663
+ ),
664
+ edge(
665
+ "api",
666
+ "pod-2",
667
+ undefined,
668
+ undefined,
669
+ EntityRelationshipType.RunsOn,
670
+ ),
671
+ edge(
672
+ "api",
673
+ "old-host",
674
+ undefined,
675
+ undefined,
676
+ EntityRelationshipType.HostedOn,
677
+ ),
678
+ ],
679
+ runsOnCounts: null,
680
+ });
681
+ expect(screen.getByTestId("map-node-api")).toHaveAttribute(
682
+ "data-footer",
683
+ "Runs on 2 pods",
684
+ );
685
+ // Infrastructure is never a node of the Service Map.
686
+ expect(screen.queryByTestId("map-node-pod-1")).not.toBeInTheDocument();
687
+ });
688
+ });
689
+
433
690
  describe("a project with services but no observed calls", () => {
434
- const entities: Array<InventoryItem> = [
691
+ const entities: Array<TopologyEntity> = [
435
692
  item("dashboard", EntityType.Service),
436
693
  item("probe", EntityType.Service),
437
694
  ];
@@ -681,32 +938,215 @@ describe("connections", () => {
681
938
  });
682
939
 
683
940
  describe("details", () => {
684
- test("the drawer navigates to map nodes and hands infrastructure to its own view", async () => {
941
+ test("the drawer opens on a preview of the node and the server's range start", async () => {
942
+ await renderGraph();
943
+ fireEvent.click(screen.getByTestId("map-node-api"));
944
+ expect(drawer()).toHaveAttribute("data-entity-key", "api");
945
+ expect(drawerTarget()).toEqual({
946
+ entityKey: "api",
947
+ entityType: EntityType.Service,
948
+ displayName: "api",
949
+ });
950
+ expect(mockDrawerProps!["rangeStart"]).toBe(RANGE_START);
951
+ expect(mockDrawerProps!["metricsWindowSeconds"]).toBe(60);
952
+ expect(mockDrawerProps!["focusButtonLabel"]).toBe("Show its connections");
953
+ expect(screen.getByTestId("drawer-subtitle")).toHaveTextContent("Service");
954
+ expect(screen.getByTestId("drawer-status")).toHaveTextContent(
955
+ "High error rate",
956
+ );
957
+ // The drawer fetches connections itself: the map hands it no rows.
958
+ expect(mockDrawerProps).not.toHaveProperty("relationships");
959
+ expect(mockDrawerProps).not.toHaveProperty("entityByKey");
960
+ });
961
+
962
+ test("an unnamed node's preview leaves naming to the drawer", async () => {
963
+ await renderGraph({
964
+ entities: [
965
+ item("svc", EntityType.Service, { displayName: undefined }),
966
+ item("db", EntityType.Database, { displayName: undefined }),
967
+ ],
968
+ relationships: [edge("svc", "db", 5, 0)],
969
+ });
970
+ fireEvent.click(screen.getByTestId("map-node-db"));
971
+ expect(drawerTarget()).toEqual({
972
+ entityKey: "db",
973
+ entityType: EntityType.Database,
974
+ displayName: undefined,
975
+ });
976
+ });
977
+
978
+ test("a row pointing at a node of the map selects that node", async () => {
685
979
  const onOpenInfrastructure: MockFunction = getJestMockFunction();
686
980
  await renderGraph({ onOpenInfrastructure });
687
981
  fireEvent.click(screen.getByTestId("map-node-api"));
688
- expect(screen.getByTestId("drawer-subtitle")).toHaveTextContent("Service");
689
982
  fireEvent.click(
690
983
  screen.getByRole("button", { name: "View related dependency" }),
691
984
  );
692
- expect(
693
- screen.getByRole("dialog", { name: "Service details" }),
694
- ).toHaveTextContent("postgres");
985
+ expect(drawer()).toHaveAttribute("data-entity-key", "postgres");
986
+ expect(screen.getByTestId("drawer-name")).toHaveTextContent("postgres");
695
987
  expect(screen.getByTestId("drawer-subtitle")).toHaveTextContent(
696
988
  "Database · PostgreSQL",
697
989
  );
990
+ // The open drawer carries on to the new node: never a remount.
991
+ expect(mockDrawerMounts).toEqual(["api"]);
992
+ expect(drawer()).toHaveAttribute("data-mounted-for", "api");
993
+ expect(onOpenInfrastructure).not.toHaveBeenCalled();
994
+ fireEvent.click(screen.getByRole("button", { name: "View caller" }));
995
+ expect(screen.getByTestId("drawer-name")).toHaveTextContent("web");
996
+ expect(screen.getByTestId("drawer-status")).toHaveTextContent(
997
+ "Entry point",
998
+ );
999
+ expect(mockDrawerMounts).toEqual(["api"]);
1000
+ });
1001
+
1002
+ test("a runs-on row opens the Infrastructure view and closes the drawer", async () => {
1003
+ const onOpenInfrastructure: MockFunction = getJestMockFunction();
1004
+ await renderGraph({ onOpenInfrastructure });
1005
+ fireEvent.click(screen.getByTestId("map-node-api"));
698
1006
  fireEvent.click(screen.getByRole("button", { name: "View placement" }));
1007
+ expect(onOpenInfrastructure).toHaveBeenCalledTimes(1);
699
1008
  expect(onOpenInfrastructure).toHaveBeenCalledWith("pod-1");
700
1009
  expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
701
1010
  });
702
1011
 
1012
+ test("any other infrastructure a row points at opens in the Infrastructure view", async () => {
1013
+ const onOpenInfrastructure: MockFunction = getJestMockFunction();
1014
+ await renderGraph({ onOpenInfrastructure });
1015
+ fireEvent.click(screen.getByTestId("map-node-api"));
1016
+ fireEvent.click(screen.getByRole("button", { name: "View related node" }));
1017
+ expect(onOpenInfrastructure).toHaveBeenCalledWith("node-7");
1018
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
1019
+ });
1020
+
1021
+ test("without an Infrastructure view, infrastructure opens in the drawer", async () => {
1022
+ await renderGraph();
1023
+ fireEvent.click(screen.getByTestId("map-node-api"));
1024
+ fireEvent.click(screen.getByRole("button", { name: "View placement" }));
1025
+ expect(drawer()).toHaveAttribute("data-entity-key", "pod-1");
1026
+ expect(drawerTarget()).toEqual({
1027
+ entityKey: "pod-1",
1028
+ entityType: EntityType.KubernetesPod,
1029
+ displayName: "pod-1",
1030
+ });
1031
+ // Not a node of this map: no traffic, and nothing to focus on.
1032
+ expect(screen.getByTestId("drawer-status")).toBeEmptyDOMElement();
1033
+ expect(mockDrawerProps!["onFocus"]).toBeUndefined();
1034
+ expect(mockDrawerProps!["onOpenInfrastructure"]).toBeUndefined();
1035
+ expect(
1036
+ screen.queryByRole("button", { name: "Show its connections" }),
1037
+ ).not.toBeInTheDocument();
1038
+ });
1039
+
1040
+ test("a service that is not on the map opens in the drawer, not elsewhere", async () => {
1041
+ const onOpenInfrastructure: MockFunction = getJestMockFunction();
1042
+ await renderGraph({ onOpenInfrastructure });
1043
+ fireEvent.click(screen.getByTestId("map-node-api"));
1044
+ fireEvent.click(
1045
+ screen.getByRole("button", { name: "View inactive caller" }),
1046
+ );
1047
+ expect(onOpenInfrastructure).not.toHaveBeenCalled();
1048
+ expect(drawer()).toHaveAttribute("data-entity-key", "legacy");
1049
+ expect(drawerTarget()).toEqual({
1050
+ entityKey: "legacy",
1051
+ entityType: EntityType.Service,
1052
+ displayName: "legacy",
1053
+ });
1054
+ expect(screen.getByTestId("drawer-status")).toBeEmptyDOMElement();
1055
+ expect(screen.getByTestId("drawer-incidents")).toHaveTextContent("none");
1056
+ expect(
1057
+ screen.queryByRole("button", { name: "Show its connections" }),
1058
+ ).not.toBeInTheDocument();
1059
+ });
1060
+
1061
+ test("application types and untyped resources open in the drawer", async () => {
1062
+ const onOpenInfrastructure: MockFunction = getJestMockFunction();
1063
+ await renderGraph({ onOpenInfrastructure });
1064
+ fireEvent.click(screen.getByTestId("map-node-api"));
1065
+ fireEvent.click(
1066
+ screen.getByRole("button", { name: "View related instance" }),
1067
+ );
1068
+ expect(drawer()).toHaveAttribute("data-entity-key", "api-instance-1");
1069
+ fireEvent.click(
1070
+ screen.getByRole("button", { name: "View untyped resource" }),
1071
+ );
1072
+ expect(drawer()).toHaveAttribute("data-entity-key", "mystery-key");
1073
+ expect(drawerTarget()).toEqual({ entityKey: "mystery-key" });
1074
+ expect(onOpenInfrastructure).not.toHaveBeenCalled();
1075
+ });
1076
+
1077
+ test("from a drawer off the map, rows and the map lead back onto it", async () => {
1078
+ await renderGraph();
1079
+ fireEvent.click(screen.getByTestId("map-node-api"));
1080
+ fireEvent.click(
1081
+ screen.getByRole("button", { name: "View inactive caller" }),
1082
+ );
1083
+ fireEvent.click(
1084
+ screen.getByRole("button", { name: "View related dependency" }),
1085
+ );
1086
+ expect(drawer()).toHaveAttribute("data-entity-key", "postgres");
1087
+ expect(
1088
+ screen.getByRole("button", { name: "Show its connections" }),
1089
+ ).toBeInTheDocument();
1090
+ fireEvent.click(
1091
+ screen.getByRole("button", { name: "View inactive caller" }),
1092
+ );
1093
+ fireEvent.click(screen.getByTestId("map-node-web"));
1094
+ expect(drawer()).toHaveAttribute("data-entity-key", "web");
1095
+ expect(screen.getByTestId("drawer-status")).toHaveTextContent(
1096
+ "Entry point",
1097
+ );
1098
+ /* Rows and map clicks alike moved the one open drawer along. */
1099
+ expect(mockDrawerMounts).toEqual(["api"]);
1100
+ });
1101
+
1102
+ test("a connection closes a drawer that is open off the map", async () => {
1103
+ await renderGraph();
1104
+ fireEvent.click(screen.getByTestId("map-node-api"));
1105
+ fireEvent.click(
1106
+ screen.getByRole("button", { name: "View inactive caller" }),
1107
+ );
1108
+ fireEvent.click(screen.getByTestId("map-edge-api->postgres"));
1109
+ expect(
1110
+ screen.queryByRole("dialog", { name: "Service details" }),
1111
+ ).not.toBeInTheDocument();
1112
+ expect(
1113
+ screen.getByRole("dialog", { name: "Connection details" }),
1114
+ ).toBeInTheDocument();
1115
+ });
1116
+
1117
+ test("with inactive services shown, the same service is a node again", async () => {
1118
+ await renderGraph({ includeInactive: true });
1119
+ fireEvent.click(screen.getByTestId("map-node-api"));
1120
+ fireEvent.click(
1121
+ screen.getByRole("button", { name: "View inactive caller" }),
1122
+ );
1123
+ expect(drawer()).toHaveAttribute("data-entity-key", "legacy");
1124
+ expect(screen.getByTestId("drawer-status")).toHaveTextContent(
1125
+ "No calls observed",
1126
+ );
1127
+ expect(
1128
+ screen.getByRole("button", { name: "Show its connections" }),
1129
+ ).toBeInTheDocument();
1130
+ });
1131
+
1132
+ test("closing the drawer clears the selection", async () => {
1133
+ await renderGraph();
1134
+ fireEvent.click(screen.getByTestId("map-node-api"));
1135
+ fireEvent.click(screen.getByRole("button", { name: "Close service" }));
1136
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
1137
+ fireEvent.click(screen.getByTestId("map-node-api"));
1138
+ expect(mockDrawerMounts).toEqual(["api", "api"]);
1139
+ });
1140
+
703
1141
  test("focusing from the drawer opens that node's connections on the map", async () => {
704
1142
  await renderGraph();
705
1143
  fireEvent.click(screen.getByTestId("service-map-view-list"));
706
1144
  fireEvent.click(
707
1145
  screen.getByRole("button", { name: "postgres", exact: true }),
708
1146
  );
709
- fireEvent.click(screen.getByRole("button", { name: "Focus this service" }));
1147
+ fireEvent.click(
1148
+ screen.getByRole("button", { name: "Show its connections" }),
1149
+ );
710
1150
  expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
711
1151
  expect(screen.getByTestId("service-map-view-map")).toHaveAttribute(
712
1152
  "aria-pressed",
@@ -716,6 +1156,38 @@ describe("details", () => {
716
1156
  expect(screen.queryByTestId("map-node-web")).not.toBeInTheDocument();
717
1157
  });
718
1158
 
1159
+ test("a service's incidents reach its drawer; a dependency's never do", async () => {
1160
+ mockStatuses.set("api", {
1161
+ serviceId: "api-id",
1162
+ activeIncidentCount: 2,
1163
+ worstIncidentSeverityName: "Critical",
1164
+ worstIncidentSeverityColor: "#dc2626",
1165
+ incidents: [],
1166
+ activeAlertCount: 0,
1167
+ worstAlertSeverityName: null,
1168
+ worstAlertSeverityColor: null,
1169
+ alerts: [],
1170
+ });
1171
+ mockStatuses.set("postgres", {
1172
+ serviceId: "postgres-id",
1173
+ activeIncidentCount: 5,
1174
+ worstIncidentSeverityName: "Critical",
1175
+ worstIncidentSeverityColor: "#dc2626",
1176
+ incidents: [],
1177
+ activeAlertCount: 0,
1178
+ worstAlertSeverityName: null,
1179
+ worstAlertSeverityColor: null,
1180
+ alerts: [],
1181
+ });
1182
+ await renderGraph();
1183
+ fireEvent.click(screen.getByTestId("map-node-api"));
1184
+ expect(screen.getByTestId("drawer-incidents")).toHaveTextContent("2");
1185
+ fireEvent.click(
1186
+ screen.getByRole("button", { name: "View related dependency" }),
1187
+ );
1188
+ expect(screen.getByTestId("drawer-incidents")).toHaveTextContent("none");
1189
+ });
1190
+
719
1191
  test("incidents take precedence over traffic in status", async () => {
720
1192
  mockStatuses.set("worker", {
721
1193
  serviceId: "worker-id",
@@ -755,7 +1227,7 @@ describe("table", () => {
755
1227
  });
756
1228
 
757
1229
  test("a large catalog opens as a paged table with a hint to narrow it", async () => {
758
- const many: Array<InventoryItem> = Array.from(
1230
+ const many: Array<TopologyEntity> = Array.from(
759
1231
  { length: 130 },
760
1232
  (_value: unknown, index: number) => {
761
1233
  return item(