@oneuptime/common 12.0.10 → 12.0.11

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 (81) 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/Utils/Telemetry/InventoryEntityRegistry.ts +186 -38
  9. package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +420 -112
  10. package/Tests/App/Dashboard/PayAsYouGoConsentGate.test.tsx +543 -0
  11. package/Tests/App/Dashboard/PayAsYouGoFreePlan.test.ts +125 -0
  12. package/Tests/App/Dashboard/PayAsYouGoNotices.test.tsx +612 -0
  13. package/Tests/App/Dashboard/SloWidgetChartRendering.test.tsx +536 -0
  14. package/Tests/Models/AnalyticsModels/CommonModel.test.ts +312 -0
  15. package/Tests/Server/Infrastructure/Postgres/DeviceRoleAndDeclaredLinkParentMigration.test.ts +250 -0
  16. package/Tests/Server/Infrastructure/Postgres/EpisodeMemberNotifyIndexesMigration.test.ts +46 -18
  17. package/Tests/Server/Services/NetworkDeviceLinkParentValidation.test.ts +1043 -0
  18. package/Tests/Server/Types/Billing/PayAsYouGoMeteredPricing.test.ts +125 -0
  19. package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +508 -0
  20. package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +668 -0
  21. package/Tests/Types/Billing/PayAsYouGoPricing.test.ts +64 -0
  22. package/Tests/Types/CustomField/CustomFieldTypeCorrespondence.test.ts +107 -0
  23. package/Tests/Types/Monitor/MonitorType.test.ts +62 -0
  24. package/Tests/UI/Components/Charts/ChartXAxisDataKey.test.tsx +256 -0
  25. package/Tests/UI/Components/Charts/DataPointUtil.test.ts +323 -0
  26. package/Tests/UI/Components/ModelTable/CustomFieldColumns.test.tsx +349 -1
  27. package/Tests/Utils/Monitor/NetworkDeviceRoleUtil.test.ts +502 -0
  28. package/Tests/Utils/Monitor/NetworkTopologyDeclaredHierarchy.test.ts +759 -0
  29. package/Tests/Utils/Schema/BaseSchema.test.ts +651 -0
  30. package/Types/Billing/PayAsYouGoPricing.ts +47 -0
  31. package/Types/CustomField/CustomFieldType.ts +28 -0
  32. package/Types/Monitor/MonitorType.ts +11 -0
  33. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +13 -0
  34. package/UI/Components/Charts/Area/AreaChart.tsx +4 -2
  35. package/UI/Components/Charts/Bar/BarChart.tsx +4 -2
  36. package/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.ts +13 -0
  37. package/UI/Components/Charts/Line/LineChart.tsx +4 -2
  38. package/UI/Components/Charts/Types/XAxis/XAxis.ts +6 -0
  39. package/UI/Components/Charts/Utils/DataPoint.ts +15 -12
  40. package/UI/Components/ModelTable/CustomFieldColumns.tsx +55 -0
  41. package/Utils/Monitor/NetworkDeviceRoleUtil.ts +45 -0
  42. package/Utils/Monitor/NetworkTopologyUtil.ts +42 -2
  43. package/build/dist/Models/DatabaseModels/NetworkDevice.js +45 -0
  44. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  45. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js +115 -0
  46. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js.map +1 -1
  47. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js +33 -0
  48. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js.map +1 -0
  49. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  50. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  51. package/build/dist/Server/Services/NetworkDeviceLinkService.js +253 -1
  52. package/build/dist/Server/Services/NetworkDeviceLinkService.js.map +1 -1
  53. package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js +12 -5
  54. package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js.map +1 -1
  55. package/build/dist/Server/Types/Database/JSONColumnQuery.js +40 -4
  56. package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -1
  57. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +128 -35
  58. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -1
  59. package/build/dist/Types/Billing/PayAsYouGoPricing.js +41 -0
  60. package/build/dist/Types/Billing/PayAsYouGoPricing.js.map +1 -0
  61. package/build/dist/Types/CustomField/CustomFieldType.js +26 -0
  62. package/build/dist/Types/CustomField/CustomFieldType.js.map +1 -1
  63. package/build/dist/Types/Monitor/MonitorType.js +10 -0
  64. package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
  65. package/build/dist/UI/Components/Charts/Area/AreaChart.js +2 -1
  66. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  67. package/build/dist/UI/Components/Charts/Bar/BarChart.js +2 -1
  68. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  69. package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js +12 -0
  70. package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js.map +1 -1
  71. package/build/dist/UI/Components/Charts/Line/LineChart.js +2 -1
  72. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  73. package/build/dist/UI/Components/Charts/Utils/DataPoint.js +17 -10
  74. package/build/dist/UI/Components/Charts/Utils/DataPoint.js.map +1 -1
  75. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js +42 -0
  76. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  77. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +37 -0
  78. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -1
  79. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +25 -3
  80. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  81. package/package.json +1 -1
@@ -646,6 +646,50 @@ export default class NetworkDevice extends BaseModel {
646
646
  })
647
647
  public monitoringMethod?: string = undefined;
648
648
 
649
+ @ColumnAccessControl({
650
+ create: [
651
+ Permission.ProjectOwner,
652
+ Permission.ProjectAdmin,
653
+ Permission.ProjectMember,
654
+ Permission.SettingsAdmin,
655
+ Permission.SettingsMember,
656
+ Permission.CreateNetworkDevice,
657
+ ],
658
+ read: [
659
+ Permission.ProjectOwner,
660
+ Permission.ProjectAdmin,
661
+ Permission.ProjectMember,
662
+ Permission.Viewer,
663
+ Permission.SettingsAdmin,
664
+ Permission.SettingsMember,
665
+ Permission.SettingsViewer,
666
+ Permission.ReadNetworkDevice,
667
+ ],
668
+ update: [
669
+ Permission.ProjectOwner,
670
+ Permission.ProjectAdmin,
671
+ Permission.ProjectMember,
672
+ Permission.SettingsAdmin,
673
+ Permission.SettingsMember,
674
+ Permission.EditNetworkDevice,
675
+ ],
676
+ })
677
+ @TableColumn({
678
+ required: false,
679
+ type: TableColumnType.ShortText,
680
+ canReadOnRelationQuery: true,
681
+ title: "Device Role",
682
+ description:
683
+ "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.",
684
+ example: "switch",
685
+ })
686
+ @Column({
687
+ nullable: true,
688
+ type: ColumnType.ShortText,
689
+ length: ColumnLength.ShortText,
690
+ })
691
+ public deviceRole?: string = undefined;
692
+
649
693
  @ColumnAccessControl({
650
694
  create: [
651
695
  Permission.ProjectOwner,
@@ -376,6 +376,122 @@ export default class NetworkDeviceLink extends BaseModel {
376
376
  })
377
377
  public toDeviceId?: ObjectID = undefined;
378
378
 
379
+ /*
380
+ * --- Which end is up ---
381
+ *
382
+ * A link says two devices are cabled together. It does not say which one
383
+ * the other hangs off, and the topology map has to decide: the
384
+ * parent-child view draws an actual tree, so somebody is the parent.
385
+ * Left to itself it infers one from device roles and connection counts,
386
+ * which is a good guess on a network that speaks SNMP and no guess at
387
+ * all on one that does not — the case issue #3192 is about, where the
388
+ * child is a ping-only device whose role nothing can classify.
389
+ *
390
+ * This column is the operator answering that question outright. It is
391
+ * NULL by default and on every row written before it existed, and NULL
392
+ * means exactly what the map did then: a peer link, direction inferred.
393
+ * So no existing map moves, and a hierarchy only becomes declared when
394
+ * somebody declares it.
395
+ *
396
+ * The value must be one of this link's own two ends — a parent that is
397
+ * not on the link is not a statement about the link — which the service
398
+ * enforces on create and on update, including when an update moves an
399
+ * end out from under a parent that was already set.
400
+ */
401
+ @ColumnAccessControl({
402
+ create: [
403
+ Permission.ProjectOwner,
404
+ Permission.ProjectAdmin,
405
+ Permission.ProjectMember,
406
+ Permission.SettingsAdmin,
407
+ Permission.SettingsMember,
408
+ Permission.CreateNetworkDeviceLink,
409
+ ],
410
+ read: [
411
+ Permission.ProjectOwner,
412
+ Permission.ProjectAdmin,
413
+ Permission.ProjectMember,
414
+ Permission.Viewer,
415
+ Permission.SettingsAdmin,
416
+ Permission.SettingsMember,
417
+ Permission.SettingsViewer,
418
+ Permission.ReadNetworkDeviceLink,
419
+ ],
420
+ update: [
421
+ Permission.ProjectOwner,
422
+ Permission.ProjectAdmin,
423
+ Permission.ProjectMember,
424
+ Permission.SettingsAdmin,
425
+ Permission.SettingsMember,
426
+ Permission.EditNetworkDeviceLink,
427
+ ],
428
+ })
429
+ @TableColumn({
430
+ manyToOneRelationColumn: "parentDeviceId",
431
+ type: TableColumnType.Entity,
432
+ modelType: NetworkDevice,
433
+ title: "Parent Device",
434
+ description:
435
+ "Relation to whichever end of this link is the parent, if the hierarchy is declared rather than inferred",
436
+ })
437
+ @ManyToOne(
438
+ () => {
439
+ return NetworkDevice;
440
+ },
441
+ {
442
+ eager: false,
443
+ nullable: true,
444
+ onDelete: "CASCADE",
445
+ orphanedRowAction: "nullify",
446
+ },
447
+ )
448
+ @JoinColumn({ name: "parentDeviceId" })
449
+ public parentDevice?: NetworkDevice = undefined;
450
+
451
+ @ColumnAccessControl({
452
+ create: [
453
+ Permission.ProjectOwner,
454
+ Permission.ProjectAdmin,
455
+ Permission.ProjectMember,
456
+ Permission.SettingsAdmin,
457
+ Permission.SettingsMember,
458
+ Permission.CreateNetworkDeviceLink,
459
+ ],
460
+ read: [
461
+ Permission.ProjectOwner,
462
+ Permission.ProjectAdmin,
463
+ Permission.ProjectMember,
464
+ Permission.Viewer,
465
+ Permission.SettingsAdmin,
466
+ Permission.SettingsMember,
467
+ Permission.SettingsViewer,
468
+ Permission.ReadNetworkDeviceLink,
469
+ ],
470
+ update: [
471
+ Permission.ProjectOwner,
472
+ Permission.ProjectAdmin,
473
+ Permission.ProjectMember,
474
+ Permission.SettingsAdmin,
475
+ Permission.SettingsMember,
476
+ Permission.EditNetworkDeviceLink,
477
+ ],
478
+ })
479
+ @Index()
480
+ @TableColumn({
481
+ type: TableColumnType.ObjectID,
482
+ required: false,
483
+ canReadOnRelationQuery: true,
484
+ title: "Parent Device ID",
485
+ description:
486
+ "ID of whichever end of this link is the parent. Must be the From Device or the To Device. Empty means the two are peers and the map infers the hierarchy.",
487
+ })
488
+ @Column({
489
+ type: ColumnType.ObjectID,
490
+ nullable: true,
491
+ transformer: ObjectID.getDatabaseTransformer(),
492
+ })
493
+ public parentDeviceId?: ObjectID = undefined;
494
+
379
495
  @ColumnAccessControl({
380
496
  create: [
381
497
  Permission.ProjectOwner,
@@ -0,0 +1,52 @@
1
+ import { MigrationInterface, QueryRunner } from "typeorm";
2
+
3
+ /*
4
+ * Two nullable columns, both of them an operator stating something the
5
+ * network cannot be asked:
6
+ *
7
+ * NetworkDevice.deviceRole — what this box actually is, for devices
8
+ * with no SNMP identity to classify.
9
+ * NetworkDeviceLink.parentDeviceId — which end of a declared link is up.
10
+ *
11
+ * Nullable on purpose, with no backfill. NULL is a meaningful value in
12
+ * both cases and it is the value every existing row should hold: no role
13
+ * override (so the classifier keeps deciding) and no declared hierarchy
14
+ * (so the map keeps inferring one). Defaulting either would silently
15
+ * restate every device and every link in every project as something an
16
+ * operator had chosen, which is the one thing neither column may mean.
17
+ */
18
+ export class AddDeviceRoleAndDeclaredLinkParent1787400000000
19
+ implements MigrationInterface
20
+ {
21
+ public name = "AddDeviceRoleAndDeclaredLinkParent1787400000000";
22
+
23
+ public async up(queryRunner: QueryRunner): Promise<void> {
24
+ await queryRunner.query(
25
+ `ALTER TABLE "NetworkDevice" ADD "deviceRole" character varying(100)`,
26
+ );
27
+ await queryRunner.query(
28
+ `ALTER TABLE "NetworkDeviceLink" ADD "parentDeviceId" uuid`,
29
+ );
30
+ await queryRunner.query(
31
+ `CREATE INDEX "IDX_01752cd47b773df38b4a54ae53" ON "NetworkDeviceLink" ("parentDeviceId") `,
32
+ );
33
+ await queryRunner.query(
34
+ `ALTER TABLE "NetworkDeviceLink" ADD CONSTRAINT "FK_01752cd47b773df38b4a54ae539" FOREIGN KEY ("parentDeviceId") REFERENCES "NetworkDevice"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
35
+ );
36
+ }
37
+
38
+ public async down(queryRunner: QueryRunner): Promise<void> {
39
+ await queryRunner.query(
40
+ `ALTER TABLE "NetworkDeviceLink" DROP CONSTRAINT "FK_01752cd47b773df38b4a54ae539"`,
41
+ );
42
+ await queryRunner.query(
43
+ `DROP INDEX "public"."IDX_01752cd47b773df38b4a54ae53"`,
44
+ );
45
+ await queryRunner.query(
46
+ `ALTER TABLE "NetworkDeviceLink" DROP COLUMN "parentDeviceId"`,
47
+ );
48
+ await queryRunner.query(
49
+ `ALTER TABLE "NetworkDevice" DROP COLUMN "deviceRole"`,
50
+ );
51
+ }
52
+ }
@@ -527,6 +527,7 @@ import { AddOnCallNotificationFallbackColumns1787000000000 } from "./17870000000
527
527
  import { AddAIConversationPageContext1787100000000 } from "./1787100000000-AddAIConversationPageContext";
528
528
  import { AddAIChatMessageFeedback1787200000000 } from "./1787200000000-AddAIChatMessageFeedback";
529
529
  import { AddEpisodeMemberNotifyIndexes1787300000000 } from "./1787300000000-AddEpisodeMemberNotifyIndexes";
530
+ import { AddDeviceRoleAndDeclaredLinkParent1787400000000 } from "./1787400000000-AddDeviceRoleAndDeclaredLinkParent";
530
531
 
531
532
  export default [
532
533
  InitialMigration,
@@ -1058,4 +1059,5 @@ export default [
1058
1059
  AddAIConversationPageContext1787100000000,
1059
1060
  AddAIChatMessageFeedback1787200000000,
1060
1061
  AddEpisodeMemberNotifyIndexes1787300000000,
1062
+ AddDeviceRoleAndDeclaredLinkParent1787400000000,
1061
1063
  ];
@@ -2,24 +2,140 @@ import DatabaseService from "./DatabaseService";
2
2
  import Model from "../../Models/DatabaseModels/NetworkDeviceLink";
3
3
  import NetworkDeviceService from "./NetworkDeviceService";
4
4
  import NetworkDevice from "../../Models/DatabaseModels/NetworkDevice";
5
- import { OnCreate } from "../Types/Database/Hooks";
5
+ import { OnCreate, OnUpdate } from "../Types/Database/Hooks";
6
6
  import CreateBy from "../Types/Database/CreateBy";
7
+ import UpdateBy from "../Types/Database/UpdateBy";
7
8
  import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
8
9
  import BadDataException from "../../Types/Exception/BadDataException";
9
10
  import ObjectID from "../../Types/ObjectID";
10
11
  import QueryHelper from "../Types/Database/QueryHelper";
11
12
  import RelationIdUtil from "../Utils/Database/RelationIdUtil";
12
13
  import LIMIT_MAX from "../../Types/Database/LimitMax";
14
+ import Query from "../Types/Database/Query";
15
+ import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps";
13
16
 
14
17
  // Both spellings of each end, for the same reason RelationIdUtil exists.
15
18
  const FROM_DEVICE_KEYS: Array<string> = ["fromDeviceId", "fromDevice"];
16
19
  const TO_DEVICE_KEYS: Array<string> = ["toDeviceId", "toDevice"];
20
+ const PARENT_DEVICE_KEYS: Array<string> = ["parentDeviceId", "parentDevice"];
21
+
22
+ /*
23
+ * A device id, compared the way Postgres compares it.
24
+ *
25
+ * These columns are `uuid`. Postgres normalises and matches them without
26
+ * regard to case, and ObjectID.isValidUUID accepts either case, so an API
27
+ * client may legitimately send `AAAAAAAA-...` for a row stored as
28
+ * `aaaaaaaa-...`. A raw JS !== between the two says "different device" and
29
+ * refuses a parent that is in fact an end of the link. Note that the
30
+ * device-existence check below cannot disagree: it runs in Postgres.
31
+ */
32
+ const sameDeviceId: (a: ObjectID, b: ObjectID) => boolean = (
33
+ a: ObjectID,
34
+ b: ObjectID,
35
+ ): boolean => {
36
+ return a.toString().toLowerCase() === b.toString().toLowerCase();
37
+ };
38
+
39
+ /*
40
+ * A declared parent has to be one of the two devices the link connects.
41
+ * Anything else is not a statement about this link at all, and the map
42
+ * would silently ignore it — the operator would have declared a hierarchy
43
+ * and watched nothing change.
44
+ */
45
+ const assertParentIsAnEnd: (
46
+ parentDeviceId: ObjectID | null,
47
+ fromDeviceId: ObjectID,
48
+ toDeviceId: ObjectID,
49
+ ) => void = (
50
+ parentDeviceId: ObjectID | null,
51
+ fromDeviceId: ObjectID,
52
+ toDeviceId: ObjectID,
53
+ ): void => {
54
+ if (!parentDeviceId) {
55
+ return;
56
+ }
57
+ if (
58
+ !sameDeviceId(parentDeviceId, fromDeviceId) &&
59
+ !sameDeviceId(parentDeviceId, toDeviceId)
60
+ ) {
61
+ throw new BadDataException(
62
+ "The parent device must be one of the two devices this link connects.",
63
+ );
64
+ }
65
+ };
66
+
67
+ /*
68
+ * Which of `keys` the payload actually WRITES.
69
+ *
70
+ * Not the same question as which keys are present. TypeORM drops
71
+ * undefined-valued keys before it builds the SET list — "it doesn't make
72
+ * sense to update undefined properties" — so a key holding undefined
73
+ * changes nothing in the database. Treating it as a write makes this hook
74
+ * validate a row that is never going to exist: model the parent as
75
+ * cleared, accept the write, and leave the stored parent stranded on a
76
+ * link whose end just moved. Null and "" are different and really do
77
+ * clear the column, which is why the test is against undefined alone.
78
+ */
79
+ const writtenKeys: (
80
+ data: Record<string, unknown>,
81
+ keys: Array<string>,
82
+ ) => Array<string> = (
83
+ data: Record<string, unknown>,
84
+ keys: Array<string>,
85
+ ): Array<string> => {
86
+ return keys.filter((key: string) => {
87
+ return key in data && data[key] !== undefined;
88
+ });
89
+ };
90
+
91
+ /*
92
+ * An update payload is a QueryDeepPartialEntity, so a column may hold a
93
+ * raw SQL-expression function instead of a value. RelationIdUtil reads one
94
+ * as null — indistinguishable from "cleared" — and the whole invariant is
95
+ * then checked against a row that bears no relation to what will be
96
+ * written. Nothing in the tree writes this model that way today; refusing
97
+ * is what keeps that true, and a caller who needs it can resolve the id
98
+ * before calling rather than have it silently mis-validated.
99
+ */
100
+ const assertNoSqlExpression: (
101
+ data: Record<string, unknown>,
102
+ keys: Array<string>,
103
+ ) => void = (data: Record<string, unknown>, keys: Array<string>): void => {
104
+ for (const key of keys) {
105
+ if (typeof data[key] === "function") {
106
+ throw new BadDataException(
107
+ `${key} cannot be set to a raw SQL expression on a network device link — the link's ends and its declared parent have to be checked against each other, which needs actual ids.`,
108
+ );
109
+ }
110
+ }
111
+ };
17
112
 
18
113
  export class Service extends DatabaseService<Model> {
19
114
  public constructor() {
20
115
  super(Model);
21
116
  }
22
117
 
118
+ /*
119
+ * onBeforeUpdate runs before DatabaseService permission-checks the query,
120
+ * so reading the raw client query as root would hand the hook rows from
121
+ * other projects. Re-apply the caller's tenant here. (Same helper as
122
+ * NetworkDeviceService and NetworkSiteService — private per service,
123
+ * which is the shape the codebase already settled on.)
124
+ */
125
+ private scopeQueryToCallerTenant(
126
+ query: Query<Model>,
127
+ props: DatabaseCommonInteractionProps,
128
+ ): Query<Model> {
129
+ if (props.isRoot || !props.tenantId) {
130
+ return query;
131
+ }
132
+
133
+ return {
134
+ ...query,
135
+ projectId: props.tenantId,
136
+ };
137
+ }
138
+
23
139
  /*
24
140
  * Two guards, both about links that would be nonsense on the map:
25
141
  *
@@ -39,6 +155,12 @@ export class Service extends DatabaseService<Model> {
39
155
  unknown
40
156
  >;
41
157
 
158
+ assertNoSqlExpression(data, [
159
+ ...FROM_DEVICE_KEYS,
160
+ ...TO_DEVICE_KEYS,
161
+ ...PARENT_DEVICE_KEYS,
162
+ ]);
163
+
42
164
  const fromDeviceId: ObjectID | null = RelationIdUtil.read(
43
165
  data,
44
166
  FROM_DEVICE_KEYS,
@@ -52,7 +174,7 @@ export class Service extends DatabaseService<Model> {
52
174
  throw new BadDataException("A link needs a device at each end.");
53
175
  }
54
176
 
55
- if (fromDeviceId.toString() === toDeviceId.toString()) {
177
+ if (sameDeviceId(fromDeviceId, toDeviceId)) {
56
178
  throw new BadDataException("A device cannot be linked to itself.");
57
179
  }
58
180
 
@@ -77,8 +199,222 @@ export class Service extends DatabaseService<Model> {
77
199
  );
78
200
  }
79
201
 
202
+ assertParentIsAnEnd(
203
+ RelationIdUtil.read(data, PARENT_DEVICE_KEYS),
204
+ fromDeviceId,
205
+ toDeviceId,
206
+ );
207
+
80
208
  return { createBy, carryForward: null };
81
209
  }
210
+
211
+ /*
212
+ * The same invariant, on the other write path.
213
+ *
214
+ * An update can break it two ways, and only one of them is obvious. The
215
+ * obvious one is setting a parent that is not on the link. The other is
216
+ * moving an END: re-point `toDevice` at a different switch and a parent
217
+ * that was valid a moment ago now names a device the link no longer
218
+ * touches. So the post-update state is what gets checked, assembled from
219
+ * the payload where it says something and from the stored row where it
220
+ * does not.
221
+ *
222
+ * An update matches a QUERY, not one row, and each matched row may have
223
+ * different ends — so every one of them is validated, and one bad row
224
+ * rejects the whole write. That is the conservative direction: a partial
225
+ * update that fixed some rows and left others contradicting themselves
226
+ * would be far harder to notice than a refusal.
227
+ *
228
+ * It also re-runs onBeforeCreate's TENANCY check on any end that moves.
229
+ * That check used to exist only on create, which left the update path
230
+ * able to re-point an end at another project's device — the exact threat
231
+ * onBeforeCreate's own comment describes, since the FK is global and the
232
+ * column is member-writable.
233
+ *
234
+ * Only costs a read when the payload touches an end or the parent at all;
235
+ * an update of ports or the bound monitor takes the early return.
236
+ */
237
+ @CaptureSpan()
238
+ protected override async onBeforeUpdate(
239
+ updateBy: UpdateBy<Model>,
240
+ ): Promise<OnUpdate<Model>> {
241
+ const data: Record<string, unknown> = (updateBy.data ||
242
+ {}) as unknown as Record<string, unknown>;
243
+
244
+ const hierarchyKeys: Array<string> = [
245
+ ...FROM_DEVICE_KEYS,
246
+ ...TO_DEVICE_KEYS,
247
+ ...PARENT_DEVICE_KEYS,
248
+ ];
249
+
250
+ if (writtenKeys(data, hierarchyKeys).length === 0) {
251
+ return { updateBy, carryForward: null };
252
+ }
253
+
254
+ assertNoSqlExpression(data, hierarchyKeys);
255
+
256
+ const writtenFromDeviceId: ObjectID | null = RelationIdUtil.read(
257
+ data,
258
+ FROM_DEVICE_KEYS,
259
+ );
260
+ const writtenToDeviceId: ObjectID | null = RelationIdUtil.read(
261
+ data,
262
+ TO_DEVICE_KEYS,
263
+ );
264
+ const writtenParentDeviceId: ObjectID | null = RelationIdUtil.read(
265
+ data,
266
+ PARENT_DEVICE_KEYS,
267
+ );
268
+
269
+ /*
270
+ * Writing the parent key with an empty value is how a hierarchy is
271
+ * cleared back to a peer link. RelationIdUtil reads that as null, which
272
+ * is indistinguishable from "not written" — so the presence of the key
273
+ * (with a value that will actually be written; see writtenKeys), not
274
+ * the truthiness of its value, is what decides whether the stored
275
+ * parent still applies.
276
+ */
277
+ const isParentWritten: boolean =
278
+ writtenKeys(data, PARENT_DEVICE_KEYS).length > 0;
279
+
280
+ const existingLinks: Array<Model> = await this.findBy({
281
+ query: this.scopeQueryToCallerTenant(updateBy.query, updateBy.props),
282
+ select: {
283
+ _id: true,
284
+ projectId: true,
285
+ fromDeviceId: true,
286
+ toDeviceId: true,
287
+ parentDeviceId: true,
288
+ },
289
+ limit: LIMIT_MAX,
290
+ skip: 0,
291
+ props: {
292
+ isRoot: true,
293
+ },
294
+ });
295
+
296
+ /*
297
+ * An end that MOVED has to be re-checked for tenancy, exactly as
298
+ * onBeforeCreate checks it. The FK only requires the device row to
299
+ * exist — it is global — so without this a project member could
300
+ * re-point an end of their own link at another project's device and
301
+ * read its name straight off the map, which is the threat
302
+ * onBeforeCreate's own comment describes. The declared parent inherits
303
+ * the check for free: it has to be one of the two ends.
304
+ *
305
+ * Grouped by project because an update may in principle match links in
306
+ * more than one, and a device is only legitimate within its own.
307
+ */
308
+ /*
309
+ * Deduped: writing the same id to both ends is a self-link, and that
310
+ * has its own error below — it must not surface as "device not found".
311
+ */
312
+ const movedEndIds: Array<string> = [
313
+ ...new Set<string>(
314
+ [writtenFromDeviceId, writtenToDeviceId]
315
+ .filter((id: ObjectID | null): id is ObjectID => {
316
+ return Boolean(id);
317
+ })
318
+ .map((id: ObjectID) => {
319
+ return id.toString();
320
+ }),
321
+ ),
322
+ ];
323
+
324
+ if (movedEndIds.length > 0 && existingLinks.length > 0) {
325
+ /*
326
+ * Every project this write touches: the caller's own tenant, plus
327
+ * the project of each row the query matched (a root caller has no
328
+ * tenant, and only root can match rows across more than one). The
329
+ * new end has to be legitimate in all of them.
330
+ *
331
+ * Guarded on there being matched rows at all: a query that matched
332
+ * nothing writes nothing, and there is no link for a foreign device
333
+ * to end up attached to.
334
+ */
335
+ const projectIds: Set<string> = new Set<string>();
336
+ if (updateBy.props.tenantId) {
337
+ projectIds.add(updateBy.props.tenantId.toString());
338
+ }
339
+ for (const link of existingLinks) {
340
+ if (link.projectId) {
341
+ projectIds.add(link.projectId.toString());
342
+ }
343
+ }
344
+
345
+ /*
346
+ * Fail closed. No project to check against and an end on the move
347
+ * is not a safe update to wave through — it is the one shape where
348
+ * a device from anywhere could be attached to this link.
349
+ */
350
+ if (projectIds.size === 0) {
351
+ throw new BadDataException(
352
+ "Both devices must exist and belong to this project.",
353
+ );
354
+ }
355
+
356
+ for (const projectId of projectIds) {
357
+ const devices: Array<NetworkDevice> = await NetworkDeviceService.findBy(
358
+ {
359
+ query: {
360
+ _id: QueryHelper.any(movedEndIds),
361
+ projectId: new ObjectID(projectId),
362
+ },
363
+ select: { _id: true },
364
+ limit: LIMIT_MAX,
365
+ skip: 0,
366
+ props: { isRoot: true },
367
+ },
368
+ );
369
+
370
+ /*
371
+ * Compared as a SET of ids rather than as a count. A count says
372
+ * "as many rows came back as I asked about", which a duplicate row
373
+ * satisfies just as well as the right rows do — and this is the
374
+ * check standing between one project's map and another project's
375
+ * device names, so it should be answering the actual question.
376
+ */
377
+ const foundIds: Set<string> = new Set<string>(
378
+ devices.map((device: NetworkDevice) => {
379
+ return (device.id || device._id || "").toString().toLowerCase();
380
+ }),
381
+ );
382
+
383
+ const isMissingAnEnd: boolean = movedEndIds.some((id: string) => {
384
+ return !foundIds.has(id.toLowerCase());
385
+ });
386
+
387
+ if (isMissingAnEnd) {
388
+ throw new BadDataException(
389
+ "Both devices must exist and belong to this project.",
390
+ );
391
+ }
392
+ }
393
+ }
394
+
395
+ for (const link of existingLinks) {
396
+ const fromDeviceId: ObjectID | undefined =
397
+ writtenFromDeviceId || link.fromDeviceId;
398
+ const toDeviceId: ObjectID | undefined =
399
+ writtenToDeviceId || link.toDeviceId;
400
+ const parentDeviceId: ObjectID | null = isParentWritten
401
+ ? writtenParentDeviceId
402
+ : link.parentDeviceId || null;
403
+
404
+ // A row mid-migration with a missing end has no invariant to check.
405
+ if (!fromDeviceId || !toDeviceId) {
406
+ continue;
407
+ }
408
+
409
+ if (sameDeviceId(fromDeviceId, toDeviceId)) {
410
+ throw new BadDataException("A device cannot be linked to itself.");
411
+ }
412
+
413
+ assertParentIsAnEnd(parentDeviceId, fromDeviceId, toDeviceId);
414
+ }
415
+
416
+ return { updateBy, carryForward: null };
417
+ }
82
418
  }
83
419
 
84
420
  export default new Service();
@@ -4,6 +4,19 @@ import ServerMeteredPlan from "./ServerMeteredPlan";
4
4
  import TelemetryMeteredPlanType from "./TelemetryMeteredPlan";
5
5
  import BadDataException from "../../../../Types/Exception/BadDataException";
6
6
  import ProductType from "../../../../Types/MeteredPlan/ProductType";
7
+ import {
8
+ SESSION_REPLAY_PRICE_IN_USD_PER_GB,
9
+ TELEMETRY_PRICE_IN_USD_PER_GB,
10
+ TELEMETRY_PRICE_RETENTION_IN_DAYS,
11
+ } from "../../../../Types/Billing/PayAsYouGoPricing";
12
+
13
+ /*
14
+ * Per GB per day, derived from the advertised per-GB-per-15-days rate so the
15
+ * price the dashboard quotes a Free plan user and the price we actually meter
16
+ * cannot drift apart.
17
+ */
18
+ const TELEMETRY_UNIT_COST_IN_USD: number =
19
+ TELEMETRY_PRICE_IN_USD_PER_GB / TELEMETRY_PRICE_RETENTION_IN_DAYS;
7
20
 
8
21
  export const ActiveMonitoringMeteredPlan: ActiveMonitoringMeteredPlanType =
9
22
  new ActiveMonitoringMeteredPlanType();
@@ -11,25 +24,25 @@ export const ActiveMonitoringMeteredPlan: ActiveMonitoringMeteredPlanType =
11
24
  export const LogDataIngestMeteredPlan: TelemetryMeteredPlanType =
12
25
  new TelemetryMeteredPlanType({
13
26
  productType: ProductType.Logs,
14
- unitCostInUSD: 0.1 / 15, // 0.10 per 15 days per GB
27
+ unitCostInUSD: TELEMETRY_UNIT_COST_IN_USD, // 0.10 per 15 days per GB
15
28
  });
16
29
 
17
30
  export const MetricsDataIngestMeteredPlan: TelemetryMeteredPlanType =
18
31
  new TelemetryMeteredPlanType({
19
32
  productType: ProductType.Metrics,
20
- unitCostInUSD: 0.1 / 15, // 0.10 per 15 days per GB
33
+ unitCostInUSD: TELEMETRY_UNIT_COST_IN_USD, // 0.10 per 15 days per GB
21
34
  });
22
35
 
23
36
  export const TracesDataIngestMetredPlan: TelemetryMeteredPlanType =
24
37
  new TelemetryMeteredPlanType({
25
38
  productType: ProductType.Traces,
26
- unitCostInUSD: 0.1 / 15, // 0.10 per 15 days per GB
39
+ unitCostInUSD: TELEMETRY_UNIT_COST_IN_USD, // 0.10 per 15 days per GB
27
40
  });
28
41
 
29
42
  export const ProfilesDataIngestMeteredPlan: TelemetryMeteredPlanType =
30
43
  new TelemetryMeteredPlanType({
31
44
  productType: ProductType.Profiles,
32
- unitCostInUSD: 0.1 / 15, // 0.10 per 15 days per GB
45
+ unitCostInUSD: TELEMETRY_UNIT_COST_IN_USD, // 0.10 per 15 days per GB
33
46
  });
34
47
 
35
48
  /*
@@ -43,7 +56,8 @@ export const ProfilesDataIngestMeteredPlan: TelemetryMeteredPlanType =
43
56
  export const SessionReplayDataIngestMeteredPlan: TelemetryMeteredPlanType =
44
57
  new TelemetryMeteredPlanType({
45
58
  productType: ProductType.SessionReplay,
46
- unitCostInUSD: 2.0 / 15, // 2.00 per 15 days per GB
59
+ unitCostInUSD:
60
+ SESSION_REPLAY_PRICE_IN_USD_PER_GB / TELEMETRY_PRICE_RETENTION_IN_DAYS, // 2.00 per 15 days per GB
47
61
  });
48
62
 
49
63
  const AllMeteredPlans: Array<ServerMeteredPlan> = [