feat(logs): add field:value selection and autocomplete suggestions for log search

This commit is contained in:
Nawaz Dhandala
2026-03-07 13:01:35 +00:00
parent f7bcf21030
commit 9bdff2e733
5 changed files with 182 additions and 12 deletions

View File

@@ -9,6 +9,7 @@ import LogsViewer, {
FacetData,
ActiveFilter,
} from "Common/UI/Components/LogsViewer/LogsViewer";
import LogSeverity from "Common/Types/Log/LogSeverity";
import API from "Common/UI/Utils/API/API";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import AnalyticsModelAPI, {
@@ -606,6 +607,46 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
[],
);
// Build value suggestions for the search bar autocomplete
const valueSuggestions: Record<string, Array<string>> = useMemo(() => {
const suggestions: Record<string, Array<string>> = {
severityText: [
LogSeverity.Fatal,
LogSeverity.Error,
LogSeverity.Warning,
LogSeverity.Information,
LogSeverity.Debug,
LogSeverity.Trace,
LogSeverity.Unspecified,
],
};
// Add service IDs from facet data
if (facetData["serviceId"]) {
suggestions["serviceId"] = facetData["serviceId"].map(
(fv: { value: string; count: number }) => fv.value,
);
}
return suggestions;
}, [facetData]);
// Handle field:value selection from search bar (adds as chip)
const handleFieldValueSelect = useCallback(
(fieldKey: string, value: string): void => {
// Map user-facing field names to internal keys
const fieldAliases: Record<string, string> = {
severity: "severityText",
level: "severityText",
service: "serviceId",
};
const resolvedKey: string = fieldAliases[fieldKey] || fieldKey;
handleFacetInclude(resolvedKey, value);
},
[handleFacetInclude],
);
// Build activeFilters array for UI display
const activeFilters: Array<ActiveFilter> = useMemo(() => {
const filters: Array<ActiveFilter> = [];
@@ -672,6 +713,8 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
activeFilters={activeFilters}
onRemoveFilter={handleRemoveFilter}
onClearAllFilters={handleClearAllFilters}
valueSuggestions={valueSuggestions}
onFieldValueSelect={handleFieldValueSelect}
/>
</div>
);

View File

@@ -74,6 +74,8 @@ export interface ComponentProps {
activeFilters?: Array<ActiveFilter> | undefined;
onRemoveFilter?: ((facetKey: string, value: string) => void) | undefined;
onClearAllFilters?: (() => void) | undefined;
valueSuggestions?: Record<string, Array<string>> | undefined;
onFieldValueSelect?: ((fieldKey: string, value: string) => void) | undefined;
}
export type LogsSortField = LogsTableSortField;
@@ -453,6 +455,8 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
onSearchSubmit={handleSearchSubmit}
valueSuggestions={props.valueSuggestions}
onFieldValueSelect={props.onFieldValueSelect}
toolbar={
<LogsViewerToolbar {...toolbarProps} />
}

View File

@@ -17,6 +17,8 @@ export interface LogSearchBarProps {
onChange: (value: string) => void;
onSubmit: () => void;
suggestions?: Array<string>;
valueSuggestions?: Record<string, Array<string>>;
onFieldValueSelect?: (fieldKey: string, value: string) => void;
placeholder?: string;
}
@@ -35,20 +37,34 @@ const LogSearchBar: FunctionComponent<LogSearchBarProps> = (
const currentWord: string = extractCurrentWord(props.value);
const filteredSuggestions: Array<string> = (props.suggestions || []).filter(
(s: string): boolean => {
if (!currentWord || currentWord.length < 1) {
return false;
}
return s.toLowerCase().startsWith(currentWord.toLowerCase());
},
);
// Determine if we're in "field:value" mode or "field name" mode
const colonIndex: number = currentWord.indexOf(":");
const isValueMode: boolean = colonIndex > 0;
const fieldPrefix: string = isValueMode
? currentWord.substring(0, colonIndex).toLowerCase()
: "";
const partialValue: string = isValueMode
? currentWord.substring(colonIndex + 1)
: "";
const filteredSuggestions: Array<string> = isValueMode
? getValueSuggestions(
fieldPrefix,
partialValue,
props.valueSuggestions || {},
)
: (props.suggestions || []).filter((s: string): boolean => {
if (!currentWord || currentWord.length < 1) {
return false;
}
return s.toLowerCase().startsWith(currentWord.toLowerCase());
});
const shouldShowSuggestions: boolean =
showSuggestions &&
isFocused &&
filteredSuggestions.length > 0 &&
currentWord.length > 0;
(isValueMode ? true : currentWord.length > 0);
// Show help when focused, input is empty, and no suggestions visible
const shouldShowHelp: boolean =
@@ -72,6 +88,43 @@ const LogSearchBar: FunctionComponent<LogSearchBarProps> = (
return;
}
// If in value mode with a typed value, try to match and apply as chip
if (
isValueMode &&
partialValue.length > 0 &&
props.onFieldValueSelect
) {
// First try exact case-insensitive match from the available values
const resolvedField: string =
FIELD_ALIAS_MAP[fieldPrefix] || fieldPrefix;
const availableValues: Array<string> =
(props.valueSuggestions || {})[resolvedField] || [];
const lowerPartial: string = partialValue.toLowerCase();
const exactMatch: string | undefined = availableValues.find(
(v: string): boolean => v.toLowerCase() === lowerPartial,
);
// Use exact match, or if there's exactly one prefix match, use that
const resolvedMatch: string | undefined =
exactMatch ||
(filteredSuggestions.length === 1
? filteredSuggestions[0]
: undefined);
if (resolvedMatch) {
props.onFieldValueSelect(fieldPrefix, resolvedMatch);
// Remove the field:value term from text
const parts: Array<string> = props.value.split(/\s+/);
parts.pop();
const remaining: string = parts.join(" ");
props.onChange(remaining ? remaining + " " : "");
setShowSuggestions(false);
setShowHelp(false);
e.preventDefault();
return;
}
}
props.onSubmit();
setShowSuggestions(false);
setShowHelp(false);
@@ -108,12 +161,33 @@ const LogSearchBar: FunctionComponent<LogSearchBarProps> = (
shouldShowSuggestions,
selectedSuggestionIndex,
filteredSuggestions,
isValueMode,
fieldPrefix,
partialValue,
props,
],
);
const applySuggestion: (suggestion: string) => void = useCallback(
(suggestion: string): void => {
if (isValueMode) {
// Value mode: apply as a chip via onFieldValueSelect
if (props.onFieldValueSelect) {
props.onFieldValueSelect(fieldPrefix, suggestion);
}
// Remove the current field:value term from the search text
const parts: Array<string> = props.value.split(/\s+/);
parts.pop(); // remove the field:partialValue
const remaining: string = parts.join(" ");
props.onChange(remaining ? remaining + " " : "");
setShowSuggestions(false);
setShowHelp(false);
inputRef.current?.focus();
return;
}
// Field name mode: append colon
const parts: Array<string> = props.value.split(/\s+/);
if (parts.length > 0) {
@@ -125,7 +199,7 @@ const LogSearchBar: FunctionComponent<LogSearchBarProps> = (
setShowHelp(false);
inputRef.current?.focus();
},
[props],
[props, isValueMode, fieldPrefix],
);
const handleExampleClick: (example: string) => void = useCallback(
@@ -219,6 +293,7 @@ const LogSearchBar: FunctionComponent<LogSearchBarProps> = (
suggestions={filteredSuggestions}
selectedIndex={selectedSuggestionIndex}
onSelect={applySuggestion}
fieldContext={isValueMode ? fieldPrefix : undefined}
/>
)}
@@ -234,4 +309,36 @@ function extractCurrentWord(value: string): string {
return parts[parts.length - 1] || "";
}
// Field alias mapping (user-facing name → internal key used in valueSuggestions)
const FIELD_ALIAS_MAP: Record<string, string> = {
severity: "severityText",
level: "severityText",
service: "serviceId",
};
function getValueSuggestions(
fieldName: string,
partialValue: string,
valueSuggestions: Record<string, Array<string>>,
): Array<string> {
// Resolve field name alias
const resolvedField: string =
FIELD_ALIAS_MAP[fieldName] || fieldName;
const values: Array<string> | undefined = valueSuggestions[resolvedField];
if (!values || values.length === 0) {
return [];
}
if (!partialValue || partialValue.length === 0) {
return values;
}
const lowerPartial: string = partialValue.toLowerCase();
return values.filter((v: string): boolean =>
v.toLowerCase().startsWith(lowerPartial),
);
}
export default LogSearchBar;

View File

@@ -4,6 +4,7 @@ export interface LogSearchSuggestionsProps {
suggestions: Array<string>;
selectedIndex: number;
onSelect: (suggestion: string) => void;
fieldContext?: string | undefined;
}
const MAX_VISIBLE_SUGGESTIONS: number = 8;
@@ -35,8 +36,19 @@ const LogSearchSuggestions: FunctionComponent<LogSearchSuggestionsProps> = (
props.onSelect(suggestion);
}}
>
<span className="font-mono text-xs text-indigo-400">@</span>
<span className="font-mono">{suggestion}</span>
{props.fieldContext ? (
<>
<span className="font-mono text-xs text-gray-400">
{props.fieldContext}:
</span>
<span className="font-mono">{suggestion}</span>
</>
) : (
<>
<span className="font-mono text-xs text-indigo-400">@</span>
<span className="font-mono">{suggestion}</span>
</>
)}
</button>
);
})}

View File

@@ -7,6 +7,8 @@ export interface LogsFilterCardProps {
searchQuery: string;
onSearchQueryChange: (query: string) => void;
onSearchSubmit: () => void;
valueSuggestions?: Record<string, Array<string>> | undefined;
onFieldValueSelect?: ((fieldKey: string, value: string) => void) | undefined;
}
const LogsFilterCard: FunctionComponent<LogsFilterCardProps> = (
@@ -28,6 +30,8 @@ const LogsFilterCard: FunctionComponent<LogsFilterCardProps> = (
onChange={props.onSearchQueryChange}
onSubmit={props.onSearchSubmit}
suggestions={searchBarSuggestions}
valueSuggestions={props.valueSuggestions}
onFieldValueSelect={props.onFieldValueSelect}
/>
</div>
<div className="flex-none pt-0.5">