@oneuptime/common 12.0.10 → 12.0.12

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 (98) hide show
  1. package/Models/DatabaseModels/NetworkDevice.ts +44 -0
  2. package/Models/DatabaseModels/NetworkDeviceLink.ts +116 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.ts +52 -0
  4. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  5. package/Server/Services/NetworkDeviceLinkService.ts +338 -2
  6. package/Server/Types/Billing/MeteredPlan/AllMeteredPlans.ts +19 -5
  7. package/Server/Types/Database/JSONColumnQuery.ts +42 -4
  8. package/Server/Types/Workflow/Components/Conditions/IfElse.ts +9 -0
  9. package/Server/Types/Workflow/Components/JavaScript.ts +9 -0
  10. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +12 -0
  11. package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +186 -38
  12. package/Server/Utils/VM/VMRunner.ts +693 -874
  13. package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +420 -112
  14. package/Tests/App/Dashboard/PayAsYouGoConsentGate.test.tsx +543 -0
  15. package/Tests/App/Dashboard/PayAsYouGoFreePlan.test.ts +125 -0
  16. package/Tests/App/Dashboard/PayAsYouGoNotices.test.tsx +612 -0
  17. package/Tests/App/Dashboard/SloWidgetChartRendering.test.tsx +536 -0
  18. package/Tests/Models/AnalyticsModels/CommonModel.test.ts +312 -0
  19. package/Tests/Server/Infrastructure/Postgres/DeviceRoleAndDeclaredLinkParentMigration.test.ts +250 -0
  20. package/Tests/Server/Infrastructure/Postgres/EpisodeMemberNotifyIndexesMigration.test.ts +46 -18
  21. package/Tests/Server/Services/NetworkDeviceLinkParentValidation.test.ts +1043 -0
  22. package/Tests/Server/Types/Billing/PayAsYouGoMeteredPricing.test.ts +125 -0
  23. package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +508 -0
  24. package/Tests/Server/Types/Workflow/Components/CustomCodeScriptError.test.ts +121 -0
  25. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorJavascriptExpression.test.ts +92 -0
  26. package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +668 -0
  27. package/Tests/Server/Utils/VM/VMRunnerIsolation.test.ts +385 -0
  28. package/Tests/Server/Utils/VM/VMRunnerSsrf.test.ts +3 -0
  29. package/Tests/Types/Billing/PayAsYouGoPricing.test.ts +64 -0
  30. package/Tests/Types/CustomField/CustomFieldTypeCorrespondence.test.ts +107 -0
  31. package/Tests/Types/Monitor/MonitorType.test.ts +62 -0
  32. package/Tests/UI/Components/Charts/ChartXAxisDataKey.test.tsx +256 -0
  33. package/Tests/UI/Components/Charts/DataPointUtil.test.ts +323 -0
  34. package/Tests/UI/Components/ModelTable/CustomFieldColumns.test.tsx +349 -1
  35. package/Tests/Utils/Monitor/NetworkDeviceRoleUtil.test.ts +502 -0
  36. package/Tests/Utils/Monitor/NetworkTopologyDeclaredHierarchy.test.ts +759 -0
  37. package/Tests/Utils/Schema/BaseSchema.test.ts +651 -0
  38. package/Types/Billing/PayAsYouGoPricing.ts +47 -0
  39. package/Types/CustomField/CustomFieldType.ts +28 -0
  40. package/Types/Monitor/CustomCodeMonitor/CustomCodeMonitorResponse.ts +14 -2
  41. package/Types/Monitor/MonitorType.ts +11 -0
  42. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +13 -0
  43. package/UI/Components/Charts/Area/AreaChart.tsx +4 -2
  44. package/UI/Components/Charts/Bar/BarChart.tsx +4 -2
  45. package/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.ts +13 -0
  46. package/UI/Components/Charts/Line/LineChart.tsx +4 -2
  47. package/UI/Components/Charts/Types/XAxis/XAxis.ts +6 -0
  48. package/UI/Components/Charts/Utils/DataPoint.ts +15 -12
  49. package/UI/Components/ModelTable/CustomFieldColumns.tsx +55 -0
  50. package/Utils/Monitor/NetworkDeviceRoleUtil.ts +45 -0
  51. package/Utils/Monitor/NetworkTopologyUtil.ts +42 -2
  52. package/build/dist/Models/DatabaseModels/NetworkDevice.js +45 -0
  53. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  54. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js +115 -0
  55. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js.map +1 -1
  56. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js +33 -0
  57. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js.map +1 -0
  58. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  59. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  60. package/build/dist/Server/Services/NetworkDeviceLinkService.js +253 -1
  61. package/build/dist/Server/Services/NetworkDeviceLinkService.js.map +1 -1
  62. package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js +12 -5
  63. package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js.map +1 -1
  64. package/build/dist/Server/Types/Database/JSONColumnQuery.js +40 -4
  65. package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -1
  66. package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js +8 -0
  67. package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js.map +1 -1
  68. package/build/dist/Server/Types/Workflow/Components/JavaScript.js +8 -0
  69. package/build/dist/Server/Types/Workflow/Components/JavaScript.js.map +1 -1
  70. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +12 -1
  71. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  72. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +128 -35
  73. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -1
  74. package/build/dist/Server/Utils/VM/VMRunner.js +402 -525
  75. package/build/dist/Server/Utils/VM/VMRunner.js.map +1 -1
  76. package/build/dist/Types/Billing/PayAsYouGoPricing.js +41 -0
  77. package/build/dist/Types/Billing/PayAsYouGoPricing.js.map +1 -0
  78. package/build/dist/Types/CustomField/CustomFieldType.js +26 -0
  79. package/build/dist/Types/CustomField/CustomFieldType.js.map +1 -1
  80. package/build/dist/Types/Monitor/MonitorType.js +10 -0
  81. package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
  82. package/build/dist/UI/Components/Charts/Area/AreaChart.js +2 -1
  83. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  84. package/build/dist/UI/Components/Charts/Bar/BarChart.js +2 -1
  85. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  86. package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js +12 -0
  87. package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js.map +1 -1
  88. package/build/dist/UI/Components/Charts/Line/LineChart.js +2 -1
  89. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  90. package/build/dist/UI/Components/Charts/Utils/DataPoint.js +17 -10
  91. package/build/dist/UI/Components/Charts/Utils/DataPoint.js.map +1 -1
  92. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js +42 -0
  93. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  94. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +37 -0
  95. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -1
  96. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +25 -3
  97. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  98. package/package.json +1 -1
@@ -0,0 +1,759 @@
1
+ import NetworkTopologyUtil, {
2
+ TopologyBuildResult,
3
+ TopologyDeviceInput,
4
+ TopologyManualLinkInput,
5
+ } from "../../../Utils/Monitor/NetworkTopologyUtil";
6
+ import {
7
+ NetworkTopologyEdge,
8
+ NetworkTopologyNode,
9
+ } from "../../../Types/Monitor/SnmpMonitor/NetworkTopology";
10
+
11
+ /*
12
+ * Issue #3192. Two things the map could not previously express, and both
13
+ * of them are statements a PERSON made rather than something SNMP said:
14
+ *
15
+ * - what a device IS, on a box nothing walks. The classifier is
16
+ * evidence-driven and a ping-only device offers it a hostname and
17
+ * nothing else, so `NetworkDevice.deviceRole` is the only answer
18
+ * available at all — and it outranks the classifier when both speak.
19
+ * - which end of a link is the PARENT. LLDP and CDP report a cable, not
20
+ * a hierarchy, so `NetworkDeviceLink.parentDeviceId` is carried onto
21
+ * the edge as `parentNodeId` for the layouts to honour.
22
+ *
23
+ * Both travel through buildTopology, which is what this file exercises:
24
+ * the units are tested next door, this is the wiring.
25
+ */
26
+ describe("NetworkTopologyUtil.buildTopology — operator-declared role and hierarchy", () => {
27
+ const now: Date = new Date("2026-07-22T12:00:00Z");
28
+ const fresh: Date = new Date("2026-07-22T11:55:00Z");
29
+
30
+ const makeDevice: (
31
+ id: string,
32
+ name: string,
33
+ overrides?: Partial<TopologyDeviceInput>,
34
+ ) => TopologyDeviceInput = (
35
+ id: string,
36
+ name: string,
37
+ overrides?: Partial<TopologyDeviceInput>,
38
+ ): TopologyDeviceInput => {
39
+ return {
40
+ id,
41
+ name,
42
+ lastSeenAt: fresh,
43
+ ...overrides,
44
+ };
45
+ };
46
+
47
+ const nodeById: (
48
+ result: TopologyBuildResult,
49
+ id: string,
50
+ ) => NetworkTopologyNode | undefined = (
51
+ result: TopologyBuildResult,
52
+ id: string,
53
+ ): NetworkTopologyNode | undefined => {
54
+ return result.nodes.find((node: NetworkTopologyNode) => {
55
+ return node.id === id;
56
+ });
57
+ };
58
+
59
+ /*
60
+ * Edges are undirected as far as identity goes — which end landed in
61
+ * fromNodeId depends on who reported the link first — so they are looked
62
+ * up by the unordered pair, exactly the way the builder keys them.
63
+ */
64
+ const edgeBetween: (
65
+ result: TopologyBuildResult,
66
+ a: string,
67
+ b: string,
68
+ ) => NetworkTopologyEdge | undefined = (
69
+ result: TopologyBuildResult,
70
+ a: string,
71
+ b: string,
72
+ ): NetworkTopologyEdge | undefined => {
73
+ const wanted: string = [a, b].sort().join("::");
74
+ return result.edges.find((edge: NetworkTopologyEdge) => {
75
+ return [edge.fromNodeId, edge.toNodeId].sort().join("::") === wanted;
76
+ });
77
+ };
78
+
79
+ describe("the operator's device role override", () => {
80
+ it("gives a role to a ping-only device the classifier could never place", () => {
81
+ /*
82
+ * The case the column exists for. Nothing walks this box, so there is
83
+ * no sysDescr, no sysObjectId, no model — and "ping-only-42" is not a
84
+ * naming convention the classifier recognises. Without the override
85
+ * this node is drawn "unknown" forever.
86
+ */
87
+ const result: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
88
+ [
89
+ makeDevice("d1", "ping-only-42", { deviceRole: "switch" }),
90
+ makeDevice("d2", "ping-only-43"),
91
+ ],
92
+ now,
93
+ );
94
+
95
+ expect(nodeById(result, "d1")!.role).toBe("switch");
96
+ // The control: same absence of evidence, no override, still unknown.
97
+ expect(nodeById(result, "d2")!.role).toBe("unknown");
98
+ });
99
+
100
+ it("outranks an SNMP identity that says something else entirely", () => {
101
+ /*
102
+ * The override is the only statement about the role that is not an
103
+ * inference, so it wins even against tier-1 evidence. Real cause: a
104
+ * chassis re-purposed as something its sysDescr never caught up with.
105
+ */
106
+ const result: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
107
+ [
108
+ makeDevice("d1", "core-1", {
109
+ sysDescr: "Cisco IOS Software, Catalyst 9300-48P",
110
+ deviceRole: "firewall",
111
+ }),
112
+ makeDevice("d2", "dmz-1", {
113
+ sysObjectId: "1.3.6.1.4.1.3375.2.1.3.4.10",
114
+ deviceRole: "router",
115
+ }),
116
+ ],
117
+ now,
118
+ );
119
+
120
+ // Classifier alone would answer "switch" and "loadBalancer" here.
121
+ expect(nodeById(result, "d1")!.role).toBe("firewall");
122
+ expect(nodeById(result, "d2")!.role).toBe("router");
123
+ });
124
+
125
+ it("leaves the classifier's answer alone when no override is stored", () => {
126
+ /*
127
+ * The override must be additive: every device that had a correctly
128
+ * derived role before the column existed has to keep it.
129
+ */
130
+ const result: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
131
+ [
132
+ makeDevice("d1", "edge-1", { sysDescr: "FortiGate-60F v7.2.5" }),
133
+ makeDevice("d2", "edge-2", {
134
+ sysDescr: "FortiGate-60F v7.2.5",
135
+ deviceRole: undefined,
136
+ }),
137
+ ],
138
+ now,
139
+ );
140
+
141
+ expect(nodeById(result, "d1")!.role).toBe("firewall");
142
+ expect(nodeById(result, "d2")!.role).toBe("firewall");
143
+ });
144
+
145
+ it("falls back to the classifier rather than blanking a role it cannot parse", () => {
146
+ /*
147
+ * An override nobody can read is not an instruction to draw a neutral
148
+ * node — it is no instruction at all, so the evidence gets its say.
149
+ * "cor switch" is the shape a typo takes; the empty and whitespace
150
+ * cases are what an operator clearing the field actually stores.
151
+ */
152
+ const result: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
153
+ [
154
+ makeDevice("d1", "edge-1", {
155
+ sysDescr: "FortiGate-60F v7.2.5",
156
+ deviceRole: "cor switch",
157
+ }),
158
+ makeDevice("d2", "edge-2", {
159
+ sysDescr: "FortiGate-60F v7.2.5",
160
+ deviceRole: "",
161
+ }),
162
+ makeDevice("d3", "edge-3", {
163
+ sysDescr: "FortiGate-60F v7.2.5",
164
+ deviceRole: " ",
165
+ }),
166
+ ],
167
+ now,
168
+ );
169
+
170
+ expect(nodeById(result, "d1")!.role).toBe("firewall");
171
+ expect(nodeById(result, "d2")!.role).toBe("firewall");
172
+ expect(nodeById(result, "d3")!.role).toBe("firewall");
173
+ });
174
+
175
+ it("refuses 'unknown' as an override so the classifier still gets to run", () => {
176
+ /*
177
+ * Storing "unknown" would silently disable the classifier on a device
178
+ * the operator was only declining to classify. d1 proves the evidence
179
+ * still wins; d2 proves the refusal is not itself an error — a device
180
+ * with nothing to go on lands on "unknown" via the classifier.
181
+ */
182
+ const result: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
183
+ [
184
+ makeDevice("d1", "edge-1", {
185
+ sysDescr: "FortiGate-60F v7.2.5",
186
+ deviceRole: "unknown",
187
+ }),
188
+ makeDevice("d2", "device-42", { deviceRole: "unknown" }),
189
+ ],
190
+ now,
191
+ );
192
+
193
+ expect(nodeById(result, "d1")!.role).toBe("firewall");
194
+ expect(nodeById(result, "d2")!.role).toBe("unknown");
195
+ });
196
+
197
+ it("reads the stored value case-insensitively and ignores surrounding space", () => {
198
+ /*
199
+ * The column is ShortText, so what lands in it depends on which form
200
+ * or import wrote it. A role that only applies when it was typed in
201
+ * exactly the internal camelCase spelling would be a trap.
202
+ */
203
+ const result: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
204
+ [
205
+ makeDevice("d1", "a", { deviceRole: "SWITCH" }),
206
+ makeDevice("d2", "b", { deviceRole: " Router " }),
207
+ makeDevice("d3", "c", { deviceRole: "wirelessaccesspoint" }),
208
+ makeDevice("d4", "d", { deviceRole: "loadBalancer" }),
209
+ ],
210
+ now,
211
+ );
212
+
213
+ expect(nodeById(result, "d1")!.role).toBe("switch");
214
+ expect(nodeById(result, "d2")!.role).toBe("router");
215
+ expect(nodeById(result, "d3")!.role).toBe("wirelessAccessPoint");
216
+ expect(nodeById(result, "d4")!.role).toBe("loadBalancer");
217
+ });
218
+
219
+ it("applies the override to the device that owns it and to nothing else", () => {
220
+ /*
221
+ * A role is a per-node property. The unmanaged peer d1 reports is a
222
+ * different box that nobody has overridden, and it must keep being
223
+ * classified from what it advertises about itself.
224
+ */
225
+ const result: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
226
+ [
227
+ makeDevice("d1", "edge-1", {
228
+ sysName: "edge-1",
229
+ deviceRole: "firewall",
230
+ cdpNeighbors: [
231
+ {
232
+ localInterfaceIndex: 1,
233
+ remoteDeviceId: "ap-lobby",
234
+ remotePlatform: "cisco AIR-CAP3702I-A-K9",
235
+ },
236
+ ],
237
+ }),
238
+ makeDevice("d2", "device-99"),
239
+ ],
240
+ now,
241
+ );
242
+
243
+ expect(nodeById(result, "d1")!.role).toBe("firewall");
244
+ expect(nodeById(result, "unmanaged:ap-lobby")!.role).toBe(
245
+ "wirelessAccessPoint",
246
+ );
247
+ expect(nodeById(result, "d2")!.role).toBe("unknown");
248
+ });
249
+
250
+ it("resolves every device's role independently of the order they arrive in", () => {
251
+ /*
252
+ * Devices come off a query whose order is not guaranteed. A role that
253
+ * depended on it would make the map flicker between rebuilds.
254
+ */
255
+ const devices: Array<TopologyDeviceInput> = [
256
+ makeDevice("d1", "core-1", {
257
+ sysDescr: "Cisco IOS Software, Catalyst 9300-48P",
258
+ deviceRole: "firewall",
259
+ }),
260
+ makeDevice("d2", "edge-1", { sysDescr: "FortiGate-60F v7.2.5" }),
261
+ makeDevice("d3", "ping-only", { deviceRole: "router" }),
262
+ ];
263
+
264
+ const forward: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
265
+ devices,
266
+ now,
267
+ );
268
+ const reversed: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
269
+ [...devices].reverse(),
270
+ now,
271
+ );
272
+
273
+ for (const deviceId of ["d1", "d2", "d3"]) {
274
+ expect(nodeById(reversed, deviceId)!.role).toBe(
275
+ nodeById(forward, deviceId)!.role,
276
+ );
277
+ }
278
+ expect(nodeById(forward, "d1")!.role).toBe("firewall");
279
+ expect(nodeById(forward, "d2")!.role).toBe("firewall");
280
+ expect(nodeById(forward, "d3")!.role).toBe("router");
281
+ });
282
+ });
283
+
284
+ describe("the parent an operator declared on a link", () => {
285
+ it("carries a parent declared as the link's 'from' end onto the edge", () => {
286
+ const result: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
287
+ [makeDevice("d1", "core-1"), makeDevice("d2", "access-1")],
288
+ now,
289
+ [],
290
+ [],
291
+ [{ fromDeviceId: "d1", toDeviceId: "d2", parentDeviceId: "d1" }],
292
+ );
293
+
294
+ expect(result.edges).toHaveLength(1);
295
+ expect(result.edges[0]!.parentNodeId).toBe("d1");
296
+ });
297
+
298
+ it("carries a parent declared as the link's 'to' end just the same", () => {
299
+ /*
300
+ * Which column the operator's parent landed in is an artefact of how
301
+ * they drew the link. The declaration names an END, not a side.
302
+ */
303
+ const result: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
304
+ [makeDevice("d1", "access-1"), makeDevice("d2", "core-1")],
305
+ now,
306
+ [],
307
+ [],
308
+ [{ fromDeviceId: "d1", toDeviceId: "d2", parentDeviceId: "d2" }],
309
+ );
310
+
311
+ expect(result.edges).toHaveLength(1);
312
+ expect(result.edges[0]!.parentNodeId).toBe("d2");
313
+ });
314
+
315
+ it("still draws the link when the declared parent is a third device", () => {
316
+ /*
317
+ * A parent that is not on the link cannot be rendered as one, so the
318
+ * declaration is dropped — but the CABLE is a separate fact and it is
319
+ * still real. Losing the line as well would delete information the
320
+ * operator did get right.
321
+ */
322
+ const result: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
323
+ [
324
+ makeDevice("d1", "core-1"),
325
+ makeDevice("d2", "access-1"),
326
+ makeDevice("d3", "bystander"),
327
+ ],
328
+ now,
329
+ [],
330
+ [],
331
+ [
332
+ {
333
+ fromDeviceId: "d1",
334
+ toDeviceId: "d2",
335
+ name: "IDF-2 uplink",
336
+ parentDeviceId: "d3",
337
+ },
338
+ ],
339
+ );
340
+
341
+ const edge: NetworkTopologyEdge | undefined = edgeBetween(
342
+ result,
343
+ "d1",
344
+ "d2",
345
+ );
346
+ expect(edge).toBeDefined();
347
+ expect(edge!.name).toBe("IDF-2 uplink");
348
+ expect(edge!.parentNodeId).toBeUndefined();
349
+ // The bystander gains nothing from having been named.
350
+ expect(edgeBetween(result, "d1", "d3")).toBeUndefined();
351
+ expect(edgeBetween(result, "d2", "d3")).toBeUndefined();
352
+ });
353
+
354
+ it("skips a link with an end outside the graph, declaration or not", () => {
355
+ /*
356
+ * A site-scoped map, or a link to a device since archived. A declared
357
+ * parent does not make an unattachable link attachable — the pre-existing
358
+ * skip has to happen before the declaration is even looked at.
359
+ */
360
+ const result: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
361
+ [makeDevice("d1", "core-1")],
362
+ now,
363
+ [],
364
+ [],
365
+ [
366
+ {
367
+ fromDeviceId: "d1",
368
+ toDeviceId: "d-elsewhere",
369
+ parentDeviceId: "d1",
370
+ },
371
+ {
372
+ fromDeviceId: "d-elsewhere",
373
+ toDeviceId: "d1",
374
+ parentDeviceId: "d-elsewhere",
375
+ },
376
+ ],
377
+ );
378
+
379
+ expect(result.edges).toHaveLength(0);
380
+ });
381
+
382
+ it("leaves a purely discovered edge with no parent at all", () => {
383
+ /*
384
+ * LLDP reports a cable, not a hierarchy: the direction a neighbour
385
+ * entry happens to be read from says nothing about which box is
386
+ * upstream. Absent must mean "not stated" so the layout keeps
387
+ * inferring instead of trusting an accident of polling order.
388
+ */
389
+ const result: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
390
+ [
391
+ makeDevice("d1", "edge-1", {
392
+ sysName: "edge-1",
393
+ lldpNeighbors: [
394
+ { localInterfaceIndex: 24, remoteSysName: "core-1" },
395
+ ],
396
+ }),
397
+ makeDevice("d2", "core-1", { sysName: "core-1" }),
398
+ ],
399
+ now,
400
+ );
401
+
402
+ expect(result.edges).toHaveLength(1);
403
+ expect(result.edges[0]!.protocols).toEqual(["lldp"]);
404
+ expect(result.edges[0]!.parentNodeId).toBeUndefined();
405
+ });
406
+
407
+ it("declares the same parent whichever order the devices were queried in", () => {
408
+ const devices: Array<TopologyDeviceInput> = [
409
+ makeDevice("d1", "core-1"),
410
+ makeDevice("d2", "access-1"),
411
+ ];
412
+ const links: Array<TopologyManualLinkInput> = [
413
+ { fromDeviceId: "d2", toDeviceId: "d1", parentDeviceId: "d1" },
414
+ ];
415
+
416
+ const forward: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
417
+ devices,
418
+ now,
419
+ [],
420
+ [],
421
+ links,
422
+ );
423
+ const reversed: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
424
+ [...devices].reverse(),
425
+ now,
426
+ [],
427
+ [],
428
+ links,
429
+ );
430
+
431
+ expect(forward.edges[0]!.parentNodeId).toBe("d1");
432
+ expect(reversed.edges[0]!.parentNodeId).toBe("d1");
433
+ });
434
+ });
435
+
436
+ describe("merging a declared parent into an edge that already exists", () => {
437
+ it("keeps one edge carrying both protocols and the declaration", () => {
438
+ /*
439
+ * The whole point of merging rather than doubling: an operator who
440
+ * declares the hierarchy on a cable LLDP already found must not end
441
+ * up with two lines between one pair of boxes.
442
+ */
443
+ const result: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
444
+ [
445
+ makeDevice("d1", "edge-1", {
446
+ sysName: "edge-1",
447
+ lldpNeighbors: [
448
+ {
449
+ localInterfaceIndex: 24,
450
+ remoteSysName: "core-1",
451
+ remotePortId: "Gi0/1",
452
+ },
453
+ ],
454
+ }),
455
+ makeDevice("d2", "core-1", { sysName: "core-1" }),
456
+ ],
457
+ now,
458
+ [],
459
+ [],
460
+ [{ fromDeviceId: "d1", toDeviceId: "d2", parentDeviceId: "d2" }],
461
+ );
462
+
463
+ expect(result.edges).toHaveLength(1);
464
+ expect(result.edges[0]!.protocols).toEqual(["lldp", "manual"]);
465
+ // The measured port survives; the declaration has no discovered rival.
466
+ expect(result.edges[0]!.toPort).toBe("Gi0/1");
467
+ expect(result.edges[0]!.parentNodeId).toBe("d2");
468
+ });
469
+
470
+ it("names the parent by END, not by which side the discovered edge stored", () => {
471
+ /*
472
+ * d2 is the one that reported the neighbour, so the edge is stored
473
+ * d2 -> d1 while the operator drew their link d1 -> d2. If the merge
474
+ * copied a SIDE rather than an id, the hierarchy would come out
475
+ * upside down for exactly half the estate.
476
+ */
477
+ const result: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
478
+ [
479
+ makeDevice("d1", "core-1", { sysName: "core-1" }),
480
+ makeDevice("d2", "access-1", {
481
+ sysName: "access-1",
482
+ lldpNeighbors: [
483
+ { localInterfaceIndex: 1, remoteSysName: "core-1" },
484
+ ],
485
+ }),
486
+ ],
487
+ now,
488
+ [],
489
+ [],
490
+ [{ fromDeviceId: "d1", toDeviceId: "d2", parentDeviceId: "d1" }],
491
+ );
492
+
493
+ expect(result.edges).toHaveLength(1);
494
+ // The discovered direction is untouched...
495
+ expect(result.edges[0]!.fromNodeId).toBe("d2");
496
+ expect(result.edges[0]!.toNodeId).toBe("d1");
497
+ // ...and the parent is still the core switch the operator named.
498
+ expect(result.edges[0]!.parentNodeId).toBe("d1");
499
+ expect(result.edges[0]!.protocols).toEqual(["lldp", "manual"]);
500
+ });
501
+
502
+ it("keeps the FIRST declaration when two links over one pair disagree", () => {
503
+ /*
504
+ * Mirrors the ordering the caller already relies on: explicit links
505
+ * are merged before rule-derived ones, so a hierarchy set on the link
506
+ * itself beats a rule that happens to cover the same pair. The
507
+ * specific statement beats the general one — the same precedence the
508
+ * ports and the name use.
509
+ */
510
+ const result: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
511
+ [makeDevice("d1", "core-1"), makeDevice("d2", "access-1")],
512
+ now,
513
+ [],
514
+ [],
515
+ [
516
+ { fromDeviceId: "d1", toDeviceId: "d2", parentDeviceId: "d1" },
517
+ { fromDeviceId: "d1", toDeviceId: "d2", parentDeviceId: "d2" },
518
+ ],
519
+ );
520
+
521
+ expect(result.edges).toHaveLength(1);
522
+ expect(result.edges[0]!.parentNodeId).toBe("d1");
523
+ });
524
+
525
+ it("takes the second link's declaration when the first stated none", () => {
526
+ /*
527
+ * "First wins" is about first STATEMENT, not first row: a silent link
528
+ * has nothing to defend, so a later one that does name a parent fills
529
+ * the gap rather than being ignored.
530
+ */
531
+ const result: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
532
+ [makeDevice("d1", "core-1"), makeDevice("d2", "access-1")],
533
+ now,
534
+ [],
535
+ [],
536
+ [
537
+ { fromDeviceId: "d1", toDeviceId: "d2", name: "cable A" },
538
+ { fromDeviceId: "d2", toDeviceId: "d1", parentDeviceId: "d1" },
539
+ ],
540
+ );
541
+
542
+ expect(result.edges).toHaveLength(1);
543
+ expect(result.edges[0]!.name).toBe("cable A");
544
+ expect(result.edges[0]!.parentNodeId).toBe("d1");
545
+ });
546
+
547
+ it("never lets an unparseable declaration displace a valid one, in either order", () => {
548
+ /*
549
+ * A parent naming a third device contributes nothing at all, so the
550
+ * one real declaration has to survive whichever row it sits in. This
551
+ * is the order-independence that matters: only DISAGREEING valid
552
+ * declarations are allowed to depend on ordering.
553
+ */
554
+ const validFirst: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
555
+ [
556
+ makeDevice("d1", "core-1"),
557
+ makeDevice("d2", "access-1"),
558
+ makeDevice("d3", "bystander"),
559
+ ],
560
+ now,
561
+ [],
562
+ [],
563
+ [
564
+ { fromDeviceId: "d1", toDeviceId: "d2", parentDeviceId: "d1" },
565
+ { fromDeviceId: "d1", toDeviceId: "d2", parentDeviceId: "d3" },
566
+ ],
567
+ );
568
+
569
+ const invalidFirst: TopologyBuildResult =
570
+ NetworkTopologyUtil.buildTopology(
571
+ [
572
+ makeDevice("d1", "core-1"),
573
+ makeDevice("d2", "access-1"),
574
+ makeDevice("d3", "bystander"),
575
+ ],
576
+ now,
577
+ [],
578
+ [],
579
+ [
580
+ { fromDeviceId: "d1", toDeviceId: "d2", parentDeviceId: "d3" },
581
+ { fromDeviceId: "d1", toDeviceId: "d2", parentDeviceId: "d1" },
582
+ ],
583
+ );
584
+
585
+ expect(edgeBetween(validFirst, "d1", "d2")!.parentNodeId).toBe("d1");
586
+ expect(edgeBetween(invalidFirst, "d1", "d2")!.parentNodeId).toBe("d1");
587
+ });
588
+
589
+ it("is order-independent when two links agree about the parent", () => {
590
+ /*
591
+ * Two rows saying the same thing — an explicit link and a rule that
592
+ * derived the same hierarchy — must not depend on merge order at all.
593
+ */
594
+ const links: Array<TopologyManualLinkInput> = [
595
+ { fromDeviceId: "d1", toDeviceId: "d2", parentDeviceId: "d1" },
596
+ // Stored the other way round, same declaration.
597
+ { fromDeviceId: "d2", toDeviceId: "d1", parentDeviceId: "d1" },
598
+ ];
599
+ const devices: Array<TopologyDeviceInput> = [
600
+ makeDevice("d1", "core-1"),
601
+ makeDevice("d2", "access-1"),
602
+ ];
603
+
604
+ const forward: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
605
+ devices,
606
+ now,
607
+ [],
608
+ [],
609
+ links,
610
+ );
611
+ const reversed: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
612
+ devices,
613
+ now,
614
+ [],
615
+ [],
616
+ [...links].reverse(),
617
+ );
618
+
619
+ expect(forward.edges).toHaveLength(1);
620
+ expect(reversed.edges).toHaveLength(1);
621
+ expect(forward.edges[0]!.parentNodeId).toBe("d1");
622
+ expect(reversed.edges[0]!.parentNodeId).toBe("d1");
623
+ });
624
+
625
+ it("declares a parent on one pair without touching the pairs around it", () => {
626
+ /*
627
+ * A three-device chain where only the middle cable is declared. The
628
+ * layout has to be able to tell a stated hierarchy from an inferred
629
+ * one per edge, so a declaration must not bleed sideways.
630
+ */
631
+ const result: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
632
+ [
633
+ makeDevice("d1", "core-1"),
634
+ makeDevice("d2", "dist-1"),
635
+ makeDevice("d3", "access-1"),
636
+ ],
637
+ now,
638
+ [],
639
+ [],
640
+ [
641
+ { fromDeviceId: "d1", toDeviceId: "d2", parentDeviceId: "d1" },
642
+ { fromDeviceId: "d2", toDeviceId: "d3" },
643
+ ],
644
+ );
645
+
646
+ expect(result.edges).toHaveLength(2);
647
+ expect(edgeBetween(result, "d1", "d2")!.parentNodeId).toBe("d1");
648
+ expect(edgeBetween(result, "d2", "d3")!.parentNodeId).toBeUndefined();
649
+ });
650
+
651
+ it("keeps declarations off the fdb edges that hang endpoints on a switch", () => {
652
+ /*
653
+ * Endpoint attachment is measured, not declared, and its edges are
654
+ * appended after the manual merge entirely. A declared parent on the
655
+ * uplink must not leak onto them.
656
+ */
657
+ const result: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
658
+ [makeDevice("d1", "core-1"), makeDevice("d2", "access-1")],
659
+ now,
660
+ [],
661
+ [
662
+ {
663
+ id: "ep-1",
664
+ macAddress: "aa:bb:cc:dd:ee:01",
665
+ attachedNetworkDeviceId: "d2",
666
+ lastSeenAt: fresh,
667
+ },
668
+ ],
669
+ [{ fromDeviceId: "d1", toDeviceId: "d2", parentDeviceId: "d1" }],
670
+ );
671
+
672
+ expect(edgeBetween(result, "d1", "d2")!.parentNodeId).toBe("d1");
673
+ const fdbEdge: NetworkTopologyEdge | undefined = edgeBetween(
674
+ result,
675
+ "d2",
676
+ "endpoint:ep-1",
677
+ );
678
+ expect(fdbEdge).toBeDefined();
679
+ expect(fdbEdge!.protocols).toEqual(["fdb"]);
680
+ expect(fdbEdge!.parentNodeId).toBeUndefined();
681
+ });
682
+ });
683
+
684
+ describe("both declarations on one graph", () => {
685
+ it("draws a ping-only access switch under its declared parent", () => {
686
+ /*
687
+ * The end-to-end shape of issue #3192: a device that only answers
688
+ * ping, given a role by hand and hung under a core switch by hand,
689
+ * with neither statement needing SNMP to have said anything.
690
+ */
691
+ const result: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
692
+ [
693
+ makeDevice("d1", "core-1", {
694
+ sysName: "core-1",
695
+ sysDescr: "Cisco IOS Software, Catalyst 9300-48P",
696
+ }),
697
+ makeDevice("d2", "idf2-ping-only", {
698
+ deviceRole: "switch",
699
+ lastSeenAt: undefined,
700
+ monitorStatus: "up",
701
+ isNeighborDiscoveryEnabled: false,
702
+ }),
703
+ ],
704
+ now,
705
+ [],
706
+ [],
707
+ [
708
+ {
709
+ fromDeviceId: "d2",
710
+ toDeviceId: "d1",
711
+ name: "IDF-2 uplink",
712
+ parentDeviceId: "d1",
713
+ },
714
+ ],
715
+ );
716
+
717
+ expect(nodeById(result, "d1")!.role).toBe("switch");
718
+ expect(nodeById(result, "d2")!.role).toBe("switch");
719
+ expect(nodeById(result, "d2")!.status).toBe("up");
720
+
721
+ expect(result.edges).toHaveLength(1);
722
+ expect(result.edges[0]!.protocols).toEqual(["manual"]);
723
+ expect(result.edges[0]!.name).toBe("IDF-2 uplink");
724
+ expect(result.edges[0]!.parentNodeId).toBe("d1");
725
+ });
726
+
727
+ it("drops a declared parent along with the node the project hid", () => {
728
+ /*
729
+ * Suppression runs last, on the finished graph, and takes an edge's
730
+ * declaration with the edge. What must NOT happen is the hierarchy
731
+ * surviving as a dangling reference to a node nobody draws.
732
+ */
733
+ const result: TopologyBuildResult = NetworkTopologyUtil.buildTopology(
734
+ [
735
+ makeDevice("d1", "core-1"),
736
+ makeDevice("d2", "dist-1"),
737
+ makeDevice("d3", "access-1"),
738
+ ],
739
+ now,
740
+ [],
741
+ [],
742
+ [
743
+ { fromDeviceId: "d1", toDeviceId: "d2", parentDeviceId: "d1" },
744
+ { fromDeviceId: "d2", toDeviceId: "d3", parentDeviceId: "d2" },
745
+ ],
746
+ new Set<string>(["d1"]),
747
+ );
748
+
749
+ expect(nodeById(result, "d1")).toBeUndefined();
750
+ expect(result.suppressedNodeCount).toBe(1);
751
+ expect(result.edges).toHaveLength(1);
752
+ expect(result.edges[0]!.parentNodeId).toBe("d2");
753
+ // No edge left pointing at the hidden parent.
754
+ for (const edge of result.edges) {
755
+ expect(edge.parentNodeId).not.toBe("d1");
756
+ }
757
+ });
758
+ });
759
+ });