@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
@@ -1,9 +1,37 @@
1
+ /*
2
+ * The member VALUES are load-bearing beyond this enum.
3
+ *
4
+ * CustomFieldsDetail passes a definition's `customFieldType` straight through
5
+ * as the `fieldType` of a Detail field and of a form field, so every member
6
+ * here has to name a member of BOTH `FieldType` (Detail) and
7
+ * `FormFieldSchemaType` (Forms) by value. `Boolean` is the standing example:
8
+ * it reaches the form as FormFieldSchemaType.Toggle, which is spelled
9
+ * `Toggle = "Boolean"` for exactly this reason.
10
+ *
11
+ * Adding a member without that correspondence compiles fine and then renders
12
+ * an empty cell and an input the user cannot type into.
13
+ */
1
14
  enum CustomFieldType {
2
15
  Text = "Text",
3
16
  Number = "Number",
4
17
  Boolean = "Boolean",
5
18
  Dropdown = "Dropdown",
6
19
  MultiSelectDropdown = "MultiSelectDropdown",
20
+
21
+ /**
22
+ * A calendar date with no time of day — purchase date, warranty expiry,
23
+ * end-of-life. Stored in the `customFields` jsonb bag as an ISO-8601 string,
24
+ * which is what makes it comparable: ISO-8601 sorts identically as text and
25
+ * as an instant, so the range filters work over a jsonb key without a cast
26
+ * (see JSONColumnQuery.compareText).
27
+ */
28
+ Date = "Date",
29
+
30
+ /**
31
+ * As `Date`, but the time of day is part of the answer — last audited at,
32
+ * decommissioned at.
33
+ */
34
+ DateTime = "DateTime",
7
35
  }
8
36
 
9
37
  export default CustomFieldType;
@@ -1,5 +1,17 @@
1
1
  import CapturedMetric from "./CapturedMetric";
2
- import { JSONObject } from "../../JSON";
2
+
3
+ export interface CustomCodeMonitorResultObject {
4
+ [key: string]: CustomCodeMonitorResult;
5
+ }
6
+
7
+ /** JSON-safe data returned from a custom or synthetic monitor script. */
8
+ export type CustomCodeMonitorResult =
9
+ | string
10
+ | number
11
+ | boolean
12
+ | null
13
+ | CustomCodeMonitorResultObject
14
+ | Array<CustomCodeMonitorResult>;
3
15
 
4
16
  export interface RetryAttempt {
5
17
  attemptNumber: number;
@@ -8,7 +20,7 @@ export interface RetryAttempt {
8
20
  }
9
21
 
10
22
  export default interface CustomCodeMonitorResponse {
11
- result: string | number | boolean | JSONObject | undefined;
23
+ result: CustomCodeMonitorResult | undefined;
12
24
  scriptError?: string | undefined;
13
25
  logMessages: string[];
14
26
  capturedMetrics: CapturedMetric[];
@@ -170,6 +170,17 @@ export class MonitorTypeHelper {
170
170
  return monitorType === MonitorType.Manual;
171
171
  }
172
172
 
173
+ /*
174
+ * Whether a monitor of this type adds to the project's Active Monitoring
175
+ * bill. Mirrors what the server actually meters -
176
+ * ActiveMonitoringMeteredPlan counts every monitor whose type is not
177
+ * Manual - so the dashboard can warn a Free plan user about a charge using
178
+ * the same rule that produces it. Manual monitors are free and unlimited.
179
+ */
180
+ public static isBilledAsActiveMonitor(monitorType: MonitorType): boolean {
181
+ return !this.isManualMonitor(monitorType);
182
+ }
183
+
173
184
  public static getAllMonitorTypeProps(): Array<MonitorTypeProps> {
174
185
  const monitorTypeProps: Array<MonitorTypeProps> = [
175
186
  {
@@ -156,6 +156,19 @@ export interface NetworkTopologyEdge {
156
156
  monitorState?: "up" | "down" | undefined;
157
157
  // Operator-supplied label, set only on manual links.
158
158
  name?: string | undefined;
159
+ /*
160
+ * Which end of this edge is the parent, when somebody has SAID so —
161
+ * either on the link itself or through a link rule, which is stated in
162
+ * child/parent labels and so knows the answer by construction.
163
+ *
164
+ * Always one of `fromNodeId`/`toNodeId`. Absent on every discovered
165
+ * edge: LLDP and CDP report a cable, not a hierarchy, and the direction
166
+ * a neighbour entry happens to be read from says nothing about which
167
+ * box is upstream. Layouts that draw a tree must therefore treat this as
168
+ * a constraint where it is present and keep inferring where it is not —
169
+ * absent is "not stated", never "these are peers".
170
+ */
171
+ parentNodeId?: string | undefined;
159
172
  }
160
173
 
161
174
  export default interface NetworkTopology {
@@ -13,7 +13,9 @@ import SeriesPoint from "../Types/SeriesPoints";
13
13
  import { XAxis } from "../Types/XAxis/XAxis";
14
14
  import YAxis from "../Types/YAxis/YAxis";
15
15
  import ChartCurve from "../Types/ChartCurve";
16
- import ChartDataPoint from "../ChartLibrary/Types/ChartDataPoint";
16
+ import ChartDataPoint, {
17
+ CHART_DATA_POINT_X_AXIS_KEY,
18
+ } from "../ChartLibrary/Types/ChartDataPoint";
17
19
  import FormattedExemplarPoint from "../ChartLibrary/Types/FormattedExemplarPoint";
18
20
  import FormattedReferenceRegion from "../ChartLibrary/Types/FormattedReferenceRegion";
19
21
  import FormattedTimeReferenceLine from "../ChartLibrary/Types/FormattedTimeReferenceLine";
@@ -185,7 +187,7 @@ const AreaChartElement: FunctionComponent<AreaInternalProps> = (
185
187
  <AreaChart
186
188
  data={records}
187
189
  tickGap={30}
188
- index={"Time"}
190
+ index={CHART_DATA_POINT_X_AXIS_KEY}
189
191
  categories={categories}
190
192
  colors={
191
193
  props.colors && props.colors.length > 0
@@ -12,7 +12,9 @@ import React, {
12
12
  import SeriesPoint from "../Types/SeriesPoints";
13
13
  import { XAxis } from "../Types/XAxis/XAxis";
14
14
  import YAxis from "../Types/YAxis/YAxis";
15
- import ChartDataPoint from "../ChartLibrary/Types/ChartDataPoint";
15
+ import ChartDataPoint, {
16
+ CHART_DATA_POINT_X_AXIS_KEY,
17
+ } from "../ChartLibrary/Types/ChartDataPoint";
16
18
  import FormattedReferenceRegion from "../ChartLibrary/Types/FormattedReferenceRegion";
17
19
  import FormattedTimeReferenceLine from "../ChartLibrary/Types/FormattedTimeReferenceLine";
18
20
  import DataPointUtil from "../Utils/DataPoint";
@@ -119,7 +121,7 @@ const BarChartElement: FunctionComponent<BarInternalProps> = (
119
121
  <BarChart
120
122
  data={records}
121
123
  tickGap={30}
122
- index={"Time"}
124
+ index={CHART_DATA_POINT_X_AXIS_KEY}
123
125
  categories={categories}
124
126
  colors={
125
127
  props.colors && props.colors.length > 0
@@ -5,6 +5,19 @@
5
5
  */
6
6
  export const CHART_DATA_POINT_DATE_KEY: string = "__date";
7
7
 
8
+ /**
9
+ * Per-row field carrying the FORMATTED x-axis label.
10
+ *
11
+ * This is the recharts `XAxis` dataKey — the Line / Bar / Area wrappers all
12
+ * pass it down as their `index` prop. `DataPointUtil` must therefore write
13
+ * the label under exactly this key: a row keyed anything else leaves every
14
+ * point with an undefined category, and recharts then draws no line, bar or
15
+ * area at all (the axes and grid still render, so the chart looks alive but
16
+ * empty). Keeping the single constant on both sides is what stops that from
17
+ * being an invisible coupling between two distant files.
18
+ */
19
+ export const CHART_DATA_POINT_X_AXIS_KEY: string = "Time";
20
+
8
21
  export default interface ChartDataPoint {
9
22
  [x: string]: number | string;
10
23
  }
@@ -13,7 +13,9 @@ import SeriesPoint from "../Types/SeriesPoints";
13
13
  import { XAxis } from "../Types/XAxis/XAxis";
14
14
  import YAxis from "../Types/YAxis/YAxis";
15
15
  import ChartCurve from "../Types/ChartCurve";
16
- import ChartDataPoint from "../ChartLibrary/Types/ChartDataPoint";
16
+ import ChartDataPoint, {
17
+ CHART_DATA_POINT_X_AXIS_KEY,
18
+ } from "../ChartLibrary/Types/ChartDataPoint";
17
19
  import FormattedExemplarPoint from "../ChartLibrary/Types/FormattedExemplarPoint";
18
20
  import FormattedReferenceRegion from "../ChartLibrary/Types/FormattedReferenceRegion";
19
21
  import FormattedTimeReferenceLine from "../ChartLibrary/Types/FormattedTimeReferenceLine";
@@ -165,7 +167,7 @@ const LineChartElement: FunctionComponent<LineInternalProps> = (
165
167
  <LineChart
166
168
  data={records}
167
169
  tickGap={30}
168
- index={"Time"}
170
+ index={CHART_DATA_POINT_X_AXIS_KEY}
169
171
  categories={categories}
170
172
  colors={
171
173
  props.colors && props.colors.length > 0
@@ -16,6 +16,12 @@ export interface XAxisOptions {
16
16
  }
17
17
 
18
18
  export interface XAxis {
19
+ /*
20
+ * Human-readable name for the axis. Purely descriptive: the per-row key
21
+ * the charts read is CHART_DATA_POINT_X_AXIS_KEY, never this string. It
22
+ * used to double as that key, which meant a caller passing anything but
23
+ * "Time" got a chart with axes and no series drawn on them.
24
+ */
19
25
  legend: string;
20
26
  options: XAxisOptions;
21
27
  }
@@ -10,6 +10,7 @@
10
10
  import BadDataException from "../../../../Types/Exception/BadDataException";
11
11
  import ChartDataPoint, {
12
12
  CHART_DATA_POINT_DATE_KEY,
13
+ CHART_DATA_POINT_X_AXIS_KEY,
13
14
  } from "../ChartLibrary/Types/ChartDataPoint";
14
15
  import SeriesPoints from "../Types/SeriesPoints";
15
16
  import { XAxis, XAxisAggregateType } from "../Types/XAxis/XAxis";
@@ -30,18 +31,14 @@ export default class DataPointUtil {
30
31
  xAxis: XAxis;
31
32
  yAxis: YAxis;
32
33
  }): Array<ChartDataPoint> {
33
- const { xAxisLegend, intervals, formatter } = this.initializeXAxisData(
34
- data.xAxis,
35
- );
34
+ const { intervals, formatter } = this.initializeXAxisData(data.xAxis);
36
35
  const arrayOfData: ChartDataPoint[] = this.initializeArrayOfData(
37
36
  intervals,
38
- xAxisLegend,
39
37
  formatter,
40
38
  );
41
39
  this.processSeriesData(
42
40
  data.seriesPoints,
43
41
  arrayOfData,
44
- xAxisLegend,
45
42
  formatter,
46
43
  data.xAxis.options.aggregateType,
47
44
  );
@@ -51,13 +48,11 @@ export default class DataPointUtil {
51
48
  private static initializeXAxisData(xAxis: XAxis): {
52
49
  xAxisMax: XAxisMaxMin;
53
50
  xAxisMin: XAxisMaxMin;
54
- xAxisLegend: string;
55
51
  intervals: Array<Date>;
56
52
  formatter: (value: Date) => string;
57
53
  } {
58
54
  const xAxisMax: XAxisMaxMin = xAxis.options.max;
59
55
  const xAxisMin: XAxisMaxMin = xAxis.options.min;
60
- const xAxisLegend: string = xAxis.legend;
61
56
  const intervals: Array<Date> = XAxisUtil.getPrecisionIntervals({
62
57
  xAxisMax,
63
58
  xAxisMin,
@@ -66,18 +61,25 @@ export default class DataPointUtil {
66
61
  xAxisMax,
67
62
  xAxisMin,
68
63
  });
69
- return { xAxisMax, xAxisMin, xAxisLegend, intervals, formatter };
64
+ return { xAxisMax, xAxisMin, intervals, formatter };
70
65
  }
71
66
 
72
67
  private static initializeArrayOfData(
73
68
  intervals: Array<Date>,
74
- xAxisLegend: string,
75
69
  formatter: (value: Date) => string,
76
70
  ): Array<ChartDataPoint> {
77
71
  const arrayOfData: Array<ChartDataPoint> = [];
78
72
  for (const interval of intervals) {
79
73
  const dataPoint: ChartDataPoint = {};
80
- dataPoint[xAxisLegend] = formatter(interval);
74
+ /*
75
+ * Always the canonical key, NEVER `xAxis.legend`. The wrappers hand
76
+ * recharts this exact key as the XAxis dataKey, so keying rows off a
77
+ * caller-supplied string meant one caller passing anything else
78
+ * (the SLO dashboard widget passed "") produced rows recharts could
79
+ * not read — every point landed on an undefined category and the
80
+ * line silently vanished while the axes still drew.
81
+ */
82
+ dataPoint[CHART_DATA_POINT_X_AXIS_KEY] = formatter(interval);
81
83
  /*
82
84
  * The formatted label is not round-trippable back to a Date, so
83
85
  * keep the raw bucket start on the row — charts use it to recover
@@ -92,7 +94,6 @@ export default class DataPointUtil {
92
94
  private static processSeriesData(
93
95
  seriesPoints: Array<SeriesPoints>,
94
96
  arrayOfData: Array<ChartDataPoint>,
95
- xAxisLegend: string,
96
97
  formatter: (value: Date) => string,
97
98
  aggregateType: XAxisAggregateType,
98
99
  ): { [key: string]: SeriesData } {
@@ -123,7 +124,9 @@ export default class DataPointUtil {
123
124
 
124
125
  const target: ChartDataPoint | undefined = arrayOfData.find(
125
126
  (chartDataPoint: ChartDataPoint) => {
126
- return chartDataPoint[xAxisLegend] === formattedDate;
127
+ return (
128
+ chartDataPoint[CHART_DATA_POINT_X_AXIS_KEY] === formattedDate
129
+ );
127
130
  },
128
131
  );
129
132
  if (!target) {
@@ -5,6 +5,7 @@ import DropdownValueBadge from "../Dropdown/DropdownValueBadge";
5
5
  import AnalyticsBaseModel from "../../../Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel";
6
6
  import BaseModel from "../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
7
7
  import CustomFieldType from "../../../Types/CustomField/CustomFieldType";
8
+ import OneUptimeDate from "../../../Types/Date";
8
9
  import { CustomFieldDefinition } from "../../../Types/CustomField/CustomFieldDefinition";
9
10
  import {
10
11
  CustomFieldDropdownOption,
@@ -200,6 +201,60 @@ export const renderCustomFieldValue: RenderCustomFieldValueFunction = (data: {
200
201
  );
201
202
  }
202
203
 
204
+ if (
205
+ definition.customFieldType === CustomFieldType.Date ||
206
+ definition.customFieldType === CustomFieldType.DateTime
207
+ ) {
208
+ /*
209
+ * Stored as ISO-8601 (the shared date Input normalises every entry through
210
+ * OneUptimeDate.toString()), which is unreadable in a cell, so it is shown
211
+ * in the viewer's own timezone — the same rendering Detail gives a date
212
+ * field, so the list and the item page agree.
213
+ */
214
+ const isDateOnly: boolean =
215
+ definition.customFieldType === CustomFieldType.Date;
216
+ const raw: string = String(value);
217
+
218
+ /*
219
+ * Validity is decided here rather than by inspecting what the formatter
220
+ * returned, because the formatter never reports failure: OneUptimeDate
221
+ * .fromString swallows moment's parse error and falls back to `new
222
+ * Date(str)`, so junk becomes an Invalid Date and moment renders it as the
223
+ * literal, truthy string "Invalid date" (and "Invalid date UTC" for the
224
+ * DateTime variant). A `formatted || raw` fallback therefore never fires.
225
+ *
226
+ * Showing the raw text instead matters because the value is not
227
+ * necessarily one this UI wrote — a project can PUT anything into the
228
+ * `customFields` bag over the API, and may well have been storing dates as
229
+ * free text before this field became a date. "Invalid date" hides which
230
+ * value is wrong; the value itself is the thing that lets someone fix it.
231
+ */
232
+ let parsed: Date | null = null;
233
+
234
+ try {
235
+ parsed = OneUptimeDate.fromString(raw);
236
+ } catch {
237
+ parsed = null;
238
+ }
239
+
240
+ if (!parsed || isNaN(parsed.getTime())) {
241
+ return <span>{raw}</span>;
242
+ }
243
+
244
+ try {
245
+ return (
246
+ <span>
247
+ {OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
248
+ parsed,
249
+ isDateOnly,
250
+ )}
251
+ </span>
252
+ );
253
+ } catch {
254
+ return <span>{raw}</span>;
255
+ }
256
+ }
257
+
203
258
  if (definition.customFieldType === CustomFieldType.MultiSelectDropdown) {
204
259
  /*
205
260
  * Tolerant on the way out: an older single-select value that was later
@@ -531,3 +531,48 @@ export function labelForDeviceRole(
531
531
  }
532
532
  return DEVICE_ROLE_LABELS[role] || DEVICE_ROLE_LABELS.unknown;
533
533
  }
534
+
535
+ /**
536
+ * Read an operator's stored role override off a NetworkDevice row.
537
+ *
538
+ * The classifier above is evidence-driven, and its evidence is SNMP: a
539
+ * device nothing walks — a ping-only device imported by discovery, say —
540
+ * offers it a hostname and nothing else, so it comes back "unknown" and
541
+ * stays there forever. `NetworkDevice.deviceRole` is the operator saying
542
+ * what the box actually is, and it is the only statement about a role that
543
+ * is not an inference, so it outranks everything the classifier can find.
544
+ *
545
+ * Undefined means "no override" — NOT "unknown". The two are different
546
+ * answers and the difference decides whether the classifier gets to run at
547
+ * all, which is why an unrecognised or empty column reads as undefined
548
+ * rather than falling back to a role. "unknown" is itself refused as an
549
+ * override for the same reason: storing it would silently disable the
550
+ * classifier on a device the operator was only declining to classify.
551
+ */
552
+ export function parseDeviceRoleOverride(
553
+ value: string | undefined | null,
554
+ ): NetworkTopologyDeviceRole | undefined {
555
+ const normalized: string = (value || "").trim().toLowerCase();
556
+ if (!normalized) {
557
+ return undefined;
558
+ }
559
+ return DEVICE_ROLES_IN_LEGEND_ORDER.find(
560
+ (role: NetworkTopologyDeviceRole) => {
561
+ return role.toLowerCase() === normalized;
562
+ },
563
+ );
564
+ }
565
+
566
+ /**
567
+ * The role to draw a managed device with: the operator's override when
568
+ * there is one, otherwise whatever the evidence supports.
569
+ *
570
+ * One function so the topology builder, the forms and the tests cannot
571
+ * drift about which of the two wins.
572
+ */
573
+ export function resolveDeviceRole(
574
+ roleOverride: string | undefined | null,
575
+ signals: DeviceRoleSignals,
576
+ ): NetworkTopologyDeviceRole {
577
+ return parseDeviceRoleOverride(roleOverride) ?? classifyDeviceRole(signals);
578
+ }
@@ -11,6 +11,7 @@ import { normalizeMac } from "./EndpointAttachmentUtil";
11
11
  import {
12
12
  classifyDeviceRole,
13
13
  classifyEndpointRole,
14
+ resolveDeviceRole,
14
15
  } from "./NetworkDeviceRoleUtil";
15
16
 
16
17
  export interface TopologyDeviceInput {
@@ -29,6 +30,14 @@ export interface TopologyDeviceInput {
29
30
  */
30
31
  sysDescr?: string | undefined;
31
32
  sysObjectId?: string | undefined;
33
+ /*
34
+ * The operator's own answer to what this device is, when they gave one.
35
+ * Outranks the classifier below, because it is the only statement about
36
+ * the role that is not an inference — and on a device nothing walks it
37
+ * is the ONLY statement available at all, the classifier having nothing
38
+ * but a hostname to go on.
39
+ */
40
+ deviceRole?: string | undefined;
32
41
  /*
33
42
  * ENTITY-MIB chassis serial. Not rendered — it is here because LLDP
34
43
  * chassis-id subtype 7 ("locally assigned") is very often exactly this
@@ -109,6 +118,13 @@ export interface TopologyManualLinkInput {
109
118
  toPortName?: string | undefined;
110
119
  // Health from a bound Monitor, already reduced by the caller.
111
120
  monitorStatus?: "up" | "down" | undefined;
121
+ /*
122
+ * Whichever end the operator declared the parent, when they declared
123
+ * one. Must be `fromDeviceId` or `toDeviceId`; anything else is dropped
124
+ * rather than drawn, because a parent that is not on the link cannot be
125
+ * rendered as one and a wrong hierarchy is worse than an inferred one.
126
+ */
127
+ parentDeviceId?: string | undefined;
112
128
  }
113
129
 
114
130
  /*
@@ -232,7 +248,10 @@ export default class NetworkTopologyUtil {
232
248
  * alias are one unmanaged node, however many switches saw it. Edges are
233
249
  * undirected and deduplicated — a link reported from both ends, or by
234
250
  * both protocols, appears once with the union of what each report knew
235
- * (ports, protocols, per-end interface state). When `interfaces` rows are
251
+ * (ports, protocols, per-end interface state). Undirected in the sense
252
+ * that neither end is privileged by how the edge was discovered: an edge
253
+ * carries a direction only where an operator declared one, as
254
+ * `parentNodeId`. When `interfaces` rows are
236
255
  * provided, each edge end whose interface is identifiable carries its
237
256
  * operational state (up/down, utilization, rates). When `endpoints` rows
238
257
  * are provided, each one that attaches to a device in the graph becomes an
@@ -306,7 +325,7 @@ export default class NetworkTopologyUtil {
306
325
  name: device.name,
307
326
  isManaged: true,
308
327
  kind: "device",
309
- role: classifyDeviceRole({
328
+ role: resolveDeviceRole(device.deviceRole, {
310
329
  sysDescr: device.sysDescr,
311
330
  sysObjectId: device.sysObjectId,
312
331
  vendor: device.vendor,
@@ -520,6 +539,18 @@ export default class NetworkTopologyUtil {
520
539
  continue;
521
540
  }
522
541
 
542
+ /*
543
+ * A declared parent is only meaningful if it is one of the two ends.
544
+ * The service refuses anything else at write time, so this is a
545
+ * second line rather than the only one — it also covers rule-derived
546
+ * links, which never pass through that service.
547
+ */
548
+ const declaredParentId: string | undefined =
549
+ link.parentDeviceId === link.fromDeviceId ||
550
+ link.parentDeviceId === link.toDeviceId
551
+ ? link.parentDeviceId
552
+ : undefined;
553
+
523
554
  const edgeKey: string = [link.fromDeviceId, link.toDeviceId]
524
555
  .sort()
525
556
  .join("::");
@@ -534,6 +565,7 @@ export default class NetworkTopologyUtil {
534
565
  protocols: ["manual"],
535
566
  monitorState: link.monitorStatus,
536
567
  name: link.name,
568
+ parentNodeId: declaredParentId,
537
569
  });
538
570
  continue;
539
571
  }
@@ -557,6 +589,14 @@ export default class NetworkTopologyUtil {
557
589
  }
558
590
  existing.name = existing.name || link.name;
559
591
  existing.monitorState = existing.monitorState ?? link.monitorStatus;
592
+ /*
593
+ * First declaration wins, matching the ordering the caller already
594
+ * relies on: explicit links are merged before rule-derived ones, so
595
+ * a hierarchy somebody set on the link itself beats a rule that
596
+ * happens to cover the same pair. The specific statement beats the
597
+ * general one — the same precedence the ports and the name use.
598
+ */
599
+ existing.parentNodeId = existing.parentNodeId || declaredParentId;
560
600
  }
561
601
 
562
602
  const edges: Array<NetworkTopologyEdge> = Array.from(edgeByKey.values());
@@ -50,6 +50,7 @@ let NetworkDevice = class NetworkDevice extends BaseModel {
50
50
  this.currentMonitorStatus = undefined;
51
51
  this.currentMonitorStatusId = undefined;
52
52
  this.monitoringMethod = undefined;
53
+ this.deviceRole = undefined;
53
54
  this.monitor = undefined;
54
55
  this.monitorId = undefined;
55
56
  this.snmpVersion = undefined;
@@ -641,6 +642,50 @@ __decorate([
641
642
  }),
642
643
  __metadata("design:type", String)
643
644
  ], NetworkDevice.prototype, "monitoringMethod", void 0);
645
+ __decorate([
646
+ ColumnAccessControl({
647
+ create: [
648
+ Permission.ProjectOwner,
649
+ Permission.ProjectAdmin,
650
+ Permission.ProjectMember,
651
+ Permission.SettingsAdmin,
652
+ Permission.SettingsMember,
653
+ Permission.CreateNetworkDevice,
654
+ ],
655
+ read: [
656
+ Permission.ProjectOwner,
657
+ Permission.ProjectAdmin,
658
+ Permission.ProjectMember,
659
+ Permission.Viewer,
660
+ Permission.SettingsAdmin,
661
+ Permission.SettingsMember,
662
+ Permission.SettingsViewer,
663
+ Permission.ReadNetworkDevice,
664
+ ],
665
+ update: [
666
+ Permission.ProjectOwner,
667
+ Permission.ProjectAdmin,
668
+ Permission.ProjectMember,
669
+ Permission.SettingsAdmin,
670
+ Permission.SettingsMember,
671
+ Permission.EditNetworkDevice,
672
+ ],
673
+ }),
674
+ TableColumn({
675
+ required: false,
676
+ type: TableColumnType.ShortText,
677
+ canReadOnRelationQuery: true,
678
+ title: "Device Role",
679
+ description: "What this device does on the network — router, switch, access point and so on. Left empty, the role is worked out from the device's own SNMP identity. Set it when there is no SNMP to read: a ping-only device has no identity to classify, and the role decides both the shape it is drawn with and where it sits in the topology hierarchy.",
680
+ example: "switch",
681
+ }),
682
+ Column({
683
+ nullable: true,
684
+ type: ColumnType.ShortText,
685
+ length: ColumnLength.ShortText,
686
+ }),
687
+ __metadata("design:type", String)
688
+ ], NetworkDevice.prototype, "deviceRole", void 0);
644
689
  __decorate([
645
690
  ColumnAccessControl({
646
691
  create: [