@oneuptime/common 10.2.4 → 10.2.7

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/Models/DatabaseModels/Service.ts +26 -0
  2. package/Server/API/GlobalConfigAPI.ts +13 -16
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/1778582583897-MigrationName.ts +15 -0
  4. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  5. package/Server/Services/OpenTelemetryIngestService.ts +15 -0
  6. package/Server/Services/ServiceService.ts +37 -0
  7. package/Server/Types/Database/QueryHelper.ts +38 -0
  8. package/Server/Types/Database/QueryUtil.ts +77 -0
  9. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +52 -0
  10. package/Types/BaseDatabase/MultiSearch.ts +53 -0
  11. package/Types/Dashboard/DashboardComponents/ComponentArgument.ts +1 -0
  12. package/Types/Dashboard/DashboardComponents/DashboardChartComponent.ts +2 -0
  13. package/Types/JSON.ts +3 -0
  14. package/Types/SerializableObjectDictionary.ts +2 -0
  15. package/UI/Components/Header/ProjectPicker/ProjectPicker.tsx +11 -6
  16. package/UI/Components/LogsViewer/components/ColumnSelector.tsx +58 -4
  17. package/UI/Components/ModelTable/BaseModelTable.tsx +1026 -10
  18. package/UI/Components/ModelTable/TableView.tsx +58 -32
  19. package/UI/Utils/GlobalConfig.ts +55 -0
  20. package/Utils/Dashboard/Components/DashboardChartComponent.ts +11 -0
  21. package/build/dist/Models/DatabaseModels/Service.js +28 -0
  22. package/build/dist/Models/DatabaseModels/Service.js.map +1 -1
  23. package/build/dist/Server/API/GlobalConfigAPI.js +10 -16
  24. package/build/dist/Server/API/GlobalConfigAPI.js.map +1 -1
  25. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778582583897-MigrationName.js +12 -0
  26. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778582583897-MigrationName.js.map +1 -0
  27. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  28. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  29. package/build/dist/Server/Services/OpenTelemetryIngestService.js +11 -0
  30. package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
  31. package/build/dist/Server/Services/ServiceService.js +34 -0
  32. package/build/dist/Server/Services/ServiceService.js.map +1 -1
  33. package/build/dist/Server/Types/Database/QueryHelper.js +33 -0
  34. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  35. package/build/dist/Server/Types/Database/QueryUtil.js +64 -0
  36. package/build/dist/Server/Types/Database/QueryUtil.js.map +1 -1
  37. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +44 -0
  38. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  39. package/build/dist/Types/BaseDatabase/MultiSearch.js +44 -0
  40. package/build/dist/Types/BaseDatabase/MultiSearch.js.map +1 -0
  41. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js +1 -0
  42. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js.map +1 -1
  43. package/build/dist/Types/JSON.js +1 -0
  44. package/build/dist/Types/JSON.js.map +1 -1
  45. package/build/dist/Types/SerializableObjectDictionary.js +2 -0
  46. package/build/dist/Types/SerializableObjectDictionary.js.map +1 -1
  47. package/build/dist/UI/Components/Header/ProjectPicker/ProjectPicker.js +2 -2
  48. package/build/dist/UI/Components/Header/ProjectPicker/ProjectPicker.js.map +1 -1
  49. package/build/dist/UI/Components/LogsViewer/components/ColumnSelector.js +33 -3
  50. package/build/dist/UI/Components/LogsViewer/components/ColumnSelector.js.map +1 -1
  51. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +618 -12
  52. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  53. package/build/dist/UI/Components/ModelTable/TableView.js +25 -18
  54. package/build/dist/UI/Components/ModelTable/TableView.js.map +1 -1
  55. package/build/dist/UI/Utils/GlobalConfig.js +38 -0
  56. package/build/dist/UI/Utils/GlobalConfig.js.map +1 -0
  57. package/build/dist/Utils/Dashboard/Components/DashboardChartComponent.js +9 -0
  58. package/build/dist/Utils/Dashboard/Components/DashboardChartComponent.js.map +1 -1
  59. package/package.json +1 -1
@@ -725,4 +725,30 @@ export default class Service extends BaseModel {
725
725
  })
726
726
  public metricDownsamplingRetentionDays?: MetricDownsamplingRetentionDays =
727
727
  undefined;
728
+
729
+ @ColumnAccessControl({
730
+ create: [],
731
+ read: [
732
+ Permission.ProjectOwner,
733
+ Permission.ProjectAdmin,
734
+ Permission.ProjectMember,
735
+ Permission.Viewer,
736
+ Permission.SettingsManager,
737
+ Permission.ReadService,
738
+ Permission.ReadAllProjectResources,
739
+ ],
740
+ update: [],
741
+ })
742
+ @TableColumn({
743
+ required: false,
744
+ type: TableColumnType.Date,
745
+ canReadOnRelationQuery: true,
746
+ title: "Last Seen At",
747
+ description: "When telemetry was last received for this service",
748
+ })
749
+ @Column({
750
+ nullable: true,
751
+ type: ColumnType.Date,
752
+ })
753
+ public lastSeenAt?: Date = undefined;
728
754
  }
@@ -30,24 +30,21 @@ export default class GlobalConfigAPI extends BaseAPI<
30
30
  `${new this.entityType().getCrudApiPath()?.toString()}/vars`,
31
31
  async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
32
32
  try {
33
- /*
34
- * const globalConfig: GlobalConfig | null =
35
- * await GlobalConfigService.findOneById({
36
- * id: ObjectID.getZeroObjectID(),
37
- * select: {
38
- * useHttps: true,
39
- * },
40
- * props: {
41
- * isRoot: true,
42
- * },
43
- * });
44
- */
33
+ const globalConfig: GlobalConfig | null =
34
+ await GlobalConfigService.findOneById({
35
+ id: ObjectID.getZeroObjectID(),
36
+ select: {
37
+ disableUserProjectCreation: true,
38
+ },
39
+ props: {
40
+ isRoot: true,
41
+ },
42
+ });
45
43
 
46
44
  return Response.sendJsonObjectResponse(req, res, {
47
- /*
48
- * USE_HTTPS:
49
- * globalConfig?.useHttps?.toString() || 'false',
50
- */
45
+ disableUserProjectCreation: Boolean(
46
+ globalConfig?.disableUserProjectCreation,
47
+ ),
51
48
  });
52
49
  } catch (err) {
53
50
  next(err);
@@ -0,0 +1,15 @@
1
+ import { MigrationInterface, QueryRunner } from "typeorm";
2
+
3
+ export class MigrationName1778582583897 implements MigrationInterface {
4
+ public name = "MigrationName1778582583897";
5
+
6
+ public async up(queryRunner: QueryRunner): Promise<void> {
7
+ await queryRunner.query(
8
+ `ALTER TABLE "Service" ADD "lastSeenAt" TIMESTAMP WITH TIME ZONE`,
9
+ );
10
+ }
11
+
12
+ public async down(queryRunner: QueryRunner): Promise<void> {
13
+ await queryRunner.query(`ALTER TABLE "Service" DROP COLUMN "lastSeenAt"`);
14
+ }
15
+ }
@@ -316,6 +316,7 @@ import { AddProjectOIDC1778506655291 } from "./1778506655291-AddProjectOIDC";
316
316
  import { MigrationName1778514515756 } from "./1778514515756-MigrationName";
317
317
  import { MigrationName1778521361934 } from "./1778521361934-MigrationName";
318
318
  import { AddStatusPageOIDC1778522070962 } from "./1778522070962-AddStatusPageOIDC";
319
+ import { MigrationName1778582583897 } from "./1778582583897-MigrationName";
319
320
  export default [
320
321
  InitialMigration,
321
322
  MigrationName1717678334852,
@@ -635,4 +636,5 @@ export default [
635
636
  MigrationName1778514515756,
636
637
  MigrationName1778521361934,
637
638
  AddStatusPageOIDC1778522070962,
639
+ MigrationName1778582583897,
638
640
  ];
@@ -64,6 +64,21 @@ export default class OTelIngestService {
64
64
  projectId: data.projectId,
65
65
  });
66
66
 
67
+ /*
68
+ * Touch `lastSeenAt` on the service. Throttled per-service inside
69
+ * ServiceService.updateLastSeen so the steady-state firehose costs
70
+ * one in-memory cache lookup per batch.
71
+ */
72
+ try {
73
+ await ServiceService.updateLastSeen(result.serviceId);
74
+ } catch (err) {
75
+ logger.warn(
76
+ `telemetryServiceFromName lastSeen update failed for "${data.serviceName}": ${
77
+ err instanceof Error ? err.message : String(err)
78
+ }`,
79
+ );
80
+ }
81
+
67
82
  /*
68
83
  * Promote `oneuptime.label.<dim>=<val>` resource attributes into
69
84
  * project labels and attach them to the discovered service. The
@@ -6,6 +6,7 @@ import ArrayUtil from "../../Utils/Array";
6
6
  import { BrightColors } from "../../Types/BrandColors";
7
7
  import BadDataException from "../../Types/Exception/BadDataException";
8
8
  import ObjectID from "../../Types/ObjectID";
9
+ import OneUptimeDate from "../../Types/Date";
9
10
  import Model from "../../Models/DatabaseModels/Service";
10
11
  import Label from "../../Models/DatabaseModels/Label";
11
12
  import Project from "../../Models/DatabaseModels/Project";
@@ -16,6 +17,9 @@ import crypto from "crypto";
16
17
 
17
18
  const DEFAULT_TELEMETRY_RETENTION_IN_DAYS: number = 15;
18
19
 
20
+ const LAST_SEEN_CACHE_NAMESPACE: string = "service-last-seen";
21
+ const LAST_SEEN_THROTTLE_SECONDS: number = 60;
22
+
19
23
  const LABELS_APPLIED_CACHE_NAMESPACE: string = "service-labels-applied";
20
24
  const LABELS_APPLIED_CACHE_TTL_SECONDS: number = 60;
21
25
 
@@ -80,6 +84,39 @@ export class Service extends DatabaseService<Model> {
80
84
  return DEFAULT_TELEMETRY_RETENTION_IN_DAYS;
81
85
  }
82
86
 
87
+ /*
88
+ * Refresh `lastSeenAt` for a service. Throttled per-service so the
89
+ * steady-state telemetry firehose (every metric/log/trace batch
90
+ * re-resolves the same serviceId) costs one in-memory cache lookup
91
+ * per batch instead of a DB write.
92
+ */
93
+ @CaptureSpan()
94
+ public async updateLastSeen(serviceId: ObjectID): Promise<void> {
95
+ const cacheKey: string = serviceId.toString();
96
+ const cached: string | null = await GlobalCache.getString(
97
+ LAST_SEEN_CACHE_NAMESPACE,
98
+ cacheKey,
99
+ );
100
+
101
+ if (cached) {
102
+ return;
103
+ }
104
+
105
+ await GlobalCache.setString(LAST_SEEN_CACHE_NAMESPACE, cacheKey, "1", {
106
+ expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS,
107
+ });
108
+
109
+ await this.updateOneById({
110
+ id: serviceId,
111
+ data: {
112
+ lastSeenAt: OneUptimeDate.getCurrentDate(),
113
+ },
114
+ props: {
115
+ isRoot: true,
116
+ },
117
+ });
118
+ }
119
+
83
120
  /**
84
121
  * Additively attach labels to a telemetry service. Existing labels
85
122
  * are never removed — manual labels set via the UI survive ingest.
@@ -138,6 +138,44 @@ export default class QueryHelper {
138
138
  );
139
139
  }
140
140
 
141
+ /**
142
+ * Searches the provided entity property names with a single OR-joined ILIKE.
143
+ *
144
+ * IMPORTANT: emit unquoted `alias.propertyName` references and let
145
+ * TypeORM's `replacePropertyNamesForTheWholeQuery` post-processor escape
146
+ * the table alias and translate property names → DB column names. Pre-
147
+ * quoting (e.g. `Incident."title"`) bypasses that pass, which leaves an
148
+ * unquoted `Incident` in the final SQL — Postgres then lowercases it and
149
+ * fails with `missing FROM-clause entry for table "incident"`.
150
+ */
151
+ @CaptureSpan()
152
+ public static multiSearch(
153
+ entityPropertyNames: Array<string>,
154
+ value: string,
155
+ ): FindWhereProperty<any> {
156
+ const trimmed: string = value.toLowerCase().trim();
157
+ const rid: string = Text.generateRandomText(10);
158
+
159
+ return Raw(
160
+ (alias: string) => {
161
+ const tableAlias: string = alias.includes(".")
162
+ ? (alias.split(".")[0] as string)
163
+ : alias;
164
+
165
+ const orConditions: string = entityPropertyNames
166
+ .map((field: string) => {
167
+ return `(CAST(${tableAlias}.${field} AS TEXT) ILIKE :${rid})`;
168
+ })
169
+ .join(" OR ");
170
+
171
+ return `(${orConditions})`;
172
+ },
173
+ {
174
+ [rid]: `%${trimmed}%`,
175
+ },
176
+ );
177
+ }
178
+
141
179
  @CaptureSpan()
142
180
  public static notContains(name: string): FindWhereProperty<any> {
143
181
  name = name.toLowerCase().trim();
@@ -17,6 +17,7 @@ import LessThanOrEqual from "../../../Types/BaseDatabase/LessThanOrEqual";
17
17
  import NotEqual from "../../../Types/BaseDatabase/NotEqual";
18
18
  import NotNull from "../../../Types/BaseDatabase/NotNull";
19
19
  import Search from "../../../Types/BaseDatabase/Search";
20
+ import MultiSearch from "../../../Types/BaseDatabase/MultiSearch";
20
21
  import { TableColumnMetadata } from "../../../Types/Database/TableColumn";
21
22
  import TableColumnType from "../../../Types/Database/TableColumnType";
22
23
  import { JSONObject } from "../../../Types/JSON";
@@ -43,6 +44,82 @@ export default class QueryUtil {
43
44
 
44
45
  query = query as Query<TBaseModel>;
45
46
 
47
+ /*
48
+ * Multi-field text search:
49
+ * A MultiSearch operator on any key fans out into an ILIKE OR across the
50
+ * listed entity fields. We hang the Raw expression off `_id` so it lands
51
+ * in the WHERE clause without TypeORM treating the synthetic key as a
52
+ * real column. Falls through silently if metadata is unavailable or no
53
+ * fields resolve (e.g. property name typo).
54
+ */
55
+ for (const key in query) {
56
+ const value: any = query[key];
57
+ if (!(value instanceof MultiSearch)) {
58
+ continue;
59
+ }
60
+
61
+ delete query[key];
62
+
63
+ const ms: MultiSearch = value as MultiSearch;
64
+ if (!ms.value || ms.fields.length === 0) {
65
+ continue;
66
+ }
67
+
68
+ /*
69
+ * Validate the requested fields against entity metadata so we only
70
+ * emit identifiers TypeORM's post-processor can map back to real
71
+ * columns (otherwise the alias.field token survives unescaped and
72
+ * Postgres lowercases the table name → "missing FROM-clause entry").
73
+ */
74
+ const validPropertyNames: Array<string> = [];
75
+ if (PostgresAppInstance.isConnected()) {
76
+ const dataSource: DataSource | null =
77
+ PostgresAppInstance.getDataSource();
78
+ if (dataSource) {
79
+ let entityMetadata: EntityMetadata | undefined;
80
+ try {
81
+ entityMetadata = dataSource.getMetadata(modelType);
82
+ } catch {
83
+ entityMetadata = undefined;
84
+ }
85
+ if (entityMetadata) {
86
+ for (const fieldName of ms.fields) {
87
+ const column: any = entityMetadata.columns.find((c: any) => {
88
+ return c.propertyName === fieldName;
89
+ });
90
+ if (column) {
91
+ validPropertyNames.push(fieldName);
92
+ }
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ if (validPropertyNames.length === 0) {
99
+ continue;
100
+ }
101
+
102
+ const rawFilter: any = QueryHelper.multiSearch(
103
+ validPropertyNames,
104
+ ms.value,
105
+ );
106
+
107
+ const existingIdFilter: any = (query as any)._id;
108
+ if (existingIdFilter instanceof FindOperator) {
109
+ (query as any)._id = And(existingIdFilter, rawFilter);
110
+ } else if (
111
+ existingIdFilter &&
112
+ typeof existingIdFilter === Typeof.String
113
+ ) {
114
+ (query as any)._id = And(
115
+ QueryHelper.equalTo(existingIdFilter as string),
116
+ rawFilter,
117
+ );
118
+ } else {
119
+ (query as any)._id = rawFilter;
120
+ }
121
+ }
122
+
46
123
  for (const key in query) {
47
124
  const tableColumnMetadata: TableColumnMetadata =
48
125
  model.getTableColumnMetadata(key);
@@ -29,6 +29,7 @@ import NotEqual from "../../../Types/BaseDatabase/NotEqual";
29
29
  import NotContains from "../../../Types/BaseDatabase/NotContains";
30
30
  import NotNull from "../../../Types/BaseDatabase/NotNull";
31
31
  import Search from "../../../Types/BaseDatabase/Search";
32
+ import MultiSearch from "../../../Types/BaseDatabase/MultiSearch";
32
33
  import StartsWith from "../../../Types/BaseDatabase/StartsWith";
33
34
  import EndsWith from "../../../Types/BaseDatabase/EndsWith";
34
35
  import SortOrder from "../../../Types/BaseDatabase/SortOrder";
@@ -356,6 +357,57 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
356
357
  let first: boolean = true;
357
358
  for (const key in query) {
358
359
  const value: any = query[key];
360
+
361
+ /*
362
+ * MultiSearch is a synthetic operator that fans out into an ILIKE OR
363
+ * across multiple columns — it does not correspond to `key` itself, so
364
+ * we resolve column metadata per field below.
365
+ */
366
+ if (value instanceof MultiSearch) {
367
+ const ms: MultiSearch = value;
368
+ if (!ms.value || ms.fields.length === 0) {
369
+ continue;
370
+ }
371
+
372
+ const resolvedColumns: Array<AnalyticsTableColumn> = [];
373
+ for (const field of ms.fields) {
374
+ const col: AnalyticsTableColumn | null =
375
+ this.model.getTableColumn(field);
376
+ if (col) {
377
+ resolvedColumns.push(col);
378
+ }
379
+ }
380
+
381
+ if (resolvedColumns.length === 0) {
382
+ continue;
383
+ }
384
+
385
+ if (first) {
386
+ first = false;
387
+ whereStatement.append(SQL`AND (`);
388
+ } else {
389
+ whereStatement.append(SQL` AND (`);
390
+ }
391
+
392
+ let isFirstCol: boolean = true;
393
+ for (const col of resolvedColumns) {
394
+ if (isFirstCol) {
395
+ isFirstCol = false;
396
+ } else {
397
+ whereStatement.append(SQL` OR `);
398
+ }
399
+ whereStatement.append(
400
+ SQL`${col.key} ILIKE ${{
401
+ value: new Search<string>(ms.value),
402
+ type: col.type,
403
+ }}`,
404
+ );
405
+ }
406
+
407
+ whereStatement.append(SQL`)`);
408
+ continue;
409
+ }
410
+
359
411
  const tableColumn: AnalyticsTableColumn | null =
360
412
  this.model.getTableColumn(key);
361
413
 
@@ -0,0 +1,53 @@
1
+ import BadDataException from "../Exception/BadDataException";
2
+ import { JSONObject, ObjectType } from "../JSON";
3
+ import QueryOperator from "./QueryOperator";
4
+
5
+ export default class MultiSearch extends QueryOperator<string> {
6
+ private _fields: Array<string> = [];
7
+ private _value: string = "";
8
+
9
+ public get fields(): Array<string> {
10
+ return this._fields;
11
+ }
12
+
13
+ public set fields(v: Array<string>) {
14
+ this._fields = v;
15
+ }
16
+
17
+ public get value(): string {
18
+ return this._value;
19
+ }
20
+
21
+ public set value(v: string) {
22
+ this._value = v;
23
+ }
24
+
25
+ public constructor(data: { fields: Array<string>; value: string }) {
26
+ super();
27
+ this.fields = data.fields;
28
+ this.value = data.value;
29
+ }
30
+
31
+ public override toString(): string {
32
+ return this.value;
33
+ }
34
+
35
+ public override toJSON(): JSONObject {
36
+ return {
37
+ _type: ObjectType.MultiSearch,
38
+ value: this.value,
39
+ fields: this.fields,
40
+ };
41
+ }
42
+
43
+ public static override fromJSON(json: JSONObject): MultiSearch {
44
+ if (json["_type"] === ObjectType.MultiSearch) {
45
+ return new MultiSearch({
46
+ fields: (json["fields"] as Array<string>) || [],
47
+ value: (json["value"] as string) || "",
48
+ });
49
+ }
50
+
51
+ throw new BadDataException("Invalid JSON: " + JSON.stringify(json));
52
+ }
53
+ }
@@ -10,6 +10,7 @@ export enum ComponentInputType {
10
10
  Decimal = "Decimal",
11
11
  MetricsQueryConfig = "MetricsQueryConfig",
12
12
  MetricsQueryConfigs = "MetricsQueryConfigs",
13
+ MetricsFormulaConfigs = "MetricsFormulaConfigs",
13
14
  LongText = "Long Text",
14
15
  Dropdown = "Dropdown",
15
16
  MultiSelectDropdown = "MultiSelectDropdown",
@@ -1,3 +1,4 @@
1
+ import MetricFormulaConfigData from "../../Metrics/MetricFormulaConfigData";
1
2
  import MetricQueryConfigData from "../../Metrics/MetricQueryConfigData";
2
3
  import ObjectID from "../../ObjectID";
3
4
  import DashboardComponentType from "../DashboardComponentType";
@@ -10,6 +11,7 @@ export default interface DashboardChartComponent extends BaseComponent {
10
11
  arguments: {
11
12
  metricQueryConfig?: MetricQueryConfigData | undefined;
12
13
  metricQueryConfigs?: Array<MetricQueryConfigData> | undefined;
14
+ metricFormulaConfigs?: Array<MetricFormulaConfigData> | undefined;
13
15
  chartTitle?: string | undefined;
14
16
  chartDescription?: string | undefined;
15
17
  chartType?: DashboardChartType | undefined;
package/Types/JSON.ts CHANGED
@@ -17,6 +17,7 @@ import LessThanOrEqual from "./BaseDatabase/LessThanOrEqual";
17
17
  import NotEqual from "./BaseDatabase/NotEqual";
18
18
  import NotNull from "./BaseDatabase/NotNull";
19
19
  import Search from "./BaseDatabase/Search";
20
+ import MultiSearch from "./BaseDatabase/MultiSearch";
20
21
  import CallRequest from "./Call/CallRequest";
21
22
  import Color from "./Color";
22
23
  import { CompareType } from "./Database/CompareBase";
@@ -61,6 +62,7 @@ export enum ObjectType {
61
62
  URL = "URL",
62
63
  Permission = "Permission",
63
64
  Search = "Search",
65
+ MultiSearch = "MultiSearch",
64
66
  GreaterThan = "GreaterThan",
65
67
  GreaterThanOrEqual = "GreaterThanOrEqual",
66
68
  GreaterThanOrNull = "GreaterThanOrNull",
@@ -123,6 +125,7 @@ export type JSONValue =
123
125
  | FilterType
124
126
  | Array<FilterType>
125
127
  | Search<string>
128
+ | MultiSearch
126
129
  | Domain
127
130
  | Array<Domain>
128
131
  | Array<Search<string>>
@@ -20,6 +20,7 @@ import GreaterThanOrNull from "./BaseDatabase/GreaterThanOrNull";
20
20
  import NotEqual from "./BaseDatabase/NotEqual";
21
21
  import NotNull from "./BaseDatabase/NotNull";
22
22
  import Search from "./BaseDatabase/Search";
23
+ import MultiSearch from "./BaseDatabase/MultiSearch";
23
24
  import Color from "./Color";
24
25
  import OneUptimeDate from "./Date";
25
26
  import Dictionary from "./Dictionary";
@@ -59,6 +60,7 @@ const SerializableObjectDictionary: Dictionary<any> = {
59
60
  [ObjectType.URL]: URL,
60
61
  [ObjectType.IP]: IP,
61
62
  [ObjectType.Search]: Search,
63
+ [ObjectType.MultiSearch]: MultiSearch,
62
64
  [ObjectType.GreaterThan]: GreaterThan,
63
65
  [ObjectType.GreaterThanOrEqual]: GreaterThanOrEqual,
64
66
  [ObjectType.LessThan]: LessThan,
@@ -18,6 +18,7 @@ export interface ComponentProps {
18
18
  selectedProjectName: string;
19
19
  onCreateProjectButtonClicked: () => void;
20
20
  onProjectSelected: (project: Project) => void;
21
+ hideCreateProjectButton?: boolean | undefined;
21
22
  }
22
23
 
23
24
  const ProjectPicker: FunctionComponent<ComponentProps> = (
@@ -101,12 +102,16 @@ const ProjectPicker: FunctionComponent<ComponentProps> = (
101
102
  <></>
102
103
  )}
103
104
  </>
104
- <CreateNewProjectButton
105
- onCreateButtonClicked={() => {
106
- setIsComponentVisible(false);
107
- props.onCreateProjectButtonClicked();
108
- }}
109
- />
105
+ {!props.hideCreateProjectButton ? (
106
+ <CreateNewProjectButton
107
+ onCreateButtonClicked={() => {
108
+ setIsComponentVisible(false);
109
+ props.onCreateProjectButtonClicked();
110
+ }}
111
+ />
112
+ ) : (
113
+ <></>
114
+ )}
110
115
  </ProjectPickerMenu>
111
116
  )}
112
117
  </div>
@@ -7,6 +7,7 @@ import React, {
7
7
  } from "react";
8
8
  import {
9
9
  DEFAULT_LOGS_TABLE_COLUMNS,
10
+ getLogsAttributeColumnId,
10
11
  LogsTableColumnOption,
11
12
  normalizeLogsTableColumns,
12
13
  } from "../types";
@@ -71,6 +72,23 @@ const ColumnSelector: FunctionComponent<ColumnSelectorProps> = (
71
72
  });
72
73
  }, [props.availableColumns, searchQuery, selectedColumnIds]);
73
74
 
75
+ const trimmedAttributeKey: string = searchQuery.trim();
76
+ const customAttributeColumnId: string = trimmedAttributeKey
77
+ ? getLogsAttributeColumnId(trimmedAttributeKey)
78
+ : "";
79
+ const isCustomAttributeAlreadySelected: boolean =
80
+ customAttributeColumnId !== "" &&
81
+ selectedColumnIds.includes(customAttributeColumnId);
82
+ const isCustomAttributeAlreadyAvailable: boolean = availableColumns.some(
83
+ (column: LogsTableColumnOption): boolean => {
84
+ return column.id === customAttributeColumnId;
85
+ },
86
+ );
87
+ const canAddCustomAttribute: boolean =
88
+ trimmedAttributeKey !== "" &&
89
+ !isCustomAttributeAlreadySelected &&
90
+ !isCustomAttributeAlreadyAvailable;
91
+
74
92
  const updateColumns: (columns: Array<string>) => void = (
75
93
  columns: Array<string>,
76
94
  ): void => {
@@ -225,15 +243,51 @@ const ColumnSelector: FunctionComponent<ColumnSelectorProps> = (
225
243
  onChange={(event: ChangeEvent<HTMLInputElement>) => {
226
244
  setSearchQuery(event.target.value);
227
245
  }}
228
- placeholder="Search columns"
229
- className="w-40 rounded-md border border-gray-200 px-2 py-1 text-xs text-gray-600 focus:border-indigo-300 focus:outline-none focus:ring-2 focus:ring-indigo-100"
246
+ onKeyDown={(
247
+ event: React.KeyboardEvent<HTMLInputElement>,
248
+ ): void => {
249
+ if (event.key === "Enter" && canAddCustomAttribute) {
250
+ event.preventDefault();
251
+ addColumn(customAttributeColumnId);
252
+ setSearchQuery("");
253
+ }
254
+ }}
255
+ placeholder="Search or type attribute"
256
+ className="w-48 rounded-md border border-gray-200 px-2 py-1 text-xs text-gray-600 focus:border-indigo-300 focus:outline-none focus:ring-2 focus:ring-indigo-100"
230
257
  />
231
258
  </div>
232
259
 
233
260
  <div className="max-h-72 space-y-2 overflow-y-auto pr-1">
234
- {availableColumns.length === 0 && (
261
+ {canAddCustomAttribute && (
262
+ <div className="flex items-center justify-between rounded-md border border-dashed border-indigo-200 bg-indigo-50/50 px-3 py-2">
263
+ <div className="min-w-0 flex-1">
264
+ <p className="truncate text-sm text-gray-700">
265
+ <span className="text-gray-500">Attribute: </span>
266
+ <span className="font-mono">{trimmedAttributeKey}</span>
267
+ </p>
268
+ <p className="text-[11px] text-gray-500">
269
+ Add as a column to show this attribute&apos;s value.
270
+ </p>
271
+ </div>
272
+
273
+ <button
274
+ type="button"
275
+ className="ml-3 rounded-md px-2 py-1 text-xs font-medium text-indigo-600 transition-colors hover:bg-indigo-100 hover:text-indigo-700"
276
+ onClick={() => {
277
+ addColumn(customAttributeColumnId);
278
+ setSearchQuery("");
279
+ }}
280
+ >
281
+ Add attribute
282
+ </button>
283
+ </div>
284
+ )}
285
+
286
+ {availableColumns.length === 0 && !canAddCustomAttribute && (
235
287
  <div className="rounded-md border border-dashed border-gray-200 px-3 py-4 text-sm text-gray-500">
236
- No matching columns available.
288
+ {trimmedAttributeKey
289
+ ? "No matching columns available."
290
+ : "Type an attribute name above to add it as a column."}
237
291
  </div>
238
292
  )}
239
293