@oneuptime/common 11.5.11 → 11.5.13

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 (142) hide show
  1. package/Models/AnalyticsModels/NetworkFlow.ts +12 -1
  2. package/Models/DatabaseModels/AIInsight.ts +30 -0
  3. package/Models/DatabaseModels/Project.ts +30 -0
  4. package/Models/DatabaseModels/TelemetryException.ts +128 -0
  5. package/Server/API/AIAgentDataAPI.ts +129 -5
  6. package/Server/API/AIInvestigationAPI.ts +87 -0
  7. package/Server/API/StatusPageAPI.ts +14 -1
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1784640000000-AddExceptionTriageAndPrivacyColumns.ts +56 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -2
  10. package/Server/Services/AIRunService.ts +8 -0
  11. package/Server/Services/IncidentService.ts +62 -67
  12. package/Server/Services/MonitorGroupService.ts +15 -2
  13. package/Server/Services/MonitorService.ts +91 -3
  14. package/Server/Services/MonitorStatusTimelineService.ts +285 -100
  15. package/Server/Services/StatusPageService.ts +59 -34
  16. package/Server/Services/TelemetryExceptionService.ts +320 -24
  17. package/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.ts +15 -4
  18. package/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.ts +15 -4
  19. package/Server/Utils/AI/SRE/Insights/InsightScanner.ts +222 -24
  20. package/Server/Utils/AI/SRE/Insights/InsightTriageRunner.ts +225 -9
  21. package/Server/Utils/AI/SRE/SubjectCodeFixRun.ts +39 -0
  22. package/Server/Utils/AI/SRE/TelemetryImprovementTaskTrigger.ts +114 -0
  23. package/Server/Utils/Monitor/MonitorResource.ts +43 -14
  24. package/Server/Utils/Monitor/MonitorStatusTimeline.ts +32 -7
  25. package/Server/Utils/Monitor/MonitorStatusTimelineReconciler.ts +362 -0
  26. package/Server/Utils/Telemetry/ExceptionSanitizer.ts +222 -0
  27. package/Tests/Server/Services/MonitorStatusTimelineService.test.ts +235 -0
  28. package/Tests/Server/Services/TelemetryExceptionCodeFixRun.test.ts +205 -1
  29. package/Tests/Server/Utils/AI/Insights/InsightScanner.test.ts +150 -141
  30. package/Tests/Server/Utils/AI/Insights/InsightTriageRunner.test.ts +277 -1
  31. package/Tests/Server/Utils/ExceptionSanitizer.test.ts +70 -0
  32. package/Tests/Server/Utils/InsightTriageClassification.test.ts +57 -0
  33. package/Tests/Server/Utils/Monitor/MonitorStatusTimelineReconciler.test.ts +555 -0
  34. package/Tests/Server/Utils/TelemetryImprovementTaskTrigger.test.ts +141 -0
  35. package/Tests/Types/Database/CompareOperatorWireSerialization.test.ts +216 -0
  36. package/Tests/Types/Database/GreaterThan.test.ts +5 -1
  37. package/Tests/Types/Database/GreaterThanOrEqual.test.ts +5 -1
  38. package/Tests/Types/Database/GreaterThanOrNull.test.ts +24 -1
  39. package/Tests/Types/Database/LessThan.test.ts +5 -1
  40. package/Tests/Types/Database/LessThanOrEqual.test.ts +24 -1
  41. package/Tests/Types/Database/LessThanOrNull.test.ts +5 -1
  42. package/Tests/UI/Components/ModelTableSelectFromColumns.test.ts +259 -0
  43. package/Tests/Utils/Uptime/UptimeUtil.test.ts +726 -0
  44. package/Types/AI/CodeFixTaskContext.ts +6 -0
  45. package/Types/AI/CodeFixTaskType.ts +38 -1
  46. package/Types/AI/ExceptionAIClassification.ts +27 -0
  47. package/Types/BaseDatabase/GreaterThan.ts +8 -1
  48. package/Types/BaseDatabase/GreaterThanOrEqual.ts +8 -1
  49. package/Types/BaseDatabase/GreaterThanOrNull.ts +9 -1
  50. package/Types/BaseDatabase/LessThan.ts +8 -1
  51. package/Types/BaseDatabase/LessThanOrEqual.ts +9 -1
  52. package/Types/BaseDatabase/LessThanOrNull.ts +8 -1
  53. package/Types/Log/LogScrubPatternType.ts +7 -0
  54. package/Types/Trace/TraceScrubPatternType.ts +7 -0
  55. package/UI/Components/ModelTable/BaseModelTable.tsx +15 -46
  56. package/UI/Components/ModelTable/SelectFromColumns.ts +129 -0
  57. package/Utils/StatusPage/ResourceUptime.ts +11 -1
  58. package/Utils/Uptime/UptimeUtil.ts +142 -36
  59. package/build/dist/Models/AnalyticsModels/NetworkFlow.js +12 -1
  60. package/build/dist/Models/AnalyticsModels/NetworkFlow.js.map +1 -1
  61. package/build/dist/Models/DatabaseModels/AIInsight.js +31 -0
  62. package/build/dist/Models/DatabaseModels/AIInsight.js.map +1 -1
  63. package/build/dist/Models/DatabaseModels/Project.js +31 -0
  64. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  65. package/build/dist/Models/DatabaseModels/TelemetryException.js +134 -0
  66. package/build/dist/Models/DatabaseModels/TelemetryException.js.map +1 -1
  67. package/build/dist/Server/API/AIAgentDataAPI.js +97 -8
  68. package/build/dist/Server/API/AIAgentDataAPI.js.map +1 -1
  69. package/build/dist/Server/API/AIInvestigationAPI.js +55 -0
  70. package/build/dist/Server/API/AIInvestigationAPI.js.map +1 -1
  71. package/build/dist/Server/API/StatusPageAPI.js +12 -2
  72. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  73. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784640000000-AddExceptionTriageAndPrivacyColumns.js +33 -0
  74. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784640000000-AddExceptionTriageAndPrivacyColumns.js.map +1 -0
  75. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -2
  76. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  77. package/build/dist/Server/Services/AIRunService.js +9 -2
  78. package/build/dist/Server/Services/AIRunService.js.map +1 -1
  79. package/build/dist/Server/Services/IncidentService.js +47 -56
  80. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  81. package/build/dist/Server/Services/MonitorGroupService.js +15 -2
  82. package/build/dist/Server/Services/MonitorGroupService.js.map +1 -1
  83. package/build/dist/Server/Services/MonitorService.js +66 -3
  84. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  85. package/build/dist/Server/Services/MonitorStatusTimelineService.js +244 -102
  86. package/build/dist/Server/Services/MonitorStatusTimelineService.js.map +1 -1
  87. package/build/dist/Server/Services/StatusPageService.js +57 -33
  88. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  89. package/build/dist/Server/Services/TelemetryExceptionService.js +263 -22
  90. package/build/dist/Server/Services/TelemetryExceptionService.js.map +1 -1
  91. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.js +12 -4
  92. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.js.map +1 -1
  93. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.js +12 -4
  94. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.js.map +1 -1
  95. package/build/dist/Server/Utils/AI/SRE/Insights/InsightScanner.js +193 -22
  96. package/build/dist/Server/Utils/AI/SRE/Insights/InsightScanner.js.map +1 -1
  97. package/build/dist/Server/Utils/AI/SRE/Insights/InsightTriageRunner.js +178 -9
  98. package/build/dist/Server/Utils/AI/SRE/Insights/InsightTriageRunner.js.map +1 -1
  99. package/build/dist/Server/Utils/AI/SRE/SubjectCodeFixRun.js +37 -0
  100. package/build/dist/Server/Utils/AI/SRE/SubjectCodeFixRun.js.map +1 -1
  101. package/build/dist/Server/Utils/AI/SRE/TelemetryImprovementTaskTrigger.js +98 -0
  102. package/build/dist/Server/Utils/AI/SRE/TelemetryImprovementTaskTrigger.js.map +1 -0
  103. package/build/dist/Server/Utils/Monitor/MonitorResource.js +34 -12
  104. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  105. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js +25 -8
  106. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js.map +1 -1
  107. package/build/dist/Server/Utils/Monitor/MonitorStatusTimelineReconciler.js +268 -0
  108. package/build/dist/Server/Utils/Monitor/MonitorStatusTimelineReconciler.js.map +1 -0
  109. package/build/dist/Server/Utils/Telemetry/ExceptionSanitizer.js +149 -0
  110. package/build/dist/Server/Utils/Telemetry/ExceptionSanitizer.js.map +1 -0
  111. package/build/dist/Types/AI/CodeFixTaskType.js +38 -1
  112. package/build/dist/Types/AI/CodeFixTaskType.js.map +1 -1
  113. package/build/dist/Types/AI/ExceptionAIClassification.js +28 -0
  114. package/build/dist/Types/AI/ExceptionAIClassification.js.map +1 -0
  115. package/build/dist/Types/BaseDatabase/GreaterThan.js +8 -1
  116. package/build/dist/Types/BaseDatabase/GreaterThan.js.map +1 -1
  117. package/build/dist/Types/BaseDatabase/GreaterThanOrEqual.js +8 -1
  118. package/build/dist/Types/BaseDatabase/GreaterThanOrEqual.js.map +1 -1
  119. package/build/dist/Types/BaseDatabase/GreaterThanOrNull.js +9 -1
  120. package/build/dist/Types/BaseDatabase/GreaterThanOrNull.js.map +1 -1
  121. package/build/dist/Types/BaseDatabase/LessThan.js +8 -1
  122. package/build/dist/Types/BaseDatabase/LessThan.js.map +1 -1
  123. package/build/dist/Types/BaseDatabase/LessThanOrEqual.js +9 -1
  124. package/build/dist/Types/BaseDatabase/LessThanOrEqual.js.map +1 -1
  125. package/build/dist/Types/BaseDatabase/LessThanOrNull.js +8 -1
  126. package/build/dist/Types/BaseDatabase/LessThanOrNull.js.map +1 -1
  127. package/build/dist/Types/Log/LogScrubPatternType.js +7 -0
  128. package/build/dist/Types/Log/LogScrubPatternType.js.map +1 -1
  129. package/build/dist/Types/Trace/TraceScrubPatternType.js +7 -0
  130. package/build/dist/Types/Trace/TraceScrubPatternType.js.map +1 -1
  131. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +12 -37
  132. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  133. package/build/dist/UI/Components/ModelTable/SelectFromColumns.js +82 -0
  134. package/build/dist/UI/Components/ModelTable/SelectFromColumns.js.map +1 -0
  135. package/build/dist/Utils/StatusPage/ResourceUptime.js +4 -1
  136. package/build/dist/Utils/StatusPage/ResourceUptime.js.map +1 -1
  137. package/build/dist/Utils/Uptime/UptimeUtil.js +86 -33
  138. package/build/dist/Utils/Uptime/UptimeUtil.js.map +1 -1
  139. package/package.json +1 -1
  140. package/Server/Infrastructure/Postgres/SchemaMigrations/1784401962564-MigrationName.ts +0 -101
  141. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784401962564-MigrationName.js +0 -40
  142. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784401962564-MigrationName.js.map +0 -1
@@ -0,0 +1,216 @@
1
+ import CompareBase, { CompareType } from "../../../Types/Database/CompareBase";
2
+ import GreaterThan from "../../../Types/BaseDatabase/GreaterThan";
3
+ import GreaterThanOrEqual from "../../../Types/BaseDatabase/GreaterThanOrEqual";
4
+ import GreaterThanOrNull from "../../../Types/BaseDatabase/GreaterThanOrNull";
5
+ import InBetween from "../../../Types/BaseDatabase/InBetween";
6
+ import LessThan from "../../../Types/BaseDatabase/LessThan";
7
+ import LessThanOrEqual from "../../../Types/BaseDatabase/LessThanOrEqual";
8
+ import LessThanOrNull from "../../../Types/BaseDatabase/LessThanOrNull";
9
+ import JSONFunctions from "../../../Types/JSONFunctions";
10
+ import { JSONObject } from "../../../Types/JSON";
11
+ import { describe, expect, it } from "@jest/globals";
12
+
13
+ /*
14
+ * Pins the WIRE CONTRACT of the comparison query operators end to end:
15
+ *
16
+ * browser: JSONFunctions.serialize(query) -> operator.toJSON()
17
+ * HTTP: JSON.stringify / JSON.parse (a raw Date becomes its full ISO string)
18
+ * server: JSONFunctions.deserialize -> Dictionary[_type].fromJSON
19
+ * SQL bind: operator.toString() -> QueryHelper.<op>(value)
20
+ *
21
+ * The bug this file guards against: toJSON used to serialize Date values via
22
+ * toString(), which collapses them to a DATE-ONLY string in the BROWSER'S LOCAL
23
+ * TIMEZONE (OneUptimeDate.asDateForDatabaseQuery). A bound of "now" therefore
24
+ * arrived at the server as midnight of the viewer's calendar date, and every
25
+ * row from later that day was silently excluded from the query - e.g. the
26
+ * Monitor View page dropped a monitor's current open status row whenever the
27
+ * status had changed the same day. Serializing the RAW value (exactly what
28
+ * InBetween always did) keeps the full timestamp across the wire in every
29
+ * timezone.
30
+ *
31
+ * The legacy date-only wire format must STAY accepted: an old client (or a
32
+ * saved query serialized before this change) sends value: "YYYY-MM-DD", and
33
+ * the server must keep treating it as an opaque string bound.
34
+ */
35
+
36
+ const FULL_ISO_TIMESTAMP: string = "2026-07-21T14:35:12.345Z";
37
+ const LEGACY_DATE_ONLY: string = "2026-07-21";
38
+
39
+ type OperatorFactory = (value: CompareType) => CompareBase<CompareType>;
40
+
41
+ type OperatorCase = {
42
+ name: string;
43
+ type: unknown;
44
+ create: OperatorFactory;
45
+ };
46
+
47
+ const OPERATOR_CASES: Array<OperatorCase> = [
48
+ {
49
+ name: "LessThan",
50
+ type: LessThan,
51
+ create: (value: CompareType) => {
52
+ return new LessThan(value);
53
+ },
54
+ },
55
+ {
56
+ name: "LessThanOrEqual",
57
+ type: LessThanOrEqual,
58
+ create: (value: CompareType) => {
59
+ return new LessThanOrEqual(value);
60
+ },
61
+ },
62
+ {
63
+ name: "LessThanOrNull",
64
+ type: LessThanOrNull,
65
+ create: (value: CompareType) => {
66
+ return new LessThanOrNull(value);
67
+ },
68
+ },
69
+ {
70
+ name: "GreaterThan",
71
+ type: GreaterThan,
72
+ create: (value: CompareType) => {
73
+ return new GreaterThan(value);
74
+ },
75
+ },
76
+ {
77
+ name: "GreaterThanOrEqual",
78
+ type: GreaterThanOrEqual,
79
+ create: (value: CompareType) => {
80
+ return new GreaterThanOrEqual(value);
81
+ },
82
+ },
83
+ {
84
+ name: "GreaterThanOrNull",
85
+ type: GreaterThanOrNull,
86
+ create: (value: CompareType) => {
87
+ return new GreaterThanOrNull(value);
88
+ },
89
+ },
90
+ ];
91
+
92
+ type SimulateHttpHopFunction = (query: JSONObject) => JSONObject;
93
+
94
+ /*
95
+ * What actually happens between ModelAPI.getList and BaseAPI: the serialized
96
+ * query is JSON.stringify'd into the request body and JSON.parse'd on the
97
+ * server. This is the step that turns a raw Date into its full ISO string.
98
+ */
99
+ const simulateHttpHop: SimulateHttpHopFunction = (
100
+ query: JSONObject,
101
+ ): JSONObject => {
102
+ return JSON.parse(JSON.stringify(JSONFunctions.serialize(query)));
103
+ };
104
+
105
+ for (const operatorCase of OPERATOR_CASES) {
106
+ describe(`${operatorCase.name} wire serialization`, () => {
107
+ it("sends a Date bound as its full ISO timestamp, not a local date-only string", () => {
108
+ const query: JSONObject = {
109
+ startsAt: operatorCase.create(new Date(FULL_ISO_TIMESTAMP)),
110
+ } as unknown as JSONObject;
111
+
112
+ const wire: JSONObject = simulateHttpHop(query);
113
+
114
+ expect((wire["startsAt"] as JSONObject)["_type"]).toBe(operatorCase.name);
115
+ /*
116
+ * The critical assertion: the full timestamp survives. Under the old
117
+ * toString()-based toJSON this was "2026-07-21" (or "2026-07-22" east
118
+ * of UTC) regardless of the time component.
119
+ */
120
+ expect((wire["startsAt"] as JSONObject)["value"]).toBe(
121
+ FULL_ISO_TIMESTAMP,
122
+ );
123
+ });
124
+
125
+ it("deserializes on the server to the operator with the full-precision bound", () => {
126
+ const query: JSONObject = {
127
+ startsAt: operatorCase.create(new Date(FULL_ISO_TIMESTAMP)),
128
+ } as unknown as JSONObject;
129
+
130
+ const deserialized: JSONObject = JSONFunctions.deserialize(
131
+ simulateHttpHop(query),
132
+ );
133
+
134
+ expect(deserialized["startsAt"]).toBeInstanceOf(operatorCase.type);
135
+
136
+ /*
137
+ * toString() is exactly what QueryUtil passes into QueryHelper as the
138
+ * SQL bind value, so this string IS the query bound. It must be the
139
+ * full ISO timestamp, passed through untouched.
140
+ */
141
+ const operator: CompareBase<CompareType> = deserialized[
142
+ "startsAt"
143
+ ] as unknown as CompareBase<CompareType>;
144
+
145
+ expect(operator.toString()).toBe(FULL_ISO_TIMESTAMP);
146
+ });
147
+
148
+ it("still accepts the legacy date-only wire format as an opaque string", () => {
149
+ const legacyWire: JSONObject = {
150
+ startsAt: {
151
+ _type: operatorCase.name,
152
+ value: LEGACY_DATE_ONLY,
153
+ },
154
+ };
155
+
156
+ const deserialized: JSONObject = JSONFunctions.deserialize(legacyWire);
157
+
158
+ expect(deserialized["startsAt"]).toBeInstanceOf(operatorCase.type);
159
+
160
+ const operator: CompareBase<CompareType> = deserialized[
161
+ "startsAt"
162
+ ] as unknown as CompareBase<CompareType>;
163
+
164
+ expect(operator.toString()).toBe(LEGACY_DATE_ONLY);
165
+ });
166
+
167
+ it("round-trips a number bound unchanged", () => {
168
+ const query: JSONObject = {
169
+ priority: operatorCase.create(42),
170
+ } as unknown as JSONObject;
171
+
172
+ const wire: JSONObject = simulateHttpHop(query);
173
+
174
+ expect((wire["priority"] as JSONObject)["value"]).toBe(42);
175
+
176
+ const deserialized: JSONObject = JSONFunctions.deserialize(wire);
177
+
178
+ const operator: CompareBase<CompareType> = deserialized[
179
+ "priority"
180
+ ] as unknown as CompareBase<CompareType>;
181
+
182
+ expect(operator.toString()).toBe("42");
183
+ });
184
+ });
185
+ }
186
+
187
+ describe("InBetween wire serialization (the reference behavior)", () => {
188
+ it("has always sent full ISO timestamps for both bounds - the operators above now match it", () => {
189
+ const startBound: string = "2026-04-22T00:30:00.000Z";
190
+
191
+ const query: JSONObject = {
192
+ createdAt: new InBetween(
193
+ new Date(startBound),
194
+ new Date(FULL_ISO_TIMESTAMP),
195
+ ),
196
+ } as unknown as JSONObject;
197
+
198
+ const wire: JSONObject = simulateHttpHop(query);
199
+
200
+ expect((wire["createdAt"] as JSONObject)["startValue"]).toBe(startBound);
201
+ expect((wire["createdAt"] as JSONObject)["endValue"]).toBe(
202
+ FULL_ISO_TIMESTAMP,
203
+ );
204
+
205
+ const deserialized: JSONObject = JSONFunctions.deserialize(wire);
206
+
207
+ expect(deserialized["createdAt"]).toBeInstanceOf(InBetween);
208
+
209
+ const operator: InBetween<CompareType> = deserialized[
210
+ "createdAt"
211
+ ] as unknown as InBetween<CompareType>;
212
+
213
+ expect(operator.toStartValueString()).toBe(startBound);
214
+ expect(operator.toEndValueString()).toBe(FULL_ISO_TIMESTAMP);
215
+ });
216
+ });
@@ -25,7 +25,11 @@ describe("GreaterThan", () => {
25
25
  const obj: GreaterThan<number> = new GreaterThan<number>(42);
26
26
  const expectedJSON: JSONObject = {
27
27
  _type: "GreaterThan",
28
- value: "42",
28
+ /*
29
+ * toJSON carries the RAW value (like InBetween), not a stringified one -
30
+ * see the comment on toJSON. For numbers that means the number itself.
31
+ */
32
+ value: 42,
29
33
  };
30
34
  expect(obj.toJSON()).toEqual(expectedJSON);
31
35
  });
@@ -27,7 +27,11 @@ describe("GreaterThanOrEqual", () => {
27
27
  const obj: GreaterThanOrEqual<number> = new GreaterThanOrEqual<number>(42);
28
28
  const expectedJSON: JSONObject = {
29
29
  _type: "GreaterThanOrEqual",
30
- value: "42",
30
+ /*
31
+ * toJSON carries the RAW value (like InBetween), not a stringified one -
32
+ * see the comment on toJSON. For numbers that means the number itself.
33
+ */
34
+ value: 42,
31
35
  };
32
36
  expect(obj.toJSON()).toEqual(expectedJSON);
33
37
  });
@@ -23,13 +23,36 @@ describe("GreaterThanOrNull", () => {
23
23
 
24
24
  it("should generate the correct JSON representation using toJSON", () => {
25
25
  const obj: GreaterThanOrNull<number> = new GreaterThanOrNull<number>(42);
26
+ /*
27
+ * toJSON carries the RAW value (like InBetween), not a stringified one -
28
+ * see the comment on toJSON. For numbers that means the number itself.
29
+ */
26
30
  const expectedJSON: JSONObject = {
27
31
  _type: "GreaterThanOrNull",
28
- value: "42",
32
+ value: 42,
29
33
  };
30
34
  expect(obj.toJSON()).toEqual(expectedJSON);
31
35
  });
32
36
 
37
+ it("keeps the full timestamp when serializing a Date value", () => {
38
+ const value: Date = new Date("2026-07-21T14:35:12.345Z");
39
+ const obj: GreaterThanOrNull<Date> = new GreaterThanOrNull<Date>(value);
40
+
41
+ /*
42
+ * The raw Date is serialized, so JSON.stringify emits the full ISO
43
+ * timestamp. The old toString()-based toJSON collapsed it to a
44
+ * local-timezone date-only string, which shifted query bounds sent from
45
+ * the browser by up to a day and silently dropped same-day rows.
46
+ */
47
+ expect(obj.toJSON()).toEqual({
48
+ _type: "GreaterThanOrNull",
49
+ value: value,
50
+ });
51
+ expect(JSON.parse(JSON.stringify(obj.toJSON()))["value"]).toBe(
52
+ "2026-07-21T14:35:12.345Z",
53
+ );
54
+ });
55
+
33
56
  it("should create a GreaterThanOrNull object from valid JSON input", () => {
34
57
  const jsonInput: JSONObject = {
35
58
  _type: "GreaterThanOrNull",
@@ -25,7 +25,11 @@ describe("LessThan", () => {
25
25
  const obj: LessThan<number> = new LessThan<number>(42);
26
26
  const expectedJSON: JSONObject = {
27
27
  _type: "LessThan",
28
- value: "42",
28
+ /*
29
+ * toJSON carries the RAW value (like InBetween), not a stringified one -
30
+ * see the comment on toJSON. For numbers that means the number itself.
31
+ */
32
+ value: 42,
29
33
  };
30
34
  expect(obj.toJSON()).toEqual(expectedJSON);
31
35
  });
@@ -23,13 +23,36 @@ describe("LessThanOrEqual", () => {
23
23
 
24
24
  it("should generate the correct JSON representation using toJSON", () => {
25
25
  const obj: LessThanOrEqual<number> = new LessThanOrEqual<number>(42);
26
+ /*
27
+ * toJSON carries the RAW value (like InBetween), not a stringified one -
28
+ * see the comment on toJSON. For numbers that means the number itself.
29
+ */
26
30
  const expectedJSON: JSONObject = {
27
31
  _type: "LessThanOrEqual",
28
- value: "42",
32
+ value: 42,
29
33
  };
30
34
  expect(obj.toJSON()).toEqual(expectedJSON);
31
35
  });
32
36
 
37
+ it("keeps the full timestamp when serializing a Date value", () => {
38
+ const value: Date = new Date("2026-07-21T14:35:12.345Z");
39
+ const obj: LessThanOrEqual<Date> = new LessThanOrEqual<Date>(value);
40
+
41
+ /*
42
+ * The raw Date is serialized, so JSON.stringify emits the full ISO
43
+ * timestamp. The old toString()-based toJSON collapsed it to a
44
+ * local-timezone date-only string, which shifted query bounds sent from
45
+ * the browser by up to a day and silently dropped same-day rows.
46
+ */
47
+ expect(obj.toJSON()).toEqual({
48
+ _type: "LessThanOrEqual",
49
+ value: value,
50
+ });
51
+ expect(JSON.parse(JSON.stringify(obj.toJSON()))["value"]).toBe(
52
+ "2026-07-21T14:35:12.345Z",
53
+ );
54
+ });
55
+
33
56
  it("should create a LessThanOrEqual object from valid JSON input", () => {
34
57
  const jsonInput: JSONObject = {
35
58
  _type: "LessThanOrEqual",
@@ -25,7 +25,11 @@ describe("LessThanOrNull", () => {
25
25
  const obj: LessThanOrNull<number> = new LessThanOrNull<number>(42);
26
26
  const expectedJSON: JSONObject = {
27
27
  _type: "LessThanOrNull",
28
- value: "42",
28
+ /*
29
+ * toJSON carries the RAW value (like InBetween), not a stringified one -
30
+ * see the comment on toJSON. For numbers that means the number itself.
31
+ */
32
+ value: 42,
29
33
  };
30
34
  expect(obj.toJSON()).toEqual(expectedJSON);
31
35
  });
@@ -0,0 +1,259 @@
1
+ import Columns from "../../../UI/Components/ModelTable/Columns";
2
+ import {
3
+ getRelationSelectFromColumns,
4
+ getSelectFromColumns,
5
+ } from "../../../UI/Components/ModelTable/SelectFromColumns";
6
+ import FieldType from "../../../UI/Components/Types/FieldType";
7
+ import Alert from "../../../Models/DatabaseModels/Alert";
8
+ import ProxmoxCluster from "../../../Models/DatabaseModels/ProxmoxCluster";
9
+ import BadDataException from "../../../Types/Exception/BadDataException";
10
+ import { describe, expect, test } from "@jest/globals";
11
+
12
+ describe("ModelTable SelectFromColumns", () => {
13
+ describe("getSelectFromColumns", () => {
14
+ test("it selects every field a column declares, not just the first", () => {
15
+ /*
16
+ * Regression for OneUptime/oneuptime#2756: the Proxmox clusters list
17
+ * rendered "0/3 online" for a fully online cluster because only the
18
+ * first key of the column's field map was selected, leaving
19
+ * onlineNodeCount undefined on every row.
20
+ */
21
+ const columns: Columns<ProxmoxCluster> = [
22
+ {
23
+ field: {
24
+ nodeCount: true,
25
+ onlineNodeCount: true,
26
+ },
27
+ title: "Nodes",
28
+ type: FieldType.Element,
29
+ },
30
+ ];
31
+
32
+ const select: Record<string, unknown> =
33
+ getSelectFromColumns<ProxmoxCluster>({
34
+ columns: columns,
35
+ model: new ProxmoxCluster(),
36
+ }) as Record<string, unknown>;
37
+
38
+ expect(select["nodeCount"]).toBe(true);
39
+ expect(select["onlineNodeCount"]).toBe(true);
40
+ });
41
+
42
+ test("it always selects _id", () => {
43
+ const select: Record<string, unknown> =
44
+ getSelectFromColumns<ProxmoxCluster>({
45
+ columns: [],
46
+ model: new ProxmoxCluster(),
47
+ }) as Record<string, unknown>;
48
+
49
+ expect(select["_id"]).toBe(true);
50
+ });
51
+
52
+ test("it merges fields across multiple columns", () => {
53
+ const columns: Columns<ProxmoxCluster> = [
54
+ {
55
+ field: { name: true },
56
+ title: "Name",
57
+ type: FieldType.Text,
58
+ },
59
+ {
60
+ field: { guestCount: true, storageCount: true },
61
+ title: "Inventory",
62
+ type: FieldType.Element,
63
+ },
64
+ ];
65
+
66
+ const select: Record<string, unknown> =
67
+ getSelectFromColumns<ProxmoxCluster>({
68
+ columns: columns,
69
+ model: new ProxmoxCluster(),
70
+ }) as Record<string, unknown>;
71
+
72
+ expect(select["name"]).toBe(true);
73
+ expect(select["guestCount"]).toBe(true);
74
+ expect(select["storageCount"]).toBe(true);
75
+ });
76
+
77
+ test("it skips columns that declare no field", () => {
78
+ const columns: Columns<ProxmoxCluster> = [
79
+ {
80
+ field: undefined as never,
81
+ title: "Actions",
82
+ type: FieldType.Element,
83
+ },
84
+ ];
85
+
86
+ const select: Record<string, unknown> =
87
+ getSelectFromColumns<ProxmoxCluster>({
88
+ columns: columns,
89
+ model: new ProxmoxCluster(),
90
+ }) as Record<string, unknown>;
91
+
92
+ expect(Object.keys(select)).toEqual(["_id"]);
93
+ });
94
+
95
+ test("it throws when the primary field is not a column on the model", () => {
96
+ const columns: Columns<ProxmoxCluster> = [
97
+ {
98
+ field: { notARealColumn: true } as never,
99
+ title: "Nope",
100
+ type: FieldType.Text,
101
+ },
102
+ ];
103
+
104
+ expect(() => {
105
+ return getSelectFromColumns<ProxmoxCluster>({
106
+ columns: columns,
107
+ model: new ProxmoxCluster(),
108
+ });
109
+ }).toThrow(BadDataException);
110
+ });
111
+
112
+ test("it ignores a non-column secondary field rather than blanking the page", () => {
113
+ const columns: Columns<ProxmoxCluster> = [
114
+ {
115
+ field: { nodeCount: true, notARealColumn: true } as never,
116
+ title: "Nodes",
117
+ type: FieldType.Element,
118
+ },
119
+ ];
120
+
121
+ const consoleError: jest.SpyInstance = jest
122
+ .spyOn(console, "error")
123
+ .mockImplementation(() => {});
124
+
125
+ const select: Record<string, unknown> =
126
+ getSelectFromColumns<ProxmoxCluster>({
127
+ columns: columns,
128
+ model: new ProxmoxCluster(),
129
+ }) as Record<string, unknown>;
130
+
131
+ expect(select["nodeCount"]).toBe(true);
132
+ expect(select["notARealColumn"]).toBeUndefined();
133
+ // It must not drop the field silently — that is how #2756 hid.
134
+ expect(consoleError).toHaveBeenCalled();
135
+
136
+ consoleError.mockRestore();
137
+ });
138
+
139
+ test("it drops a secondary field the user cannot read, so the API does not reject the whole request", () => {
140
+ /*
141
+ * checkSelectPermission throws NotAuthorizedException for the entire
142
+ * request when the select names an unreadable column, so widening the
143
+ * select must never add one.
144
+ */
145
+ const columns: Columns<ProxmoxCluster> = [
146
+ {
147
+ field: {
148
+ nodeCount: true,
149
+ onlineNodeCount: true,
150
+ },
151
+ title: "Nodes",
152
+ type: FieldType.Element,
153
+ },
154
+ ];
155
+
156
+ const select: Record<string, unknown> =
157
+ getSelectFromColumns<ProxmoxCluster>({
158
+ columns: columns,
159
+ model: new ProxmoxCluster(),
160
+ hasPermissionToReadField: (field: string): boolean => {
161
+ return field !== "onlineNodeCount";
162
+ },
163
+ }) as Record<string, unknown>;
164
+
165
+ expect(select["nodeCount"]).toBe(true);
166
+ expect(select["onlineNodeCount"]).toBeUndefined();
167
+ });
168
+
169
+ test("it keeps the primary field even when the permission callback rejects it, preserving prior behaviour", () => {
170
+ const columns: Columns<ProxmoxCluster> = [
171
+ {
172
+ field: { nodeCount: true, onlineNodeCount: true },
173
+ title: "Nodes",
174
+ type: FieldType.Element,
175
+ },
176
+ ];
177
+
178
+ const select: Record<string, unknown> =
179
+ getSelectFromColumns<ProxmoxCluster>({
180
+ columns: columns,
181
+ model: new ProxmoxCluster(),
182
+ hasPermissionToReadField: (): boolean => {
183
+ return false;
184
+ },
185
+ }) as Record<string, unknown>;
186
+
187
+ expect(select["nodeCount"]).toBe(true);
188
+ expect(select["onlineNodeCount"]).toBeUndefined();
189
+ });
190
+ });
191
+
192
+ describe("getRelationSelectFromColumns", () => {
193
+ test("it selects every relation a column declares, not just the first", () => {
194
+ const columns: Columns<Alert> = [
195
+ {
196
+ field: {
197
+ monitor: { name: true, _id: true },
198
+ labels: { name: true, color: true },
199
+ },
200
+ title: "Resource",
201
+ type: FieldType.Element,
202
+ },
203
+ ];
204
+
205
+ const relationSelect: Record<string, unknown> =
206
+ getRelationSelectFromColumns<Alert>({
207
+ columns: columns,
208
+ model: new Alert(),
209
+ }) as Record<string, unknown>;
210
+
211
+ expect(relationSelect["monitor"]).toEqual({ name: true, _id: true });
212
+ expect(relationSelect["labels"]).toEqual({ name: true, color: true });
213
+ });
214
+
215
+ test("it leaves non-entity fields out of the relation select", () => {
216
+ const columns: Columns<ProxmoxCluster> = [
217
+ {
218
+ field: {
219
+ nodeCount: true,
220
+ onlineNodeCount: true,
221
+ },
222
+ title: "Nodes",
223
+ type: FieldType.Element,
224
+ },
225
+ ];
226
+
227
+ const relationSelect: Record<string, unknown> =
228
+ getRelationSelectFromColumns<ProxmoxCluster>({
229
+ columns: columns,
230
+ model: new ProxmoxCluster(),
231
+ }) as Record<string, unknown>;
232
+
233
+ expect(relationSelect).toEqual({});
234
+ });
235
+
236
+ test("it merges the same relation declared by two columns", () => {
237
+ const columns: Columns<Alert> = [
238
+ {
239
+ field: { monitor: { name: true } },
240
+ title: "Monitor",
241
+ type: FieldType.Element,
242
+ },
243
+ {
244
+ field: { monitor: { _id: true } },
245
+ title: "Monitor Id",
246
+ type: FieldType.Element,
247
+ },
248
+ ];
249
+
250
+ const relationSelect: Record<string, unknown> =
251
+ getRelationSelectFromColumns<Alert>({
252
+ columns: columns,
253
+ model: new Alert(),
254
+ }) as Record<string, unknown>;
255
+
256
+ expect(relationSelect["monitor"]).toEqual({ name: true, _id: true });
257
+ });
258
+ });
259
+ });