mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
feat(logs): add field:value selection and autocomplete suggestions for log search
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user