@oneuptime/common 10.0.86 → 10.0.88

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 (90) 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/TelemetryAPI.ts +24 -0
  6. package/Server/EnvironmentConfig.ts +10 -0
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1777629313843-MigrationName.ts +59 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  9. package/Server/Infrastructure/Queue.ts +4 -4
  10. package/Server/Services/TelemetryAttributeService.ts +37 -3
  11. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +174 -7
  12. package/Tests/Types/Date.test.ts +46 -0
  13. package/Types/Date.ts +9 -4
  14. package/UI/Components/AutocompleteTextInput/AutocompleteTextInput.tsx +60 -21
  15. package/UI/Components/Dictionary/Dictionary.tsx +188 -26
  16. package/UI/Components/Dictionary/DictionaryFilterOperator.ts +357 -0
  17. package/UI/Components/Dictionary/DictionaryOfStrings.tsx +12 -7
  18. package/UI/Components/EditionLabel/EditionLabel.tsx +224 -10
  19. package/UI/Components/Filters/FilterViewer.tsx +81 -16
  20. package/UI/Components/Filters/FiltersForm.tsx +18 -3
  21. package/UI/Components/Filters/JSONFilter.tsx +11 -2
  22. package/UI/Components/Filters/Types/Filter.ts +3 -0
  23. package/UI/Components/Forms/Fields/FormField.tsx +6 -1
  24. package/UI/Components/Forms/Types/Field.ts +5 -0
  25. package/UI/Components/LogsViewer/LogsViewer.tsx +73 -4
  26. package/UI/Components/LogsViewer/components/LogSearchBar.tsx +77 -31
  27. package/UI/Components/LogsViewer/components/LogSearchSuggestions.tsx +44 -1
  28. package/UI/Components/LogsViewer/components/LogsFilterCard.tsx +7 -5
  29. package/UI/Components/TelemetryViewer/TelemetryViewer.tsx +6 -0
  30. package/UI/Components/TelemetryViewer/components/TelemetrySearchBar.tsx +84 -25
  31. package/UI/Components/TelemetryViewer/components/TelemetrySearchSuggestions.tsx +44 -1
  32. package/build/dist/Models/DatabaseModels/EnterpriseLicense.js +57 -0
  33. package/build/dist/Models/DatabaseModels/EnterpriseLicense.js.map +1 -1
  34. package/build/dist/Models/DatabaseModels/GlobalConfig.js +54 -0
  35. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  36. package/build/dist/Server/API/EnterpriseLicenseAPI.js +64 -1
  37. package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
  38. package/build/dist/Server/API/GlobalConfigAPI.js +47 -0
  39. package/build/dist/Server/API/GlobalConfigAPI.js.map +1 -1
  40. package/build/dist/Server/API/TelemetryAPI.js +9 -0
  41. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  42. package/build/dist/Server/EnvironmentConfig.js +3 -0
  43. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  44. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1777629313843-MigrationName.js +26 -0
  45. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1777629313843-MigrationName.js.map +1 -0
  46. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  47. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  48. package/build/dist/Server/Infrastructure/Queue.js +3 -3
  49. package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
  50. package/build/dist/Server/Services/TelemetryAttributeService.js +36 -7
  51. package/build/dist/Server/Services/TelemetryAttributeService.js.map +1 -1
  52. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +135 -5
  53. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  54. package/build/dist/Tests/Types/Date.test.js +40 -0
  55. package/build/dist/Tests/Types/Date.test.js.map +1 -1
  56. package/build/dist/Types/Date.js +7 -2
  57. package/build/dist/Types/Date.js.map +1 -1
  58. package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js +21 -10
  59. package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js.map +1 -1
  60. package/build/dist/UI/Components/Dictionary/Dictionary.js +109 -16
  61. package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
  62. package/build/dist/UI/Components/Dictionary/DictionaryFilterOperator.js +263 -0
  63. package/build/dist/UI/Components/Dictionary/DictionaryFilterOperator.js.map +1 -0
  64. package/build/dist/UI/Components/Dictionary/DictionaryOfStrings.js +10 -6
  65. package/build/dist/UI/Components/Dictionary/DictionaryOfStrings.js.map +1 -1
  66. package/build/dist/UI/Components/EditionLabel/EditionLabel.js +124 -6
  67. package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
  68. package/build/dist/UI/Components/Filters/FilterViewer.js +50 -12
  69. package/build/dist/UI/Components/Filters/FilterViewer.js.map +1 -1
  70. package/build/dist/UI/Components/Filters/FiltersForm.js +5 -4
  71. package/build/dist/UI/Components/Filters/FiltersForm.js.map +1 -1
  72. package/build/dist/UI/Components/Filters/JSONFilter.js +1 -1
  73. package/build/dist/UI/Components/Filters/JSONFilter.js.map +1 -1
  74. package/build/dist/UI/Components/Forms/Fields/FormField.js +1 -1
  75. package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
  76. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +54 -5
  77. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  78. package/build/dist/UI/Components/LogsViewer/components/LogSearchBar.js +59 -29
  79. package/build/dist/UI/Components/LogsViewer/components/LogSearchBar.js.map +1 -1
  80. package/build/dist/UI/Components/LogsViewer/components/LogSearchSuggestions.js +10 -2
  81. package/build/dist/UI/Components/LogsViewer/components/LogSearchSuggestions.js.map +1 -1
  82. package/build/dist/UI/Components/LogsViewer/components/LogsFilterCard.js +2 -5
  83. package/build/dist/UI/Components/LogsViewer/components/LogsFilterCard.js.map +1 -1
  84. package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js +1 -1
  85. package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js.map +1 -1
  86. package/build/dist/UI/Components/TelemetryViewer/components/TelemetrySearchBar.js +59 -22
  87. package/build/dist/UI/Components/TelemetryViewer/components/TelemetrySearchBar.js.map +1 -1
  88. package/build/dist/UI/Components/TelemetryViewer/components/TelemetrySearchSuggestions.js +10 -2
  89. package/build/dist/UI/Components/TelemetryViewer/components/TelemetrySearchSuggestions.js.map +1 -1
  90. package/package.json +1 -1
@@ -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
  );
@@ -9,6 +9,8 @@ export interface LogsFilterCardProps {
9
9
  onSearchSubmit: () => void;
10
10
  valueSuggestions?: Record<string, Array<string>> | undefined;
11
11
  onFieldValueSelect?: ((fieldKey: string, value: string) => void) | undefined;
12
+ isAttributesLoading?: boolean | undefined;
13
+ isValuesLoading?: boolean | undefined;
12
14
  }
13
15
 
14
16
  const LogsFilterCard: React.ForwardRefExoticComponent<
@@ -18,14 +20,11 @@ const LogsFilterCard: React.ForwardRefExoticComponent<
18
20
  props: LogsFilterCardProps,
19
21
  ref: React.Ref<LogSearchBarRef>,
20
22
  ): ReactElement => {
21
- const searchBarSuggestions: Array<string> = [
23
+ const fieldSuggestions: Array<string> = [
22
24
  "severity",
23
25
  "service",
24
26
  "trace",
25
27
  "span",
26
- ...props.logAttributes.map((attr: string) => {
27
- return `@${attr}`;
28
- }),
29
28
  ];
30
29
 
31
30
  return (
@@ -36,9 +35,12 @@ const LogsFilterCard: React.ForwardRefExoticComponent<
36
35
  value={props.searchQuery}
37
36
  onChange={props.onSearchQueryChange}
38
37
  onSubmit={props.onSearchSubmit}
39
- suggestions={searchBarSuggestions}
38
+ suggestions={fieldSuggestions}
39
+ attributeSuggestions={props.logAttributes}
40
40
  valueSuggestions={props.valueSuggestions}
41
41
  onFieldValueSelect={props.onFieldValueSelect}
42
+ isAttributesLoading={props.isAttributesLoading}
43
+ isValuesLoading={props.isValuesLoading}
42
44
  />
43
45
  </div>
44
46
  <div>{props.toolbar}</div>
@@ -42,6 +42,7 @@ export interface TelemetryViewerProps<T> {
42
42
  onSearchSubmit: () => void;
43
43
  searchPlaceholder?: string | undefined;
44
44
  searchSuggestions?: Array<string> | undefined;
45
+ searchAttributeSuggestions?: Array<string> | undefined;
45
46
  searchValueSuggestions?: Record<string, Array<string>> | undefined;
46
47
  searchFieldAliasMap?: Record<string, string> | undefined;
47
48
  onSearchFieldValueSelect?:
@@ -50,6 +51,8 @@ export interface TelemetryViewerProps<T> {
50
51
  searchHelpRows?: Array<SearchHelpRow> | undefined;
51
52
  searchHelpCombinedExample?: string | undefined;
52
53
  searchBarRef?: React.Ref<TelemetrySearchBarRef> | undefined;
54
+ searchAttributesLoading?: boolean | undefined;
55
+ searchValuesLoading?: boolean | undefined;
53
56
 
54
57
  // -- Toolbar: time --
55
58
  timeRange: RangeStartAndEndDateTime;
@@ -118,11 +121,14 @@ function TelemetryViewerInner<T>(props: TelemetryViewerProps<T>): ReactElement {
118
121
  onSubmit={props.onSearchSubmit}
119
122
  placeholder={props.searchPlaceholder}
120
123
  suggestions={props.searchSuggestions}
124
+ attributeSuggestions={props.searchAttributeSuggestions}
121
125
  valueSuggestions={props.searchValueSuggestions}
122
126
  fieldAliasMap={props.searchFieldAliasMap}
123
127
  onFieldValueSelect={props.onSearchFieldValueSelect}
124
128
  helpRows={props.searchHelpRows}
125
129
  helpCombinedExample={props.searchHelpCombinedExample}
130
+ isAttributesLoading={props.searchAttributesLoading}
131
+ isValuesLoading={props.searchValuesLoading}
126
132
  />
127
133
  </div>
128
134
 
@@ -18,8 +18,17 @@ export interface TelemetrySearchBarProps {
18
18
  value: string;
19
19
  onChange: (value: string) => void;
20
20
  onSubmit: () => void;
21
- // Field-name suggestions shown when user types "@".
21
+ /*
22
+ * Top-level field names like "service", "name" — used as `field:value`
23
+ * (no @). Shown when the user types regular text.
24
+ */
22
25
  suggestions?: Array<string> | undefined;
26
+ /*
27
+ * Telemetry attribute keys like "host.name", "container.id" — used as
28
+ * `@attr:value`. Shown when the user types `@`. Pass plain keys, not
29
+ * pre-prefixed with `@` — the bar adds it on submit.
30
+ */
31
+ attributeSuggestions?: Array<string> | undefined;
23
32
  // field → allowed value completions (resolved field keys).
24
33
  valueSuggestions?: Record<string, Array<string>> | undefined;
25
34
  // Called when the user picks a concrete field:value chip from the dropdown.
@@ -30,6 +39,10 @@ export interface TelemetrySearchBarProps {
30
39
  // Rows rendered in the help popover when the bar is empty + focused.
31
40
  helpRows?: Array<SearchHelpRow> | undefined;
32
41
  helpCombinedExample?: string | undefined;
42
+ // Loading state for `@attribute` autocomplete (initial fetch of keys).
43
+ isAttributesLoading?: boolean | undefined;
44
+ // Loading state for `@attribute:value` autocomplete (per-key value fetch).
45
+ isValuesLoading?: boolean | undefined;
33
46
  }
34
47
 
35
48
  export interface TelemetrySearchBarRef {
@@ -79,6 +92,17 @@ const TelemetrySearchBar: React.ForwardRefExoticComponent<
79
92
  ? normalizedWord.substring(colonIndex + 1)
80
93
  : "";
81
94
 
95
+ /*
96
+ * Pick the suggestion list based on whether the user typed `@`.
97
+ * `@` is the explicit trigger for attribute mode — only show attribute
98
+ * keys there. Without `@`, only show top-level field names. Mixing the
99
+ * two led to confusing dropdowns that rendered field names like "name"
100
+ * as if they were attributes.
101
+ */
102
+ const activeSuggestions: Array<string> = hasAtPrefix
103
+ ? props.attributeSuggestions || []
104
+ : props.suggestions || [];
105
+
82
106
  const filteredSuggestions: Array<string> = isValueMode
83
107
  ? getValueSuggestions(
84
108
  fieldPrefix,
@@ -86,23 +110,32 @@ const TelemetrySearchBar: React.ForwardRefExoticComponent<
86
110
  props.valueSuggestions || {},
87
111
  fieldAliasMap,
88
112
  )
89
- : (props.suggestions || []).filter((s: string): boolean => {
113
+ : activeSuggestions.filter((s: string): boolean => {
90
114
  if (!normalizedWord && !hasAtPrefix) {
91
115
  return false;
92
116
  }
93
117
  if (hasAtPrefix && normalizedWord.length === 0) {
94
118
  return true;
95
119
  }
96
- const normalizedSuggestion: string = s.startsWith("@")
97
- ? s.substring(1).toLowerCase()
98
- : s.toLowerCase();
99
- return normalizedSuggestion.startsWith(normalizedWord.toLowerCase());
120
+ return s.toLowerCase().startsWith(normalizedWord.toLowerCase());
100
121
  });
101
122
 
123
+ /*
124
+ * Show a loader inside the dropdown while the parent is fetching:
125
+ * - attribute keys: `@` was just typed but the keys haven't arrived
126
+ * - attribute values: `@key:` was typed but values for that key
127
+ * haven't arrived yet
128
+ */
129
+ const isLoadingForCurrentMode: boolean = isValueMode
130
+ ? Boolean(props.isValuesLoading)
131
+ : hasAtPrefix
132
+ ? Boolean(props.isAttributesLoading)
133
+ : false;
134
+
102
135
  const shouldShowSuggestions: boolean =
103
136
  showSuggestions &&
104
137
  isFocused &&
105
- filteredSuggestions.length > 0 &&
138
+ (filteredSuggestions.length > 0 || isLoadingForCurrentMode) &&
106
139
  (isValueMode ? true : currentWord.length > 0);
107
140
 
108
141
  const shouldShowHelp: boolean =
@@ -123,6 +156,7 @@ const TelemetrySearchBar: React.ForwardRefExoticComponent<
123
156
  if (e.key === "Enter") {
124
157
  if (
125
158
  shouldShowSuggestions &&
159
+ !isLoadingForCurrentMode &&
126
160
  selectedSuggestionIndex >= 0 &&
127
161
  selectedSuggestionIndex < filteredSuggestions.length
128
162
  ) {
@@ -136,6 +170,11 @@ const TelemetrySearchBar: React.ForwardRefExoticComponent<
136
170
  partialValue.length > 0 &&
137
171
  props.onFieldValueSelect
138
172
  ) {
173
+ /*
174
+ * Prefer a match from the suggestion list (so casing matches
175
+ * what's actually in the data); otherwise accept the typed
176
+ * value as-is so users aren't blocked when no suggestion exists.
177
+ */
139
178
  const resolvedField: string =
140
179
  fieldAliasMap[fieldPrefix] || fieldPrefix;
141
180
  const availableValues: Array<string> =
@@ -147,23 +186,21 @@ const TelemetrySearchBar: React.ForwardRefExoticComponent<
147
186
  },
148
187
  );
149
188
 
150
- const resolvedMatch: string | undefined =
189
+ const resolvedMatch: string =
151
190
  exactMatch ||
152
191
  (filteredSuggestions.length === 1
153
- ? filteredSuggestions[0]
154
- : undefined);
155
-
156
- if (resolvedMatch) {
157
- props.onFieldValueSelect(fieldPrefix, resolvedMatch);
158
- const parts: Array<string> = props.value.split(/\s+/);
159
- parts.pop();
160
- const remaining: string = parts.join(" ");
161
- props.onChange(remaining ? remaining + " " : "");
162
- setShowSuggestions(false);
163
- setShowHelp(false);
164
- e.preventDefault();
165
- return;
166
- }
192
+ ? filteredSuggestions[0]!
193
+ : partialValue);
194
+
195
+ props.onFieldValueSelect(fieldPrefix, resolvedMatch);
196
+ const parts: Array<string> = props.value.split(/\s+/);
197
+ parts.pop();
198
+ const remaining: string = parts.join(" ");
199
+ props.onChange(remaining ? remaining + " " : "");
200
+ setShowSuggestions(false);
201
+ setShowHelp(false);
202
+ e.preventDefault();
203
+ return;
167
204
  }
168
205
 
169
206
  props.onSubmit();
@@ -178,7 +215,7 @@ const TelemetrySearchBar: React.ForwardRefExoticComponent<
178
215
  return;
179
216
  }
180
217
 
181
- if (!shouldShowSuggestions) {
218
+ if (!shouldShowSuggestions || isLoadingForCurrentMode) {
182
219
  return;
183
220
  }
184
221
 
@@ -206,6 +243,7 @@ const TelemetrySearchBar: React.ForwardRefExoticComponent<
206
243
  partialValue,
207
244
  props,
208
245
  fieldAliasMap,
246
+ isLoadingForCurrentMode,
209
247
  ],
210
248
  );
211
249
 
@@ -229,7 +267,13 @@ const TelemetrySearchBar: React.ForwardRefExoticComponent<
229
267
  const parts: Array<string> = props.value.split(/\s+/);
230
268
 
231
269
  if (parts.length > 0) {
232
- parts[parts.length - 1] = suggestion + ":";
270
+ /*
271
+ * Attribute suggestions are stored without `@`; add it back
272
+ * when filling the bar so the parser recognizes it as an attribute.
273
+ */
274
+ parts[parts.length - 1] = hasAtPrefix
275
+ ? "@" + suggestion + ":"
276
+ : suggestion + ":";
233
277
  }
234
278
 
235
279
  props.onChange(parts.join(" "));
@@ -237,7 +281,7 @@ const TelemetrySearchBar: React.ForwardRefExoticComponent<
237
281
  setShowHelp(false);
238
282
  inputRef.current?.focus();
239
283
  },
240
- [props, isValueMode, fieldPrefix],
284
+ [props, isValueMode, fieldPrefix, hasAtPrefix],
241
285
  );
242
286
 
243
287
  const handleExampleClick: (example: string) => void = useCallback(
@@ -268,6 +312,17 @@ const TelemetrySearchBar: React.ForwardRefExoticComponent<
268
312
  };
269
313
  }, []);
270
314
 
315
+ const loadingMessage: string = isValueMode
316
+ ? `Loading values for ${fieldPrefix}...`
317
+ : "Loading attributes...";
318
+
319
+ const emptyMessage: string | undefined =
320
+ isValueMode &&
321
+ !isLoadingForCurrentMode &&
322
+ filteredSuggestions.length === 0
323
+ ? `No matching values — press Enter to filter by "${partialValue}"`
324
+ : undefined;
325
+
271
326
  return (
272
327
  <div ref={containerRef} className="relative">
273
328
  <div
@@ -329,6 +384,10 @@ const TelemetrySearchBar: React.ForwardRefExoticComponent<
329
384
  selectedIndex={selectedSuggestionIndex}
330
385
  onSelect={applySuggestion}
331
386
  fieldContext={isValueMode ? fieldPrefix : undefined}
387
+ isAttributeMode={hasAtPrefix}
388
+ isLoading={isLoadingForCurrentMode}
389
+ loadingMessage={loadingMessage}
390
+ emptyMessage={emptyMessage}
332
391
  />
333
392
  )}
334
393