@oneuptime/common 11.3.5 → 11.3.6

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 (59) hide show
  1. package/Server/Infrastructure/Queue.ts +103 -16
  2. package/Server/Services/AnalyticsDatabaseService.ts +9 -5
  3. package/Server/Services/TeamMemberService.ts +38 -1
  4. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +31 -0
  5. package/Server/Utils/Monitor/IncomingRequestIncidentGrouping.ts +428 -0
  6. package/Server/Utils/Monitor/MonitorAlert.ts +158 -8
  7. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +26 -0
  8. package/Server/Utils/Monitor/MonitorIncident.ts +147 -2
  9. package/Server/Utils/Monitor/MonitorResource.ts +74 -0
  10. package/Tests/Server/Utils/AnalyticsDatabase/ClusterAwareSchema.test.ts +2 -1
  11. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +32 -0
  12. package/Tests/Server/Utils/Monitor/IncomingRequestIncidentGrouping.test.ts +389 -0
  13. package/Tests/Server/Utils/Monitor/SeriesAbsenceResolutionGuard.test.ts +177 -0
  14. package/Tests/Types/Events/Recurring.test.ts +475 -0
  15. package/Tests/Types/Monitor/CriteriaFilter.test.ts +348 -0
  16. package/Tests/Types/OnCallDutyPolicy/RestrictionTimes.test.ts +331 -0
  17. package/Tests/UI/Components/Alert.test.tsx +23 -15
  18. package/Tests/UI/Components/Breadcrumbs.test.tsx +18 -6
  19. package/Tests/UI/Components/Button.test.tsx +3 -3
  20. package/Tests/UI/Components/DictionaryFilterOperator.test.ts +174 -0
  21. package/Tests/UI/Components/EmptyState/EmptyState.test.tsx +4 -3
  22. package/Tests/UI/Components/Input.test.tsx +3 -2
  23. package/Tests/UI/Components/Pill.test.tsx +4 -2
  24. package/Tests/UI/Components/SideMenuItem.test.tsx +18 -8
  25. package/Tests/UI/Components/TextArea.test.tsx +6 -4
  26. package/Types/Monitor/IncomingMonitor/IncidentGroupingConfig.ts +74 -0
  27. package/Types/Monitor/MonitorCriteriaInstance.ts +22 -0
  28. package/UI/Components/Dictionary/Dictionary.tsx +123 -50
  29. package/UI/Components/Dictionary/DictionaryFilterOperator.ts +96 -1
  30. package/UI/Components/MasterPage/MasterPage.tsx +5 -3
  31. package/build/dist/Server/Infrastructure/Queue.js +83 -15
  32. package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
  33. package/build/dist/Server/Services/AnalyticsDatabaseService.js +9 -5
  34. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  35. package/build/dist/Server/Services/TeamMemberService.js +37 -1
  36. package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
  37. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +26 -0
  38. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  39. package/build/dist/Server/Utils/Monitor/IncomingRequestIncidentGrouping.js +288 -0
  40. package/build/dist/Server/Utils/Monitor/IncomingRequestIncidentGrouping.js.map +1 -0
  41. package/build/dist/Server/Utils/Monitor/MonitorAlert.js +119 -8
  42. package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
  43. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +24 -0
  44. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  45. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +103 -2
  46. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  47. package/build/dist/Server/Utils/Monitor/MonitorResource.js +62 -0
  48. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  49. package/build/dist/Types/Monitor/IncomingMonitor/IncidentGroupingConfig.js +8 -0
  50. package/build/dist/Types/Monitor/IncomingMonitor/IncidentGroupingConfig.js.map +1 -0
  51. package/build/dist/Types/Monitor/MonitorCriteriaInstance.js +10 -0
  52. package/build/dist/Types/Monitor/MonitorCriteriaInstance.js.map +1 -1
  53. package/build/dist/UI/Components/Dictionary/Dictionary.js +60 -9
  54. package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
  55. package/build/dist/UI/Components/Dictionary/DictionaryFilterOperator.js +69 -0
  56. package/build/dist/UI/Components/Dictionary/DictionaryFilterOperator.js.map +1 -1
  57. package/build/dist/UI/Components/MasterPage/MasterPage.js +5 -3
  58. package/build/dist/UI/Components/MasterPage/MasterPage.js.map +1 -1
  59. package/package.json +1 -1
@@ -88,8 +88,8 @@ export default class Queue {
88
88
  // Keep BullMQ data under control to avoid Redis bloat
89
89
  defaultJobOptions: {
90
90
  // keep only recent completed/failed jobs
91
- removeOnComplete: { count: 500 }, // keep last 1000 completed jobs
92
- removeOnFail: { count: 100 }, // keep last 500 failed jobs
91
+ removeOnComplete: { count: 500 }, // keep last 500 completed jobs
92
+ removeOnFail: { count: 100 }, // keep last 100 failed jobs
93
93
  },
94
94
  /*
95
95
  * Optionally cap the event stream length (supported in BullMQ >= v5)
@@ -112,24 +112,111 @@ export default class Queue {
112
112
  logger.error(err);
113
113
  });
114
114
 
115
- // Fire-and-forget initial cleanup for legacy/old data if not done before
116
- if (!this.cleanedQueueNames.has(queueName)) {
117
- this.cleanedQueueNames.add(queueName);
118
- // Clean jobs older than 1 days to reclaim memory from historic runs
119
- const oneDaysMs: number = 1 * 24 * 60 * 60 * 1000;
120
- void (async () => {
121
- try {
122
- await queue.clean(oneDaysMs, 1000, "completed");
123
- await queue.clean(oneDaysMs, 1000, "failed");
124
- } catch {
125
- // ignore cleanup errors to not impact normal flow
126
- }
127
- })();
128
- }
115
+ /*
116
+ * Lazy fallback for the startup sweep: the first time a queue is created in
117
+ * this process, kick off its once-per-process terminal-job cleanup. The
118
+ * eager path (cleanAllQueuesOnStartup, called at service boot) normally
119
+ * wins; whichever runs first is the only one to sweep, via the gate inside
120
+ * cleanQueueOnStartup(). Fire and forget so it never blocks enqueue.
121
+ */
122
+ void this.cleanQueueOnStartup(queueName);
129
123
 
130
124
  return queue;
131
125
  }
132
126
 
127
+ /**
128
+ * Eagerly sweeps every queue's terminal (completed/failed) jobs at process
129
+ * startup, so the Completed/Failed counts on the admin Health page reset on a
130
+ * pod (re)start regardless of when — or whether — each queue next produces a
131
+ * job. Call once during service boot (see App/Index.ts). Each queue is swept
132
+ * at most once per process via the cleanedQueueNames gate, so this is safe to
133
+ * call alongside the lazy getQueue() path and from any service role.
134
+ */
135
+ @CaptureSpan()
136
+ public static async cleanAllQueuesOnStartup(): Promise<void> {
137
+ await Promise.all(
138
+ Object.values(QueueName).map((queueName: QueueName) => {
139
+ return this.cleanQueueOnStartup(queueName);
140
+ }),
141
+ );
142
+ }
143
+
144
+ /*
145
+ * How old (in ms) a completed/failed job must be before the startup sweep
146
+ * removes it. 0 means "remove all terminal jobs on every restart", which
147
+ * resets the Completed/Failed counts on the admin Health page so operators
148
+ * stop seeing stale failures after a deploy/restart. Raise this (e.g. to
149
+ * 24 * 60 * 60 * 1000) if you'd rather keep recent failures around for
150
+ * crash-loop debugging.
151
+ */
152
+ private static readonly STARTUP_CLEANUP_GRACE_MS: number = 0;
153
+ // Number of jobs removed per clean() call; we loop until the state is drained.
154
+ private static readonly STARTUP_CLEANUP_BATCH_SIZE: number = 1000;
155
+ // Safety cap so a pathologically large backlog can't loop forever.
156
+ private static readonly STARTUP_CLEANUP_MAX_BATCHES: number = 1000;
157
+
158
+ /**
159
+ * Removes terminal (completed/failed) jobs for a single queue, at most once
160
+ * per process (gated by cleanedQueueNames). Drains in batches because
161
+ * BullMQ's clean() caps each call at `limit` removals. Leaves live states
162
+ * (waiting/active/delayed) untouched. Best-effort: any error is logged and
163
+ * swallowed so a transient Redis hiccup at boot never breaks queue setup.
164
+ */
165
+ private static async cleanQueueOnStartup(
166
+ queueName: QueueName,
167
+ ): Promise<void> {
168
+ /*
169
+ * Gate synchronously, before any await, so the lazy getQueue() trigger and
170
+ * the eager cleanAllQueuesOnStartup() call can't both sweep the same queue.
171
+ */
172
+ if (this.cleanedQueueNames.has(queueName)) {
173
+ return;
174
+ }
175
+ this.cleanedQueueNames.add(queueName);
176
+
177
+ const queue: BullQueue = this.getQueue(queueName);
178
+
179
+ const terminalStates: Array<"completed" | "failed"> = [
180
+ "completed",
181
+ "failed",
182
+ ];
183
+
184
+ for (const state of terminalStates) {
185
+ try {
186
+ let removedTotal: number = 0;
187
+
188
+ for (
189
+ let batch: number = 0;
190
+ batch < this.STARTUP_CLEANUP_MAX_BATCHES;
191
+ batch++
192
+ ) {
193
+ const removed: string[] = await queue.clean(
194
+ this.STARTUP_CLEANUP_GRACE_MS,
195
+ this.STARTUP_CLEANUP_BATCH_SIZE,
196
+ state,
197
+ );
198
+
199
+ removedTotal += removed.length;
200
+
201
+ // A short batch means the state is drained; stop looping.
202
+ if (removed.length < this.STARTUP_CLEANUP_BATCH_SIZE) {
203
+ break;
204
+ }
205
+ }
206
+
207
+ if (removedTotal > 0) {
208
+ logger.debug(
209
+ `Queue ${queueName}: removed ${removedTotal} ${state} job(s) on startup`,
210
+ );
211
+ }
212
+ } catch (err) {
213
+ // Ignore cleanup errors to not impact normal flow.
214
+ logger.debug(`Queue ${queueName}: startup ${state} cleanup failed`);
215
+ logger.debug(err);
216
+ }
217
+ }
218
+ }
219
+
133
220
  @CaptureSpan()
134
221
  public static async removeJob(
135
222
  queueName: QueueName,
@@ -126,8 +126,10 @@ export const MigrationExecuteOptions: ClickhouseExecuteOptions = {
126
126
  useMigrationConnection: true,
127
127
  clickhouseSettings: {
128
128
  send_progress_in_http_headers: 1,
129
- // Emit a progress header every 10s — well under the App pool's 58s and the
130
- // migration pool's 30-minute idle ceiling.
129
+ /*
130
+ * Emit a progress header every 10s — well under the App pool's 58s and the
131
+ * migration pool's 30-minute idle ceiling.
132
+ */
131
133
  http_headers_progress_interval_ms: "10000",
132
134
  },
133
135
  };
@@ -487,9 +489,11 @@ export default class AnalyticsDatabaseService<
487
489
  ): Promise<void> {
488
490
  const statement: Statement =
489
491
  this.statementGenerator.toAddColumnStatement(column);
490
- // Schema-sync / migration-only path: route through the migration pool so a
491
- // column add that backfills a DEFAULT/MATERIALIZED expression on a large
492
- // table is not destroyed at the App pool's 58s socket-idle timeout.
492
+ /*
493
+ * Schema-sync / migration-only path: route through the migration pool so a
494
+ * column add that backfills a DEFAULT/MATERIALIZED expression on a large
495
+ * table is not destroyed at the App pool's 58s socket-idle timeout.
496
+ */
493
497
  await this.execute(statement, MigrationExecuteOptions);
494
498
 
495
499
  // Add skip index separately (ClickHouse requires ADD INDEX as a separate ALTER statement)
@@ -28,6 +28,7 @@ import SubscriptionPlan, {
28
28
  import LIMIT_MAX from "../../Types/Database/LimitMax";
29
29
  import Email from "../../Types/Email";
30
30
  import EmailTemplateType from "../../Types/Email/EmailTemplateType";
31
+ import Name from "../../Types/Name";
31
32
  import BadDataException from "../../Types/Exception/BadDataException";
32
33
  import ObjectID from "../../Types/ObjectID";
33
34
  import PositiveNumber from "../../Types/PositiveNumber";
@@ -125,6 +126,15 @@ export class TeamMemberService extends DatabaseService<TeamMember> {
125
126
  if (createBy.miscDataProps && createBy.miscDataProps["email"]) {
126
127
  const email: Email = new Email(createBy.miscDataProps["email"] as string);
127
128
 
129
+ /*
130
+ * Optional name supplied on the invite form. Used only to set the name on
131
+ * a brand-new user, or to backfill an existing user who has no name yet —
132
+ * we never overwrite a name the user has already set.
133
+ */
134
+ const nameValue: string | undefined = createBy.miscDataProps["name"]
135
+ ? (createBy.miscDataProps["name"] as string).trim()
136
+ : undefined;
137
+
128
138
  let user: User | null = await UserService.findByEmail(email, {
129
139
  isRoot: true,
130
140
  });
@@ -133,13 +143,40 @@ export class TeamMemberService extends DatabaseService<TeamMember> {
133
143
 
134
144
  if (!user) {
135
145
  isNewUser = true;
146
+
136
147
  user = await UserService.createByEmail({
137
148
  email,
138
- name: undefined, // name is not required for now.
149
+ name: nameValue ? new Name(nameValue) : undefined,
139
150
  props: {
140
151
  isRoot: true,
141
152
  },
142
153
  });
154
+ } else if (nameValue) {
155
+ /*
156
+ * User already exists. Backfill their name only if they don't have one
157
+ * yet; if they already have a name, leave it untouched.
158
+ */
159
+ const existingUser: User | null = await UserService.findOneById({
160
+ id: user.id!,
161
+ select: {
162
+ name: true,
163
+ },
164
+ props: {
165
+ isRoot: true,
166
+ },
167
+ });
168
+
169
+ if (existingUser && !existingUser.name?.toString()) {
170
+ await UserService.updateOneById({
171
+ id: user.id!,
172
+ data: {
173
+ name: new Name(nameValue),
174
+ },
175
+ props: {
176
+ isRoot: true,
177
+ },
178
+ });
179
+ }
143
180
  }
144
181
 
145
182
  createBy.data.userId = user.id!;
@@ -29,6 +29,7 @@ import GreaterThan from "../../../Types/BaseDatabase/GreaterThan";
29
29
  import GreaterThanOrEqual from "../../../Types/BaseDatabase/GreaterThanOrEqual";
30
30
  import InBetween from "../../../Types/BaseDatabase/InBetween";
31
31
  import Includes from "../../../Types/BaseDatabase/Includes";
32
+ import IncludesNone from "../../../Types/BaseDatabase/IncludesNone";
32
33
  import ObjectID from "../../../Types/ObjectID";
33
34
  import IsNull from "../../../Types/BaseDatabase/IsNull";
34
35
  import LessThan from "../../../Types/BaseDatabase/LessThan";
@@ -887,6 +888,36 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
887
888
  continue;
888
889
  }
889
890
 
891
+ /*
892
+ * Multi-value exclusion (IncludesNone / "is none of"):
893
+ * `attributes['k'] NOT IN (...)`. Map subscript returns '' for a
894
+ * missing key, so (like NotEqual above) rows lacking the attribute
895
+ * pass the NOT IN test, which matches "the value is none of these".
896
+ * An empty IncludesNone is treated as "All" — skip the predicate
897
+ * rather than emit `NOT IN ()`. Values bind via a fresh `Includes`
898
+ * since Statement only types `Includes` as Array(String).
899
+ */
900
+ if (mapEntry instanceof IncludesNone) {
901
+ const excludeValues: Array<string> = (
902
+ (mapEntry as IncludesNone).values || []
903
+ ).map((v: string | ObjectID | number) => {
904
+ return String(v);
905
+ });
906
+ if (excludeValues.length === 0) {
907
+ continue;
908
+ }
909
+ whereStatement.append(
910
+ SQL`AND ${key}[${{
911
+ value: mapKey,
912
+ type: TableColumnType.Text,
913
+ }}] NOT IN ${{
914
+ value: new Includes(excludeValues),
915
+ type: TableColumnType.Text,
916
+ }}`,
917
+ );
918
+ continue;
919
+ }
920
+
890
921
  // Bare string/number/boolean — direct Map subscript.
891
922
  whereStatement.append(
892
923
  SQL`AND ${key}[${{