@oneuptime/common 12.0.7 → 12.0.8

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 (186) hide show
  1. package/Models/DatabaseModels/TelemetryEntity.ts +45 -0
  2. package/Models/DatabaseModels/TelemetryEntityRelationship.ts +26 -0
  3. package/Models/DatabaseModels/WorkflowLog.ts +38 -0
  4. package/Server/API/UserAPI.ts +16 -1
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.ts +41 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.ts +17 -0
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  8. package/Server/Middleware/MasterAdminAuthorization.ts +11 -6
  9. package/Server/Services/TelemetryEntityRelationshipService.ts +3 -0
  10. package/Server/Services/TelemetryEntityService.ts +114 -1
  11. package/Server/Types/Workflow/Components/API/Utils.ts +44 -1
  12. package/Server/Types/Workflow/TriggerCode.ts +12 -0
  13. package/Server/Types/Workflow/Workflow.ts +5 -0
  14. package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +689 -0
  15. package/Server/Utils/VM/VMAPI.ts +56 -8
  16. package/Tests/App/Dashboard/MonitorCriteriaAttributeFilter.test.tsx +574 -0
  17. package/Tests/Server/API/UserProjectsAPI.test.ts +478 -4
  18. package/Tests/Server/Services/TelemetryEntityManualCreate.test.ts +248 -0
  19. package/Tests/Server/Types/Workflow/Components/ApiComponentHeaders.test.ts +192 -0
  20. package/Tests/Server/Utils/AnalyticsDatabase/QuerySettingsHelper.test.ts +152 -0
  21. package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +322 -0
  22. package/Tests/Server/Utils/VM/VMAPISubstitution.test.ts +243 -0
  23. package/Tests/Types/SerializableObjectDictionaryImportCycle.test.ts +197 -0
  24. package/Tests/Types/Telemetry/EntityTypeGroups.test.ts +140 -0
  25. package/Tests/Types/Workflow/StepTrace.test.ts +235 -0
  26. package/Tests/Types/Workflow/TemplateSyntax.test.ts +379 -0
  27. package/Tests/Types/Workflow/Templates.test.ts +403 -0
  28. package/Tests/UI/Components/CustomTimeRangeModal.test.tsx +459 -0
  29. package/Tests/UI/Components/DictionaryAttributeFilterRow.test.tsx +477 -0
  30. package/Tests/UI/Components/Forms/AnchoredFieldPopupKeyboard.test.tsx +382 -0
  31. package/Tests/UI/Components/Forms/ColorPickerPicking.test.tsx +569 -0
  32. package/Tests/UI/Components/Forms/ValidationJSON.test.ts +241 -0
  33. package/Tests/UI/Components/LogTimeRangePicker.test.tsx +42 -0
  34. package/Tests/UI/Components/ModalPortalDismissal.test.tsx +90 -0
  35. package/Tests/UI/Components/TelemetryTimeRangePicker.test.tsx +49 -7
  36. package/Tests/UI/Components/TimeRangePickerDropdown.test.tsx +325 -0
  37. package/Tests/UI/Components/Workflow/GraphLint.test.ts +821 -0
  38. package/Tests/UI/Components/Workflow/ModelColumnEditor.test.ts +287 -0
  39. package/Tests/UI/Components/Workflow/ModelColumnEditorServerContract.test.ts +185 -0
  40. package/Tests/UI/Components/Workflow/ModelSchema.test.ts +174 -0
  41. package/Tests/UI/Components/Workflow/RunStatusWatcher.test.ts +152 -0
  42. package/Tests/UI/Components/Workflow/Utils.test.ts +192 -0
  43. package/Tests/UI/Monitor/MonitorStepCriteriaView.test.tsx +432 -0
  44. package/Tests/Utils/Monitor/NetworkDeviceRoleUtil.test.ts +514 -0
  45. package/Tests/Utils/Monitor/NetworkTopologyUtil.test.ts +240 -0
  46. package/Tests/Utils/Schema/AnalyticsModelSchema.test.ts +469 -0
  47. package/Tests/Utils/Schema/ModelSchema.test.ts +268 -0
  48. package/Tests/Utils/Telemetry/EntityKeyNonTelemetry.test.ts +193 -0
  49. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +31 -0
  50. package/Types/SerializableObjectDictionary.ts +133 -39
  51. package/Types/Telemetry/EntitySource.ts +40 -0
  52. package/Types/Telemetry/EntityType.ts +27 -0
  53. package/Types/Telemetry/EntityTypeGroups.ts +65 -0
  54. package/Types/Workflow/Component.ts +29 -0
  55. package/Types/Workflow/Components/API.ts +35 -0
  56. package/Types/Workflow/Components/BaseModel.ts +7 -3
  57. package/Types/Workflow/StepTrace.ts +181 -0
  58. package/Types/Workflow/TemplateSyntax.ts +499 -0
  59. package/Types/Workflow/Templates.ts +316 -0
  60. package/UI/Components/Date/CustomTimeRangeModal.tsx +278 -0
  61. package/UI/Components/Date/TimeRangePickerDropdown.tsx +250 -0
  62. package/UI/Components/Dictionary/Dictionary.tsx +23 -6
  63. package/UI/Components/Forms/Fields/ColorPicker.tsx +66 -10
  64. package/UI/Components/Forms/Fields/IconPicker.tsx +48 -10
  65. package/UI/Components/Forms/Types/Field.ts +7 -0
  66. package/UI/Components/Forms/Validation.ts +63 -1
  67. package/UI/Components/Input/Input.tsx +31 -3
  68. package/UI/Components/LogsViewer/components/LogTimeRangePicker.tsx +11 -216
  69. package/UI/Components/Modal/Modal.tsx +37 -4
  70. package/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.tsx +11 -210
  71. package/UI/Components/Workflow/ArgumentsForm.tsx +194 -7
  72. package/UI/Components/Workflow/Component.tsx +28 -26
  73. package/UI/Components/Workflow/ComponentSettingsModal.tsx +69 -8
  74. package/UI/Components/Workflow/GraphLint.ts +591 -0
  75. package/UI/Components/Workflow/ModelColumnEditor.tsx +396 -0
  76. package/UI/Components/Workflow/ModelSchema.ts +196 -0
  77. package/UI/Components/Workflow/RunForm.tsx +41 -7
  78. package/UI/Components/Workflow/RunStatusWatcher.ts +122 -0
  79. package/UI/Components/Workflow/StepTraceViewer.tsx +186 -0
  80. package/UI/Components/Workflow/Utils.ts +93 -1
  81. package/UI/Components/Workflow/Workflow.tsx +77 -4
  82. package/UI/Types/LayeredDismissal.ts +31 -0
  83. package/UI/Types/UseAnchoredFieldPopup.ts +99 -0
  84. package/Utils/Monitor/NetworkDeviceRoleUtil.ts +533 -0
  85. package/Utils/Monitor/NetworkTopologyUtil.ts +45 -7
  86. package/Utils/Telemetry/EntityKey.ts +66 -0
  87. package/build/dist/Models/DatabaseModels/TelemetryEntity.js +47 -0
  88. package/build/dist/Models/DatabaseModels/TelemetryEntity.js.map +1 -1
  89. package/build/dist/Models/DatabaseModels/TelemetryEntityRelationship.js +27 -0
  90. package/build/dist/Models/DatabaseModels/TelemetryEntityRelationship.js.map +1 -1
  91. package/build/dist/Models/DatabaseModels/WorkflowLog.js +39 -0
  92. package/build/dist/Models/DatabaseModels/WorkflowLog.js.map +1 -1
  93. package/build/dist/Server/API/UserAPI.js +16 -1
  94. package/build/dist/Server/API/UserAPI.js.map +1 -1
  95. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.js +20 -0
  96. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.js.map +1 -0
  97. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.js +12 -0
  98. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.js.map +1 -0
  99. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  100. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  101. package/build/dist/Server/Middleware/MasterAdminAuthorization.js +11 -6
  102. package/build/dist/Server/Middleware/MasterAdminAuthorization.js.map +1 -1
  103. package/build/dist/Server/Services/TelemetryEntityRelationshipService.js +3 -0
  104. package/build/dist/Server/Services/TelemetryEntityRelationshipService.js.map +1 -1
  105. package/build/dist/Server/Services/TelemetryEntityService.js +91 -1
  106. package/build/dist/Server/Services/TelemetryEntityService.js.map +1 -1
  107. package/build/dist/Server/Types/Workflow/Components/API/Utils.js +28 -0
  108. package/build/dist/Server/Types/Workflow/Components/API/Utils.js.map +1 -1
  109. package/build/dist/Server/Types/Workflow/TriggerCode.js.map +1 -1
  110. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +507 -0
  111. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -0
  112. package/build/dist/Server/Utils/VM/VMAPI.js +51 -4
  113. package/build/dist/Server/Utils/VM/VMAPI.js.map +1 -1
  114. package/build/dist/Types/SerializableObjectDictionary.js +133 -39
  115. package/build/dist/Types/SerializableObjectDictionary.js.map +1 -1
  116. package/build/dist/Types/Telemetry/EntitySource.js +39 -0
  117. package/build/dist/Types/Telemetry/EntitySource.js.map +1 -0
  118. package/build/dist/Types/Telemetry/EntityType.js +27 -0
  119. package/build/dist/Types/Telemetry/EntityType.js.map +1 -1
  120. package/build/dist/Types/Telemetry/EntityTypeGroups.js +57 -0
  121. package/build/dist/Types/Telemetry/EntityTypeGroups.js.map +1 -0
  122. package/build/dist/Types/Workflow/Component.js +21 -0
  123. package/build/dist/Types/Workflow/Component.js.map +1 -1
  124. package/build/dist/Types/Workflow/Components/API.js +35 -0
  125. package/build/dist/Types/Workflow/Components/API.js.map +1 -1
  126. package/build/dist/Types/Workflow/Components/BaseModel.js +7 -3
  127. package/build/dist/Types/Workflow/Components/BaseModel.js.map +1 -1
  128. package/build/dist/Types/Workflow/StepTrace.js +104 -0
  129. package/build/dist/Types/Workflow/StepTrace.js.map +1 -0
  130. package/build/dist/Types/Workflow/TemplateSyntax.js +326 -0
  131. package/build/dist/Types/Workflow/TemplateSyntax.js.map +1 -0
  132. package/build/dist/Types/Workflow/Templates.js +218 -0
  133. package/build/dist/Types/Workflow/Templates.js.map +1 -0
  134. package/build/dist/UI/Components/Date/CustomTimeRangeModal.js +126 -0
  135. package/build/dist/UI/Components/Date/CustomTimeRangeModal.js.map +1 -0
  136. package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js +123 -0
  137. package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js.map +1 -0
  138. package/build/dist/UI/Components/Dictionary/Dictionary.js +22 -6
  139. package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
  140. package/build/dist/UI/Components/Forms/Fields/ColorPicker.js +52 -13
  141. package/build/dist/UI/Components/Forms/Fields/ColorPicker.js.map +1 -1
  142. package/build/dist/UI/Components/Forms/Fields/IconPicker.js +28 -12
  143. package/build/dist/UI/Components/Forms/Fields/IconPicker.js.map +1 -1
  144. package/build/dist/UI/Components/Forms/Validation.js +44 -0
  145. package/build/dist/UI/Components/Forms/Validation.js.map +1 -1
  146. package/build/dist/UI/Components/Input/Input.js +12 -4
  147. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  148. package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js +5 -108
  149. package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js.map +1 -1
  150. package/build/dist/UI/Components/Modal/Modal.js +31 -5
  151. package/build/dist/UI/Components/Modal/Modal.js.map +1 -1
  152. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js +5 -105
  153. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js.map +1 -1
  154. package/build/dist/UI/Components/Workflow/ArgumentsForm.js +140 -11
  155. package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
  156. package/build/dist/UI/Components/Workflow/Component.js +20 -19
  157. package/build/dist/UI/Components/Workflow/Component.js.map +1 -1
  158. package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js +31 -3
  159. package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js.map +1 -1
  160. package/build/dist/UI/Components/Workflow/GraphLint.js +396 -0
  161. package/build/dist/UI/Components/Workflow/GraphLint.js.map +1 -0
  162. package/build/dist/UI/Components/Workflow/ModelColumnEditor.js +225 -0
  163. package/build/dist/UI/Components/Workflow/ModelColumnEditor.js.map +1 -0
  164. package/build/dist/UI/Components/Workflow/ModelSchema.js +118 -0
  165. package/build/dist/UI/Components/Workflow/ModelSchema.js.map +1 -0
  166. package/build/dist/UI/Components/Workflow/RunForm.js +22 -6
  167. package/build/dist/UI/Components/Workflow/RunForm.js.map +1 -1
  168. package/build/dist/UI/Components/Workflow/RunStatusWatcher.js +76 -0
  169. package/build/dist/UI/Components/Workflow/RunStatusWatcher.js.map +1 -0
  170. package/build/dist/UI/Components/Workflow/StepTraceViewer.js +76 -0
  171. package/build/dist/UI/Components/Workflow/StepTraceViewer.js.map +1 -0
  172. package/build/dist/UI/Components/Workflow/Utils.js +73 -1
  173. package/build/dist/UI/Components/Workflow/Utils.js.map +1 -1
  174. package/build/dist/UI/Components/Workflow/Workflow.js +51 -6
  175. package/build/dist/UI/Components/Workflow/Workflow.js.map +1 -1
  176. package/build/dist/UI/Types/LayeredDismissal.js +21 -0
  177. package/build/dist/UI/Types/LayeredDismissal.js.map +1 -0
  178. package/build/dist/UI/Types/UseAnchoredFieldPopup.js +74 -1
  179. package/build/dist/UI/Types/UseAnchoredFieldPopup.js.map +1 -1
  180. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +386 -0
  181. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -0
  182. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +35 -6
  183. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  184. package/build/dist/Utils/Telemetry/EntityKey.js +52 -0
  185. package/build/dist/Utils/Telemetry/EntityKey.js.map +1 -1
  186. package/package.json +1 -1
@@ -1,6 +1,8 @@
1
+ import { consumePressForAnchoredPopup } from "./LayeredDismissal";
1
2
  import React, {
2
3
  useCallback,
3
4
  useEffect,
5
+ useId,
4
6
  useLayoutEffect,
5
7
  useRef,
6
8
  useState,
@@ -44,10 +46,26 @@ export interface AnchoredFieldPopup {
44
46
  isPopupOpen: boolean;
45
47
  popupPosition: AnchoredFieldPopupPosition | null;
46
48
  portalTarget: HTMLElement | null;
49
+ popupId: string;
47
50
  closePopup: (shouldReturnFocus?: boolean) => void;
48
51
  togglePopup: () => void;
52
+ // Opens the popup from a trigger's keyboard, and moves focus into it.
53
+ onTriggerKeyDown: (event: React.KeyboardEvent) => void;
49
54
  }
50
55
 
56
+ /*
57
+ * The keys that open a popup from its trigger. ArrowDown/ArrowUp match the
58
+ * listbox convention every other menu in this codebase follows; Enter and Space
59
+ * are what a button-shaped control is expected to answer to.
60
+ */
61
+ const POPUP_OPENING_KEYS: Array<string> = [
62
+ "Enter",
63
+ " ",
64
+ "Spacebar",
65
+ "ArrowDown",
66
+ "ArrowUp",
67
+ ];
68
+
51
69
  type GetFocusableElementsFunction = (
52
70
  container: HTMLElement,
53
71
  ) => Array<HTMLElement>;
@@ -83,6 +101,27 @@ const useAnchoredFieldPopup: UseAnchoredFieldPopupFunction = (
83
101
  const popupRef: React.MutableRefObject<HTMLDivElement | null> =
84
102
  useRef<HTMLDivElement | null>(null);
85
103
 
104
+ const popupId: string = useId();
105
+
106
+ /*
107
+ * A pointer user wants the popup to appear under the field and their pointer
108
+ * to stay where it is. A keyboard user has no other way in, so opening from
109
+ * the keyboard hands focus to the popup's first control - the hex box for a
110
+ * colour, the search box for an icon.
111
+ */
112
+ const shouldMoveFocusIntoPopupRef: React.MutableRefObject<boolean> =
113
+ useRef<boolean>(false);
114
+
115
+ const focusPopup: () => void = useCallback((): void => {
116
+ const popup: HTMLDivElement | null = popupRef.current;
117
+
118
+ if (!popup) {
119
+ return;
120
+ }
121
+
122
+ (getFocusableElements(popup)[0] || popup).focus();
123
+ }, []);
124
+
86
125
  const closePopup: (shouldReturnFocus?: boolean) => void = useCallback(
87
126
  (shouldReturnFocus?: boolean): void => {
88
127
  setIsPopupOpen(false);
@@ -115,6 +154,58 @@ const useAnchoredFieldPopup: UseAnchoredFieldPopupFunction = (
115
154
  });
116
155
  }, []);
117
156
 
157
+ const openPopup: (shouldMoveFocusIntoPopup?: boolean) => void = useCallback(
158
+ (shouldMoveFocusIntoPopup?: boolean): void => {
159
+ shouldMoveFocusIntoPopupRef.current = Boolean(shouldMoveFocusIntoPopup);
160
+ setIsPopupOpen(true);
161
+ },
162
+ [],
163
+ );
164
+
165
+ const onTriggerKeyDown: (event: React.KeyboardEvent) => void = useCallback(
166
+ (event: React.KeyboardEvent): void => {
167
+ if (event.defaultPrevented || !POPUP_OPENING_KEYS.includes(event.key)) {
168
+ return;
169
+ }
170
+
171
+ /*
172
+ * The key opened the popup and means nothing else after that: Input's
173
+ * own onEnterPress stands down on a defaultPrevented event, and a
174
+ * trigger placed inside a real <form> - which nothing about Input
175
+ * prevents, even though this codebase's forms are not form elements -
176
+ * would otherwise submit it on the way past.
177
+ */
178
+ event.preventDefault();
179
+
180
+ if (isPopupOpen) {
181
+ focusPopup();
182
+ return;
183
+ }
184
+
185
+ openPopup(true);
186
+ },
187
+ [isPopupOpen, focusPopup, openPopup],
188
+ );
189
+
190
+ /*
191
+ * Waits for a measured position, not just for the open flag: until then the
192
+ * popup is `visibility: hidden` so it cannot flash at the top left corner,
193
+ * and a hidden element refuses focus. jsdom has no such scruples, so this
194
+ * ordering only shows itself in a browser.
195
+ */
196
+ useEffect(() => {
197
+ if (
198
+ !isPopupOpen ||
199
+ !popupPosition ||
200
+ !shouldMoveFocusIntoPopupRef.current
201
+ ) {
202
+ return;
203
+ }
204
+
205
+ shouldMoveFocusIntoPopupRef.current = false;
206
+ focusPopup();
207
+ }, [isPopupOpen, popupPosition, focusPopup]);
208
+
118
209
  const updatePopupPosition: () => void = useCallback((): void => {
119
210
  if (!anchorRef.current || typeof window === "undefined") {
120
211
  return;
@@ -222,6 +313,12 @@ const useAnchoredFieldPopup: UseAnchoredFieldPopupFunction = (
222
313
  return;
223
314
  }
224
315
 
316
+ /*
317
+ * This press is spent on closing the popup. Claiming it stops the modal
318
+ * underneath from reading the same press as a backdrop dismissal and
319
+ * throwing the form away along with the picker.
320
+ */
321
+ consumePressForAnchoredPopup(event);
225
322
  closePopup(false);
226
323
  };
227
324
 
@@ -311,6 +408,8 @@ const useAnchoredFieldPopup: UseAnchoredFieldPopupFunction = (
311
408
  anchorRef,
312
409
  closePopup,
313
410
  isPopupOpen,
411
+ onTriggerKeyDown,
412
+ popupId,
314
413
  popupPosition,
315
414
  popupRef,
316
415
  portalTarget: typeof document === "undefined" ? null : document.body,
@@ -0,0 +1,533 @@
1
+ import { NetworkTopologyDeviceRole } from "../../Types/Monitor/SnmpMonitor/NetworkTopology";
2
+
3
+ /*
4
+ * Works out what a node on the network topology map DOES — router, switch,
5
+ * firewall, access point, load balancer, server, storage, or one of the
6
+ * leaf roles (printer, camera, phone, plain host).
7
+ *
8
+ * Why derive it rather than store it: nothing in SNMP hands us a role. The
9
+ * evidence we already collect does, though, and it arrives in a strict
10
+ * order of trustworthiness:
11
+ *
12
+ * 1. the product family in sysDescr / model / the CDP platform string
13
+ * ("Catalyst 2960", "FortiGate-60F", "SRX340") — a named family is
14
+ * the closest thing to a declaration of purpose,
15
+ * 2. the sysObjectId enterprise arc, for the vendors whose entire
16
+ * catalogue is one role (Palo Alto, F5, NetApp, ...),
17
+ * 3. the generic words in sysDescr ("... Switch ...", "Load Balancer"),
18
+ * 4. the naming convention in the hostname ("core-sw-1", "edge-fw01"),
19
+ * 5. last and weakest, a general-purpose OS or SNMP agent in sysDescr —
20
+ * "it is a computer", which loses to a hostname that says otherwise
21
+ * because switches and firewalls announce Linux kernels too.
22
+ *
23
+ * Each tier is weaker than the one above it, so they are tried in order
24
+ * and the first answer wins. When nothing matches the answer is "unknown",
25
+ * which is deliberate: a neutral node is honest, and a router drawn as a
26
+ * firewall because its hostname happened to contain "fw" is not. This is
27
+ * also why unmanaged CDP/LLDP peers — for which we have a platform string
28
+ * and a name and nothing else — mostly come back "unknown", and should.
29
+ *
30
+ * Pure and dependency-free so it runs identically in the topology builder
31
+ * on the server and in the unit tests.
32
+ */
33
+
34
+ /** Everything the classifier is allowed to look at. All best-effort. */
35
+ export interface DeviceRoleSignals {
36
+ sysDescr?: string | undefined;
37
+ sysObjectId?: string | undefined;
38
+ vendor?: string | undefined;
39
+ /** NetworkDevice.deviceModel for managed nodes. */
40
+ deviceModel?: string | undefined;
41
+ /** The CDP platform string an unmanaged peer advertises about itself. */
42
+ platform?: string | undefined;
43
+ name?: string | undefined;
44
+ sysName?: string | undefined;
45
+ }
46
+
47
+ /** What a discovered (ARP/FDB) endpoint tells us about itself. */
48
+ export interface EndpointRoleSignals {
49
+ /** User-editable free text: "POS", "Camera", "Printer", ... */
50
+ classification?: string | undefined;
51
+ vendor?: string | undefined;
52
+ name?: string | undefined;
53
+ }
54
+
55
+ interface RoleRule {
56
+ role: NetworkTopologyDeviceRole;
57
+ pattern: RegExp;
58
+ /*
59
+ * Only applied when the vendor evidence matches. This is what keeps
60
+ * Juniper's MX960 (a core router) apart from Meraki's MX64 (a security
61
+ * appliance) — the model strings are otherwise the same shape.
62
+ */
63
+ vendor?: RegExp | undefined;
64
+ }
65
+
66
+ /** Human-readable role names. One source of truth for UI and tests. */
67
+ export const DEVICE_ROLE_LABELS: Record<NetworkTopologyDeviceRole, string> = {
68
+ router: "Router",
69
+ switch: "Switch",
70
+ firewall: "Firewall",
71
+ wirelessAccessPoint: "Wireless AP",
72
+ loadBalancer: "Load balancer",
73
+ server: "Server",
74
+ storage: "Storage",
75
+ printer: "Printer",
76
+ camera: "Camera",
77
+ phone: "IP phone",
78
+ host: "Host",
79
+ unknown: "Unknown type",
80
+ };
81
+
82
+ /** Every role except "unknown", in the order a legend should list them. */
83
+ export const DEVICE_ROLES_IN_LEGEND_ORDER: ReadonlyArray<NetworkTopologyDeviceRole> =
84
+ [
85
+ "router",
86
+ "switch",
87
+ "firewall",
88
+ "wirelessAccessPoint",
89
+ "loadBalancer",
90
+ "server",
91
+ "storage",
92
+ "printer",
93
+ "camera",
94
+ "phone",
95
+ "host",
96
+ ];
97
+
98
+ /*
99
+ * Tier 1 — named product families. Ordered most specific first, because
100
+ * the first match wins: the leaf roles come before the infrastructure
101
+ * ones so "Cisco IP Phone 7961" is a phone rather than being swallowed by
102
+ * a later Cisco rule.
103
+ *
104
+ * A note on the boundaries. Every pattern is anchored with a LEADING \b
105
+ * and, for the model-code alternatives, deliberately no trailing one:
106
+ * real model strings run straight on into suffixes ("AIR-CAP3702I-A-K9",
107
+ * "C9300L-24P", "DCS-7050SX3-48YC8"), and a closing \b silently refuses
108
+ * to match every one of them. Alternatives that are ordinary words keep
109
+ * their own trailing \b, so "nas" cannot match "nasa".
110
+ */
111
+ const PRODUCT_RULES: ReadonlyArray<RoleRule> = [
112
+ // --- leaf devices that also speak SNMP ---
113
+ {
114
+ role: "camera",
115
+ pattern:
116
+ /\b(?:ip[- ]?camera|network camera|cctv\b|nvr\b|hikvision|dahua|axis [pqmf]\d{4})/,
117
+ },
118
+ { role: "camera", pattern: /\bmv\d{2}/, vendor: /meraki|cisco/ },
119
+ {
120
+ role: "phone",
121
+ pattern:
122
+ /\b(?:ip phone|voip phone|desk phone|polycom|yealink|grandstream|snom\b|spa\d{3}|cp-\d{4})/,
123
+ },
124
+ {
125
+ role: "printer",
126
+ pattern:
127
+ /\b(?:laserjet|officejet|deskjet|designjet|jetdirect|imagerunner|workcentre|workcenter|bizhub|kyocera|lexmark|mfp\b|multifunction printer)/,
128
+ },
129
+
130
+ // --- firewalls / security appliances ---
131
+ { role: "firewall", pattern: /\b(?:fortigate|fortiwifi|forti-gate)/ },
132
+ {
133
+ role: "firewall",
134
+ pattern: /\b(?:palo alto|pan-?os\b|pa-\d{3,4})/,
135
+ },
136
+ {
137
+ role: "firewall",
138
+ pattern:
139
+ /\b(?:adaptive security appliance|cisco asa\b|asa\d{4}|firepower|ftd\b|pix\b)/,
140
+ },
141
+ { role: "firewall", pattern: /\bsrx ?\d{2,4}|\bsrx\b/ },
142
+ {
143
+ role: "firewall",
144
+ pattern:
145
+ /\b(?:sonicwall|watchguard|check ?point|gaia\b|pfsense|opnsense|untangle|barracuda)/,
146
+ },
147
+ { role: "firewall", pattern: /\bsophos (?:xg|xgs|utm|sg)\b/ },
148
+ {
149
+ role: "firewall",
150
+ pattern: /\bmx\d{2,3}|\bz\d{1,2}\b/,
151
+ vendor: /meraki/,
152
+ },
153
+
154
+ // --- load balancers / application delivery ---
155
+ {
156
+ role: "loadBalancer",
157
+ pattern:
158
+ /\b(?:big-?ip|f5 networks|tmos\b|netscaler|citrix adc|a10 thunder|ax\d{4}|loadmaster|avi vantage|haproxy)/,
159
+ },
160
+
161
+ // --- wireless ---
162
+ {
163
+ role: "wirelessAccessPoint",
164
+ pattern:
165
+ /\b(?:aironet|air-(?:cap|ap|lap|sap|ct)|wlc\b|wireless lan controller|wireless controller)/,
166
+ },
167
+ /*
168
+ * Cisco recycled "Catalyst" across three roles, so the two that are not
169
+ * switches have to be claimed before the blanket Catalyst rule below:
170
+ * a 9800 is a wireless controller and an 8000 is a router.
171
+ */
172
+ {
173
+ role: "wirelessAccessPoint",
174
+ pattern: /\b(?:catalyst 98\d{2}|c98\d{2})/,
175
+ },
176
+ {
177
+ role: "wirelessAccessPoint",
178
+ pattern:
179
+ /\b(?:unifi ap|uap[- ]|u6-|nanostation|instant ap|iap\b|aruba ap|ap-\d{3}|fortiap|omada|eap\d{3}|cw9\d{3})/,
180
+ },
181
+ {
182
+ role: "wirelessAccessPoint",
183
+ pattern: /\bmr\d{2,3}/,
184
+ vendor: /meraki/,
185
+ },
186
+
187
+ /*
188
+ * --- switches ---
189
+ * First, the other half of the Catalyst disambiguation above.
190
+ */
191
+ {
192
+ role: "router",
193
+ pattern: /\b(?:catalyst 8\d{3}|c8\d{3})/,
194
+ },
195
+ {
196
+ role: "switch",
197
+ pattern:
198
+ /\b(?:catalyst|nexus|ws-c\d|c9[2345]\d{2}|c1000|c2960|c3[578]\d{2}|me-\d{4})/,
199
+ },
200
+ {
201
+ role: "switch",
202
+ pattern: /\b(?:ex\d{4}|qfx\d{4})/,
203
+ },
204
+ {
205
+ role: "switch",
206
+ pattern: /\bms\d{2,3}/,
207
+ vendor: /meraki/,
208
+ },
209
+ {
210
+ role: "switch",
211
+ pattern:
212
+ /\b(?:fortiswitch|procurve|powerconnect|force10|dcs-\d{4}|icx\d{4}|edgeswitch|unifi switch|usw-|crs\d{3}|css\d{3}|sg\d{3}|gs\d{3}|jl\d{3}[a-z]|s\d{4}-on|n\d{4}-on)/,
213
+ },
214
+
215
+ // --- routers ---
216
+ {
217
+ role: "router",
218
+ pattern: /\b(?:isr ?\d{3,4}|isr\b|asr ?\d{3,4}|c11\d{2}|c89\d\b|csr1000v)/,
219
+ },
220
+ {
221
+ role: "router",
222
+ pattern: /\b(?:mx\d{2,4}|ptx\d{4}|acx\d{4})/,
223
+ vendor: /juniper|junos/,
224
+ },
225
+ {
226
+ role: "router",
227
+ pattern:
228
+ /\b(?:routeros|mikrotik|ccr\d{4}|rb\d{3,4}|edgerouter|vyos\b|vsr\b)/,
229
+ },
230
+
231
+ // --- storage ---
232
+ {
233
+ role: "storage",
234
+ pattern:
235
+ /\b(?:netapp|data ontap|isilon|powerstore|powervault|unity xt|synology|qnap|truenas|freenas|pure ?storage|nimble storage|storwize|nas\b|san\b)/,
236
+ },
237
+
238
+ // --- servers / hypervisors / lights-out controllers ---
239
+ {
240
+ role: "server",
241
+ pattern:
242
+ /\b(?:esxi|vmware|proxmox|hyper-?v\b|xenserver|idrac\b|ilo\b|integrated lights-out|cimc\b|poweredge|proliant|thinksystem|primergy)/,
243
+ },
244
+ ];
245
+
246
+ /*
247
+ * Tier 3 — the generic words. Only consulted when no product family
248
+ * matched, and only against sysDescr, which is the one field vendors
249
+ * write a sentence into.
250
+ */
251
+ const GENERIC_RULES: ReadonlyArray<RoleRule> = [
252
+ {
253
+ role: "firewall",
254
+ pattern: /\b(firewall|security appliance|utm appliance)\b/,
255
+ },
256
+ {
257
+ role: "loadBalancer",
258
+ pattern: /\b(load ?balancer|application delivery controller)\b/,
259
+ },
260
+ {
261
+ role: "wirelessAccessPoint",
262
+ pattern: /\b(access point|wireless|wi-?fi|wlan)\b/,
263
+ },
264
+ { role: "printer", pattern: /\b(printer|print server|copier|scanner)\b/ },
265
+ { role: "camera", pattern: /\bcamera\b/ },
266
+ { role: "phone", pattern: /\b(phone|voip)\b/ },
267
+ { role: "switch", pattern: /\b(switch|switching)\b/ },
268
+ { role: "router", pattern: /\b(router|routing|gateway)\b/ },
269
+ { role: "storage", pattern: /\b(storage array|storage system|filer)\b/ },
270
+ ];
271
+
272
+ /*
273
+ * The weakest evidence there is: a general-purpose operating system in
274
+ * sysDescr. It says the box is a computer, not what job it does — plenty
275
+ * of switches and firewalls announce a Linux kernel — so it is consulted
276
+ * only after the hostname has had its say. A machine called "gw-1" that
277
+ * runs Linux is a router; a machine called "app-3" that runs Linux is a
278
+ * server.
279
+ */
280
+ const OPERATING_SYSTEM_RULES: ReadonlyArray<RoleRule> = [
281
+ {
282
+ role: "server",
283
+ pattern:
284
+ /\b(linux|windows|ubuntu|debian|centos|red ?hat|freebsd|solaris|net-snmp|\bserver\b)\b/,
285
+ },
286
+ ];
287
+
288
+ /*
289
+ * Tier 2 — sysObjectId enterprise arcs, for vendors that only make one
290
+ * kind of box. Deliberately short: a vendor with a mixed catalogue (Cisco,
291
+ * Juniper, HPE, Ubiquiti) tells us nothing here, and a wrong shape is
292
+ * worse than a neutral one. Matched on dotted-prefix boundaries so
293
+ * ".1.3.6.1.4.1.11" cannot swallow ".1.3.6.1.4.1.1124".
294
+ */
295
+ const SYS_OBJECT_ID_RULES: ReadonlyArray<{
296
+ prefix: string;
297
+ role: NetworkTopologyDeviceRole;
298
+ }> = [
299
+ // Printers, most specific arcs first (HP's printer arc sits under HP's).
300
+ { prefix: "1.3.6.1.4.1.11.2.3.9", role: "printer" },
301
+ { prefix: "1.3.6.1.4.1.641", role: "printer" }, // Lexmark
302
+ { prefix: "1.3.6.1.4.1.1602", role: "printer" }, // Canon
303
+ { prefix: "1.3.6.1.4.1.367", role: "printer" }, // Ricoh
304
+ { prefix: "1.3.6.1.4.1.253", role: "printer" }, // Xerox
305
+ { prefix: "1.3.6.1.4.1.1248", role: "printer" }, // Epson
306
+ { prefix: "1.3.6.1.4.1.236", role: "printer" }, // Samsung printers
307
+ // Firewalls.
308
+ { prefix: "1.3.6.1.4.1.12356", role: "firewall" }, // Fortinet
309
+ { prefix: "1.3.6.1.4.1.25461", role: "firewall" }, // Palo Alto
310
+ { prefix: "1.3.6.1.4.1.2620", role: "firewall" }, // Check Point
311
+ { prefix: "1.3.6.1.4.1.8741", role: "firewall" }, // SonicWall
312
+ { prefix: "1.3.6.1.4.1.3097", role: "firewall" }, // WatchGuard
313
+ { prefix: "1.3.6.1.4.1.2604", role: "firewall" }, // Sophos / Astaro
314
+ // Load balancers.
315
+ { prefix: "1.3.6.1.4.1.3375", role: "loadBalancer" }, // F5
316
+ { prefix: "1.3.6.1.4.1.5951", role: "loadBalancer" }, // Citrix NetScaler
317
+ { prefix: "1.3.6.1.4.1.22610", role: "loadBalancer" }, // A10
318
+ // Storage.
319
+ { prefix: "1.3.6.1.4.1.789", role: "storage" }, // NetApp
320
+ // Switching / routing houses.
321
+ { prefix: "1.3.6.1.4.1.30065", role: "switch" }, // Arista
322
+ { prefix: "1.3.6.1.4.1.6027", role: "switch" }, // Force10 / Dell
323
+ { prefix: "1.3.6.1.4.1.14988", role: "router" }, // MikroTik
324
+ ];
325
+
326
+ /*
327
+ * Generic SNMP agents. These identify the AGENT, not the appliance — a
328
+ * Linux firewall answers on Net-SNMP just as readily as a database server
329
+ * does — so, like the OS rules, they are consulted only after the
330
+ * hostname.
331
+ */
332
+ const GENERIC_AGENT_SYS_OBJECT_ID_RULES: ReadonlyArray<{
333
+ prefix: string;
334
+ role: NetworkTopologyDeviceRole;
335
+ }> = [
336
+ { prefix: "1.3.6.1.4.1.8072", role: "server" }, // Net-SNMP
337
+ { prefix: "1.3.6.1.4.1.2021", role: "server" }, // UCD-SNMP
338
+ { prefix: "1.3.6.1.4.1.311", role: "server" }, // Microsoft
339
+ { prefix: "1.3.6.1.4.1.6876", role: "server" }, // VMware
340
+ ];
341
+
342
+ /*
343
+ * Tier 4 — hostname conventions. Whole tokens only ("core-sw-1" → "sw",
344
+ * "edge-fw01" → "fw01"), never substrings, so "swan" is not a switch and
345
+ * "apex" is not an access point.
346
+ */
347
+ const NAME_RULES: ReadonlyArray<RoleRule> = [
348
+ { role: "router", pattern: /^(rtr|router|rt|gw|gwy|gateway)\d*$/ },
349
+ {
350
+ role: "switch",
351
+ pattern: /^(sw|swi|switch|sws|dsw|asw|csw|tor|leaf|spine)\d*$/,
352
+ },
353
+ { role: "firewall", pattern: /^(fw|fwl|firewall|asa|ngfw|utm)\d*$/ },
354
+ { role: "wirelessAccessPoint", pattern: /^(ap|wap|wifi|wlan|wlc)\d*$/ },
355
+ { role: "loadBalancer", pattern: /^(lb|slb|adc|vip)\d*$/ },
356
+ { role: "server", pattern: /^(srv|server|esx|esxi|vmhost)\d*$/ },
357
+ { role: "storage", pattern: /^(nas|san|stor|storage|filer)\d*$/ },
358
+ { role: "camera", pattern: /^(cam|camera|ipcam|cctv)\d*$/ },
359
+ { role: "printer", pattern: /^(prn|print|printer|mfp)\d*$/ },
360
+ { role: "phone", pattern: /^(phone|voip|sip)\d*$/ },
361
+ ];
362
+
363
+ /*
364
+ * Endpoint classifications are free text a human typed, so they are
365
+ * matched loosely — but still whole-word, and still ordered so the
366
+ * specific ones win.
367
+ */
368
+ const ENDPOINT_RULES: ReadonlyArray<RoleRule> = [
369
+ {
370
+ role: "camera",
371
+ pattern:
372
+ /\b(camera|cam|cctv|nvr|dvr|hikvision|dahua|axis communications)\b/,
373
+ },
374
+ {
375
+ role: "printer",
376
+ pattern:
377
+ /\b(printer|print|mfp|copier|scanner|label|zebra|brother|lexmark|kyocera|ricoh|xerox|epson)\b/,
378
+ },
379
+ {
380
+ role: "phone",
381
+ pattern:
382
+ /\b(phone|voip|sip|handset|intercom|polycom|yealink|avaya|mitel)\b/,
383
+ },
384
+ {
385
+ role: "wirelessAccessPoint",
386
+ pattern: /\b(access point|\bap\b|wireless|wi-?fi)\b/,
387
+ },
388
+ { role: "switch", pattern: /\bswitch\b/ },
389
+ { role: "router", pattern: /\b(router|gateway)\b/ },
390
+ { role: "server", pattern: /\b(server|nas|hypervisor)\b/ },
391
+ ];
392
+
393
+ /** Lowercase, whitespace-collapsed, and safe on undefined. */
394
+ function normalize(value: string | undefined): string {
395
+ return (value || "").toLowerCase().replace(/\s+/g, " ").trim();
396
+ }
397
+
398
+ function firstMatch(
399
+ rules: ReadonlyArray<RoleRule>,
400
+ haystack: string,
401
+ vendorText: string,
402
+ ): NetworkTopologyDeviceRole | undefined {
403
+ if (!haystack) {
404
+ return undefined;
405
+ }
406
+ for (const rule of rules) {
407
+ if (rule.vendor && !rule.vendor.test(vendorText)) {
408
+ continue;
409
+ }
410
+ if (rule.pattern.test(haystack)) {
411
+ return rule.role;
412
+ }
413
+ }
414
+ return undefined;
415
+ }
416
+
417
+ /*
418
+ * Dotted-prefix containment: the arc must end at a dot or at the end of
419
+ * the OID. Leading dots (".1.3.6.1...", which plenty of agents emit) are
420
+ * stripped first.
421
+ */
422
+ function roleForSysObjectId(
423
+ sysObjectId: string | undefined,
424
+ rules: ReadonlyArray<{ prefix: string; role: NetworkTopologyDeviceRole }>,
425
+ ): NetworkTopologyDeviceRole | undefined {
426
+ const oid: string = normalize(sysObjectId).replace(/^\.+/, "");
427
+ if (!oid) {
428
+ return undefined;
429
+ }
430
+ for (const rule of rules) {
431
+ if (oid === rule.prefix || oid.startsWith(`${rule.prefix}.`)) {
432
+ return rule.role;
433
+ }
434
+ }
435
+ return undefined;
436
+ }
437
+
438
+ function roleForNameText(
439
+ value: string | undefined,
440
+ ): NetworkTopologyDeviceRole | undefined {
441
+ const text: string = normalize(value);
442
+ if (!text) {
443
+ return undefined;
444
+ }
445
+ /*
446
+ * Split on anything that is not a letter or a digit, which is how the
447
+ * separators people actually use ("-", ".", "_", " ") all behave.
448
+ */
449
+ for (const token of text.split(/[^a-z0-9]+/)) {
450
+ if (!token) {
451
+ continue;
452
+ }
453
+ for (const rule of NAME_RULES) {
454
+ if (rule.pattern.test(token)) {
455
+ return rule.role;
456
+ }
457
+ }
458
+ }
459
+ return undefined;
460
+ }
461
+
462
+ /**
463
+ * The role of one network device (managed) or discovery-protocol peer
464
+ * (unmanaged).
465
+ *
466
+ * Total: always returns a role, "unknown" when nothing is conclusive.
467
+ */
468
+ export function classifyDeviceRole(
469
+ signals: DeviceRoleSignals,
470
+ ): NetworkTopologyDeviceRole {
471
+ const model: string = normalize(
472
+ [signals.deviceModel, signals.platform].filter(Boolean).join(" "),
473
+ );
474
+ const sysDescr: string = normalize(signals.sysDescr);
475
+ /*
476
+ * Vendor guards read every identity field, not just `vendor` — the word
477
+ * "Meraki" usually arrives inside sysDescr or the platform string, and
478
+ * `vendor` itself is often empty on an unmanaged peer.
479
+ */
480
+ const vendorText: string = normalize(
481
+ [signals.vendor, signals.deviceModel, signals.platform, signals.sysDescr]
482
+ .filter(Boolean)
483
+ .join(" "),
484
+ );
485
+
486
+ return (
487
+ firstMatch(PRODUCT_RULES, model, vendorText) ??
488
+ firstMatch(PRODUCT_RULES, sysDescr, vendorText) ??
489
+ roleForSysObjectId(signals.sysObjectId, SYS_OBJECT_ID_RULES) ??
490
+ firstMatch(GENERIC_RULES, sysDescr, vendorText) ??
491
+ roleForNameText(signals.sysName) ??
492
+ roleForNameText(signals.name) ??
493
+ roleForSysObjectId(
494
+ signals.sysObjectId,
495
+ GENERIC_AGENT_SYS_OBJECT_ID_RULES,
496
+ ) ??
497
+ firstMatch(OPERATING_SYSTEM_RULES, sysDescr, vendorText) ??
498
+ "unknown"
499
+ );
500
+ }
501
+
502
+ /**
503
+ * The role of a discovered (ARP/FDB) endpoint.
504
+ *
505
+ * An endpoint is a host until something says otherwise — that is what
506
+ * being on the far side of an access port means — so this never returns
507
+ * "unknown". The classification field is a human's own words about the
508
+ * thing, so it is read first, then the OUI vendor, then the name.
509
+ */
510
+ export function classifyEndpointRole(
511
+ signals: EndpointRoleSignals,
512
+ ): NetworkTopologyDeviceRole {
513
+ const classification: string = normalize(signals.classification);
514
+ const vendor: string = normalize(signals.vendor);
515
+ const name: string = normalize(signals.name);
516
+
517
+ return (
518
+ firstMatch(ENDPOINT_RULES, classification, classification) ??
519
+ firstMatch(ENDPOINT_RULES, vendor, vendor) ??
520
+ firstMatch(ENDPOINT_RULES, name, name) ??
521
+ "host"
522
+ );
523
+ }
524
+
525
+ /** Display name for a role. Falls back to the "unknown" label. */
526
+ export function labelForDeviceRole(
527
+ role: NetworkTopologyDeviceRole | undefined,
528
+ ): string {
529
+ if (!role) {
530
+ return DEVICE_ROLE_LABELS.unknown;
531
+ }
532
+ return DEVICE_ROLE_LABELS[role] || DEVICE_ROLE_LABELS.unknown;
533
+ }