@oneuptime/common 10.0.19 → 10.0.21

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 (92) hide show
  1. package/Server/API/GitHubAPI.ts +104 -12
  2. package/Server/API/TelemetryAPI.ts +208 -0
  3. package/Server/API/UserCallAPI.ts +29 -0
  4. package/Server/API/UserEmailAPI.ts +29 -0
  5. package/Server/API/UserSmsAPI.ts +29 -0
  6. package/Server/API/UserWhatsAppAPI.ts +29 -0
  7. package/Server/Services/LogAggregationService.ts +251 -0
  8. package/Server/Utils/VM/VMRunner.ts +45 -0
  9. package/Types/Log/LogQueryParser.ts +252 -0
  10. package/Types/Log/LogQueryToFilter.ts +131 -0
  11. package/UI/Components/CopyTextButton/CopyTextButton.tsx +3 -3
  12. package/UI/Components/LogsViewer/LogsViewer.tsx +166 -93
  13. package/UI/Components/LogsViewer/components/ActiveFilterChips.tsx +58 -0
  14. package/UI/Components/LogsViewer/components/FacetSection.tsx +119 -0
  15. package/UI/Components/LogsViewer/components/FacetValueRow.tsx +102 -0
  16. package/UI/Components/LogsViewer/components/HistogramTooltip.tsx +122 -0
  17. package/UI/Components/LogsViewer/components/LiveLogsToggle.tsx +4 -4
  18. package/UI/Components/LogsViewer/components/LogDetailsPanel.tsx +22 -26
  19. package/UI/Components/LogsViewer/components/LogSearchBar.tsx +360 -0
  20. package/UI/Components/LogsViewer/components/LogSearchHelp.tsx +128 -0
  21. package/UI/Components/LogsViewer/components/LogSearchSuggestions.tsx +64 -0
  22. package/UI/Components/LogsViewer/components/LogTimeRangePicker.tsx +199 -0
  23. package/UI/Components/LogsViewer/components/LogsFacetSidebar.tsx +172 -0
  24. package/UI/Components/LogsViewer/components/LogsFilterCard.tsx +27 -57
  25. package/UI/Components/LogsViewer/components/LogsHistogram.tsx +268 -0
  26. package/UI/Components/LogsViewer/components/LogsPagination.tsx +12 -10
  27. package/UI/Components/LogsViewer/components/LogsTable.tsx +33 -32
  28. package/UI/Components/LogsViewer/components/LogsViewerToolbar.tsx +16 -18
  29. package/UI/Components/LogsViewer/components/severityColors.ts +31 -0
  30. package/UI/Components/LogsViewer/components/severityTheme.ts +25 -25
  31. package/UI/Components/LogsViewer/types.ts +20 -0
  32. package/build/dist/Server/API/GitHubAPI.js +40 -9
  33. package/build/dist/Server/API/GitHubAPI.js.map +1 -1
  34. package/build/dist/Server/API/TelemetryAPI.js +136 -0
  35. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  36. package/build/dist/Server/API/UserCallAPI.js +17 -0
  37. package/build/dist/Server/API/UserCallAPI.js.map +1 -1
  38. package/build/dist/Server/API/UserEmailAPI.js +17 -0
  39. package/build/dist/Server/API/UserEmailAPI.js.map +1 -1
  40. package/build/dist/Server/API/UserSmsAPI.js +17 -0
  41. package/build/dist/Server/API/UserSmsAPI.js.map +1 -1
  42. package/build/dist/Server/API/UserWhatsAppAPI.js +17 -0
  43. package/build/dist/Server/API/UserWhatsAppAPI.js.map +1 -1
  44. package/build/dist/Server/Services/LogAggregationService.js +163 -0
  45. package/build/dist/Server/Services/LogAggregationService.js.map +1 -0
  46. package/build/dist/Server/Utils/VM/VMRunner.js +31 -0
  47. package/build/dist/Server/Utils/VM/VMRunner.js.map +1 -1
  48. package/build/dist/Types/Log/LogQueryParser.js +200 -0
  49. package/build/dist/Types/Log/LogQueryParser.js.map +1 -0
  50. package/build/dist/Types/Log/LogQueryToFilter.js +96 -0
  51. package/build/dist/Types/Log/LogQueryToFilter.js.map +1 -0
  52. package/build/dist/UI/Components/CopyTextButton/CopyTextButton.js +3 -3
  53. package/build/dist/UI/Components/CopyTextButton/CopyTextButton.js.map +1 -1
  54. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +64 -42
  55. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  56. package/build/dist/UI/Components/LogsViewer/components/ActiveFilterChips.js +24 -0
  57. package/build/dist/UI/Components/LogsViewer/components/ActiveFilterChips.js.map +1 -0
  58. package/build/dist/UI/Components/LogsViewer/components/FacetSection.js +46 -0
  59. package/build/dist/UI/Components/LogsViewer/components/FacetSection.js.map +1 -0
  60. package/build/dist/UI/Components/LogsViewer/components/FacetValueRow.js +35 -0
  61. package/build/dist/UI/Components/LogsViewer/components/FacetValueRow.js.map +1 -0
  62. package/build/dist/UI/Components/LogsViewer/components/HistogramTooltip.js +64 -0
  63. package/build/dist/UI/Components/LogsViewer/components/HistogramTooltip.js.map +1 -0
  64. package/build/dist/UI/Components/LogsViewer/components/LiveLogsToggle.js +4 -4
  65. package/build/dist/UI/Components/LogsViewer/components/LiveLogsToggle.js.map +1 -1
  66. package/build/dist/UI/Components/LogsViewer/components/LogDetailsPanel.js +19 -21
  67. package/build/dist/UI/Components/LogsViewer/components/LogDetailsPanel.js.map +1 -1
  68. package/build/dist/UI/Components/LogsViewer/components/LogSearchBar.js +230 -0
  69. package/build/dist/UI/Components/LogsViewer/components/LogSearchBar.js.map +1 -0
  70. package/build/dist/UI/Components/LogsViewer/components/LogSearchHelp.js +84 -0
  71. package/build/dist/UI/Components/LogsViewer/components/LogSearchHelp.js.map +1 -0
  72. package/build/dist/UI/Components/LogsViewer/components/LogSearchSuggestions.js +27 -0
  73. package/build/dist/UI/Components/LogsViewer/components/LogSearchSuggestions.js.map +1 -0
  74. package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js +100 -0
  75. package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js.map +1 -0
  76. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js +104 -0
  77. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js.map +1 -0
  78. package/build/dist/UI/Components/LogsViewer/components/LogsFilterCard.js +14 -35
  79. package/build/dist/UI/Components/LogsViewer/components/LogsFilterCard.js.map +1 -1
  80. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js +127 -0
  81. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js.map +1 -0
  82. package/build/dist/UI/Components/LogsViewer/components/LogsPagination.js +9 -9
  83. package/build/dist/UI/Components/LogsViewer/components/LogsPagination.js.map +1 -1
  84. package/build/dist/UI/Components/LogsViewer/components/LogsTable.js +31 -30
  85. package/build/dist/UI/Components/LogsViewer/components/LogsTable.js.map +1 -1
  86. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js +7 -8
  87. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js.map +1 -1
  88. package/build/dist/UI/Components/LogsViewer/components/severityColors.js +22 -0
  89. package/build/dist/UI/Components/LogsViewer/components/severityColors.js.map +1 -0
  90. package/build/dist/UI/Components/LogsViewer/components/severityTheme.js +25 -25
  91. package/build/dist/UI/Components/LogsViewer/components/severityTheme.js.map +1 -1
  92. package/package.json +1 -1
@@ -0,0 +1,122 @@
1
+ import React, { FunctionComponent, ReactElement } from "react";
2
+ import { getSeverityColor } from "./severityColors";
3
+
4
+ export interface TooltipEntry {
5
+ severity: string;
6
+ count: number;
7
+ }
8
+
9
+ export interface HistogramTooltipProps {
10
+ active?: boolean;
11
+ label?: string;
12
+ payload?: Array<{
13
+ dataKey: string;
14
+ value: number;
15
+ payload: Record<string, number>;
16
+ }>;
17
+ }
18
+
19
+ function formatTooltipTime(label: string | undefined): string {
20
+ if (!label) {
21
+ return "";
22
+ }
23
+
24
+ const date: Date = new Date(label);
25
+
26
+ if (isNaN(date.getTime())) {
27
+ return label;
28
+ }
29
+
30
+ const now: Date = new Date();
31
+ const isToday: boolean = date.toDateString() === now.toDateString();
32
+
33
+ const time: string = date.toLocaleTimeString([], {
34
+ hour: "2-digit",
35
+ minute: "2-digit",
36
+ second: "2-digit",
37
+ hour12: false,
38
+ });
39
+
40
+ if (isToday) {
41
+ return time;
42
+ }
43
+
44
+ const dateStr: string = date.toLocaleDateString([], {
45
+ month: "short",
46
+ day: "numeric",
47
+ });
48
+
49
+ return `${dateStr}, ${time}`;
50
+ }
51
+
52
+ const HistogramTooltip: FunctionComponent<HistogramTooltipProps> = (
53
+ props: HistogramTooltipProps,
54
+ ): ReactElement | null => {
55
+ if (!props.active || !props.payload || props.payload.length === 0) {
56
+ return null;
57
+ }
58
+
59
+ const entries: Array<TooltipEntry> = props.payload
60
+ .filter((entry: { value: number }): boolean => {
61
+ return entry.value > 0;
62
+ })
63
+ .map((entry: { dataKey: string; value: number }): TooltipEntry => {
64
+ return {
65
+ severity: entry.dataKey,
66
+ count: entry.value,
67
+ };
68
+ });
69
+
70
+ if (entries.length === 0) {
71
+ return null;
72
+ }
73
+
74
+ const total: number = entries.reduce(
75
+ (sum: number, e: TooltipEntry): number => {
76
+ return sum + e.count;
77
+ },
78
+ 0,
79
+ );
80
+
81
+ return (
82
+ <div className="rounded-md border border-gray-200 bg-white px-3 py-2 shadow-md">
83
+ <p className="mb-1.5 border-b border-gray-100 pb-1.5 font-mono text-[11px] font-medium text-gray-500">
84
+ {formatTooltipTime(props.label)}
85
+ </p>
86
+ <div className="space-y-0.5">
87
+ {entries.map((entry: TooltipEntry) => {
88
+ const color: string = getSeverityColor(entry.severity).fill;
89
+ const colorLabel: string =
90
+ getSeverityColor(entry.severity).label || entry.severity;
91
+ return (
92
+ <div
93
+ key={entry.severity}
94
+ className="flex items-center justify-between gap-6 py-0.5"
95
+ >
96
+ <div className="flex items-center gap-1.5">
97
+ <span
98
+ className="inline-block h-2.5 w-2.5 rounded-sm"
99
+ style={{ backgroundColor: color }}
100
+ />
101
+ <span className="text-xs text-gray-600">{colorLabel}</span>
102
+ </div>
103
+ <span className="font-mono text-xs font-semibold tabular-nums text-gray-800">
104
+ {entry.count.toLocaleString()}
105
+ </span>
106
+ </div>
107
+ );
108
+ })}
109
+ </div>
110
+ {entries.length > 1 && (
111
+ <div className="mt-1.5 flex items-center justify-between border-t border-gray-100 pt-1.5">
112
+ <span className="text-xs text-gray-500">Total</span>
113
+ <span className="font-mono text-xs font-semibold tabular-nums text-gray-800">
114
+ {total.toLocaleString()}
115
+ </span>
116
+ </div>
117
+ )}
118
+ </div>
119
+ );
120
+ };
121
+
122
+ export default HistogramTooltip;
@@ -9,10 +9,10 @@ const LiveLogsToggle: FunctionComponent<LiveLogsToggleProps> = (
9
9
  const { isLive, onToggle, isDisabled } = props;
10
10
 
11
11
  const baseClasses: string =
12
- "inline-flex items-center gap-2 rounded-full border px-3 py-1 text-sm font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-emerald-500/60 bg-white/90 backdrop-blur";
12
+ "inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm font-medium transition-all focus:outline-none focus:ring-2 focus:ring-emerald-200";
13
13
  const activeClasses: string = isLive
14
- ? "border-emerald-600 text-emerald-700 hover:border-emerald-500 hover:bg-emerald-50"
15
- : "border-slate-300 text-slate-600 hover:border-slate-400 hover:bg-white";
14
+ ? "border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100"
15
+ : "border-gray-200 bg-white text-gray-600 hover:bg-gray-50";
16
16
  const disabledClasses: string = isDisabled
17
17
  ? "cursor-not-allowed opacity-50"
18
18
  : "cursor-pointer";
@@ -33,7 +33,7 @@ const LiveLogsToggle: FunctionComponent<LiveLogsToggleProps> = (
33
33
  >
34
34
  <span
35
35
  className={`h-2 w-2 rounded-full ${
36
- isLive ? "bg-emerald-500 animate-pulse" : "bg-slate-400"
36
+ isLive ? "bg-emerald-500 animate-pulse" : "bg-gray-300"
37
37
  }`}
38
38
  />
39
39
  <span className="font-semibold">Live</span>
@@ -139,19 +139,15 @@ const LogDetailsPanel: FunctionComponent<LogDetailsPanelProps> = (
139
139
 
140
140
  const containerClassName: string =
141
141
  variant === "embedded"
142
- ? "rounded-xl border border-slate-900 bg-slate-950 p-5 shadow-inner shadow-slate-950/40"
143
- : "rounded-xl border border-slate-800 bg-slate-950/90 p-5 shadow-sm ring-1 ring-inset ring-transparent";
142
+ ? "rounded-lg border border-gray-200 bg-white p-5 shadow-sm"
143
+ : "rounded-lg border border-gray-200 bg-white p-5 shadow-md";
144
144
 
145
- const headerBorderClass: string =
146
- variant === "embedded" ? "border-slate-900" : "border-slate-800";
145
+ const headerBorderClass: string = "border-gray-200";
147
146
 
148
- const surfaceCardClass: string =
149
- variant === "embedded"
150
- ? "border-slate-900 bg-slate-950/70"
151
- : "border-slate-800 bg-slate-950/80";
147
+ const surfaceCardClass: string = "border-gray-200 bg-gray-50";
152
148
 
153
149
  const smallBadgeClass: string =
154
- "inline-flex items-center gap-1 rounded-full border border-slate-800 bg-slate-900 px-2 py-1 text-[11px] font-mono uppercase tracking-wide text-slate-300";
150
+ "inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2 py-1 text-[11px] font-mono uppercase tracking-wide text-gray-600";
155
151
 
156
152
  return (
157
153
  <div className={containerClassName}>
@@ -160,13 +156,13 @@ const LogDetailsPanel: FunctionComponent<LogDetailsPanelProps> = (
160
156
  >
161
157
  <div className="flex flex-1 items-start gap-3">
162
158
  <span
163
- className="mt-1 h-3 w-3 flex-none rounded-full border border-slate-700"
159
+ className="mt-1 h-3 w-3 flex-none rounded-full border border-gray-200"
164
160
  style={{ backgroundColor: serviceColor }}
165
161
  aria-hidden="true"
166
162
  />
167
163
  <div className="space-y-3">
168
164
  <div className="flex flex-wrap items-center gap-3">
169
- <h3 className="text-lg font-semibold text-slate-50">
165
+ <h3 className="text-lg font-semibold text-gray-900">
170
166
  {serviceName}
171
167
  </h3>
172
168
  <SeverityBadge severity={props.log.severityText} />
@@ -200,7 +196,7 @@ const LogDetailsPanel: FunctionComponent<LogDetailsPanelProps> = (
200
196
  <button
201
197
  type="button"
202
198
  onClick={props.onClose}
203
- className="flex h-9 w-9 items-center justify-center rounded-full border border-slate-800 bg-slate-900 text-slate-400 transition-colors hover:border-slate-700 hover:text-slate-100"
199
+ className="flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 bg-gray-50 text-gray-400 transition-colors hover:border-gray-300 hover:text-gray-600"
204
200
  title="Close details"
205
201
  >
206
202
  <Icon icon={IconProp.Close} className="h-4 w-4" />
@@ -208,9 +204,9 @@ const LogDetailsPanel: FunctionComponent<LogDetailsPanelProps> = (
208
204
  )}
209
205
  </div>
210
206
 
211
- <div className="mt-4 space-y-5 text-sm text-slate-200">
207
+ <div className="mt-4 space-y-5 text-sm text-gray-700">
212
208
  <section className="space-y-3">
213
- <header className="flex items-center justify-between text-[11px] uppercase tracking-wide text-slate-500">
209
+ <header className="flex items-center justify-between text-[11px] uppercase tracking-wide text-gray-400">
214
210
  <span>Log Body</span>
215
211
  <CopyTextButton
216
212
  textToBeCopied={bodyDetails.raw}
@@ -223,11 +219,11 @@ const LogDetailsPanel: FunctionComponent<LogDetailsPanelProps> = (
223
219
 
224
220
  <div className={`rounded-lg border ${surfaceCardClass} p-4`}>
225
221
  {bodyDetails.isJson ? (
226
- <pre className="max-h-80 overflow-auto whitespace-pre-wrap break-words font-mono text-[13px] leading-6 text-slate-100">
222
+ <pre className="max-h-80 overflow-auto whitespace-pre-wrap break-words font-mono text-[13px] leading-6 text-gray-800">
227
223
  {bodyDetails.pretty}
228
224
  </pre>
229
225
  ) : (
230
- <p className="whitespace-pre-wrap break-words font-mono text-[13px] leading-6 text-slate-100">
226
+ <p className="whitespace-pre-wrap break-words font-mono text-[13px] leading-6 text-gray-800">
231
227
  {bodyDetails.pretty || "-"}
232
228
  </p>
233
229
  )}
@@ -238,7 +234,7 @@ const LogDetailsPanel: FunctionComponent<LogDetailsPanelProps> = (
238
234
  <section className="grid gap-4 md:grid-cols-2">
239
235
  {traceId && (
240
236
  <div className={`rounded-lg border ${surfaceCardClass} p-4`}>
241
- <div className="mb-2 flex items-center justify-between text-[11px] uppercase tracking-wide text-slate-500">
237
+ <div className="mb-2 flex items-center justify-between text-[11px] uppercase tracking-wide text-gray-400">
242
238
  <span>Trace ID</span>
243
239
  <CopyTextButton
244
240
  textToBeCopied={traceId}
@@ -252,14 +248,14 @@ const LogDetailsPanel: FunctionComponent<LogDetailsPanelProps> = (
252
248
  {traceRoute ? (
253
249
  <Link
254
250
  to={traceRoute}
255
- className="max-w-full truncate font-mono text-xs text-indigo-200 hover:text-indigo-100"
251
+ className="max-w-full truncate font-mono text-xs text-indigo-600 hover:text-indigo-500"
256
252
  title={`View trace ${traceId}`}
257
253
  >
258
254
  {traceId}
259
255
  </Link>
260
256
  ) : (
261
257
  <span
262
- className="max-w-full truncate font-mono text-xs text-slate-200"
258
+ className="max-w-full truncate font-mono text-xs text-gray-700"
263
259
  title={traceId}
264
260
  >
265
261
  {traceId}
@@ -268,7 +264,7 @@ const LogDetailsPanel: FunctionComponent<LogDetailsPanelProps> = (
268
264
  {traceRoute && (
269
265
  <Icon
270
266
  icon={IconProp.ExternalLink}
271
- className="h-4 w-4 flex-none text-indigo-300"
267
+ className="h-4 w-4 flex-none text-indigo-400"
272
268
  />
273
269
  )}
274
270
  </div>
@@ -277,7 +273,7 @@ const LogDetailsPanel: FunctionComponent<LogDetailsPanelProps> = (
277
273
 
278
274
  {spanId && (
279
275
  <div className={`rounded-lg border ${surfaceCardClass} p-4`}>
280
- <div className="mb-2 flex items-center justify-between text-[11px] uppercase tracking-wide text-slate-500">
276
+ <div className="mb-2 flex items-center justify-between text-[11px] uppercase tracking-wide text-gray-400">
281
277
  <span>Span ID</span>
282
278
  <CopyTextButton
283
279
  textToBeCopied={spanId}
@@ -291,14 +287,14 @@ const LogDetailsPanel: FunctionComponent<LogDetailsPanelProps> = (
291
287
  {spanRoute ? (
292
288
  <Link
293
289
  to={spanRoute}
294
- className="max-w-full truncate font-mono text-xs text-indigo-200 hover:text-indigo-100"
290
+ className="max-w-full truncate font-mono text-xs text-indigo-600 hover:text-indigo-500"
295
291
  title={`View span ${spanId}`}
296
292
  >
297
293
  {spanId}
298
294
  </Link>
299
295
  ) : (
300
296
  <span
301
- className="max-w-full truncate font-mono text-xs text-slate-200"
297
+ className="max-w-full truncate font-mono text-xs text-gray-700"
302
298
  title={spanId}
303
299
  >
304
300
  {spanId}
@@ -307,7 +303,7 @@ const LogDetailsPanel: FunctionComponent<LogDetailsPanelProps> = (
307
303
  {spanRoute && (
308
304
  <Icon
309
305
  icon={IconProp.ExternalLink}
310
- className="h-4 w-4 flex-none text-indigo-300"
306
+ className="h-4 w-4 flex-none text-indigo-400"
311
307
  />
312
308
  )}
313
309
  </div>
@@ -318,7 +314,7 @@ const LogDetailsPanel: FunctionComponent<LogDetailsPanelProps> = (
318
314
 
319
315
  {prettyAttributes && (
320
316
  <section className="space-y-3">
321
- <header className="flex items-center justify-between text-[11px] uppercase tracking-wide text-slate-500">
317
+ <header className="flex items-center justify-between text-[11px] uppercase tracking-wide text-gray-400">
322
318
  <span>Attributes</span>
323
319
  <CopyTextButton
324
320
  textToBeCopied={prettyAttributes}
@@ -329,7 +325,7 @@ const LogDetailsPanel: FunctionComponent<LogDetailsPanelProps> = (
329
325
  />
330
326
  </header>
331
327
  <div className={`rounded-lg border ${surfaceCardClass} p-4`}>
332
- <pre className="max-h-72 overflow-auto whitespace-pre-wrap break-words font-mono text-[13px] leading-6 text-slate-100">
328
+ <pre className="max-h-72 overflow-auto whitespace-pre-wrap break-words font-mono text-[13px] leading-6 text-gray-800">
333
329
  {prettyAttributes}
334
330
  </pre>
335
331
  </div>
@@ -0,0 +1,360 @@
1
+ import React, {
2
+ FunctionComponent,
3
+ ReactElement,
4
+ useState,
5
+ useCallback,
6
+ useRef,
7
+ useEffect,
8
+ KeyboardEvent,
9
+ } from "react";
10
+ import Icon from "../../Icon/Icon";
11
+ import IconProp from "../../../../Types/Icon/IconProp";
12
+ import LogSearchSuggestions from "./LogSearchSuggestions";
13
+ import LogSearchHelp from "./LogSearchHelp";
14
+
15
+ export interface LogSearchBarProps {
16
+ value: string;
17
+ onChange: (value: string) => void;
18
+ onSubmit: () => void;
19
+ suggestions?: Array<string> | undefined;
20
+ valueSuggestions?: Record<string, Array<string>> | undefined;
21
+ onFieldValueSelect?: ((fieldKey: string, value: string) => void) | undefined;
22
+ placeholder?: string | undefined;
23
+ }
24
+
25
+ const LogSearchBar: FunctionComponent<LogSearchBarProps> = (
26
+ props: LogSearchBarProps,
27
+ ): ReactElement => {
28
+ const [isFocused, setIsFocused] = useState<boolean>(false);
29
+ const [showSuggestions, setShowSuggestions] = useState<boolean>(false);
30
+ const [showHelp, setShowHelp] = useState<boolean>(false);
31
+ const [selectedSuggestionIndex, setSelectedSuggestionIndex] =
32
+ useState<number>(-1);
33
+ const inputRef: React.RefObject<HTMLInputElement> = useRef<HTMLInputElement>(
34
+ null!,
35
+ );
36
+ const containerRef: React.RefObject<HTMLDivElement> = useRef<HTMLDivElement>(
37
+ null!,
38
+ );
39
+
40
+ const currentWord: string = extractCurrentWord(props.value);
41
+
42
+ // Strip leading "@" — treat it as a trigger character for suggestions
43
+ const hasAtPrefix: boolean = currentWord.startsWith("@");
44
+ const normalizedWord: string = hasAtPrefix
45
+ ? currentWord.substring(1)
46
+ : currentWord;
47
+
48
+ // Determine if we're in "field:value" mode or "field name" mode
49
+ const colonIndex: number = normalizedWord.indexOf(":");
50
+ const isValueMode: boolean = colonIndex > 0;
51
+ const fieldPrefix: string = isValueMode
52
+ ? normalizedWord.substring(0, colonIndex).toLowerCase()
53
+ : "";
54
+ const partialValue: string = isValueMode
55
+ ? normalizedWord.substring(colonIndex + 1)
56
+ : "";
57
+
58
+ const filteredSuggestions: Array<string> = isValueMode
59
+ ? getValueSuggestions(
60
+ fieldPrefix,
61
+ partialValue,
62
+ props.valueSuggestions || {},
63
+ )
64
+ : (props.suggestions || []).filter((s: string): boolean => {
65
+ if (!normalizedWord && !hasAtPrefix) {
66
+ return false;
67
+ }
68
+ // When just "@" is typed, show all suggestions
69
+ if (hasAtPrefix && normalizedWord.length === 0) {
70
+ return true;
71
+ }
72
+ // Match against the suggestion name, stripping any leading "@" from the suggestion too
73
+ const normalizedSuggestion: string = s.startsWith("@")
74
+ ? s.substring(1).toLowerCase()
75
+ : s.toLowerCase();
76
+ return normalizedSuggestion.startsWith(normalizedWord.toLowerCase());
77
+ });
78
+
79
+ const shouldShowSuggestions: boolean =
80
+ showSuggestions &&
81
+ isFocused &&
82
+ filteredSuggestions.length > 0 &&
83
+ (isValueMode ? true : currentWord.length > 0);
84
+
85
+ // Show help when focused, input is empty, and no suggestions visible
86
+ const shouldShowHelp: boolean =
87
+ showHelp && isFocused && props.value.length === 0 && !shouldShowSuggestions;
88
+
89
+ useEffect(() => {
90
+ setSelectedSuggestionIndex(-1);
91
+ }, [currentWord]);
92
+
93
+ const handleKeyDown: (e: KeyboardEvent<HTMLInputElement>) => void =
94
+ useCallback(
95
+ (e: KeyboardEvent<HTMLInputElement>): void => {
96
+ if (e.key === "Enter") {
97
+ if (
98
+ shouldShowSuggestions &&
99
+ selectedSuggestionIndex >= 0 &&
100
+ selectedSuggestionIndex < filteredSuggestions.length
101
+ ) {
102
+ applySuggestion(filteredSuggestions[selectedSuggestionIndex]!);
103
+ e.preventDefault();
104
+ return;
105
+ }
106
+
107
+ // If in value mode with a typed value, try to match and apply as chip
108
+ if (
109
+ isValueMode &&
110
+ partialValue.length > 0 &&
111
+ props.onFieldValueSelect
112
+ ) {
113
+ // First try exact case-insensitive match from the available values
114
+ const resolvedField: string =
115
+ FIELD_ALIAS_MAP[fieldPrefix] || fieldPrefix;
116
+ const availableValues: Array<string> =
117
+ (props.valueSuggestions || {})[resolvedField] || [];
118
+ const lowerPartial: string = partialValue.toLowerCase();
119
+ const exactMatch: string | undefined = availableValues.find(
120
+ (v: string): boolean => {
121
+ return v.toLowerCase() === lowerPartial;
122
+ },
123
+ );
124
+
125
+ // Use exact match, or if there's exactly one prefix match, use that
126
+ const resolvedMatch: string | undefined =
127
+ exactMatch ||
128
+ (filteredSuggestions.length === 1
129
+ ? filteredSuggestions[0]
130
+ : undefined);
131
+
132
+ if (resolvedMatch) {
133
+ props.onFieldValueSelect(fieldPrefix, resolvedMatch);
134
+ // Remove the field:value term from text
135
+ const parts: Array<string> = props.value.split(/\s+/);
136
+ parts.pop();
137
+ const remaining: string = parts.join(" ");
138
+ props.onChange(remaining ? remaining + " " : "");
139
+ setShowSuggestions(false);
140
+ setShowHelp(false);
141
+ e.preventDefault();
142
+ return;
143
+ }
144
+ }
145
+
146
+ props.onSubmit();
147
+ setShowSuggestions(false);
148
+ setShowHelp(false);
149
+ return;
150
+ }
151
+
152
+ if (e.key === "Escape") {
153
+ setShowSuggestions(false);
154
+ setShowHelp(false);
155
+ return;
156
+ }
157
+
158
+ if (!shouldShowSuggestions) {
159
+ return;
160
+ }
161
+
162
+ if (e.key === "ArrowDown") {
163
+ e.preventDefault();
164
+ setSelectedSuggestionIndex((prev: number): number => {
165
+ return Math.min(prev + 1, filteredSuggestions.length - 1);
166
+ });
167
+ return;
168
+ }
169
+
170
+ if (e.key === "ArrowUp") {
171
+ e.preventDefault();
172
+ setSelectedSuggestionIndex((prev: number): number => {
173
+ return Math.max(prev - 1, 0);
174
+ });
175
+ }
176
+ },
177
+ [
178
+ shouldShowSuggestions,
179
+ selectedSuggestionIndex,
180
+ filteredSuggestions,
181
+ isValueMode,
182
+ fieldPrefix,
183
+ partialValue,
184
+ props,
185
+ ],
186
+ );
187
+
188
+ const applySuggestion: (suggestion: string) => void = useCallback(
189
+ (suggestion: string): void => {
190
+ if (isValueMode) {
191
+ // Value mode: apply as a chip via onFieldValueSelect
192
+ if (props.onFieldValueSelect) {
193
+ props.onFieldValueSelect(fieldPrefix, suggestion);
194
+ }
195
+
196
+ // Remove the current field:value term from the search text
197
+ const parts: Array<string> = props.value.split(/\s+/);
198
+ parts.pop(); // remove the field:partialValue
199
+ const remaining: string = parts.join(" ");
200
+ props.onChange(remaining ? remaining + " " : "");
201
+ setShowSuggestions(false);
202
+ setShowHelp(false);
203
+ inputRef.current?.focus();
204
+ return;
205
+ }
206
+
207
+ // Field name mode: append colon
208
+ const parts: Array<string> = props.value.split(/\s+/);
209
+
210
+ if (parts.length > 0) {
211
+ parts[parts.length - 1] = suggestion + ":";
212
+ }
213
+
214
+ props.onChange(parts.join(" "));
215
+ setShowSuggestions(false);
216
+ setShowHelp(false);
217
+ inputRef.current?.focus();
218
+ },
219
+ [props, isValueMode, fieldPrefix],
220
+ );
221
+
222
+ const handleExampleClick: (example: string) => void = useCallback(
223
+ (example: string): void => {
224
+ props.onChange(example);
225
+ setShowHelp(false);
226
+ inputRef.current?.focus();
227
+ },
228
+ [props],
229
+ );
230
+
231
+ useEffect(() => {
232
+ const handleClickOutside: (e: MouseEvent) => void = (
233
+ e: MouseEvent,
234
+ ): void => {
235
+ if (
236
+ containerRef.current &&
237
+ !containerRef.current.contains(e.target as Node)
238
+ ) {
239
+ setShowSuggestions(false);
240
+ setShowHelp(false);
241
+ }
242
+ };
243
+
244
+ document.addEventListener("mousedown", handleClickOutside);
245
+ return () => {
246
+ document.removeEventListener("mousedown", handleClickOutside);
247
+ };
248
+ }, []);
249
+
250
+ return (
251
+ <div ref={containerRef} className="relative">
252
+ <div
253
+ className={`flex items-center gap-2 rounded-lg border bg-white px-3 py-2 transition-colors ${
254
+ isFocused
255
+ ? "border-indigo-400 ring-2 ring-indigo-100"
256
+ : "border-gray-200 hover:border-gray-300"
257
+ }`}
258
+ >
259
+ <Icon
260
+ icon={IconProp.Search}
261
+ className="h-4 w-4 flex-none text-gray-400"
262
+ />
263
+ <input
264
+ ref={inputRef}
265
+ type="text"
266
+ value={props.value}
267
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
268
+ props.onChange(e.target.value);
269
+ setShowSuggestions(true);
270
+ setShowHelp(false);
271
+ }}
272
+ onFocus={() => {
273
+ setIsFocused(true);
274
+ setShowSuggestions(true);
275
+ if (props.value.length === 0) {
276
+ setShowHelp(true);
277
+ }
278
+ }}
279
+ onBlur={() => {
280
+ setIsFocused(false);
281
+ }}
282
+ onKeyDown={handleKeyDown}
283
+ placeholder={
284
+ props.placeholder ||
285
+ 'Search logs... (e.g. severity:error service:api "connection refused")'
286
+ }
287
+ className="flex-1 bg-transparent font-mono text-sm text-gray-900 placeholder-gray-400 outline-none"
288
+ spellCheck={false}
289
+ autoComplete="off"
290
+ />
291
+ {props.value.length > 0 && (
292
+ <button
293
+ type="button"
294
+ className="flex-none rounded-full p-1 text-gray-400 hover:bg-gray-100"
295
+ onClick={() => {
296
+ props.onChange("");
297
+ setShowHelp(true);
298
+ setShowSuggestions(false);
299
+ inputRef.current?.focus();
300
+ }}
301
+ title="Clear search"
302
+ >
303
+ <Icon icon={IconProp.Close} className="h-3.5 w-3.5" />
304
+ </button>
305
+ )}
306
+ </div>
307
+
308
+ {shouldShowSuggestions && (
309
+ <LogSearchSuggestions
310
+ suggestions={filteredSuggestions}
311
+ selectedIndex={selectedSuggestionIndex}
312
+ onSelect={applySuggestion}
313
+ fieldContext={isValueMode ? fieldPrefix : undefined}
314
+ />
315
+ )}
316
+
317
+ {shouldShowHelp && <LogSearchHelp onExampleClick={handleExampleClick} />}
318
+ </div>
319
+ );
320
+ };
321
+
322
+ function extractCurrentWord(value: string): string {
323
+ const parts: Array<string> = value.split(/\s+/);
324
+ return parts[parts.length - 1] || "";
325
+ }
326
+
327
+ // Field alias mapping (user-facing name → internal key used in valueSuggestions)
328
+ const FIELD_ALIAS_MAP: Record<string, string> = {
329
+ severity: "severityText",
330
+ level: "severityText",
331
+ service: "serviceId",
332
+ trace: "traceId",
333
+ span: "spanId",
334
+ };
335
+
336
+ function getValueSuggestions(
337
+ fieldName: string,
338
+ partialValue: string,
339
+ valueSuggestions: Record<string, Array<string>>,
340
+ ): Array<string> {
341
+ // Resolve field name alias
342
+ const resolvedField: string = FIELD_ALIAS_MAP[fieldName] || fieldName;
343
+
344
+ const values: Array<string> | undefined = valueSuggestions[resolvedField];
345
+
346
+ if (!values || values.length === 0) {
347
+ return [];
348
+ }
349
+
350
+ if (!partialValue || partialValue.length === 0) {
351
+ return values;
352
+ }
353
+
354
+ const lowerPartial: string = partialValue.toLowerCase();
355
+ return values.filter((v: string): boolean => {
356
+ return v.toLowerCase().startsWith(lowerPartial);
357
+ });
358
+ }
359
+
360
+ export default LogSearchBar;