@oneuptime/common 8.0.5414 → 8.0.5438

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/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel.ts +22 -0
  2. package/Models/AnalyticsModels/ExceptionInstance.ts +341 -323
  3. package/Models/AnalyticsModels/Log.ts +278 -231
  4. package/Models/AnalyticsModels/Metric.ts +504 -446
  5. package/Models/AnalyticsModels/MonitorLog.ts +99 -93
  6. package/Models/AnalyticsModels/Span.ts +473 -417
  7. package/Server/Services/AlertService.ts +12 -0
  8. package/Server/Services/IncidentService.ts +12 -0
  9. package/Server/Services/OpenTelemetryIngestService.ts +4 -0
  10. package/Server/Services/TelemetryAttributeService.ts +21 -4
  11. package/Server/Utils/Monitor/MonitorResource.ts +24 -0
  12. package/Server/Utils/Telemetry/Telemetry.ts +13 -0
  13. package/Types/AnalyticsDatabase/MaterializedView.ts +4 -0
  14. package/Types/AnalyticsDatabase/Projection.ts +4 -0
  15. package/Types/Date.ts +108 -0
  16. package/UI/Components/AutocompleteTextInput/AutocompleteTextInput.tsx +250 -0
  17. package/UI/Components/Dictionary/Dictionary.tsx +53 -66
  18. package/UI/Components/Filters/JSONFilter.tsx +2 -2
  19. package/UI/Components/Forms/Fields/FormField.tsx +2 -2
  20. package/UI/Components/GanttChart/Bar/Index.tsx +13 -0
  21. package/UI/Components/GanttChart/Index.tsx +7 -1
  22. package/UI/Components/GanttChart/Row/Index.tsx +1 -0
  23. package/UI/Components/GanttChart/Row/Row.tsx +101 -10
  24. package/UI/Components/GanttChart/Row/RowLabel.tsx +7 -2
  25. package/UI/Components/GanttChart/Rows.tsx +7 -1
  26. package/UI/Components/LogsViewer/LogItem.tsx +149 -10
  27. package/UI/Components/LogsViewer/LogsViewer.tsx +25 -1
  28. package/build/dist/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel.js +16 -0
  29. package/build/dist/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel.js.map +1 -1
  30. package/build/dist/Models/AnalyticsModels/ExceptionInstance.js +325 -310
  31. package/build/dist/Models/AnalyticsModels/ExceptionInstance.js.map +1 -1
  32. package/build/dist/Models/AnalyticsModels/Log.js +263 -222
  33. package/build/dist/Models/AnalyticsModels/Log.js.map +1 -1
  34. package/build/dist/Models/AnalyticsModels/Metric.js +477 -427
  35. package/build/dist/Models/AnalyticsModels/Metric.js.map +1 -1
  36. package/build/dist/Models/AnalyticsModels/MonitorLog.js +95 -90
  37. package/build/dist/Models/AnalyticsModels/MonitorLog.js.map +1 -1
  38. package/build/dist/Models/AnalyticsModels/Span.js +449 -400
  39. package/build/dist/Models/AnalyticsModels/Span.js.map +1 -1
  40. package/build/dist/Server/Services/AlertService.js +4 -0
  41. package/build/dist/Server/Services/AlertService.js.map +1 -1
  42. package/build/dist/Server/Services/IncidentService.js +4 -0
  43. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  44. package/build/dist/Server/Services/OpenTelemetryIngestService.js +1 -0
  45. package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
  46. package/build/dist/Server/Services/TelemetryAttributeService.js +19 -4
  47. package/build/dist/Server/Services/TelemetryAttributeService.js.map +1 -1
  48. package/build/dist/Server/Utils/Monitor/MonitorResource.js +12 -0
  49. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  50. package/build/dist/Server/Utils/Telemetry/Telemetry.js +6 -0
  51. package/build/dist/Server/Utils/Telemetry/Telemetry.js.map +1 -1
  52. package/build/dist/Types/AnalyticsDatabase/MaterializedView.js +2 -0
  53. package/build/dist/Types/AnalyticsDatabase/MaterializedView.js.map +1 -0
  54. package/build/dist/Types/AnalyticsDatabase/Projection.js +2 -0
  55. package/build/dist/Types/AnalyticsDatabase/Projection.js.map +1 -0
  56. package/build/dist/Types/Date.js +82 -0
  57. package/build/dist/Types/Date.js.map +1 -1
  58. package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js +143 -0
  59. package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js.map +1 -0
  60. package/build/dist/UI/Components/Dictionary/Dictionary.js +25 -36
  61. package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
  62. package/build/dist/UI/Components/Filters/JSONFilter.js +2 -2
  63. package/build/dist/UI/Components/Filters/JSONFilter.js.map +1 -1
  64. package/build/dist/UI/Components/Forms/Fields/FormField.js +2 -2
  65. package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
  66. package/build/dist/UI/Components/GanttChart/Bar/Index.js +10 -0
  67. package/build/dist/UI/Components/GanttChart/Bar/Index.js.map +1 -1
  68. package/build/dist/UI/Components/GanttChart/Index.js +6 -2
  69. package/build/dist/UI/Components/GanttChart/Index.js.map +1 -1
  70. package/build/dist/UI/Components/GanttChart/Row/Index.js.map +1 -1
  71. package/build/dist/UI/Components/GanttChart/Row/Row.js +62 -9
  72. package/build/dist/UI/Components/GanttChart/Row/Row.js.map +1 -1
  73. package/build/dist/UI/Components/GanttChart/Row/RowLabel.js +2 -2
  74. package/build/dist/UI/Components/GanttChart/Row/RowLabel.js.map +1 -1
  75. package/build/dist/UI/Components/GanttChart/Rows.js +5 -1
  76. package/build/dist/UI/Components/GanttChart/Rows.js.map +1 -1
  77. package/build/dist/UI/Components/LogsViewer/LogItem.js +73 -5
  78. package/build/dist/UI/Components/LogsViewer/LogItem.js.map +1 -1
  79. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +7 -1
  80. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  81. package/package.json +1 -1
@@ -1155,6 +1155,9 @@ ${alertSeverity.name}
1155
1155
  alertSeverityId: alert.alertSeverity?._id?.toString(),
1156
1156
  alertSeverityName: alert.alertSeverity?.name?.toString(),
1157
1157
  };
1158
+ alertCountMetric.attributeKeys = TelemetryUtil.getAttributeKeys(
1159
+ alertCountMetric.attributes,
1160
+ );
1158
1161
 
1159
1162
  alertCountMetric.time = alertStartsAt;
1160
1163
  alertCountMetric.timeUnixNano = OneUptimeDate.toUnixNano(
@@ -1203,6 +1206,9 @@ ${alertSeverity.name}
1203
1206
  alertSeverityId: alert.alertSeverity?._id?.toString(),
1204
1207
  alertSeverityName: alert.alertSeverity?.name?.toString(),
1205
1208
  };
1209
+ timeToAcknowledgeMetric.attributeKeys = TelemetryUtil.getAttributeKeys(
1210
+ timeToAcknowledgeMetric.attributes,
1211
+ );
1206
1212
 
1207
1213
  timeToAcknowledgeMetric.time =
1208
1214
  ackAlertStateTimeline?.startsAt ||
@@ -1256,6 +1262,9 @@ ${alertSeverity.name}
1256
1262
  alertSeverityId: alert.alertSeverity?._id?.toString(),
1257
1263
  alertSeverityName: alert.alertSeverity?.name?.toString(),
1258
1264
  };
1265
+ timeToResolveMetric.attributeKeys = TelemetryUtil.getAttributeKeys(
1266
+ timeToResolveMetric.attributes,
1267
+ );
1259
1268
 
1260
1269
  timeToResolveMetric.time =
1261
1270
  resolvedAlertStateTimeline?.startsAt ||
@@ -1302,6 +1311,9 @@ ${alertSeverity.name}
1302
1311
  alertSeverityId: alert.alertSeverity?._id?.toString(),
1303
1312
  alertSeverityName: alert.alertSeverity?.name?.toString(),
1304
1313
  };
1314
+ alertDurationMetric.attributeKeys = TelemetryUtil.getAttributeKeys(
1315
+ alertDurationMetric.attributes,
1316
+ );
1305
1317
 
1306
1318
  alertDurationMetric.time =
1307
1319
  lastAlertStateTimeline?.startsAt ||
@@ -1956,6 +1956,9 @@ ${incidentSeverity.name}
1956
1956
  incidentSeverityId: incident.incidentSeverity?._id?.toString(),
1957
1957
  incidentSeverityName: incident.incidentSeverity?.name?.toString(),
1958
1958
  };
1959
+ incidentCountMetric.attributeKeys = TelemetryUtil.getAttributeKeys(
1960
+ incidentCountMetric.attributes,
1961
+ );
1959
1962
 
1960
1963
  incidentCountMetric.time = incidentStartsAt;
1961
1964
  incidentCountMetric.timeUnixNano = OneUptimeDate.toUnixNano(
@@ -2013,6 +2016,9 @@ ${incidentSeverity.name}
2013
2016
  incidentSeverityId: incident.incidentSeverity?._id?.toString(),
2014
2017
  incidentSeverityName: incident.incidentSeverity?.name?.toString(),
2015
2018
  };
2019
+ timeToAcknowledgeMetric.attributeKeys = TelemetryUtil.getAttributeKeys(
2020
+ timeToAcknowledgeMetric.attributes,
2021
+ );
2016
2022
 
2017
2023
  timeToAcknowledgeMetric.time =
2018
2024
  ackIncidentStateTimeline?.startsAt ||
@@ -2075,6 +2081,9 @@ ${incidentSeverity.name}
2075
2081
  incidentSeverityId: incident.incidentSeverity?._id?.toString(),
2076
2082
  incidentSeverityName: incident.incidentSeverity?.name?.toString(),
2077
2083
  };
2084
+ timeToResolveMetric.attributeKeys = TelemetryUtil.getAttributeKeys(
2085
+ timeToResolveMetric.attributes,
2086
+ );
2078
2087
 
2079
2088
  timeToResolveMetric.time =
2080
2089
  resolvedIncidentStateTimeline?.startsAt ||
@@ -2132,6 +2141,9 @@ ${incidentSeverity.name}
2132
2141
  incidentSeverityId: incident.incidentSeverity?._id?.toString(),
2133
2142
  incidentSeverityName: incident.incidentSeverity?.name?.toString(),
2134
2143
  };
2144
+ incidentDurationMetric.attributeKeys = TelemetryUtil.getAttributeKeys(
2145
+ incidentDurationMetric.attributes,
2146
+ );
2135
2147
 
2136
2148
  incidentDurationMetric.time =
2137
2149
  lastIncidentStateTimeline?.startsAt ||
@@ -212,6 +212,10 @@ export default class OTelIngestService {
212
212
  };
213
213
  }
214
214
 
215
+ newDbMetric.attributeKeys = TelemetryUtil.getAttributeKeys(
216
+ newDbMetric.attributes,
217
+ );
218
+
215
219
  // aggregationTemporality
216
220
 
217
221
  if (aggregationTemporality) {
@@ -18,6 +18,7 @@ type TelemetrySource = {
18
18
  service: AnalyticsDatabaseService<any>;
19
19
  tableName: string;
20
20
  attributesColumn: string;
21
+ attributeKeysColumn: string;
21
22
  timeColumn: string;
22
23
  };
23
24
 
@@ -42,6 +43,7 @@ export class TelemetryAttributeService {
42
43
  service: LogDatabaseService,
43
44
  tableName: LogDatabaseService.model.tableName,
44
45
  attributesColumn: "attributes",
46
+ attributeKeysColumn: "attributeKeys",
45
47
  timeColumn: "time",
46
48
  };
47
49
  case TelemetryType.Metric:
@@ -49,6 +51,7 @@ export class TelemetryAttributeService {
49
51
  service: MetricDatabaseService,
50
52
  tableName: MetricDatabaseService.model.tableName,
51
53
  attributesColumn: "attributes",
54
+ attributeKeysColumn: "attributeKeys",
52
55
  timeColumn: "time",
53
56
  };
54
57
  case TelemetryType.Trace:
@@ -56,6 +59,7 @@ export class TelemetryAttributeService {
56
59
  service: SpanDatabaseService,
57
60
  tableName: SpanDatabaseService.model.tableName,
58
61
  attributesColumn: "attributes",
62
+ attributeKeysColumn: "attributeKeys",
59
63
  timeColumn: "startTime",
60
64
  };
61
65
  default:
@@ -213,6 +217,7 @@ export class TelemetryAttributeService {
213
217
  projectId: ObjectID;
214
218
  tableName: string;
215
219
  attributesColumn: string;
220
+ attributeKeysColumn: string;
216
221
  timeColumn: string;
217
222
  }): Statement {
218
223
  const lookbackStartDate: Date =
@@ -220,14 +225,24 @@ export class TelemetryAttributeService {
220
225
 
221
226
  const statement: Statement = SQL`
222
227
  WITH filtered AS (
223
- SELECT ${data.attributesColumn} AS attrs
228
+ SELECT arrayJoin(
229
+ if(
230
+ ${data.attributeKeysColumn} IS NULL OR empty(${data.attributeKeysColumn}),
231
+ JSONExtractKeys(${data.attributesColumn}),
232
+ ${data.attributeKeysColumn}
233
+ )
234
+ ) AS attribute
224
235
  FROM ${data.tableName}
225
236
  WHERE projectId = ${{
226
237
  type: TableColumnType.ObjectID,
227
238
  value: data.projectId,
228
239
  }}
229
- AND ${data.attributesColumn} IS NOT NULL
230
- AND ${data.attributesColumn} != ''
240
+ AND (
241
+ ${data.attributeKeysColumn} IS NOT NULL OR (
242
+ ${data.attributesColumn} IS NOT NULL AND
243
+ ${data.attributesColumn} != ''
244
+ )
245
+ )
231
246
  AND ${data.timeColumn} >= ${{
232
247
  type: TableColumnType.Date,
233
248
  value: lookbackStartDate,
@@ -238,8 +253,9 @@ export class TelemetryAttributeService {
238
253
  value: TelemetryAttributeService.ROW_SCAN_LIMIT,
239
254
  }}
240
255
  )
241
- SELECT DISTINCT arrayJoin(JSONExtractKeys(attrs)) AS attribute
256
+ SELECT DISTINCT attribute
242
257
  FROM filtered
258
+ WHERE attribute IS NOT NULL AND attribute != ''
243
259
  ORDER BY attribute ASC
244
260
  LIMIT ${{
245
261
  type: TableColumnType.Number,
@@ -259,6 +275,7 @@ export class TelemetryAttributeService {
259
275
  projectId: data.projectId,
260
276
  tableName: data.source.tableName,
261
277
  attributesColumn: data.source.attributesColumn,
278
+ attributeKeysColumn: data.source.attributeKeysColumn,
262
279
  timeColumn: data.source.timeColumn,
263
280
  });
264
281
 
@@ -61,6 +61,12 @@ import MonitorLogService from "../../Services/MonitorLogService";
61
61
  import ExceptionMessages from "../../../Types/Exception/ExceptionMessages";
62
62
 
63
63
  export default class MonitorResourceUtil {
64
+ private static setAttributeKeys(metric: Metric): void {
65
+ metric.attributeKeys = TelemetryUtil.getAttributeKeys(
66
+ metric.attributes as JSONObject,
67
+ );
68
+ }
69
+
64
70
  @CaptureSpan()
65
71
  public static async monitorResource(
66
72
  dataToProcess: DataToProcess,
@@ -683,6 +689,8 @@ export default class MonitorResourceUtil {
683
689
  monitorMetric.attributes["probeName"] = data.probeName.toString();
684
690
  }
685
691
 
692
+ MonitorResourceUtil.setAttributeKeys(monitorMetric);
693
+
686
694
  monitorMetric.time = OneUptimeDate.getCurrentDate();
687
695
  monitorMetric.timeUnixNano = OneUptimeDate.getCurrentDateAsUnixNano();
688
696
  monitorMetric.metricPointType = MetricPointType.Sum;
@@ -730,6 +738,8 @@ export default class MonitorResourceUtil {
730
738
  monitorMetric.attributes["probeName"] = data.probeName.toString();
731
739
  }
732
740
 
741
+ MonitorResourceUtil.setAttributeKeys(monitorMetric);
742
+
733
743
  monitorMetric.time = OneUptimeDate.getCurrentDate();
734
744
  monitorMetric.timeUnixNano = OneUptimeDate.getCurrentDateAsUnixNano();
735
745
  monitorMetric.metricPointType = MetricPointType.Sum;
@@ -768,6 +778,8 @@ export default class MonitorResourceUtil {
768
778
  monitorMetric.attributes["probeName"] = data.probeName.toString();
769
779
  }
770
780
 
781
+ MonitorResourceUtil.setAttributeKeys(monitorMetric);
782
+
771
783
  monitorMetric.time = OneUptimeDate.getCurrentDate();
772
784
  monitorMetric.timeUnixNano = OneUptimeDate.getCurrentDateAsUnixNano();
773
785
  monitorMetric.metricPointType = MetricPointType.Sum;
@@ -809,6 +821,8 @@ export default class MonitorResourceUtil {
809
821
  monitorMetric.attributes["probeName"] = data.probeName.toString();
810
822
  }
811
823
 
824
+ MonitorResourceUtil.setAttributeKeys(monitorMetric);
825
+
812
826
  monitorMetric.time = OneUptimeDate.getCurrentDate();
813
827
  monitorMetric.timeUnixNano = OneUptimeDate.getCurrentDateAsUnixNano();
814
828
  monitorMetric.metricPointType = MetricPointType.Sum;
@@ -857,6 +871,8 @@ export default class MonitorResourceUtil {
857
871
  monitorMetric.attributes["probeName"] = data.probeName.toString();
858
872
  }
859
873
 
874
+ MonitorResourceUtil.setAttributeKeys(monitorMetric);
875
+
860
876
  monitorMetric.time = OneUptimeDate.getCurrentDate();
861
877
  monitorMetric.timeUnixNano = OneUptimeDate.getCurrentDateAsUnixNano();
862
878
  monitorMetric.metricPointType = MetricPointType.Sum;
@@ -909,6 +925,8 @@ export default class MonitorResourceUtil {
909
925
  monitorMetric.attributes["probeName"] = data.probeName.toString();
910
926
  }
911
927
 
928
+ MonitorResourceUtil.setAttributeKeys(monitorMetric);
929
+
912
930
  monitorMetric.time = OneUptimeDate.getCurrentDate();
913
931
  monitorMetric.timeUnixNano = OneUptimeDate.getCurrentDateAsUnixNano();
914
932
  monitorMetric.metricPointType = MetricPointType.Sum;
@@ -952,6 +970,8 @@ export default class MonitorResourceUtil {
952
970
  monitorMetric.attributes["probeName"] = data.probeName.toString();
953
971
  }
954
972
 
973
+ MonitorResourceUtil.setAttributeKeys(monitorMetric);
974
+
955
975
  monitorMetric.time = OneUptimeDate.getCurrentDate();
956
976
  monitorMetric.timeUnixNano = OneUptimeDate.getCurrentDateAsUnixNano();
957
977
  monitorMetric.metricPointType = MetricPointType.Sum;
@@ -995,6 +1015,8 @@ export default class MonitorResourceUtil {
995
1015
  monitorMetric.attributes["probeName"] = data.probeName.toString();
996
1016
  }
997
1017
 
1018
+ MonitorResourceUtil.setAttributeKeys(monitorMetric);
1019
+
998
1020
  monitorMetric.time = OneUptimeDate.getCurrentDate();
999
1021
  monitorMetric.timeUnixNano = OneUptimeDate.getCurrentDateAsUnixNano();
1000
1022
  monitorMetric.metricPointType = MetricPointType.Sum;
@@ -1029,6 +1051,8 @@ export default class MonitorResourceUtil {
1029
1051
  ).probeId.toString(),
1030
1052
  };
1031
1053
 
1054
+ MonitorResourceUtil.setAttributeKeys(monitorMetric);
1055
+
1032
1056
  monitorMetric.time = OneUptimeDate.getCurrentDate();
1033
1057
  monitorMetric.timeUnixNano = OneUptimeDate.getCurrentDateAsUnixNano();
1034
1058
  monitorMetric.metricPointType = MetricPointType.Sum;
@@ -252,4 +252,17 @@ export default class TelemetryUtil {
252
252
 
253
253
  return finalObj;
254
254
  }
255
+
256
+ public static getAttributeKeys(
257
+ attributes:
258
+ | Dictionary<AttributeType | Array<AttributeType> | JSONObject>
259
+ | JSONObject
260
+ | undefined,
261
+ ): Array<string> {
262
+ if (!attributes || typeof attributes !== "object") {
263
+ return [];
264
+ }
265
+
266
+ return Object.keys(attributes).sort();
267
+ }
255
268
  }
@@ -0,0 +1,4 @@
1
+ export default interface MaterializedView {
2
+ name: string;
3
+ query: string;
4
+ }
@@ -0,0 +1,4 @@
1
+ export default interface Projection {
2
+ name: string;
3
+ query: string;
4
+ }
package/Types/Date.ts CHANGED
@@ -1076,6 +1076,114 @@ export default class OneUptimeDate {
1076
1076
  return formattedString;
1077
1077
  }
1078
1078
 
1079
+ public static getHumanizedDurationFromNanoseconds(data: {
1080
+ nanoseconds: number;
1081
+ maxParts?: number;
1082
+ }): string {
1083
+ let { nanoseconds } = data;
1084
+ const maxParts: number = data.maxParts ?? 2;
1085
+
1086
+ if (!Number.isFinite(nanoseconds)) {
1087
+ return "-";
1088
+ }
1089
+
1090
+ if (nanoseconds === 0) {
1091
+ return "0 ms";
1092
+ }
1093
+
1094
+ const sign: string = nanoseconds < 0 ? "-" : "";
1095
+ nanoseconds = Math.abs(nanoseconds);
1096
+
1097
+ if (nanoseconds < 1000) {
1098
+ return sign + Math.round(nanoseconds).toString() + " ns";
1099
+ }
1100
+
1101
+ const microseconds: number = nanoseconds / 1000;
1102
+ if (microseconds < 1000) {
1103
+ return sign + OneUptimeDate.formatDurationValue(microseconds) + " μs";
1104
+ }
1105
+
1106
+ const milliseconds: number = nanoseconds / 1000000;
1107
+ if (milliseconds < 1000) {
1108
+ return sign + OneUptimeDate.formatDurationValue(milliseconds) + " ms";
1109
+ }
1110
+
1111
+ const seconds: number = nanoseconds / 1000000000;
1112
+
1113
+ if (seconds < 60) {
1114
+ return sign + OneUptimeDate.formatDurationValue(seconds) + " s";
1115
+ }
1116
+
1117
+ const units: Array<{ label: string; seconds: number }> = [
1118
+ { label: "d", seconds: 86400 },
1119
+ { label: "h", seconds: 3600 },
1120
+ { label: "m", seconds: 60 },
1121
+ { label: "s", seconds: 1 },
1122
+ ];
1123
+
1124
+ let remainingSeconds: number = Math.floor(seconds);
1125
+ const parts: string[] = [];
1126
+
1127
+ for (const unit of units) {
1128
+ if (parts.length >= maxParts) {
1129
+ break;
1130
+ }
1131
+
1132
+ if (remainingSeconds < unit.seconds) {
1133
+ continue;
1134
+ }
1135
+
1136
+ const value: number = Math.floor(remainingSeconds / unit.seconds);
1137
+
1138
+ if (value > 0) {
1139
+ parts.push(`${value}${unit.label}`);
1140
+ remainingSeconds -= value * unit.seconds;
1141
+ }
1142
+ }
1143
+
1144
+ const fractionalSeconds: number = seconds - Math.floor(seconds);
1145
+ const leftoverSeconds: number = remainingSeconds + fractionalSeconds;
1146
+
1147
+ if (leftoverSeconds > 0) {
1148
+ if (parts.length === 0) {
1149
+ parts.push(`${OneUptimeDate.formatDurationValue(leftoverSeconds)}s`);
1150
+ } else if (parts[parts.length - 1]?.endsWith("s")) {
1151
+ const numericValue: number = parseFloat(
1152
+ parts[parts.length - 1]!.slice(0, -1),
1153
+ );
1154
+ const updatedSeconds: number = numericValue + leftoverSeconds;
1155
+ parts[parts.length - 1] =
1156
+ `${OneUptimeDate.formatDurationValue(updatedSeconds)}s`;
1157
+ } else if (parts.length < maxParts) {
1158
+ parts.push(`${OneUptimeDate.formatDurationValue(leftoverSeconds)}s`);
1159
+ }
1160
+ }
1161
+
1162
+ if (parts.length === 0) {
1163
+ return sign + OneUptimeDate.formatDurationValue(seconds) + " s";
1164
+ }
1165
+
1166
+ const trimmedParts: string[] = parts.slice(0, maxParts);
1167
+
1168
+ if (sign) {
1169
+ trimmedParts[0] = sign + trimmedParts[0];
1170
+ }
1171
+
1172
+ return trimmedParts.join(" ");
1173
+ }
1174
+
1175
+ private static formatDurationValue(value: number): string {
1176
+ const abs: number = Math.abs(value);
1177
+
1178
+ if (abs >= 100) {
1179
+ return Math.round(value).toString();
1180
+ }
1181
+
1182
+ const decimalPlaces: number = 2;
1183
+ const fixed: string = value.toFixed(decimalPlaces);
1184
+ return fixed.replace(/\.0+$/, "").replace(/(\.\d*?[1-9])0+$/, "$1");
1185
+ }
1186
+
1079
1187
  public static getGmtOffsetByTimezone(timezone: Timezone): number {
1080
1188
  return moment.tz(timezone).utcOffset();
1081
1189
  }
@@ -0,0 +1,250 @@
1
+ import React, {
2
+ FunctionComponent,
3
+ ReactElement,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ } from "react";
9
+
10
+ export interface ComponentProps {
11
+ value?: string;
12
+ onChange?: ((value: string) => void) | undefined;
13
+ suggestions?: Array<string> | undefined;
14
+ placeholder?: string | undefined;
15
+ className?: string | undefined;
16
+ menuClassName?: string | undefined;
17
+ disabled?: boolean | undefined;
18
+ autoFocus?: boolean | undefined;
19
+ dataTestId?: string | undefined;
20
+ onFocus?: (() => void) | undefined;
21
+ onBlur?: (() => void) | undefined;
22
+ outerDivClassName?: string | undefined;
23
+ disableSpellCheck?: boolean | undefined;
24
+ }
25
+
26
+ const BASE_INPUT_CLASS: string =
27
+ "block w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-3 text-sm placeholder-gray-500 focus:border-indigo-500 focus:text-gray-900 focus:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm";
28
+
29
+ const MAX_SUGGESTIONS: number = 50;
30
+
31
+ // Provides a free-form text input with an optional suggestion dropdown.
32
+ const AutocompleteTextInput: FunctionComponent<ComponentProps> = (
33
+ props: ComponentProps,
34
+ ): ReactElement => {
35
+ const [inputValue, setInputValue] = useState<string>(props.value || "");
36
+ const [isMenuVisible, setIsMenuVisible] = useState<boolean>(false);
37
+ const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
38
+ const containerRef: React.MutableRefObject<HTMLDivElement | null> =
39
+ useRef<HTMLDivElement | null>(null);
40
+ const blurTimeoutRef: React.MutableRefObject<number | null> = useRef<
41
+ number | null
42
+ >(null);
43
+ const listboxIdRef: React.MutableRefObject<string> = useRef<string>(
44
+ `autocomplete-suggestions-${Math.random().toString(36).slice(2, 10)}`,
45
+ );
46
+
47
+ useEffect(() => {
48
+ setInputValue(props.value || "");
49
+ }, [props.value]);
50
+
51
+ useEffect(() => {
52
+ const handleClickOutside: (event: MouseEvent) => void = (
53
+ event: MouseEvent,
54
+ ) => {
55
+ if (
56
+ containerRef.current &&
57
+ event.target instanceof Node &&
58
+ !containerRef.current.contains(event.target)
59
+ ) {
60
+ setIsMenuVisible(false);
61
+ setHighlightedIndex(-1);
62
+ }
63
+ };
64
+
65
+ document.addEventListener("mousedown", handleClickOutside);
66
+ return () => {
67
+ document.removeEventListener("mousedown", handleClickOutside);
68
+ };
69
+ }, []);
70
+
71
+ const suggestions: Array<string> = useMemo(() => {
72
+ const uniqueSuggestions: Array<string> = props.suggestions
73
+ ? Array.from(new Set(props.suggestions))
74
+ : [];
75
+
76
+ if (uniqueSuggestions.length === 0) {
77
+ return [];
78
+ }
79
+
80
+ const normalizedInput: string = inputValue.trim().toLowerCase();
81
+
82
+ if (normalizedInput === "") {
83
+ return uniqueSuggestions.slice(0, MAX_SUGGESTIONS);
84
+ }
85
+
86
+ return uniqueSuggestions
87
+ .filter((suggestion: string) => {
88
+ return suggestion.toLowerCase().includes(normalizedInput);
89
+ })
90
+ .slice(0, MAX_SUGGESTIONS);
91
+ }, [inputValue, props.suggestions]);
92
+
93
+ const showMenu: boolean = isMenuVisible && suggestions.length > 0;
94
+
95
+ const getInputClassName: () => string = (): string => {
96
+ let className: string = props.className || BASE_INPUT_CLASS;
97
+
98
+ if (props.disabled) {
99
+ className += " bg-gray-100 text-gray-500 cursor-not-allowed";
100
+ }
101
+
102
+ return className;
103
+ };
104
+
105
+ const clearBlurTimeout: () => void = (): void => {
106
+ if (blurTimeoutRef.current !== null) {
107
+ window.clearTimeout(blurTimeoutRef.current);
108
+ blurTimeoutRef.current = null;
109
+ }
110
+ };
111
+
112
+ const handleSuggestionSelect: (value: string) => void = (value: string) => {
113
+ clearBlurTimeout();
114
+ setInputValue(value);
115
+ props.onChange?.(value);
116
+ setIsMenuVisible(false);
117
+ setHighlightedIndex(-1);
118
+ };
119
+
120
+ const handleInputChange: (
121
+ event: React.ChangeEvent<HTMLInputElement>,
122
+ ) => void = (event: React.ChangeEvent<HTMLInputElement>) => {
123
+ const value: string = event.target.value;
124
+ setInputValue(value);
125
+ props.onChange?.(value);
126
+ setIsMenuVisible(true);
127
+ setHighlightedIndex(-1);
128
+ };
129
+
130
+ const handleInputFocus: () => void = () => {
131
+ clearBlurTimeout();
132
+ setIsMenuVisible(true);
133
+ props.onFocus?.();
134
+ };
135
+
136
+ const handleInputBlur: () => void = () => {
137
+ clearBlurTimeout();
138
+ blurTimeoutRef.current = window.setTimeout(() => {
139
+ setIsMenuVisible(false);
140
+ setHighlightedIndex(-1);
141
+ props.onBlur?.();
142
+ }, 150);
143
+ };
144
+
145
+ const handleKeyDown: (
146
+ event: React.KeyboardEvent<HTMLInputElement>,
147
+ ) => void = (event: React.KeyboardEvent<HTMLInputElement>) => {
148
+ if (!showMenu) {
149
+ return;
150
+ }
151
+
152
+ if (event.key === "ArrowDown") {
153
+ event.preventDefault();
154
+ setHighlightedIndex((previousIndex: number) => {
155
+ const nextIndex: number = previousIndex + 1;
156
+ if (nextIndex >= suggestions.length) {
157
+ return 0;
158
+ }
159
+ return nextIndex;
160
+ });
161
+ }
162
+
163
+ if (event.key === "ArrowUp") {
164
+ event.preventDefault();
165
+ setHighlightedIndex((previousIndex: number) => {
166
+ if (previousIndex <= 0) {
167
+ return suggestions.length - 1;
168
+ }
169
+ return previousIndex - 1;
170
+ });
171
+ }
172
+
173
+ if (event.key === "Enter") {
174
+ if (highlightedIndex >= 0 && highlightedIndex < suggestions.length) {
175
+ event.preventDefault();
176
+ handleSuggestionSelect(suggestions[highlightedIndex]!);
177
+ }
178
+ }
179
+
180
+ if (event.key === "Escape") {
181
+ setIsMenuVisible(false);
182
+ setHighlightedIndex(-1);
183
+ }
184
+ };
185
+
186
+ useEffect(() => {
187
+ if (highlightedIndex >= suggestions.length) {
188
+ setHighlightedIndex(suggestions.length - 1);
189
+ }
190
+ }, [suggestions, highlightedIndex]);
191
+
192
+ return (
193
+ <div
194
+ ref={containerRef}
195
+ className={props.outerDivClassName || "relative mt-2 mb-1 w-full"}
196
+ >
197
+ <input
198
+ autoFocus={props.autoFocus}
199
+ className={getInputClassName()}
200
+ data-testid={props.dataTestId}
201
+ disabled={props.disabled}
202
+ aria-autocomplete="list"
203
+ aria-controls={listboxIdRef.current}
204
+ aria-expanded={showMenu}
205
+ role="combobox"
206
+ onBlur={handleInputBlur}
207
+ onChange={handleInputChange}
208
+ onFocus={handleInputFocus}
209
+ onKeyDown={handleKeyDown}
210
+ placeholder={props.placeholder}
211
+ spellCheck={!props.disableSpellCheck}
212
+ type="text"
213
+ value={inputValue}
214
+ />
215
+ {showMenu && (
216
+ <div
217
+ className={
218
+ props.menuClassName ||
219
+ "absolute z-10 mt-1 max-h-56 w-full overflow-auto rounded-md border border-gray-200 bg-white py-1 text-sm shadow-lg"
220
+ }
221
+ id={listboxIdRef.current}
222
+ role="listbox"
223
+ >
224
+ {suggestions.map((suggestion: string, index: number) => {
225
+ const isActive: boolean = index === highlightedIndex;
226
+ return (
227
+ <button
228
+ key={`${suggestion}-${index}`}
229
+ type="button"
230
+ role="option"
231
+ aria-selected={isActive}
232
+ className={`flex w-full items-center px-3 py-2 text-left hover:bg-indigo-50 ${isActive ? "bg-indigo-600 text-white hover:bg-indigo-500" : "text-gray-700"}`}
233
+ onMouseDown={(event: React.MouseEvent<HTMLButtonElement>) => {
234
+ event.preventDefault();
235
+ }}
236
+ onClick={() => {
237
+ handleSuggestionSelect(suggestion);
238
+ }}
239
+ >
240
+ {suggestion}
241
+ </button>
242
+ );
243
+ })}
244
+ </div>
245
+ )}
246
+ </div>
247
+ );
248
+ };
249
+
250
+ export default AutocompleteTextInput;