@oneuptime/common 10.0.86 → 10.0.89

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 (126) hide show
  1. package/Models/DatabaseModels/EnterpriseLicense.ts +54 -0
  2. package/Models/DatabaseModels/GlobalConfig.ts +51 -0
  3. package/Server/API/EnterpriseLicenseAPI.ts +83 -0
  4. package/Server/API/GlobalConfigAPI.ts +59 -0
  5. package/Server/API/MetricAPI.ts +149 -0
  6. package/Server/API/TelemetryAPI.ts +24 -0
  7. package/Server/EnvironmentConfig.ts +10 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1777629313843-MigrationName.ts +59 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  10. package/Server/Infrastructure/Queue.ts +4 -4
  11. package/Server/Services/AnalyticsDatabaseService.ts +21 -0
  12. package/Server/Services/MetricService.ts +193 -1
  13. package/Server/Services/TelemetryAttributeService.ts +37 -3
  14. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +174 -7
  15. package/Tests/Types/Date.test.ts +46 -0
  16. package/Types/Dashboard/DashboardComponentType.ts +3 -0
  17. package/Types/Dashboard/DashboardComponents/DashboardAlertListComponent.ts +13 -0
  18. package/Types/Dashboard/DashboardComponents/DashboardIncidentListComponent.ts +13 -0
  19. package/Types/Dashboard/DashboardComponents/DashboardMonitorListComponent.ts +13 -0
  20. package/Types/Date.ts +9 -4
  21. package/Types/JSONFunctions.ts +61 -1
  22. package/UI/Components/AutocompleteTextInput/AutocompleteTextInput.tsx +60 -21
  23. package/UI/Components/Dictionary/Dictionary.tsx +188 -26
  24. package/UI/Components/Dictionary/DictionaryFilterOperator.ts +357 -0
  25. package/UI/Components/Dictionary/DictionaryOfStrings.tsx +12 -7
  26. package/UI/Components/EditionLabel/EditionLabel.tsx +224 -10
  27. package/UI/Components/Filters/FilterViewer.tsx +81 -16
  28. package/UI/Components/Filters/FiltersForm.tsx +18 -3
  29. package/UI/Components/Filters/JSONFilter.tsx +11 -2
  30. package/UI/Components/Filters/Types/Filter.ts +3 -0
  31. package/UI/Components/Forms/Fields/FormField.tsx +6 -1
  32. package/UI/Components/Forms/Types/Field.ts +5 -0
  33. package/UI/Components/LogsViewer/LogsViewer.tsx +73 -4
  34. package/UI/Components/LogsViewer/components/LogSearchBar.tsx +77 -31
  35. package/UI/Components/LogsViewer/components/LogSearchSuggestions.tsx +44 -1
  36. package/UI/Components/LogsViewer/components/LogsFilterCard.tsx +7 -5
  37. package/UI/Components/TelemetryViewer/TelemetryViewer.tsx +6 -0
  38. package/UI/Components/TelemetryViewer/components/TelemetrySearchBar.tsx +84 -25
  39. package/UI/Components/TelemetryViewer/components/TelemetrySearchSuggestions.tsx +44 -1
  40. package/Utils/Dashboard/Components/DashboardAlertListComponent.ts +86 -0
  41. package/Utils/Dashboard/Components/DashboardIncidentListComponent.ts +86 -0
  42. package/Utils/Dashboard/Components/DashboardMonitorListComponent.ts +85 -0
  43. package/Utils/Dashboard/Components/Index.ts +21 -0
  44. package/build/dist/Models/DatabaseModels/EnterpriseLicense.js +57 -0
  45. package/build/dist/Models/DatabaseModels/EnterpriseLicense.js.map +1 -1
  46. package/build/dist/Models/DatabaseModels/GlobalConfig.js +54 -0
  47. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  48. package/build/dist/Server/API/EnterpriseLicenseAPI.js +64 -1
  49. package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
  50. package/build/dist/Server/API/GlobalConfigAPI.js +47 -0
  51. package/build/dist/Server/API/GlobalConfigAPI.js.map +1 -1
  52. package/build/dist/Server/API/MetricAPI.js +123 -0
  53. package/build/dist/Server/API/MetricAPI.js.map +1 -0
  54. package/build/dist/Server/API/TelemetryAPI.js +9 -0
  55. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  56. package/build/dist/Server/EnvironmentConfig.js +3 -0
  57. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  58. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1777629313843-MigrationName.js +26 -0
  59. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1777629313843-MigrationName.js.map +1 -0
  60. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  61. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  62. package/build/dist/Server/Infrastructure/Queue.js +3 -3
  63. package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
  64. package/build/dist/Server/Services/AnalyticsDatabaseService.js +18 -0
  65. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  66. package/build/dist/Server/Services/MetricService.js +151 -1
  67. package/build/dist/Server/Services/MetricService.js.map +1 -1
  68. package/build/dist/Server/Services/TelemetryAttributeService.js +36 -7
  69. package/build/dist/Server/Services/TelemetryAttributeService.js.map +1 -1
  70. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +135 -5
  71. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  72. package/build/dist/Tests/Types/Date.test.js +40 -0
  73. package/build/dist/Tests/Types/Date.test.js.map +1 -1
  74. package/build/dist/Types/Dashboard/DashboardComponentType.js +3 -0
  75. package/build/dist/Types/Dashboard/DashboardComponentType.js.map +1 -1
  76. package/build/dist/Types/Dashboard/DashboardComponents/DashboardAlertListComponent.js +2 -0
  77. package/build/dist/Types/Dashboard/DashboardComponents/DashboardAlertListComponent.js.map +1 -0
  78. package/build/dist/Types/Dashboard/DashboardComponents/DashboardIncidentListComponent.js +2 -0
  79. package/build/dist/Types/Dashboard/DashboardComponents/DashboardIncidentListComponent.js.map +1 -0
  80. package/build/dist/Types/Dashboard/DashboardComponents/DashboardMonitorListComponent.js +2 -0
  81. package/build/dist/Types/Dashboard/DashboardComponents/DashboardMonitorListComponent.js.map +1 -0
  82. package/build/dist/Types/Date.js +7 -2
  83. package/build/dist/Types/Date.js.map +1 -1
  84. package/build/dist/Types/JSONFunctions.js +47 -1
  85. package/build/dist/Types/JSONFunctions.js.map +1 -1
  86. package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js +21 -10
  87. package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js.map +1 -1
  88. package/build/dist/UI/Components/Dictionary/Dictionary.js +109 -16
  89. package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
  90. package/build/dist/UI/Components/Dictionary/DictionaryFilterOperator.js +263 -0
  91. package/build/dist/UI/Components/Dictionary/DictionaryFilterOperator.js.map +1 -0
  92. package/build/dist/UI/Components/Dictionary/DictionaryOfStrings.js +10 -6
  93. package/build/dist/UI/Components/Dictionary/DictionaryOfStrings.js.map +1 -1
  94. package/build/dist/UI/Components/EditionLabel/EditionLabel.js +124 -6
  95. package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
  96. package/build/dist/UI/Components/Filters/FilterViewer.js +50 -12
  97. package/build/dist/UI/Components/Filters/FilterViewer.js.map +1 -1
  98. package/build/dist/UI/Components/Filters/FiltersForm.js +5 -4
  99. package/build/dist/UI/Components/Filters/FiltersForm.js.map +1 -1
  100. package/build/dist/UI/Components/Filters/JSONFilter.js +1 -1
  101. package/build/dist/UI/Components/Filters/JSONFilter.js.map +1 -1
  102. package/build/dist/UI/Components/Forms/Fields/FormField.js +1 -1
  103. package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
  104. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +54 -5
  105. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  106. package/build/dist/UI/Components/LogsViewer/components/LogSearchBar.js +59 -29
  107. package/build/dist/UI/Components/LogsViewer/components/LogSearchBar.js.map +1 -1
  108. package/build/dist/UI/Components/LogsViewer/components/LogSearchSuggestions.js +10 -2
  109. package/build/dist/UI/Components/LogsViewer/components/LogSearchSuggestions.js.map +1 -1
  110. package/build/dist/UI/Components/LogsViewer/components/LogsFilterCard.js +2 -5
  111. package/build/dist/UI/Components/LogsViewer/components/LogsFilterCard.js.map +1 -1
  112. package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js +1 -1
  113. package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js.map +1 -1
  114. package/build/dist/UI/Components/TelemetryViewer/components/TelemetrySearchBar.js +59 -22
  115. package/build/dist/UI/Components/TelemetryViewer/components/TelemetrySearchBar.js.map +1 -1
  116. package/build/dist/UI/Components/TelemetryViewer/components/TelemetrySearchSuggestions.js +10 -2
  117. package/build/dist/UI/Components/TelemetryViewer/components/TelemetrySearchSuggestions.js.map +1 -1
  118. package/build/dist/Utils/Dashboard/Components/DashboardAlertListComponent.js +70 -0
  119. package/build/dist/Utils/Dashboard/Components/DashboardAlertListComponent.js.map +1 -0
  120. package/build/dist/Utils/Dashboard/Components/DashboardIncidentListComponent.js +70 -0
  121. package/build/dist/Utils/Dashboard/Components/DashboardIncidentListComponent.js.map +1 -0
  122. package/build/dist/Utils/Dashboard/Components/DashboardMonitorListComponent.js +69 -0
  123. package/build/dist/Utils/Dashboard/Components/DashboardMonitorListComponent.js.map +1 -0
  124. package/build/dist/Utils/Dashboard/Components/Index.js +12 -0
  125. package/build/dist/Utils/Dashboard/Components/Index.js.map +1 -1
  126. package/package.json +1 -1
@@ -1,3 +1,9 @@
1
+ import {
2
+ DictionaryFilterOperator,
3
+ DictionaryFilterOperatorOption,
4
+ detectOperatorFromValue,
5
+ getOperatorOption,
6
+ } from "../Dictionary/DictionaryFilterOperator";
1
7
  import Icon, { SizeProp } from "../Icon/Icon";
2
8
  import Includes from "../../../Types/BaseDatabase/Includes";
3
9
  import IncludesAll from "../../../Types/BaseDatabase/IncludesAll";
@@ -81,20 +87,50 @@ const FilterComponent: FilterComponentFunction = <T extends GenericObject>(
81
87
  const formatJson: FormatJsonFunction = (
82
88
  json: Dictionary<string | number | boolean>,
83
89
  ): ReactElement => {
90
+ /*
91
+ * Ignore entries with empty key or empty value, and translate
92
+ * operator wrappers into their display symbol so chips like
93
+ * `host.name != prod-1` or `path contains api` render correctly.
94
+ */
95
+ const isMeaningfulEntry: (key: string, value: unknown) => boolean = (
96
+ key: string,
97
+ value: unknown,
98
+ ): boolean => {
99
+ if (key.trim() === "" || value === undefined || value === null) {
100
+ return false;
101
+ }
102
+ const detected: { operator: DictionaryFilterOperator; rawValue: string } =
103
+ detectOperatorFromValue(value);
104
+ const option: DictionaryFilterOperatorOption = getOperatorOption(
105
+ detected.operator,
106
+ );
107
+ if (option.hidesValueInput) {
108
+ return true;
109
+ }
110
+ return detected.rawValue.trim() !== "";
111
+ };
112
+
113
+ const visibleKeys: Array<string> = Object.keys(json).filter(
114
+ (key: string) => {
115
+ return isMeaningfulEntry(key, json[key]);
116
+ },
117
+ );
118
+
84
119
  return (
85
120
  <div className="flex space-x-2 -mt-1">
86
- {Object.keys(json).map((key: string, i: number) => {
87
- let jsonText: string | number | boolean = json[key] as
88
- | string
89
- | number
90
- | boolean;
91
-
92
- if (typeof jsonText === "boolean" && jsonText === true) {
93
- jsonText = "True";
94
- }
121
+ {visibleKeys.map((key: string, i: number) => {
122
+ const rawValue: unknown = json[key];
123
+ const detected: {
124
+ operator: DictionaryFilterOperator;
125
+ rawValue: string;
126
+ } = detectOperatorFromValue(rawValue);
127
+ const option: DictionaryFilterOperatorOption = getOperatorOption(
128
+ detected.operator,
129
+ );
95
130
 
96
- if (typeof jsonText === "boolean" && jsonText === false) {
97
- jsonText = "False";
131
+ let jsonText: string = detected.rawValue;
132
+ if (typeof rawValue === "boolean") {
133
+ jsonText = rawValue ? "True" : "False";
98
134
  }
99
135
 
100
136
  return (
@@ -102,8 +138,14 @@ const FilterComponent: FilterComponentFunction = <T extends GenericObject>(
102
138
  key={i}
103
139
  className="rounded-full h-7 bg-gray-100 text-gray-500 border-2 border-gray-200 p-1 pr-2 pl-2 text-xs"
104
140
  >
105
- <span className="font-medium">{key}</span> ={" "}
106
- <span className="font-medium">{jsonText}</span>
141
+ <span className="font-medium">{key}</span>{" "}
142
+ <span>{option.symbol}</span>
143
+ {!option.hidesValueInput && (
144
+ <>
145
+ {" "}
146
+ <span className="font-medium">{jsonText}</span>
147
+ </>
148
+ )}
107
149
  </div>
108
150
  );
109
151
  })}
@@ -334,13 +376,36 @@ const FilterComponent: FilterComponentFunction = <T extends GenericObject>(
334
376
  key
335
377
  ] as Dictionary<string | number | boolean>;
336
378
 
337
- // if json is empty, return null
379
+ /*
380
+ * Count only meaningful entries — empty key/value pairs aren't
381
+ * real filters, and value-less operators (IsEmpty/IsNotEmpty) are
382
+ * valid even with no value.
383
+ */
384
+ const nonEmptyEntryCount: number = Object.keys(json).filter(
385
+ (entryKey: string) => {
386
+ const value: unknown = json[entryKey];
387
+ if (entryKey.trim() === "" || value === undefined || value === null) {
388
+ return false;
389
+ }
390
+ const detected: {
391
+ operator: DictionaryFilterOperator;
392
+ rawValue: string;
393
+ } = detectOperatorFromValue(value);
394
+ const option: DictionaryFilterOperatorOption = getOperatorOption(
395
+ detected.operator,
396
+ );
397
+ if (option.hidesValueInput) {
398
+ return true;
399
+ }
400
+ return detected.rawValue.trim() !== "";
401
+ },
402
+ ).length;
338
403
 
339
- if (Object.keys(json).length === 0) {
404
+ if (nonEmptyEntryCount === 0) {
340
405
  return null;
341
406
  }
342
407
 
343
- const isPlural: boolean = Object.keys(json).length > 1;
408
+ const isPlural: boolean = nonEmptyEntryCount > 1;
344
409
 
345
410
  return (
346
411
  <span className="inline-flex items-center space-x-1">
@@ -111,13 +111,21 @@ const FiltersForm: FiltersFormFunction = <T extends GenericObject>(
111
111
  props.filterData[filter.key] !== undefined &&
112
112
  props.filterData[filter.key] !== null;
113
113
 
114
+ const isMultiRowFilter: boolean = filter.type === FieldType.JSON;
115
+
114
116
  return (
115
117
  <div
116
118
  key={i}
117
- className="grid grid-cols-[140px_1fr_auto] items-center gap-3"
119
+ className={`grid grid-cols-[140px_1fr_auto] gap-3 ${
120
+ isMultiRowFilter ? "items-start" : "items-center"
121
+ }`}
118
122
  >
119
123
  {/* Label column */}
120
- <div className="flex items-center min-w-0">
124
+ <div
125
+ className={`flex items-center min-w-0 ${
126
+ isMultiRowFilter ? "pt-7" : ""
127
+ }`}
128
+ >
121
129
  <label className="text-sm font-medium text-gray-700 truncate">
122
130
  {filter.title}
123
131
  </label>
@@ -169,11 +177,18 @@ const FiltersForm: FiltersFormFunction = <T extends GenericObject>(
169
177
  jsonKeys={filter.jsonKeys}
170
178
  jsonValueSuggestions={filter.jsonValueSuggestions}
171
179
  onJsonKeySelected={filter.onJsonKeySelected}
180
+ isLoadingJsonKeys={filter.isLoadingJsonKeys}
181
+ loadingJsonValueKeys={filter.loadingJsonValueKeys}
182
+ enableOperators={filter.jsonEnableOperators}
172
183
  />
173
184
  </div>
174
185
 
175
186
  {/* Clear column */}
176
- <div className="flex items-center">
187
+ <div
188
+ className={`flex items-center ${
189
+ isMultiRowFilter ? "pt-7" : ""
190
+ }`}
191
+ >
177
192
  {hasValue && filter.key ? (
178
193
  <button
179
194
  type="button"
@@ -1,5 +1,6 @@
1
1
  import { FindWhereProperty } from "../../../Types/BaseDatabase/Query";
2
2
  import DictionaryForm, { ValueType } from "../Dictionary/Dictionary";
3
+ import { DictionaryEntryValue } from "../Dictionary/DictionaryFilterOperator";
3
4
  import FieldType from "../Types/FieldType";
4
5
  import Filter from "./Types/Filter";
5
6
  import FilterData from "./Types/FilterData";
@@ -14,6 +15,9 @@ export interface ComponentProps<T extends GenericObject> {
14
15
  jsonKeys?: Array<string> | undefined;
15
16
  jsonValueSuggestions?: Record<string, Array<string>> | undefined;
16
17
  onJsonKeySelected?: ((key: string) => void) | undefined;
18
+ isLoadingJsonKeys?: boolean | undefined;
19
+ loadingJsonValueKeys?: Array<string> | undefined;
20
+ enableOperators?: boolean | undefined;
17
21
  }
18
22
 
19
23
  type JSONFilterFunction = <T extends GenericObject>(
@@ -32,12 +36,17 @@ const JSONFilter: JSONFilterFunction = <T extends GenericObject>(
32
36
  keys={props.jsonKeys}
33
37
  valueSuggestions={props.jsonValueSuggestions}
34
38
  onKeySelected={props.onJsonKeySelected}
39
+ isLoadingKeys={props.isLoadingJsonKeys}
40
+ loadingValueKeys={props.loadingJsonValueKeys}
41
+ enableOperators={props.enableOperators}
35
42
  addButtonSuffix={filter.title}
36
43
  keyPlaceholder={"Key"}
37
44
  valuePlaceholder={"Value"}
38
45
  valueTypes={[ValueType.Text]}
39
- initialValue={(filterData[filter.key] as Dictionary<string>) || {}}
40
- onChange={(value: Dictionary<string | number | boolean>) => {
46
+ initialValue={
47
+ (filterData[filter.key] as Dictionary<DictionaryEntryValue>) || {}
48
+ }
49
+ onChange={(value: Dictionary<DictionaryEntryValue>) => {
41
50
  // if no keys in the dictionary, remove the filter
42
51
 
43
52
  if (Object.keys(value).length > 0) {
@@ -10,5 +10,8 @@ export default interface Filter<T extends GenericObject> {
10
10
  jsonKeys?: Array<string> | undefined;
11
11
  jsonValueSuggestions?: Record<string, Array<string>> | undefined;
12
12
  onJsonKeySelected?: ((key: string) => void) | undefined;
13
+ isLoadingJsonKeys?: boolean | undefined;
14
+ loadingJsonValueKeys?: Array<string> | undefined;
15
+ jsonEnableOperators?: boolean | undefined;
13
16
  isAdvancedFilter?: boolean | undefined;
14
17
  }
@@ -457,6 +457,11 @@ const FormField: <T extends GenericObject>(
457
457
  {props.field.fieldType === FormFieldSchemaType.Dictionary && (
458
458
  <DictionaryForm
459
459
  keys={props.field.jsonKeysForDictionary}
460
+ valueSuggestions={props.field.dictionaryValueSuggestions}
461
+ onKeySelected={props.field.onDictionaryKeySelected}
462
+ isLoadingKeys={props.field.isLoadingDictionaryKeys}
463
+ loadingValueKeys={props.field.loadingDictionaryValueKeys}
464
+ enableOperators={props.field.dictionaryEnableOperators}
460
465
  addButtonSuffix={props.field.title}
461
466
  keyPlaceholder={"Key"}
462
467
  valuePlaceholder={"Value"}
@@ -467,7 +472,7 @@ const FormField: <T extends GenericObject>(
467
472
  ? (props.currentValues as any)[props.fieldName]
468
473
  : props.field.defaultValue || {}
469
474
  }
470
- onChange={(value: Dictionary<string | number | boolean>) => {
475
+ onChange={(value: Dictionary<any>) => {
471
476
  onChange(value);
472
477
  props.setFieldValue(props.fieldName, value);
473
478
  }}
@@ -122,6 +122,11 @@ export default interface Field<TEntity> {
122
122
 
123
123
  //
124
124
  jsonKeysForDictionary?: Array<string> | undefined;
125
+ isLoadingDictionaryKeys?: boolean | undefined;
126
+ dictionaryValueSuggestions?: Record<string, Array<string>> | undefined;
127
+ loadingDictionaryValueKeys?: Array<string> | undefined;
128
+ onDictionaryKeySelected?: ((key: string) => void) | undefined;
129
+ dictionaryEnableOperators?: boolean | undefined;
125
130
 
126
131
  hideOptionalLabel?: boolean | undefined;
127
132
 
@@ -197,6 +197,15 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
197
197
  const [attributesLoaded, setAttributesLoaded] = useState<boolean>(false);
198
198
  const [attributesLoading, setAttributesLoading] = useState<boolean>(false);
199
199
 
200
+ // Per-attribute value suggestions, populated lazily as the user types `@key:`.
201
+ const [attributeValueSuggestions, setAttributeValueSuggestions] = useState<
202
+ Record<string, Array<string>>
203
+ >({});
204
+ const [attributeValuesLoading, setAttributeValuesLoading] =
205
+ useState<boolean>(false);
206
+ const lastValueSuggestionKeyRef: React.MutableRefObject<string> =
207
+ useRef<string>("");
208
+
200
209
  const [isPageLoading, setIsPageLoading] = useState<boolean>(true);
201
210
  const [pageError, setPageError] = useState<string>("");
202
211
 
@@ -441,6 +450,58 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
441
450
  }
442
451
  }, [attributesLoaded, attributesLoading, loadAttributes]);
443
452
 
453
+ /*
454
+ * Lazily fetch values for the attribute the user is currently typing.
455
+ * Triggered by `@key:` patterns in the search bar — the same approach
456
+ * used by Traces/Metrics so the value dropdown can populate.
457
+ */
458
+ useEffect(() => {
459
+ const currentWord: string = (searchQuery.split(/\s+/).pop() || "").trim();
460
+ if (!currentWord.startsWith("@") || !currentWord.includes(":")) {
461
+ return;
462
+ }
463
+ const colonIdx: number = currentWord.indexOf(":");
464
+ const attrKey: string = currentWord.substring(1, colonIdx);
465
+
466
+ if (!attrKey || attrKey === lastValueSuggestionKeyRef.current) {
467
+ return;
468
+ }
469
+ lastValueSuggestionKeyRef.current = attrKey;
470
+
471
+ const loadValues: () => Promise<void> = async (): Promise<void> => {
472
+ try {
473
+ setAttributeValuesLoading(true);
474
+ const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
475
+ await API.post({
476
+ url: URL.fromString(APP_API_URL.toString()).addRoute(
477
+ "/telemetry/logs/get-attribute-values",
478
+ ),
479
+ data: { attributeKey: attrKey },
480
+ headers: {
481
+ ...ModelAPI.getCommonHeaders(),
482
+ },
483
+ });
484
+ if (response instanceof HTTPErrorResponse) {
485
+ throw response;
486
+ }
487
+ const values: Array<string> = (response.data["values"] ||
488
+ []) as Array<string>;
489
+ setAttributeValueSuggestions(
490
+ (
491
+ prev: Record<string, Array<string>>,
492
+ ): Record<string, Array<string>> => {
493
+ return { ...prev, [attrKey]: values };
494
+ },
495
+ );
496
+ } catch {
497
+ // non-critical — leave the dropdown empty so users can still free-type
498
+ } finally {
499
+ setAttributeValuesLoading(false);
500
+ }
501
+ };
502
+ void loadValues();
503
+ }, [searchQuery]);
504
+
444
505
  // Reset focused row when displayed logs change
445
506
  useEffect(() => {
446
507
  setFocusedRowIndex(-1);
@@ -669,17 +730,23 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
669
730
  }, [props.activeFilters, serviceMap]);
670
731
 
671
732
  /*
672
- * Replace serviceId UUIDs with human-readable names in value suggestions
733
+ * Replace serviceId UUIDs with human-readable names in value suggestions,
734
+ * and merge in the lazily-fetched attribute value suggestions.
673
735
  * Must be before early returns to maintain consistent hook call order.
674
736
  */
675
737
  const resolvedValueSuggestions: Record<string, Array<string>> | undefined =
676
738
  useMemo(() => {
677
- if (!props.valueSuggestions) {
739
+ const hasParentSuggestions: boolean = Boolean(props.valueSuggestions);
740
+ const hasAttributeSuggestions: boolean =
741
+ Object.keys(attributeValueSuggestions).length > 0;
742
+
743
+ if (!hasParentSuggestions && !hasAttributeSuggestions) {
678
744
  return undefined;
679
745
  }
680
746
 
681
747
  const suggestions: Record<string, Array<string>> = {
682
- ...props.valueSuggestions,
748
+ ...(props.valueSuggestions || {}),
749
+ ...attributeValueSuggestions,
683
750
  };
684
751
 
685
752
  if (suggestions["serviceId"] && Object.keys(serviceMap).length > 0) {
@@ -692,7 +759,7 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
692
759
  }
693
760
 
694
761
  return suggestions;
695
- }, [props.valueSuggestions, serviceMap]);
762
+ }, [props.valueSuggestions, serviceMap, attributeValueSuggestions]);
696
763
 
697
764
  /*
698
765
  * Wrap onFieldValueSelect to resolve service names back to UUIDs
@@ -828,6 +895,8 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
828
895
  onSearchSubmit={handleSearchSubmit}
829
896
  valueSuggestions={resolvedValueSuggestions}
830
897
  onFieldValueSelect={handleFieldValueSelectWithServiceResolve}
898
+ isAttributesLoading={attributesLoading}
899
+ isValuesLoading={attributeValuesLoading}
831
900
  toolbar={<LogsViewerToolbar {...toolbarProps} />}
832
901
  />
833
902
  </div>
@@ -17,10 +17,17 @@ export interface LogSearchBarProps {
17
17
  value: string;
18
18
  onChange: (value: string) => void;
19
19
  onSubmit: () => void;
20
+ // Top-level field names (e.g. "severity", "service") — used as `field:value`.
20
21
  suggestions?: Array<string> | undefined;
22
+ // Telemetry attribute keys (no leading `@`) — used as `@attr:value`.
23
+ attributeSuggestions?: Array<string> | undefined;
21
24
  valueSuggestions?: Record<string, Array<string>> | undefined;
22
25
  onFieldValueSelect?: ((fieldKey: string, value: string) => void) | undefined;
23
26
  placeholder?: string | undefined;
27
+ // Loading state for `@attribute` autocomplete (initial fetch of keys).
28
+ isAttributesLoading?: boolean | undefined;
29
+ // Loading state for `@attribute:value` autocomplete (per-key value fetch).
30
+ isValuesLoading?: boolean | undefined;
24
31
  }
25
32
 
26
33
  export interface LogSearchBarRef {
@@ -67,31 +74,48 @@ const LogSearchBar: React.ForwardRefExoticComponent<
67
74
  ? normalizedWord.substring(colonIndex + 1)
68
75
  : "";
69
76
 
77
+ /*
78
+ * `@` is the explicit trigger for attribute mode — only show attribute
79
+ * keys there. Without `@`, only show top-level field names. Mixing
80
+ * the two led to confusing dropdowns that rendered field names like
81
+ * "severity" as if they were attributes.
82
+ */
83
+ const activeSuggestions: Array<string> = hasAtPrefix
84
+ ? props.attributeSuggestions || []
85
+ : props.suggestions || [];
86
+
70
87
  const filteredSuggestions: Array<string> = isValueMode
71
88
  ? getValueSuggestions(
72
89
  fieldPrefix,
73
90
  partialValue,
74
91
  props.valueSuggestions || {},
75
92
  )
76
- : (props.suggestions || []).filter((s: string): boolean => {
93
+ : activeSuggestions.filter((s: string): boolean => {
77
94
  if (!normalizedWord && !hasAtPrefix) {
78
95
  return false;
79
96
  }
80
- // When just "@" is typed, show all suggestions
81
97
  if (hasAtPrefix && normalizedWord.length === 0) {
82
98
  return true;
83
99
  }
84
- // Match against the suggestion name, stripping any leading "@" from the suggestion too
85
- const normalizedSuggestion: string = s.startsWith("@")
86
- ? s.substring(1).toLowerCase()
87
- : s.toLowerCase();
88
- return normalizedSuggestion.startsWith(normalizedWord.toLowerCase());
100
+ return s.toLowerCase().startsWith(normalizedWord.toLowerCase());
89
101
  });
90
102
 
103
+ /*
104
+ * Show a loader inside the dropdown while the parent is fetching:
105
+ * - attribute keys: `@` was just typed but the keys haven't arrived
106
+ * - attribute values: `@key:` was typed but values for that key
107
+ * haven't arrived yet
108
+ */
109
+ const isLoadingForCurrentMode: boolean = isValueMode
110
+ ? Boolean(props.isValuesLoading)
111
+ : hasAtPrefix
112
+ ? Boolean(props.isAttributesLoading)
113
+ : false;
114
+
91
115
  const shouldShowSuggestions: boolean =
92
116
  showSuggestions &&
93
117
  isFocused &&
94
- filteredSuggestions.length > 0 &&
118
+ (filteredSuggestions.length > 0 || isLoadingForCurrentMode) &&
95
119
  (isValueMode ? true : currentWord.length > 0);
96
120
 
97
121
  // Show help when focused, input is empty, and no suggestions visible
@@ -111,6 +135,7 @@ const LogSearchBar: React.ForwardRefExoticComponent<
111
135
  if (e.key === "Enter") {
112
136
  if (
113
137
  shouldShowSuggestions &&
138
+ !isLoadingForCurrentMode &&
114
139
  selectedSuggestionIndex >= 0 &&
115
140
  selectedSuggestionIndex < filteredSuggestions.length
116
141
  ) {
@@ -119,13 +144,17 @@ const LogSearchBar: React.ForwardRefExoticComponent<
119
144
  return;
120
145
  }
121
146
 
122
- // If in value mode with a typed value, try to match and apply as chip
147
+ // If in value mode with a typed value, apply as a chip
123
148
  if (
124
149
  isValueMode &&
125
150
  partialValue.length > 0 &&
126
151
  props.onFieldValueSelect
127
152
  ) {
128
- // First try exact case-insensitive match from the available values
153
+ /*
154
+ * Prefer a match from the suggestion list (so casing matches
155
+ * what's actually in the data); otherwise accept the typed
156
+ * value as-is so users aren't blocked when no suggestion exists.
157
+ */
129
158
  const resolvedField: string =
130
159
  FIELD_ALIAS_MAP[fieldPrefix] || fieldPrefix;
131
160
  const availableValues: Array<string> =
@@ -137,25 +166,21 @@ const LogSearchBar: React.ForwardRefExoticComponent<
137
166
  },
138
167
  );
139
168
 
140
- // Use exact match, or if there's exactly one prefix match, use that
141
- const resolvedMatch: string | undefined =
169
+ const resolvedMatch: string =
142
170
  exactMatch ||
143
171
  (filteredSuggestions.length === 1
144
- ? filteredSuggestions[0]
145
- : undefined);
146
-
147
- if (resolvedMatch) {
148
- props.onFieldValueSelect(fieldPrefix, resolvedMatch);
149
- // Remove the field:value term from text
150
- const parts: Array<string> = props.value.split(/\s+/);
151
- parts.pop();
152
- const remaining: string = parts.join(" ");
153
- props.onChange(remaining ? remaining + " " : "");
154
- setShowSuggestions(false);
155
- setShowHelp(false);
156
- e.preventDefault();
157
- return;
158
- }
172
+ ? filteredSuggestions[0]!
173
+ : partialValue);
174
+
175
+ props.onFieldValueSelect(fieldPrefix, resolvedMatch);
176
+ const parts: Array<string> = props.value.split(/\s+/);
177
+ parts.pop();
178
+ const remaining: string = parts.join(" ");
179
+ props.onChange(remaining ? remaining + " " : "");
180
+ setShowSuggestions(false);
181
+ setShowHelp(false);
182
+ e.preventDefault();
183
+ return;
159
184
  }
160
185
 
161
186
  props.onSubmit();
@@ -170,7 +195,7 @@ const LogSearchBar: React.ForwardRefExoticComponent<
170
195
  return;
171
196
  }
172
197
 
173
- if (!shouldShowSuggestions) {
198
+ if (!shouldShowSuggestions || isLoadingForCurrentMode) {
174
199
  return;
175
200
  }
176
201
 
@@ -197,6 +222,7 @@ const LogSearchBar: React.ForwardRefExoticComponent<
197
222
  fieldPrefix,
198
223
  partialValue,
199
224
  props,
225
+ isLoadingForCurrentMode,
200
226
  ],
201
227
  );
202
228
 
@@ -219,11 +245,16 @@ const LogSearchBar: React.ForwardRefExoticComponent<
219
245
  return;
220
246
  }
221
247
 
222
- // Field name mode: append colon
248
+ /*
249
+ * Field name mode: append colon (re-prefix `@` for attribute keys
250
+ * since they're stored without it)
251
+ */
223
252
  const parts: Array<string> = props.value.split(/\s+/);
224
253
 
225
254
  if (parts.length > 0) {
226
- parts[parts.length - 1] = suggestion + ":";
255
+ parts[parts.length - 1] = hasAtPrefix
256
+ ? "@" + suggestion + ":"
257
+ : suggestion + ":";
227
258
  }
228
259
 
229
260
  props.onChange(parts.join(" "));
@@ -231,7 +262,7 @@ const LogSearchBar: React.ForwardRefExoticComponent<
231
262
  setShowHelp(false);
232
263
  inputRef.current?.focus();
233
264
  },
234
- [props, isValueMode, fieldPrefix],
265
+ [props, isValueMode, fieldPrefix, hasAtPrefix],
235
266
  );
236
267
 
237
268
  const handleExampleClick: (example: string) => void = useCallback(
@@ -262,6 +293,17 @@ const LogSearchBar: React.ForwardRefExoticComponent<
262
293
  };
263
294
  }, []);
264
295
 
296
+ const loadingMessage: string = isValueMode
297
+ ? `Loading values for ${fieldPrefix}...`
298
+ : "Loading attributes...";
299
+
300
+ const emptyMessage: string | undefined =
301
+ isValueMode &&
302
+ !isLoadingForCurrentMode &&
303
+ filteredSuggestions.length === 0
304
+ ? `No matching values — press Enter to filter by "${partialValue}"`
305
+ : undefined;
306
+
265
307
  return (
266
308
  <div ref={containerRef} className="relative">
267
309
  <div
@@ -326,6 +368,10 @@ const LogSearchBar: React.ForwardRefExoticComponent<
326
368
  selectedIndex={selectedSuggestionIndex}
327
369
  onSelect={applySuggestion}
328
370
  fieldContext={isValueMode ? fieldPrefix : undefined}
371
+ isAttributeMode={hasAtPrefix}
372
+ isLoading={isLoadingForCurrentMode}
373
+ loadingMessage={loadingMessage}
374
+ emptyMessage={emptyMessage}
329
375
  />
330
376
  )}
331
377
 
@@ -5,6 +5,14 @@ export interface LogSearchSuggestionsProps {
5
5
  selectedIndex: number;
6
6
  onSelect: (suggestion: string) => void;
7
7
  fieldContext?: string | undefined;
8
+ /*
9
+ * When true, items are attribute keys (rendered with `@`).
10
+ * When false, items are top-level field names (no prefix).
11
+ */
12
+ isAttributeMode?: boolean | undefined;
13
+ isLoading?: boolean | undefined;
14
+ loadingMessage?: string | undefined;
15
+ emptyMessage?: string | undefined;
8
16
  }
9
17
 
10
18
  const MAX_VISIBLE_SUGGESTIONS: number = 8;
@@ -19,6 +27,39 @@ const LogSearchSuggestions: FunctionComponent<LogSearchSuggestionsProps> = (
19
27
 
20
28
  return (
21
29
  <div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-56 overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
30
+ {props.isLoading && (
31
+ <div className="flex w-full items-center px-3 py-2 text-left text-sm text-gray-500">
32
+ <svg
33
+ className="animate-spin -ml-0.5 mr-2 h-4 w-4 text-indigo-500"
34
+ xmlns="http://www.w3.org/2000/svg"
35
+ fill="none"
36
+ viewBox="0 0 24 24"
37
+ aria-hidden="true"
38
+ >
39
+ <circle
40
+ className="opacity-25"
41
+ cx="12"
42
+ cy="12"
43
+ r="10"
44
+ stroke="currentColor"
45
+ strokeWidth="4"
46
+ ></circle>
47
+ <path
48
+ className="opacity-75"
49
+ fill="currentColor"
50
+ d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
51
+ ></path>
52
+ </svg>
53
+ <span>{props.loadingMessage || "Loading..."}</span>
54
+ </div>
55
+ )}
56
+ {!props.isLoading &&
57
+ visible.length === 0 &&
58
+ props.emptyMessage !== undefined && (
59
+ <div className="px-3 py-2 text-sm text-gray-400">
60
+ {props.emptyMessage}
61
+ </div>
62
+ )}
22
63
  {visible.map((suggestion: string, index: number) => {
23
64
  const isSelected: boolean = index === props.selectedIndex;
24
65
 
@@ -43,11 +84,13 @@ const LogSearchSuggestions: FunctionComponent<LogSearchSuggestionsProps> = (
43
84
  </span>
44
85
  <span className="font-mono">{suggestion}</span>
45
86
  </>
46
- ) : (
87
+ ) : props.isAttributeMode ? (
47
88
  <>
48
89
  <span className="font-mono text-xs text-indigo-400">@</span>
49
90
  <span className="font-mono">{suggestion}</span>
50
91
  </>
92
+ ) : (
93
+ <span className="font-mono">{suggestion}</span>
51
94
  )}
52
95
  </button>
53
96
  );