From e9f592315af6a2fe7091c8184a59cab1c6f4a616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Thu, 16 Feb 2023 12:47:51 +0100 Subject: [PATCH 01/33] feat: add a search bar component Features: - Supports multiple query languages - Decouple the business logic of query languages of the search bar component - Ability of query language to interact with the search bar Query language implementations - AQL: custom implementation of the Wazuh Query Language. Include suggestions. - UIQL: simple implementation (as another example) --- public/components/search-bar/README.md | 99 ++++ public/components/search-bar/index.tsx | 186 ++++++ .../search-bar/query-language/aql.test.tsx | 88 +++ .../search-bar/query-language/aql.tsx | 538 ++++++++++++++++++ .../search-bar/query-language/index.ts | 26 + .../search-bar/query-language/uiql.tsx | 71 +++ .../agent/components/agents-table.js | 90 ++- 7 files changed, 1095 insertions(+), 3 deletions(-) create mode 100644 public/components/search-bar/README.md create mode 100644 public/components/search-bar/index.tsx create mode 100644 public/components/search-bar/query-language/aql.test.tsx create mode 100644 public/components/search-bar/query-language/aql.tsx create mode 100644 public/components/search-bar/query-language/index.ts create mode 100644 public/components/search-bar/query-language/uiql.tsx diff --git a/public/components/search-bar/README.md b/public/components/search-bar/README.md new file mode 100644 index 0000000000..764b1848ba --- /dev/null +++ b/public/components/search-bar/README.md @@ -0,0 +1,99 @@ +# Component + +The `SearchBar` component is a base component of a search bar. + +It is designed to be extensible through the self-contained query language implementations. This means +the behavior of the search bar depends on the business logic of each query language. For example, a +query language can display suggestions according to the user input or prepend some buttons to the search bar. + +It is based on a custom `EuiSuggest` component defined in `public/components/eui-suggest/suggest.js`. So the +abilities are restricted by this one. + +## Features + +- Supports multiple query languages. +- Switch the selected query language. +- Self-contained query language implementation and ability to interact with the search bar component +- React to external changes to set the new input. This enables to change the input from external components. + +# Usage + +Basic usage: + +```tsx + +``` + +# Query languages + +The built-in query languages are: + +- AQL: API Query Language. Based on https://documentation.wazuh.com/current/user-manual/api/queries.html. + +## How to add a new query language + +### Definition + +The language expects to take the interface: + +```ts +type SearchBarQueryLanguage = { + description: string; + documentationLink?: string; + id: string; + label: string; + getConfiguration?: () => any; + run: (input: string | undefined, params: any) => any; + transformUnifiedQuery: (unifiedQuery) => any; +}; +``` + +where: + +- `description`: It is the description of the query language. This is displayed in a query language popover + on the right side of the search bar. Required. +- `documentationLink`: URL to the documentation link. Optional. +- `id`: identification of the query language. +- `label`: name +- `getConfiguration`: method that returns the configuration of the language. This allows custom behavior. +- `run`: method that returns the properties that will used by the base search bar component and the output used when searching + +Create a new file located in `public/components/search-bar/query-language` and define the expected interface; + +### Register + +Go to `public/components/search-bar/query-language/index.ts` and add the new query language: + +```ts +import { AQL } from './aql'; + +// Import the custom query language +import { CustomQL } from './custom'; + +// [...] + +// Register the query languages +export const searchBarQueryLanguages: { + [key: string]: SearchBarQueryLanguage; +} = [ + AQL, + CustomQL, // Add the new custom query language +].reduce((accum, item) => { + if (accum[item.id]) { + throw new Error(`Query language with id: ${item.id} already registered.`); + } + return { + ...accum, + [item.id]: item, + }; +}, {}); +``` diff --git a/public/components/search-bar/index.tsx b/public/components/search-bar/index.tsx new file mode 100644 index 0000000000..5497ac9965 --- /dev/null +++ b/public/components/search-bar/index.tsx @@ -0,0 +1,186 @@ +import React, { useEffect, useState } from 'react'; +import { + EuiButtonEmpty, + EuiLink, + EuiPopover, + EuiSpacer, + EuiSelect, + EuiText, +} from '@elastic/eui'; +import { EuiSuggest } from '../eui-suggest'; +import { searchBarQueryLanguages } from './query-language'; + +type Props = { + defaultMode?: string; + modes: { id: string; [key: string]: any }[]; + onChange?: (params: any) => void; + onSearch: (params: any) => void; + input?: string; +}; + +export const SearchBar = ({ + defaultMode, + modes, + onChange, + onSearch, + ...rest +}: Props) => { + // Query language ID and configuration + const [queryLanguage, setQueryLanguage] = useState<{ + id: string; + configuration: any; + }>({ + id: defaultMode || modes[0].id, + configuration: + searchBarQueryLanguages[ + defaultMode || modes[0].id + ]?.getConfiguration?.() || {}, + }); + // Popover query language is open + const [isOpenPopoverQueryLanguage, setIsOpenPopoverQueryLanguage] = + useState(false); + // Input field + const [input, setInput] = useState(''); + // Query language output of run method + const [queryLanguageOutputRun, setQueryLanguageOutputRun] = useState({ + searchBarProps: { suggestions: [] }, + output: undefined, + }); + // Controls when the suggestion popover is open/close + const [isOpenSuggestionPopover, setIsOpenSuggestionPopover] = + useState(false); + // Reference to the input + const [inputRef, setInputRef] = useState(); + + // Handler when searching + const _onSearch = (output: any) => { + // TODO: fix when searching + inputRef && inputRef.blur(); + setIsOpenSuggestionPopover(false); + onSearch(output); + }; + + // Handler on change the input field text + const onChangeInput = (event: React.ChangeEvent) => + setInput(event.target.value); + + // Handler when pressing a key + const onKeyPressHandler = event => { + if (event.key === 'Enter') { + _onSearch(queryLanguageOutputRun.output); + } + }; + + useEffect(() => { + // React to external changes and set the internal input text. Use the `transformUnifiedQuery` of + // the query language in use + setInput( + searchBarQueryLanguages[queryLanguage.id]?.transformUnifiedQuery?.( + rest.input, + ), + ); + }, [rest.input]); + + useEffect(() => { + (async () => { + // Set the query language output + setQueryLanguageOutputRun( + await searchBarQueryLanguages[queryLanguage.id].run(input, { + onSearch: _onSearch, + setInput, + closeSuggestionPopover: () => setIsOpenSuggestionPopover(false), + openSuggestionPopover: () => setIsOpenSuggestionPopover(true), + queryLanguage: { + configuration: queryLanguage.configuration, + parameters: modes.find(({ id }) => id === queryLanguage.id), + }, + setQueryLanguageConfiguration: (configuration: any) => + setQueryLanguage(state => ({ + ...state, + configuration: + configuration?.(state.configuration) || configuration, + })), + }), + ); + })(); + }, [input, queryLanguage]); + + useEffect(() => { + onChange && onChange(queryLanguageOutputRun.output); + }, [queryLanguageOutputRun.output]); + + const onQueryLanguagePopoverSwitch = () => + setIsOpenPopoverQueryLanguage(state => !state); + + return ( + {}} + isPopoverOpen={ + queryLanguageOutputRun.searchBarProps.suggestions.length > 0 && + isOpenSuggestionPopover + } + onClosePopover={() => setIsOpenSuggestionPopover(false)} + onPopoverFocus={() => setIsOpenSuggestionPopover(true)} + placeholder={'Search'} + append={ + + {searchBarQueryLanguages[queryLanguage.id].label} + + } + isOpen={isOpenPopoverQueryLanguage} + closePopover={onQueryLanguagePopoverSwitch} + > + + {searchBarQueryLanguages[queryLanguage.id].description} + + {searchBarQueryLanguages[queryLanguage.id].documentationLink && ( + <> + +
+ + Documentation + +
+ + )} + {modes?.length && modes.length > 1 && ( + <> + + ({ + value: id, + text: searchBarQueryLanguages[id].label, + }))} + value={queryLanguage.id} + onChange={(event: React.ChangeEvent) => { + const queryLanguageID: string = event.target.value; + setQueryLanguage({ + id: queryLanguageID, + configuration: + searchBarQueryLanguages[ + queryLanguageID + ]?.getConfiguration?.() || {}, + }); + setInput(''); + }} + aria-label='query-language-selector' + /> + + )} +
+ } + {...queryLanguageOutputRun.searchBarProps} + /> + ); +}; diff --git a/public/components/search-bar/query-language/aql.test.tsx b/public/components/search-bar/query-language/aql.test.tsx new file mode 100644 index 0000000000..c2af8efd9d --- /dev/null +++ b/public/components/search-bar/query-language/aql.test.tsx @@ -0,0 +1,88 @@ +import { getSuggestions, tokenizer } from './aql'; + +describe('Query language - AQL', () => { + // Tokenize the input + it.each` + input | tokens + ${''} | ${[]} + ${'f'} | ${[{ type: 'field', value: 'f' }]} + ${'field'} | ${[{ type: 'field', value: 'field' }]} + ${'field.subfield'} | ${[{ type: 'field', value: 'field.subfield' }]} + ${'field='} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }]} + ${'field!='} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '!=' }]} + ${'field>'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }]} + ${'field<'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '<' }]} + ${'field~'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '~' }]} + ${'field=value'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }]} + ${'field=value;'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ';' }]} + ${'field=value,'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ',' }]} + ${'field=value,field2'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ',' }, { type: 'field', value: 'field2' }]} + ${'field=value,field2.subfield'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ',' }, { type: 'field', value: 'field2.subfield' }]} + ${'('} | ${[{ type: 'operator_group', value: '(' }]} + ${'(f'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'f' }]} + ${'(field'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }]} + ${'(field.subfield'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field.subfield' }]} + ${'(field='} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }]} + ${'(field!='} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '!=' }]} + ${'(field>'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }]} + ${'(field<'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '<' }]} + ${'(field~'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '~' }]} + ${'(field=value'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }]} + ${'(field=value,field2=value2)'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ',' }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value2' }, { type: 'operator_group', value: ')' }]} + ${'(field=value;field2=value2)'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ';' }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value2' }, { type: 'operator_group', value: ')' }]} + ${'(field=value;field2=value2),field3=value3'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ';' }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value2' }, { type: 'operator_group', value: ')' }, { type: 'conjunction', value: ',' }, { type: 'field', value: 'field3' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value3' }]} + `('Tokenize the input: $input', ({ input, tokens }) => { + expect(tokenizer({ input })).toEqual(tokens); + }); + + // Get suggestions + it.each` + input | suggestions + ${''} | ${[]} + ${'w'} | ${[]} + ${'f'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }]} + ${'field'} | ${[{ description: 'Field2', label: 'field2', type: 'field' }, { description: 'equality', label: '=', type: 'operator_compare' }, { description: 'not equality', label: '!=', type: 'operator_compare' }, { description: 'bigger', label: '>', type: 'operator_compare' }, { description: 'smaller', label: '<', type: 'operator_compare' }, { description: 'like as', label: '~', type: 'operator_compare' }]} + ${'field='} | ${[{ label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }]} + ${'field=v'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { description: 'Current value', label: 'v', type: 'value' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=value'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { description: 'Current value', label: 'value', type: 'value' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=value;'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }, { description: 'open group', label: '(', type: 'operator_group' }]} + ${'field=value;field2'} | ${[{ description: 'equality', label: '=', type: 'operator_compare' }, { description: 'not equality', label: '!=', type: 'operator_compare' }, { description: 'bigger', label: '>', type: 'operator_compare' }, { description: 'smaller', label: '<', type: 'operator_compare' }, { description: 'like as', label: '~', type: 'operator_compare' }]} + ${'field=value;field2='} | ${[{ label: '127.0.0.1', type: 'value' }, { label: '127.0.0.2', type: 'value' }, { label: '190.0.0.1', type: 'value' }, { label: '190.0.0.2', type: 'value' }]} + ${'field=value;field2=127'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { description: 'Current value', label: '127', type: 'value' }, { label: '127.0.0.1', type: 'value' }, { label: '127.0.0.2', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + `('Get suggestion from the input: $input', async ({ input, suggestions }) => { + expect( + await getSuggestions(tokenizer({ input }), { + id: 'aql', + suggestions: { + field(currentValue) { + return [ + { label: 'field', description: 'Field' }, + { label: 'field2', description: 'Field2' }, + ].map(({ label, description }) => ({ + type: 'field', + label, + description, + })); + }, + value(currentValue = '', { previousField }) { + switch (previousField) { + case 'field': + return ['value', 'value2', 'value3', 'value4'] + .filter(value => value.startsWith(currentValue)) + .map(value => ({ type: 'value', label: value })); + break; + case 'field2': + return ['127.0.0.1', '127.0.0.2', '190.0.0.1', '190.0.0.2'] + .filter(value => value.startsWith(currentValue)) + .map(value => ({ type: 'value', label: value })); + break; + default: + return []; + break; + } + }, + }, + }), + ).toEqual(suggestions); + }); +}); diff --git a/public/components/search-bar/query-language/aql.tsx b/public/components/search-bar/query-language/aql.tsx new file mode 100644 index 0000000000..d96f31ead8 --- /dev/null +++ b/public/components/search-bar/query-language/aql.tsx @@ -0,0 +1,538 @@ +import React from 'react'; +import { EuiButtonEmpty, EuiPopover, EuiText, EuiCode } from '@elastic/eui'; +import { webDocumentationLink } from '../../../../common/services/web_documentation'; + +type ITokenType = + | 'field' + | 'operator_compare' + | 'operator_group' + | 'value' + | 'conjunction'; +type IToken = { type: ITokenType; value: string }; +type ITokens = IToken[]; + +/* API Query Language +https://documentation.wazuh.com/current/user-manual/api/queries.html + +Syntax schema: +??? +*/ + +// Language definition +const language = { + // Tokens + tokens: { + field: { + regex: /[\w.]/, + }, + // eslint-disable-next-line camelcase + operator_compare: { + literal: { + '=': 'equality', + '!=': 'not equality', + '>': 'bigger', + '<': 'smaller', + '~': 'like as', + }, + }, + conjunction: { + literal: { + ';': 'and', + ',': 'or', + }, + }, + // eslint-disable-next-line camelcase + operator_group: { + literal: { + '(': 'open group', + ')': 'close group', + }, + }, + }, +}; + +// Suggestion mapper by language token type +const suggestionMappingLanguageTokenType = { + field: { iconType: 'kqlField', color: 'tint4' }, + // eslint-disable-next-line camelcase + operator_compare: { iconType: 'kqlOperand', color: 'tint1' }, + value: { iconType: 'kqlValue', color: 'tint0' }, + conjunction: { iconType: 'kqlSelector', color: 'tint3' }, + // eslint-disable-next-line camelcase + operator_group: { iconType: 'tokenDenseVector', color: 'tint3' }, + // eslint-disable-next-line camelcase + function_search: { iconType: 'search', color: 'tint5' }, +}; + +type ITokenizerInput = { input: string; output?: ITokens }; + +/** + * Tokenize the input string. Returns an array with the tokens. + * @param param0 + * @returns + */ +export function tokenizer({ input, output = [] }: ITokenizerInput): ITokens { + if (!input) { + return output; + } + const character = input[0]; + + // When there is no tokens, the first expected values are: + if (!output.length) { + // A literal `(` + if (character === '(') { + output.push({ type: 'operator_group', value: '(' }); + } + + // Any character that matches the regex for the field + if (language.tokens.field.regex.test(character)) { + output.push({ type: 'field', value: character }); + } + } else { + // Get the last token + const lastToken = output[output.length - 1]; + + switch (lastToken.type) { + // Token: field + case 'field': { + if ( + Object.keys(language.tokens.operator_compare.literal) + .map(str => str[0]) + .includes(character) + ) { + // If the character is the first character of an operator_compare token, + // add a new token with the input character + output.push({ type: 'operator_compare', value: character }); + } else if ( + Object.keys(language.tokens.operator_compare.literal).includes( + character, + ) + ) { + // If the character matches with an operator_compare token, + // add a new token with the input character + output.push({ type: 'operator_compare', value: character }); + } else if (language.tokens.field.regex.test(character)) { + // If the character matches with a character of field, + // appends the character to the current field token + lastToken.value = lastToken.value + character; + } + break; + } + + // Token: operator_compare + case 'operator_compare': { + if ( + Object.keys(language.tokens.operator_compare.literal) + .map(str => str[lastToken.value.length]) + .includes(character) + ) { + // If the character is included in the operator_compare token, + // appends the character to the current operator_compare token + lastToken.value = lastToken.value + character; + } else { + // If the character is not a operator_compare token, + // add a new value token with the character + output.push({ type: 'value', value: character }); + } + break; + } + + // Token: value + case 'value': { + if ( + Object.keys(language.tokens.conjunction.literal).includes(character) + ) { + // If the character is a conjunction, add a new conjunction token with the character + output.push({ type: 'conjunction', value: character }); + } else if (character === ')') { + // If the character is the ")" literal, then add a new operator_group token + output.push({ type: 'operator_group', value: character }); + } else { + // Else appends the character to the current value token + lastToken.value = lastToken.value + character; + } + break; + } + + // Token: conjunction + case 'conjunction': { + if (character === '(') { + // If the character is the "(" literal, then add a new operator_group token + output.push({ type: 'operator_group', value: character }); + } else if (language.tokens.field.regex.test(character)) { + // If the character matches with a character of field, + // appends the character to the current field token + output.push({ type: 'field', value: character }); + } + break; + } + + // Token: operator_group + case 'operator_group': { + if (lastToken.value === '(') { + // If the character is the "(" literal + if (language.tokens.field.regex.test(character)) { + // If the character matches with a character of field, + // appends the character to the current field token + output.push({ type: 'field', value: character }); + } + } else if (lastToken.value === ')') { + if ( + Object.keys(language.tokens.conjunction.literal).includes(character) + ) { + // If the character is a conjunction, add a new conjunction token with the character + output.push({ type: 'conjunction', value: character }); + } + } + break; + } + + default: + } + } + + // Split the string from the second character + const substring = input.substring(1); + + // Call recursively + return tokenizer({ input: substring, output }, language); +} + +/** + * Check the + * @param tokens + * @returns + */ +function validate(tokens: ITokens): boolean { + // TODO: enhance the validation + return tokens.every( + ({ type }, index) => + type === ['field', 'operator_compare', 'value', 'conjunction'][index % 4], + ); +} + +type OptionSuggestionHandler = ( + currentValue: string | undefined, + { + previousField, + previousOperatorCompare, + }: { previousField: string; previousOperatorCompare: string }, +) => Promise<{ description?: string; label: string; type: string }[]>; + +type optionsQL = { + suggestions: { + field: OptionSuggestionHandler; + value: OptionSuggestionHandler; + }; +}; + +/** + * Get the last token by type + * @param tokens Tokens + * @param tokenType token type to search + * @returns + */ +function getLastTokenByType( + tokens: ITokens, + tokenType: ITokenType, +): IToken | undefined { + // Find the last token by type + // Reverse the tokens array and use the Array.protorype.find method + const shallowCopyArray = Array.from([...tokens]); + const shallowCopyArrayReversed = shallowCopyArray.reverse(); + const tokenFound = shallowCopyArrayReversed.find( + ({ type }) => type === tokenType, + ); + return tokenFound; +} + +/** + * Get the suggestions from the tokens + * @param tokens + * @param language + * @param options + * @returns + */ +export async function getSuggestions(tokens: ITokens, options: optionsQL) { + if (!tokens.length) { + return []; + } + + // Get last token + const lastToken = tokens[tokens.length - 1]; + + switch (lastToken.type) { + case 'field': + return [ + // fields that starts with the input but is not equals + ...(await options.suggestions.field()).filter( + ({ label }) => + label.startsWith(lastToken.value) && label !== lastToken.value, + ), + // operators if the input field is exact + ...((await options.suggestions.field()).some( + ({ label }) => label === lastToken.value, + ) + ? [ + ...Object.keys(language.tokens.operator_compare.literal).map( + operator => ({ + type: 'operator_compare', + label: operator, + description: + language.tokens.operator_compare.literal[operator], + }), + ), + ] + : []), + ]; + break; + case 'operator_compare': + return [ + ...Object.keys(language.tokens.operator_compare.literal) + .filter( + operator => + operator.startsWith(lastToken.value) && + operator !== lastToken.value, + ) + .map(operator => ({ + type: 'operator_compare', + label: operator, + description: language.tokens.operator_compare.literal[operator], + })), + ...(Object.keys(language.tokens.operator_compare.literal).some( + operator => operator === lastToken.value, + ) + ? [ + ...(await options.suggestions.value(undefined, { + previousField: getLastTokenByType(tokens, 'field')!.value, + previousOperatorCompare: getLastTokenByType( + tokens, + 'operator_compare', + )!.value, + })), + ] + : []), + ]; + break; + case 'value': + return [ + ...(lastToken.value + ? [ + { + type: 'function_search', + label: 'Search', + description: 'Run the search query', + }, + ] + : []), + { + type: 'value', + label: lastToken.value, + description: 'Current value', + }, + ...(await options.suggestions.value(lastToken.value, { + previousField: getLastTokenByType(tokens, 'field')!.value, + previousOperatorCompare: getLastTokenByType( + tokens, + 'operator_compare', + )!.value, + })), + ...Object.entries(language.tokens.conjunction.literal).map( + ([conjunction, description]) => ({ + type: 'conjunction', + label: conjunction, + description, + }), + ), + { + type: 'operator_group', + label: ')', + description: language.tokens.operator_group.literal[')'], + }, + ]; + break; + case 'conjunction': + return [ + ...Object.keys(language.tokens.conjunction.literal) + .filter( + conjunction => + conjunction.startsWith(lastToken.value) && + conjunction !== lastToken.value, + ) + .map(conjunction => ({ + type: 'conjunction', + label: conjunction, + description: language.tokens.conjunction.literal[conjunction], + })), + // fields if the input field is exact + ...(Object.keys(language.tokens.conjunction.literal).some( + conjunction => conjunction === lastToken.value, + ) + ? [ + ...(await options.suggestions.field()).map( + ({ label, description }) => ({ + type: 'field', + label, + description, + }), + ), + ] + : []), + { + type: 'operator_group', + label: '(', + description: language.tokens.operator_group.literal['('], + }, + ]; + break; + case 'operator_group': + if (lastToken.value === '(') { + return [ + // fields + ...(await options.suggestions.field()).map( + ({ label, description }) => ({ type: 'field', label, description }), + ), + ]; + } else if (lastToken.value === ')') { + return [ + // conjunction + ...Object.keys(language.tokens.conjunction.literal).map( + conjunction => ({ + type: 'conjunction', + label: conjunction, + description: language.tokens.conjunction.literal[conjunction], + }), + ), + ]; + } + break; + default: + return []; + break; + } + + return []; +} + +/** + * Transform the suggestion object to the expected object by EuiSuggestItem + * @param suggestions + * @param options + * @returns + */ +function transformSuggestionsToUI( + suggestions: { type: string; label: string; description?: string }[], + mapSuggestionByLanguageToken: any, +) { + return suggestions.map(({ type, ...rest }) => ({ + type: { ...mapSuggestionByLanguageToken[type] }, + ...rest, + })); +} + +/** + * Get the output from the input + * @param input + * @returns + */ +function getOutput(input: string, options: {implicitQuery?: string} = {}) { + return { + language: AQL.id, + query: `${options?.implicitQuery ?? ''}${input}`, + }; +}; + +export const AQL = { + id: 'aql', + label: 'AQL', + description: 'API Query Language (AQL) allows to do queries.', + documentationLink: webDocumentationLink('user-manual/api/queries.html'), + getConfiguration() { + return { + isOpenPopoverImplicitFilter: false, + }; + }, + async run(input, params) { + // Get the tokens from the input + const tokens: ITokens = tokenizer({ input }, language); + + return { + searchBarProps: { + // Props that will be used by the EuiSuggest component + // Suggestions + suggestions: transformSuggestionsToUI( + await getSuggestions(tokens, params.queryLanguage.parameters), + suggestionMappingLanguageTokenType, + ), + // Handler to manage when clicking in a suggestion item + onItemClick: item => { + // When the clicked item has the `search` iconType, run the `onSearch` function + if (item.type.iconType === 'search') { + // Execute the search action + params.onSearch(getOutput(input, params.queryLanguage.parameters)); + } else { + // When the clicked item has another iconType + const lastToken: IToken = tokens[tokens.length - 1]; + // if the clicked suggestion is of same type of last token + if ( + suggestionMappingLanguageTokenType[lastToken.type].iconType === + item.type.iconType + ) { + // replace the value of last token + lastToken.value = item.label; + } else { + // add a new token of the selected type and value + tokens.push({ + type: Object.entries(suggestionMappingLanguageTokenType).find( + ([, { iconType }]) => iconType === item.type.iconType, + )[0], + value: item.label, + }); + } + } + // Change the input + params.setInput(tokens.map(({ value }) => value).join('')); + }, + prepend: params.queryLanguage.parameters.implicitQuery ? ( + + params.setQueryLanguageConfiguration(state => ({ + ...state, + isOpenPopoverImplicitFilter: + !state.isOpenPopoverImplicitFilter, + })) + } + iconType='filter' + > + + {params.queryLanguage.parameters.implicitQuery} + + + } + isOpen={ + params.queryLanguage.configuration.isOpenPopoverImplicitFilter + } + closePopover={() => + params.setQueryLanguageConfiguration(state => ({ + ...state, + isOpenPopoverImplicitFilter: false, + })) + } + > + + Implicit query:{' '} + {params.queryLanguage.parameters.implicitQuery} + + This query is added to the input. + + ) : null, + }, + output: getOutput(input, params.queryLanguage.parameters), + }; + }, + transformUnifiedQuery(unifiedQuery) { + return unifiedQuery; + }, +}; diff --git a/public/components/search-bar/query-language/index.ts b/public/components/search-bar/query-language/index.ts new file mode 100644 index 0000000000..1895aae4fb --- /dev/null +++ b/public/components/search-bar/query-language/index.ts @@ -0,0 +1,26 @@ +import { AQL } from './aql'; +import { UIQL } from './uiql'; + +type SearchBarQueryLanguage = { + description: string; + documentationLink?: string; + id: string; + label: string; + getConfiguration?: () => any; + run: (input: string | undefined, params: any) => any; + transformUnifiedQuery: (unifiedQuery) => any; +}; + +// Register the query languages +export const searchBarQueryLanguages: { + [key: string]: SearchBarQueryLanguage; +} = [AQL, UIQL].reduce((accum, item) => { + if (accum[item.id]) { + throw new Error(`Query language with id: ${item.id} already registered.`); + } + return { + ...accum, + [item.id]: item, + }; + ['hola']; +}, {}); diff --git a/public/components/search-bar/query-language/uiql.tsx b/public/components/search-bar/query-language/uiql.tsx new file mode 100644 index 0000000000..97b1927f54 --- /dev/null +++ b/public/components/search-bar/query-language/uiql.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { EuiButtonEmpty, EuiPopover, EuiText, EuiCode } from '@elastic/eui'; + +/* UI Query language +https://documentation.wazuh.com/current/user-manual/api/queries.html + +// Example of another query language definition +*/ + +/** + * Get the output from the input + * @param input + * @returns + */ +function getOutput(input: string, options: {implicitQuery?: string} = {}) { + return { + language: UIQL.id, + query: `${options?.implicitQuery ?? ''}${input}`, + }; +}; + +export const UIQL = { + id: 'uiql', + label: 'UIQL', + description: 'UIQL allows to do queries.', + documentationLink: '', + getConfiguration() { + return { + anotherProp: false, + }; + }, + async run(input, params) { + // Get the tokens from the input + return { + searchBarProps: { + // Props that will be used by the EuiSuggest component + // Suggestions + suggestions: [], + // Handler to manage when clicking in a suggestion item + prepend: params.queryLanguage.parameters.implicitQuery ? ( + + params.setQueryLanguageConfiguration(state => ({ + ...state, + anotherProp: !state.anotherProp, + })) + } + iconType='filter' + > + } + isOpen={params.queryLanguage.configuration.anotherProp} + closePopover={() => + params.setQueryLanguageConfiguration(state => ({ + ...state, + anotherProp: false, + })) + } + > + + Implicit UIQL query:{' '} + {params.queryLanguage.parameters.implicitQuery} + + + ) : null, + }, + output: getOutput(input, params.queryLanguage.parameters), + }; + }, +}; diff --git a/public/controllers/agent/components/agents-table.js b/public/controllers/agent/components/agents-table.js index 55ce4d48d9..ddc7e68195 100644 --- a/public/controllers/agent/components/agents-table.js +++ b/public/controllers/agent/components/agents-table.js @@ -41,6 +41,7 @@ import { UI_ERROR_SEVERITIES } from '../../../react-services/error-orchestrator/ import { getErrorOrchestrator } from '../../../react-services/common-services'; import { AgentStatus } from '../../../components/agents/agent_status'; import { AgentSynced } from '../../../components/agents/agent-synced'; +import { SearchBar } from '../../../components/search-bar'; export const AgentsTable = withErrorBoundary( class AgentsTable extends Component { @@ -611,12 +612,95 @@ export const AgentsTable = withErrorBoundary( noDeleteFiltersOnUpdateSuggests filters={this.state.filters} suggestions={this.suggestions} - onFiltersChange={(filters) => this.setState({ filters, pageIndex: 0 })} - placeholder="Filter or search agent" + onFiltersChange={filters => + this.setState({ filters, pageIndex: 0 }) + } + placeholder='Filter or search agent' + /> + {/** Example implementation */} + ({ type: 'field', ...field })); + }, + value: async (currentValue, { previousField }) => { + switch (previousField) { + case 'status': + try { + const results = await this.props.wzReq( + 'GET', + `/agents/stats/distinct`, + { + params: { + fields: 'status', + limit: 30, + }, + }, + ); + + return results.data.data.affected_items.map( + ({ status }) => ({ + type: 'value', + label: status, + }), + ); + } catch (error) { + console.log({ error }); + return []; + } + break; + case 'ip': + try { + const results = await this.props.wzReq( + 'GET', + `/agents/stats/distinct`, + { + params: { + fields: 'ip', + limit: 30, + }, + }, + ); + + console.log({ results }); + return results.data.data.affected_items.map( + ({ ip }) => ({ type: 'value', label: ip }), + ); + } catch (error) { + console.log({ error }); + return []; + } + break; + default: + return []; + break; + } + }, + }, + }, + { + id: 'uiql', + implicitQuery: 'id!=000;', + }, + ]} + onChange={console.log} + onSearch={console.log} /> - this.reloadAgents()}> + this.reloadAgents()} + > Refresh From eff5d2b3d8181bd12c05d919ac73a3ea4294f2a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Wed, 1 Mar 2023 10:00:44 +0100 Subject: [PATCH 02/33] feat(search-bar): change the AQL implemenation to use the regular expression used in the Wazuh manager API - Change the implementation of AQL query language to use the regular expression decomposition defined in the Wazuh manager API - Adapt the tests for the tokenizer and getting the suggestions - Enchance documentation of search bar - Add documentation of AQL query language - Add more fields and values for the use example in Agents section - Add description to the query language select input --- public/components/search-bar/README.md | 131 ++++++- public/components/search-bar/index.tsx | 43 +- .../search-bar/query-language/aql.md | 178 +++++++++ .../search-bar/query-language/aql.test.tsx | 61 ++- .../search-bar/query-language/aql.tsx | 367 +++++++++--------- .../agent/components/agents-table.js | 168 +++++--- 6 files changed, 669 insertions(+), 279 deletions(-) create mode 100644 public/components/search-bar/query-language/aql.md diff --git a/public/components/search-bar/README.md b/public/components/search-bar/README.md index 764b1848ba..046157a9a0 100644 --- a/public/components/search-bar/README.md +++ b/public/components/search-bar/README.md @@ -13,7 +13,7 @@ abilities are restricted by this one. - Supports multiple query languages. - Switch the selected query language. -- Self-contained query language implementation and ability to interact with the search bar component +- Self-contained query language implementation and ability to interact with the search bar component. - React to external changes to set the new input. This enables to change the input from external components. # Usage @@ -28,6 +28,135 @@ Basic usage: { id: 'aql', // specific query language parameters + implicitQuery: 'id!=000;', + suggestions: { + field(currentValue) { + return [ + { label: 'configSum', description: 'Config sum' }, + { label: 'dateAdd', description: 'Date add' }, + { label: 'id', description: 'ID' }, + { label: 'ip', description: 'IP address' }, + { label: 'group', description: 'Group' }, + { label: 'group_config_status', description: 'Synced configuration status' }, + { label: 'lastKeepAline', description: 'Date add' }, + { label: 'manager', description: 'Manager' }, + { label: 'mergedSum', description: 'Merged sum' }, + { label: 'name', description: 'Agent name' }, + { label: 'node_name', description: 'Node name' }, + { label: 'os.platform', description: 'Operating system platform' }, + { label: 'status', description: 'Status' }, + { label: 'version', description: 'Version' }, + ] + .map(field => ({ type: 'field', ...field })); + }, + value: async (currentValue, { previousField }) => { + switch (previousField) { + case 'configSum': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'dateAdd': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'id': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'ip': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'group': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'group_config_status': + return [AGENT_SYNCED_STATUS.SYNCED, AGENT_SYNCED_STATUS.NOT_SYNCED].map( + (status) => ({ + type: 'value', + label: status, + }), + ); + break; + case 'lastKeepAline': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'manager': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'mergedSum': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'name': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'node_name': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'os.platform': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'status': + return UI_ORDER_AGENT_STATUS.map( + (status) => ({ + type: 'value', + label: status, + }), + ); + break; + case 'version': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + default: + return []; + break; + } + }, + } }, ]} > diff --git a/public/components/search-bar/index.tsx b/public/components/search-bar/index.tsx index 5497ac9965..01a3764e14 100644 --- a/public/components/search-bar/index.tsx +++ b/public/components/search-bar/index.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import { EuiButtonEmpty, + EuiFormRow, EuiLink, EuiPopover, EuiSpacer, @@ -156,26 +157,28 @@ export const SearchBar = ({ {modes?.length && modes.length > 1 && ( <> - ({ - value: id, - text: searchBarQueryLanguages[id].label, - }))} - value={queryLanguage.id} - onChange={(event: React.ChangeEvent) => { - const queryLanguageID: string = event.target.value; - setQueryLanguage({ - id: queryLanguageID, - configuration: - searchBarQueryLanguages[ - queryLanguageID - ]?.getConfiguration?.() || {}, - }); - setInput(''); - }} - aria-label='query-language-selector' - /> + + ({ + value: id, + text: searchBarQueryLanguages[id].label, + }))} + value={queryLanguage.id} + onChange={(event: React.ChangeEvent) => { + const queryLanguageID: string = event.target.value; + setQueryLanguage({ + id: queryLanguageID, + configuration: + searchBarQueryLanguages[ + queryLanguageID + ]?.getConfiguration?.() || {}, + }); + setInput(''); + }} + aria-label='query-language-selector' + /> + )} diff --git a/public/components/search-bar/query-language/aql.md b/public/components/search-bar/query-language/aql.md new file mode 100644 index 0000000000..637e8c9147 --- /dev/null +++ b/public/components/search-bar/query-language/aql.md @@ -0,0 +1,178 @@ +# Query Language - AQL + +AQL (API Query Language) is a query language based in the `q` query parameters of the Wazuh API +endpoints. + +Documentation: https://wazuh.com/./user-manual/api/queries.html + +The implementation is adapted to work with the search bar component defined +`public/components/search-bar/index.tsx`. + +## Features +- Suggestions for `fields` (configurable), `operators` and `values` (configurable) +- Support implicit query + +## Options + +- `implicitQuery`: add an implicit query that is added to the user input. This can't be changed by +the user. If this is defined, will be displayed as a prepend of the search bar. + +```ts +// language options +implicitQuery: 'id!=000;' // ID is not 000 and +``` + +- `suggestions`: define the suggestion handlers. This is required. + + - `field`: method that returns the suggestions for the fields + + ```ts + // language options + field(currentValue) { + return [ + { label: 'configSum', description: 'Config sum' }, + { label: 'dateAdd', description: 'Date add' }, + { label: 'id', description: 'ID' }, + { label: 'ip', description: 'IP address' }, + { label: 'group', description: 'Group' }, + { label: 'group_config_status', description: 'Synced configuration status' }, + { label: 'lastKeepAline', description: 'Date add' }, + { label: 'manager', description: 'Manager' }, + { label: 'mergedSum', description: 'Merged sum' }, + { label: 'name', description: 'Agent name' }, + { label: 'node_name', description: 'Node name' }, + { label: 'os.platform', description: 'Operating system platform' }, + { label: 'status', description: 'Status' }, + { label: 'version', description: 'Version' }, + ] + .map(field => ({ type: 'field', ...field })); + } + ``` + + - `value`: method that returns the suggestion for the values + ```ts + // language options + value: async (currentValue, { previousField }) => { + switch (previousField) { + case 'configSum': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'dateAdd': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'id': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'ip': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'group': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'group_config_status': + return [AGENT_SYNCED_STATUS.SYNCED, AGENT_SYNCED_STATUS.NOT_SYNCED].map( + (status) => ({ + type: 'value', + label: status, + }), + ); + break; + case 'lastKeepAline': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'manager': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'mergedSum': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'name': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'node_name': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'os.platform': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'status': + return UI_ORDER_AGENT_STATUS.map( + (status) => ({ + type: 'value', + label: status, + }), + ); + break; + case 'version': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + default: + return []; + break; + } + } + ``` + +## How to get the suggestions + +```mermaid +graph TD; + user_input[User input]-->tokenize; + subgraph tokenize + tokenize_regex + end + + tokenize-->suggestions[Get suggestions]; + subgraph suggestions[Get suggestions]; + get_last_token_with_value-->get_suggestions[Get suggestions] + end + suggestions-->EuiSuggestItem +``` \ No newline at end of file diff --git a/public/components/search-bar/query-language/aql.test.tsx b/public/components/search-bar/query-language/aql.test.tsx index c2af8efd9d..37c34904bc 100644 --- a/public/components/search-bar/query-language/aql.test.tsx +++ b/public/components/search-bar/query-language/aql.test.tsx @@ -1,38 +1,35 @@ -import { getSuggestions, tokenizer } from './aql'; +import { getSuggestionsAPI, tokenizerAPI, validate } from './aql'; describe('Query language - AQL', () => { // Tokenize the input it.each` - input | tokens - ${''} | ${[]} - ${'f'} | ${[{ type: 'field', value: 'f' }]} - ${'field'} | ${[{ type: 'field', value: 'field' }]} - ${'field.subfield'} | ${[{ type: 'field', value: 'field.subfield' }]} - ${'field='} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }]} - ${'field!='} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '!=' }]} - ${'field>'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }]} - ${'field<'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '<' }]} - ${'field~'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '~' }]} - ${'field=value'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }]} - ${'field=value;'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ';' }]} - ${'field=value,'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ',' }]} - ${'field=value,field2'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ',' }, { type: 'field', value: 'field2' }]} - ${'field=value,field2.subfield'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ',' }, { type: 'field', value: 'field2.subfield' }]} - ${'('} | ${[{ type: 'operator_group', value: '(' }]} - ${'(f'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'f' }]} - ${'(field'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }]} - ${'(field.subfield'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field.subfield' }]} - ${'(field='} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }]} - ${'(field!='} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '!=' }]} - ${'(field>'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }]} - ${'(field<'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '<' }]} - ${'(field~'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '~' }]} - ${'(field=value'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }]} - ${'(field=value,field2=value2)'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ',' }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value2' }, { type: 'operator_group', value: ')' }]} - ${'(field=value;field2=value2)'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ';' }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value2' }, { type: 'operator_group', value: ')' }]} - ${'(field=value;field2=value2),field3=value3'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ';' }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value2' }, { type: 'operator_group', value: ')' }, { type: 'conjunction', value: ',' }, { type: 'field', value: 'field3' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value3' }]} - `('Tokenize the input: $input', ({ input, tokens }) => { - expect(tokenizer({ input })).toEqual(tokens); + input | tokens + ${''} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'f'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'f' }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field='} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field=value'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field=value with spaces'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value with spaces' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field=value with spaces<'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value with spaces<' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field=value with (parenthesis)'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value with (parenthesis)' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field=value;'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field=value;field2'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field=value;field2!='} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '!=' }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field=value;field2!=value2'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '!=' }, { type: 'value', value: 'value2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'('} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2)'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: ')' }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2);'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: ')' }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2;'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2;field2'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2;field2='} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2;field2=value2'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2;field2=value2)'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value2' }, { type: 'operator_group', value: ')' }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2;field2=custom value())'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'custom value()' }, { type: 'operator_group', value: ')' }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + `(`Tokenizer API input $input`, ({input, tokens}) => { + expect(tokenizerAPI(input)).toEqual(tokens); }); // Get suggestions @@ -51,7 +48,7 @@ describe('Query language - AQL', () => { ${'field=value;field2=127'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { description: 'Current value', label: '127', type: 'value' }, { label: '127.0.0.1', type: 'value' }, { label: '127.0.0.2', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} `('Get suggestion from the input: $input', async ({ input, suggestions }) => { expect( - await getSuggestions(tokenizer({ input }), { + await getSuggestionsAPI(tokenizerAPI(input), { id: 'aql', suggestions: { field(currentValue) { diff --git a/public/components/search-bar/query-language/aql.tsx b/public/components/search-bar/query-language/aql.tsx index d96f31ead8..9569b807d0 100644 --- a/public/components/search-bar/query-language/aql.tsx +++ b/public/components/search-bar/query-language/aql.tsx @@ -12,10 +12,18 @@ type IToken = { type: ITokenType; value: string }; type ITokens = IToken[]; /* API Query Language +Define the API Query Language to use in the search bar. +It is based in the language used by the q query parameter. https://documentation.wazuh.com/current/user-manual/api/queries.html -Syntax schema: +Use the regular expression of API with some modifications to allow the decomposition of +input in entities that doesn't compose a valid query. It allows get not-completed queries. + +API schema: ??? + +Implemented schema: +?????? */ // Language definition @@ -64,151 +72,127 @@ const suggestionMappingLanguageTokenType = { function_search: { iconType: 'search', color: 'tint5' }, }; -type ITokenizerInput = { input: string; output?: ITokens }; /** * Tokenize the input string. Returns an array with the tokens. - * @param param0 + * @param input * @returns */ -export function tokenizer({ input, output = [] }: ITokenizerInput): ITokens { - if (!input) { - return output; - } - const character = input[0]; - - // When there is no tokens, the first expected values are: - if (!output.length) { - // A literal `(` - if (character === '(') { - output.push({ type: 'operator_group', value: '(' }); - } - - // Any character that matches the regex for the field - if (language.tokens.field.regex.test(character)) { - output.push({ type: 'field', value: character }); - } - } else { - // Get the last token - const lastToken = output[output.length - 1]; - - switch (lastToken.type) { - // Token: field - case 'field': { - if ( - Object.keys(language.tokens.operator_compare.literal) - .map(str => str[0]) - .includes(character) - ) { - // If the character is the first character of an operator_compare token, - // add a new token with the input character - output.push({ type: 'operator_compare', value: character }); - } else if ( - Object.keys(language.tokens.operator_compare.literal).includes( - character, - ) - ) { - // If the character matches with an operator_compare token, - // add a new token with the input character - output.push({ type: 'operator_compare', value: character }); - } else if (language.tokens.field.regex.test(character)) { - // If the character matches with a character of field, - // appends the character to the current field token - lastToken.value = lastToken.value + character; - } - break; - } - - // Token: operator_compare - case 'operator_compare': { - if ( - Object.keys(language.tokens.operator_compare.literal) - .map(str => str[lastToken.value.length]) - .includes(character) - ) { - // If the character is included in the operator_compare token, - // appends the character to the current operator_compare token - lastToken.value = lastToken.value + character; - } else { - // If the character is not a operator_compare token, - // add a new value token with the character - output.push({ type: 'value', value: character }); - } - break; - } - - // Token: value - case 'value': { - if ( - Object.keys(language.tokens.conjunction.literal).includes(character) - ) { - // If the character is a conjunction, add a new conjunction token with the character - output.push({ type: 'conjunction', value: character }); - } else if (character === ')') { - // If the character is the ")" literal, then add a new operator_group token - output.push({ type: 'operator_group', value: character }); - } else { - // Else appends the character to the current value token - lastToken.value = lastToken.value + character; - } - break; - } +export function tokenizerAPI(input: string): ITokens{ + // API regular expression + // https://github.com/wazuh/wazuh/blob/v4.4.0-rc1/framework/wazuh/core/utils.py#L1242-L1257 + // self.query_regex = re.compile( + // # A ( character. + // r"(\()?" + + // # Field name: name of the field to look on DB. + // r"([\w.]+)" + + // # Operator: looks for '=', '!=', '<', '>' or '~'. + // rf"([{''.join(self.query_operators.keys())}]{{1,2}})" + + // # Value: A string. + // r"((?:(?:\((?:\[[\[\]\w _\-.,:?\\/'\"=@%<>{}]*]|[\[\]\w _\-.:?\\/'\"=@%<>{}]*)\))*" + // r"(?:\[[\[\]\w _\-.,:?\\/'\"=@%<>{}]*]|[\[\]\w _\-.:?\\/'\"=@%<>{}]+)" + // r"(?:\((?:\[[\[\]\w _\-.,:?\\/'\"=@%<>{}]*]|[\[\]\w _\-.:?\\/'\"=@%<>{}]*)\))*)+)" + + // # A ) character. + // r"(\))?" + + // # Separator: looks for ';', ',' or nothing. + // rf"([{''.join(self.query_separators.keys())}])?" + // ) - // Token: conjunction - case 'conjunction': { - if (character === '(') { - // If the character is the "(" literal, then add a new operator_group token - output.push({ type: 'operator_group', value: character }); - } else if (language.tokens.field.regex.test(character)) { - // If the character matches with a character of field, - // appends the character to the current field token - output.push({ type: 'field', value: character }); - } - break; - } - - // Token: operator_group - case 'operator_group': { - if (lastToken.value === '(') { - // If the character is the "(" literal - if (language.tokens.field.regex.test(character)) { - // If the character matches with a character of field, - // appends the character to the current field token - output.push({ type: 'field', value: character }); - } - } else if (lastToken.value === ')') { - if ( - Object.keys(language.tokens.conjunction.literal).includes(character) - ) { - // If the character is a conjunction, add a new conjunction token with the character - output.push({ type: 'conjunction', value: character }); - } - } - break; - } - - default: - } - } - - // Split the string from the second character - const substring = input.substring(1); + const re = new RegExp( + // The following regular expression is based in API one but was modified to use named groups + // and added the optional operator to allow matching the entities when the query is not + // completed. This helps to tokenize the query and manage when the input is not completed. + // A ( character. + '(?\\()?' + + // Field name: name of the field to look on DB. + '(?[\\w.]+)?' + // Added a optional find + // Operator: looks for '=', '!=', '<', '>' or '~'. + // This seems to be a bug because is not searching the literal valid operators. + // I guess the operator is validated after the regular expression matches + `(?[${Object.keys(language.tokens.operator_compare.literal)}]{1,2})?` + // Added a optional find + // Value: A string. + '(?(?:(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\/\'"=@%<>{}]*)\\))*' + + '(?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]+)' + + '(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]*)\\))*)+)?' + // Added a optional find + // A ) character. + '(?\\))?' + + `(?[${Object.keys(language.tokens.conjunction.literal)}])?`, + 'g' + ); - // Call recursively - return tokenizer({ input: substring, output }, language); -} + return [ + ...input.matchAll(re)] + .map( + ({groups}) => Object.entries(groups) + .map(([key, value]) => ({ + type: key.startsWith('operator_group') ? 'operator_group' : key, + value}) + ) + ).flat(); +}; /** - * Check the + * Check if the input is valid * @param tokens * @returns */ -function validate(tokens: ITokens): boolean { +export function validate(input: string, options): boolean { // TODO: enhance the validation - return tokens.every( - ({ type }, index) => - type === ['field', 'operator_compare', 'value', 'conjunction'][index % 4], + + // API regular expression + // self.query_regex = re.compile( + // # A ( character. + // r"(\()?" + + // # Field name: name of the field to look on DB. + // r"([\w.]+)" + + // # Operator: looks for '=', '!=', '<', '>' or '~'. + // rf"([{''.join(self.query_operators.keys())}]{{1,2}})" + + // # Value: A string. + // r"((?:(?:\((?:\[[\[\]\w _\-.,:?\\/'\"=@%<>{}]*]|[\[\]\w _\-.:?\\/'\"=@%<>{}]*)\))*" + // r"(?:\[[\[\]\w _\-.,:?\\/'\"=@%<>{}]*]|[\[\]\w _\-.:?\\/'\"=@%<>{}]+)" + // r"(?:\((?:\[[\[\]\w _\-.,:?\\/'\"=@%<>{}]*]|[\[\]\w _\-.:?\\/'\"=@%<>{}]*)\))*)+)" + + // # A ) character. + // r"(\))?" + + // # Separator: looks for ';', ',' or nothing. + // rf"([{''.join(self.query_separators.keys())}])?" + // ) + + const re = new RegExp( + // A ( character. + '(\\()?' + + // Field name: name of the field to look on DB. + '([\\w.]+)?' + + // Operator: looks for '=', '!=', '<', '>' or '~'. + // This seems to be a bug because is not searching the literal valid operators. + // I guess the operator is validated after the regular expression matches + `([${Object.keys(language.tokens.operator_compare.literal)}]{1,2})?` + + // Value: A string. + '((?:(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\/\'"=@%<>{}]*)\\))*' + + '(?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]+)' + + '(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]*)\\))*)+)?' + + // '([\\w]+)'+ + // A ) character. + '(\\))?' + + `([${Object.keys(language.tokens.conjunction.literal)}])?`, + 'g' ); + + [...input.matchAll(re)].reduce((accum, [_, operator_group_open, field, operator, value, operator_group_close, conjunction ]) => { + if(!accum){ + return accum; + }; + + return [operator_group_open, field, operator, value, operator_group_close, conjunction] + }, true); + + const errors = []; + + for (let [_, operator_group_open, field, operator, value, operator_group_close, conjunction ] in input.matchAll(re)) { + if(!options.fields.includes(field)){ + errors.push(`Field ${field} is not valid.`) + }; + } + return errors.length === 0; } type OptionSuggestionHandler = ( @@ -227,12 +211,30 @@ type optionsQL = { }; /** - * Get the last token by type + * Get the last token with value * @param tokens Tokens * @param tokenType token type to search * @returns */ -function getLastTokenByType( +function getLastTokenWithValue( + tokens: ITokens +): IToken | undefined { + // Reverse the tokens array and use the Array.protorype.find method + const shallowCopyArray = Array.from([...tokens]); + const shallowCopyArrayReversed = shallowCopyArray.reverse(); + const tokenFound = shallowCopyArrayReversed.find( + ({ value }) => value, + ); + return tokenFound; +} + +/** + * Get the last token with value by type + * @param tokens Tokens + * @param tokenType token type to search + * @returns + */ +function getLastTokenWithValueByType( tokens: ITokens, tokenType: ITokenType, ): IToken | undefined { @@ -241,7 +243,7 @@ function getLastTokenByType( const shallowCopyArray = Array.from([...tokens]); const shallowCopyArrayReversed = shallowCopyArray.reverse(); const tokenFound = shallowCopyArrayReversed.find( - ({ type }) => type === tokenType, + ({ type, value }) => type === tokenType && value, ); return tokenFound; } @@ -253,13 +255,18 @@ function getLastTokenByType( * @param options * @returns */ -export async function getSuggestions(tokens: ITokens, options: optionsQL) { +export async function getSuggestionsAPI(tokens: ITokens, options: optionsQL) { if (!tokens.length) { return []; } // Get last token - const lastToken = tokens[tokens.length - 1]; + const lastToken = getLastTokenWithValue(tokens); + + // If it can't get a token with value, then no return suggestions + if(!lastToken?.type){ + return []; + }; switch (lastToken.type) { case 'field': @@ -274,15 +281,15 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL) { ({ label }) => label === lastToken.value, ) ? [ - ...Object.keys(language.tokens.operator_compare.literal).map( - operator => ({ - type: 'operator_compare', - label: operator, - description: - language.tokens.operator_compare.literal[operator], - }), - ), - ] + ...Object.keys(language.tokens.operator_compare.literal).map( + operator => ({ + type: 'operator_compare', + label: operator, + description: + language.tokens.operator_compare.literal[operator], + }), + ), + ] : []), ]; break; @@ -303,14 +310,14 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL) { operator => operator === lastToken.value, ) ? [ - ...(await options.suggestions.value(undefined, { - previousField: getLastTokenByType(tokens, 'field')!.value, - previousOperatorCompare: getLastTokenByType( - tokens, - 'operator_compare', - )!.value, - })), - ] + ...(await options.suggestions.value(undefined, { + previousField: getLastTokenWithValueByType(tokens, 'field')!.value, + previousOperatorCompare: getLastTokenWithValueByType( + tokens, + 'operator_compare', + )!.value, + })), + ] : []), ]; break; @@ -318,27 +325,22 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL) { return [ ...(lastToken.value ? [ - { - type: 'function_search', - label: 'Search', - description: 'Run the search query', - }, - ] + { + type: 'function_search', + label: 'Search', + description: 'Run the search query', + }, + ] : []), - { - type: 'value', - label: lastToken.value, - description: 'Current value', - }, ...(await options.suggestions.value(lastToken.value, { - previousField: getLastTokenByType(tokens, 'field')!.value, - previousOperatorCompare: getLastTokenByType( + previousField: getLastTokenWithValueByType(tokens, 'field')!.value, + previousOperatorCompare: getLastTokenWithValueByType( tokens, 'operator_compare', )!.value, })), ...Object.entries(language.tokens.conjunction.literal).map( - ([conjunction, description]) => ({ + ([ conjunction, description]) => ({ type: 'conjunction', label: conjunction, description, @@ -369,14 +371,14 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL) { conjunction => conjunction === lastToken.value, ) ? [ - ...(await options.suggestions.field()).map( - ({ label, description }) => ({ - type: 'field', - label, - description, - }), - ), - ] + ...(await options.suggestions.field()).map( + ({ label, description }) => ({ + type: 'field', + label, + description, + }), + ), + ] : []), { type: 'operator_group', @@ -454,14 +456,14 @@ export const AQL = { }, async run(input, params) { // Get the tokens from the input - const tokens: ITokens = tokenizer({ input }, language); + const tokens: ITokens = tokenizerAPI(input); return { searchBarProps: { // Props that will be used by the EuiSuggest component // Suggestions suggestions: transformSuggestionsToUI( - await getSuggestions(tokens, params.queryLanguage.parameters), + await getSuggestionsAPI(tokens, params.queryLanguage.parameters), suggestionMappingLanguageTokenType, ), // Handler to manage when clicking in a suggestion item @@ -472,7 +474,7 @@ export const AQL = { params.onSearch(getOutput(input, params.queryLanguage.parameters)); } else { // When the clicked item has another iconType - const lastToken: IToken = tokens[tokens.length - 1]; + const lastToken: IToken = getLastTokenWithValue(tokens); // if the clicked suggestion is of same type of last token if ( suggestionMappingLanguageTokenType[lastToken.type].iconType === @@ -491,7 +493,12 @@ export const AQL = { } } // Change the input - params.setInput(tokens.map(({ value }) => value).join('')); + params.setInput(tokens + .filter(value => value) // Ensure the input is rebuilt using tokens with value. + // The input tokenization can contain tokens with no value due to the used + // regular expression. + .map(({ value }) => value) + .join('')); }, prepend: params.queryLanguage.parameters.implicitQuery ? ( ({ type: 'field', ...field })); + { label: 'group', description: 'Group' }, + { label: 'group_config_status', description: 'Synced configuration status' }, + { label: 'lastKeepAline', description: 'Date add' }, + { label: 'manager', description: 'Manager' }, + { label: 'mergedSum', description: 'Merged sum' }, + { label: 'name', description: 'Agent name' }, + { label: 'node_name', description: 'Node name' }, + { label: 'os.platform', description: 'Operating system platform' }, + { label: 'status', description: 'Status' }, + { label: 'version', description: 'Version' }, + ] + .map(field => ({ type: 'field', ...field })); }, value: async (currentValue, { previousField }) => { switch (previousField) { - case 'status': - try { - const results = await this.props.wzReq( - 'GET', - `/agents/stats/distinct`, - { - params: { - fields: 'status', - limit: 30, - }, - }, - ); - - return results.data.data.affected_items.map( - ({ status }) => ({ - type: 'value', - label: status, - }), - ); - } catch (error) { - console.log({ error }); - return []; - } + case 'configSum': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'dateAdd': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'id': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); break; case 'ip': - try { - const results = await this.props.wzReq( - 'GET', - `/agents/stats/distinct`, - { - params: { - fields: 'ip', - limit: 30, - }, - }, - ); - - console.log({ results }); - return results.data.data.affected_items.map( - ({ ip }) => ({ type: 'value', label: ip }), - ); - } catch (error) { - console.log({ error }); - return []; - } + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'group': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'group_config_status': + return [AGENT_SYNCED_STATUS.SYNCED, AGENT_SYNCED_STATUS.NOT_SYNCED].map( + (status) => ({ + type: 'value', + label: status, + }), + ); + break; + case 'lastKeepAline': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'manager': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'mergedSum': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'name': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'node_name': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'os.platform': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'status': + return UI_ORDER_AGENT_STATUS.map( + (status) => ({ + type: 'value', + label: status, + }), + ); + break; + case 'version': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); break; default: return []; @@ -879,3 +946,12 @@ AgentsTable.propTypes = { timeService: PropTypes.func, reload: PropTypes.func, }; + + +const getAgentFilterValuesMapToSearchBarSuggestion = async (key, value, params) => { + try{ + return (await getAgentFilterValues(key, value, params)).map(label => ({type: 'value', label})); + }catch(error){ + return []; + }; +}; From 0923569685105ba124bd05ce7037a8719ca774f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Thu, 2 Mar 2023 16:48:24 +0100 Subject: [PATCH 03/33] fix(search-bar): fixes a problem hidding the suggestion popover when using the Search suggestion in AQL - Fixes a problem hidding the suggestion popover when using the Search suggestion in AQL - Fixes a problem of input text with undefined value - Minor fixes - Remove `syntax` property of SearchBar component - Add disableFocusTrap property to the custom EuiSuggestInput component to be forwarded to the EuiInputPopover - Replace the inputRef by a reference instead of a state and pass as a parameter in the query language run function - Move the rebuiding of input text when using some suggestion that changes the input to be done when a related suggestion was clicked instead of any suggestion (exclude Search). --- .../components/eui-suggest/suggest_input.js | 2 ++ public/components/search-bar/index.tsx | 20 ++++++++--------- .../search-bar/query-language/aql.tsx | 22 ++++++++++++------- .../agent/components/agents-table.js | 1 - 4 files changed, 26 insertions(+), 19 deletions(-) diff --git a/public/components/eui-suggest/suggest_input.js b/public/components/eui-suggest/suggest_input.js index 55393ba820..f67149d489 100644 --- a/public/components/eui-suggest/suggest_input.js +++ b/public/components/eui-suggest/suggest_input.js @@ -53,6 +53,7 @@ export class EuiSuggestInput extends Component { onPopoverFocus, isPopoverOpen, onClosePopover, + disableFocusTrap, ...rest } = this.props; @@ -108,6 +109,7 @@ export class EuiSuggestInput extends Component { panelPaddingSize="none" fullWidth closePopover={onClosePopover} + disableFocusTrap={disableFocusTrap} >
{suggestions}
diff --git a/public/components/search-bar/index.tsx b/public/components/search-bar/index.tsx index 01a3764e14..25a11c0be4 100644 --- a/public/components/search-bar/index.tsx +++ b/public/components/search-bar/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { EuiButtonEmpty, EuiFormRow, @@ -51,14 +51,13 @@ export const SearchBar = ({ const [isOpenSuggestionPopover, setIsOpenSuggestionPopover] = useState(false); // Reference to the input - const [inputRef, setInputRef] = useState(); + const inputRef = useRef(); // Handler when searching const _onSearch = (output: any) => { // TODO: fix when searching - inputRef && inputRef.blur(); - setIsOpenSuggestionPopover(false); onSearch(output); + setIsOpenSuggestionPopover(false); }; // Handler on change the input field text @@ -75,7 +74,7 @@ export const SearchBar = ({ useEffect(() => { // React to external changes and set the internal input text. Use the `transformUnifiedQuery` of // the query language in use - setInput( + rest.input && setInput( searchBarQueryLanguages[queryLanguage.id]?.transformUnifiedQuery?.( rest.input, ), @@ -91,16 +90,17 @@ export const SearchBar = ({ setInput, closeSuggestionPopover: () => setIsOpenSuggestionPopover(false), openSuggestionPopover: () => setIsOpenSuggestionPopover(true), - queryLanguage: { - configuration: queryLanguage.configuration, - parameters: modes.find(({ id }) => id === queryLanguage.id), - }, setQueryLanguageConfiguration: (configuration: any) => setQueryLanguage(state => ({ ...state, configuration: configuration?.(state.configuration) || configuration, })), + inputRef, + queryLanguage: { + configuration: queryLanguage.configuration, + parameters: modes.find(({ id }) => id === queryLanguage.id), + }, }), ); })(); @@ -115,7 +115,7 @@ export const SearchBar = ({ return ( value) // Ensure the input is rebuilt using tokens with value. + // The input tokenization can contain tokens with no value due to the used + // regular expression. + .map(({ value }) => value) + .join('')); } - // Change the input - params.setInput(tokens - .filter(value => value) // Ensure the input is rebuilt using tokens with value. - // The input tokenization can contain tokens with no value due to the used - // regular expression. - .map(({ value }) => value) - .join('')); }, prepend: params.queryLanguage.parameters.implicitQuery ? ( This query is added to the input. ) : null, + // Disable the focus trap in the EuiInputPopover. + // This causes when using the Search suggestion, the suggestion popover can be closed. + // If this is disabled, then the suggestion popover is open after a short time for this + // use case. + disableFocusTrap: true }, output: getOutput(input, params.queryLanguage.parameters), }; diff --git a/public/controllers/agent/components/agents-table.js b/public/controllers/agent/components/agents-table.js index 5f3d01a2b5..7d01d14d7a 100644 --- a/public/controllers/agent/components/agents-table.js +++ b/public/controllers/agent/components/agents-table.js @@ -619,7 +619,6 @@ export const AgentsTable = withErrorBoundary( /> {/** Example implementation */} Date: Fri, 3 Mar 2023 15:45:53 +0100 Subject: [PATCH 04/33] feat(search-bar): add the ability to update the input of example implemenation - Add the ability to update the input of the search bar in the example implementation - Enhance the component documentation --- public/components/search-bar/README.md | 9 +++++- .../agent/components/agents-table.js | 28 +++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/public/components/search-bar/README.md b/public/components/search-bar/README.md index 046157a9a0..7729aa8d70 100644 --- a/public/components/search-bar/README.md +++ b/public/components/search-bar/README.md @@ -22,7 +22,7 @@ Basic usage: ```tsx ``` diff --git a/public/controllers/agent/components/agents-table.js b/public/controllers/agent/components/agents-table.js index 7d01d14d7a..1fef9c9eb5 100644 --- a/public/controllers/agent/components/agents-table.js +++ b/public/controllers/agent/components/agents-table.js @@ -63,6 +63,7 @@ export const AgentsTable = withErrorBoundary( filters: sessionStorage.getItem('agents_preview_selected_options') ? JSON.parse(sessionStorage.getItem('agents_preview_selected_options')) : [], + query: '' }; this.suggestions = [ { @@ -210,7 +211,7 @@ export const AgentsTable = withErrorBoundary( this.props.filters && this.props.filters.length ) { - this.setState({ filters: this.props.filters, pageIndex: 0 }); + this.setState({ filters: this.props.filters, pageIndex: 0, query: this.props.filters.find(({field}) => field === 'q')?.value || '' }); this.props.removeFilters(); } } @@ -619,6 +620,7 @@ export const AgentsTable = withErrorBoundary( /> {/** Example implementation */} { + try{ + this.setState({isLoading: true}); + const response = await this.props.wzReq('GET', '/agents', { params: { + limit: this.state.pageSize, + offset: 0, + q: query, + sort: this.buildSortFilter() + }}); + + const formatedAgents = response?.data?.data?.affected_items?.map( + this.formatAgent.bind(this) + ); + + this._isMount && this.setState({ + agents: formatedAgents, + totalItems: response?.data?.data?.total_affected_items, + isLoading: false, + }); + }catch(error){ + this.setState({isLoading: false}); + }; + }} /> From 0b0b4519668483c5783d7a6b9503db820dc2c782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Mon, 6 Mar 2023 10:21:24 +0100 Subject: [PATCH 05/33] feat(search-bar): add initial suggestions to AQL - (AQL) Add the fields and an open operator group when there is no input text --- .../components/search-bar/query-language/aql.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/public/components/search-bar/query-language/aql.tsx b/public/components/search-bar/query-language/aql.tsx index 0d240a0482..41902517e1 100644 --- a/public/components/search-bar/query-language/aql.tsx +++ b/public/components/search-bar/query-language/aql.tsx @@ -263,9 +263,17 @@ export async function getSuggestionsAPI(tokens: ITokens, options: optionsQL) { // Get last token const lastToken = getLastTokenWithValue(tokens); - // If it can't get a token with value, then no return suggestions + // If it can't get a token with value, then returns fields and open operator group if(!lastToken?.type){ - return []; + return [ + // fields + ...(await options.suggestions.field()), + { + type: 'operator_group', + label: '(', + description: language.tokens.operator_group.literal['('], + } + ]; }; switch (lastToken.type) { @@ -477,7 +485,7 @@ export const AQL = { const lastToken: IToken = getLastTokenWithValue(tokens); // if the clicked suggestion is of same type of last token if ( - suggestionMappingLanguageTokenType[lastToken.type].iconType === + lastToken && suggestionMappingLanguageTokenType[lastToken.type].iconType === item.type.iconType ) { // replace the value of last token From ab8a555c0342700acd55a4d1077a182312a86a20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Tue, 7 Mar 2023 16:39:50 +0100 Subject: [PATCH 06/33] feat(search-bar): add target and rel attributes to the documentation link of query language displayed in the popover --- public/components/search-bar/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/public/components/search-bar/index.tsx b/public/components/search-bar/index.tsx index 25a11c0be4..8b0ebcd1b7 100644 --- a/public/components/search-bar/index.tsx +++ b/public/components/search-bar/index.tsx @@ -148,13 +148,15 @@ export const SearchBar = ({ href={ searchBarQueryLanguages[queryLanguage.id].documentationLink } + target='__blank' + rel='noopener noreferrer' > Documentation )} - {modes?.length && modes.length > 1 && ( + {modes?.length > 1 && ( <> From 8d9f7461155478a3c284117ffb286399808f3582 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Tue, 7 Mar 2023 16:43:54 +0100 Subject: [PATCH 07/33] feat(search-bar): enhancements in AQL and search bar documentation - AQL enhancements: - documentation: - Enhance some descriptions - Enhance input processing - Remove intermetiate interface of EuiSuggestItem - Remove the intermediate interface of EuiSuggestItem. Now it is managed in the internal of query language instead of be built by the suggestion handler - Display suggestions when the input text is empty - Add the unifiedQuery field to the query language output - Adapt tests - Search Bar component: - Enhance documentation --- public/components/search-bar/README.md | 56 +++++++- .../search-bar/query-language/aql.md | 54 +++++--- .../search-bar/query-language/aql.test.tsx | 8 +- .../search-bar/query-language/aql.tsx | 130 +++++------------- 4 files changed, 128 insertions(+), 120 deletions(-) diff --git a/public/components/search-bar/README.md b/public/components/search-bar/README.md index 7729aa8d70..df007a4ea0 100644 --- a/public/components/search-bar/README.md +++ b/public/components/search-bar/README.md @@ -28,6 +28,10 @@ Basic usage: { id: 'aql', // specific query language parameters + // implicit query. Optional + // Set a implicit query that can't be changed by the user. + // Use the UQL (Unified Query Language) syntax. + // Each query language implementation must interpret implicitQuery: 'id!=000;', suggestions: { field(currentValue) { @@ -46,8 +50,7 @@ Basic usage: { label: 'os.platform', description: 'Operating system platform' }, { label: 'status', description: 'Status' }, { label: 'version', description: 'Version' }, - ] - .map(field => ({ type: 'field', ...field })); + ]; }, value: async (currentValue, { previousField }) => { switch (previousField) { @@ -165,7 +168,10 @@ Basic usage: onSearch={onSearch} // Used to define the internal input. Optional. // This could be used to change the input text from the external components. - input="" + // Use the UQL (Unified Query Language) syntax. + input="" + // Define the default mode. Optional. If not defined, it will use the first one mode. + defaultMode="" > ``` @@ -188,20 +194,37 @@ type SearchBarQueryLanguage = { id: string; label: string; getConfiguration?: () => any; - run: (input: string | undefined, params: any) => any; - transformUnifiedQuery: (unifiedQuery) => any; + run: (input: string | undefined, params: any) => Promise<{ + searchBarProps: any, + output: { + language: string, + unifiedQuery: string, + query: string + } + }>; + transformUnifiedQuery: (unifiedQuery: string) => string; }; ``` where: -- `description`: It is the description of the query language. This is displayed in a query language popover +- `description`: is the description of the query language. This is displayed in a query language popover on the right side of the search bar. Required. - `documentationLink`: URL to the documentation link. Optional. - `id`: identification of the query language. - `label`: name - `getConfiguration`: method that returns the configuration of the language. This allows custom behavior. -- `run`: method that returns the properties that will used by the base search bar component and the output used when searching +- `run`: method that returns: + - `searchBarProps`: properties to be passed to the search bar component. This allows the + customization the properties that will used by the base search bar component and the output used when searching + - `output`: + - `language`: query language ID + - `unifiedQuery`: query in unified query syntax + - `query`: current query in the specified language +- `transformUnifiedQuery`: method that transform the Unified Query Language to the specific query + language. This is used when receives a external input in the Unified Query Language, the returned + value is converted to the specific query language to set the new input text of the search bar + component. Create a new file located in `public/components/search-bar/query-language` and define the expected interface; @@ -233,3 +256,22 @@ export const searchBarQueryLanguages: { }; }, {}); ``` + +## Unified Query Language - UQL + +This is an unified syntax used by the search bar component that provides a way to communicate +with the different query language implementations. + +The input and output parameters of the search bar component must use this syntax. + +This is used in: +- input: + - `input` component property +- output: + - `onChange` component handler + - `onSearch` component handler + +Its syntax is equal to Wazuh API Query Language +https://wazuh.com/./user-manual/api/queries.html + +> The AQL query language is a implementation of this syntax. \ No newline at end of file diff --git a/public/components/search-bar/query-language/aql.md b/public/components/search-bar/query-language/aql.md index 637e8c9147..af0d2ccee9 100644 --- a/public/components/search-bar/query-language/aql.md +++ b/public/components/search-bar/query-language/aql.md @@ -12,14 +12,20 @@ The implementation is adapted to work with the search bar component defined - Suggestions for `fields` (configurable), `operators` and `values` (configurable) - Support implicit query +## Language syntax + +Documentation: https://wazuh.com/./user-manual/api/queries.html + ## Options -- `implicitQuery`: add an implicit query that is added to the user input. This can't be changed by -the user. If this is defined, will be displayed as a prepend of the search bar. +- `implicitQuery`: add an implicit query that is added to the user input. Optional. +Use UQL (Unified Query Language). +This can't be changed by the user. If this is defined, will be displayed as a prepend of the search bar. ```ts // language options -implicitQuery: 'id!=000;' // ID is not 000 and +// ID is not equal to 000 and . This is defined in UQL that is transformed internally to the specific query language. +implicitQuery: 'id!=000;' ``` - `suggestions`: define the suggestion handlers. This is required. @@ -44,8 +50,7 @@ implicitQuery: 'id!=000;' // ID is not 000 and { label: 'os.platform', description: 'Operating system platform' }, { label: 'status', description: 'Status' }, { label: 'version', description: 'Version' }, - ] - .map(field => ({ type: 'field', ...field })); + ]; } ``` @@ -161,18 +166,35 @@ implicitQuery: 'id!=000;' // ID is not 000 and } ``` -## How to get the suggestions +## Language workflow ```mermaid graph TD; - user_input[User input]-->tokenize; - subgraph tokenize - tokenize_regex - end - - tokenize-->suggestions[Get suggestions]; - subgraph suggestions[Get suggestions]; - get_last_token_with_value-->get_suggestions[Get suggestions] - end - suggestions-->EuiSuggestItem + user_input[User input]-->tokenizerAPI; + subgraph tokenizerAPI + tokenize_regex[Wazuh API `q` regular expression] + end + + tokenizerAPI-->tokens; + + tokens-->searchBarProps; + subgraph searchBarProps; + searchBarProps_suggestions-->searchBarProps_suggestions_get_last_token_with_value[Get last token with value] + searchBarProps_suggestions_get_last_token_with_value[Get last token with value]-->searchBarProps_suggestions__result[Suggestions] + searchBarProps_suggestions__result[Suggestions]-->EuiSuggestItem + searchBarProps_prepend[prepend]-->searchBarProps_prepend_implicitQuery{implicitQuery} + searchBarProps_prepend_implicitQuery{implicitQuery}-->searchBarProps_prepend_implicitQuery_yes[Yes]-->EuiButton + searchBarProps_prepend_implicitQuery{implicitQuery}-->searchBarProps_prepend_implicitQuery_no[No]-->null + searchBarProps_disableFocusTrap:true[disableFocusTrap = true] + searchBarProps_onItemClick[onItemClickSuggestion onclick handler]-->searchBarProps_onItemClick_suggestion_search[Search suggestion] + searchBarProps_onItemClick[onItemClickSuggestion onclick handler]-->searchBarProps_onItemClick_suggestion_edit_current_token[Edit current token]-->searchBarProps_onItemClick_build_input[Build input] + searchBarProps_onItemClick[onItemClickSuggestion onclick handler]-->searchBarProps_onItemClick_suggestion_add_new_token[Add new token]-->searchBarProps_onItemClick_build_input[Build input] + end + + tokens-->output; + subgraph output[output]; + output_result[implicitFilter + user input] + end + + output-->output_search_bar[Output] ``` \ No newline at end of file diff --git a/public/components/search-bar/query-language/aql.test.tsx b/public/components/search-bar/query-language/aql.test.tsx index 37c34904bc..a165ef5b75 100644 --- a/public/components/search-bar/query-language/aql.test.tsx +++ b/public/components/search-bar/query-language/aql.test.tsx @@ -35,17 +35,17 @@ describe('Query language - AQL', () => { // Get suggestions it.each` input | suggestions - ${''} | ${[]} + ${''} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }, { description: 'open group', label: '(', type: 'operator_group' }]} ${'w'} | ${[]} ${'f'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }]} ${'field'} | ${[{ description: 'Field2', label: 'field2', type: 'field' }, { description: 'equality', label: '=', type: 'operator_compare' }, { description: 'not equality', label: '!=', type: 'operator_compare' }, { description: 'bigger', label: '>', type: 'operator_compare' }, { description: 'smaller', label: '<', type: 'operator_compare' }, { description: 'like as', label: '~', type: 'operator_compare' }]} ${'field='} | ${[{ label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }]} - ${'field=v'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { description: 'Current value', label: 'v', type: 'value' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} - ${'field=value'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { description: 'Current value', label: 'value', type: 'value' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=v'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=value'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} ${'field=value;'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }, { description: 'open group', label: '(', type: 'operator_group' }]} ${'field=value;field2'} | ${[{ description: 'equality', label: '=', type: 'operator_compare' }, { description: 'not equality', label: '!=', type: 'operator_compare' }, { description: 'bigger', label: '>', type: 'operator_compare' }, { description: 'smaller', label: '<', type: 'operator_compare' }, { description: 'like as', label: '~', type: 'operator_compare' }]} ${'field=value;field2='} | ${[{ label: '127.0.0.1', type: 'value' }, { label: '127.0.0.2', type: 'value' }, { label: '190.0.0.1', type: 'value' }, { label: '190.0.0.2', type: 'value' }]} - ${'field=value;field2=127'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { description: 'Current value', label: '127', type: 'value' }, { label: '127.0.0.1', type: 'value' }, { label: '127.0.0.2', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=value;field2=127'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { label: '127.0.0.1', type: 'value' }, { label: '127.0.0.2', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} `('Get suggestion from the input: $input', async ({ input, suggestions }) => { expect( await getSuggestionsAPI(tokenizerAPI(input), { diff --git a/public/components/search-bar/query-language/aql.tsx b/public/components/search-bar/query-language/aql.tsx index 41902517e1..22d678844f 100644 --- a/public/components/search-bar/query-language/aql.tsx +++ b/public/components/search-bar/query-language/aql.tsx @@ -27,12 +27,9 @@ Implemented schema: */ // Language definition -const language = { +export const language = { // Tokens tokens: { - field: { - regex: /[\w.]/, - }, // eslint-disable-next-line camelcase operator_compare: { literal: { @@ -72,6 +69,23 @@ const suggestionMappingLanguageTokenType = { function_search: { iconType: 'search', color: 'tint5' }, }; +/** + * Creator of intermediate interfacte of EuiSuggestItem + * @param type + * @returns + */ +function mapSuggestionCreator(type: ITokenType ){ + return function({...params}){ + return { + type, + ...params + }; + }; +}; + +const mapSuggestionCreatorField = mapSuggestionCreator('field'); +const mapSuggestionCreatorValue = mapSuggestionCreator('value'); + /** * Tokenize the input string. Returns an array with the tokens. @@ -105,15 +119,15 @@ export function tokenizerAPI(input: string): ITokens{ // A ( character. '(?\\()?' + // Field name: name of the field to look on DB. - '(?[\\w.]+)?' + // Added a optional find + '(?[\\w.]+)?' + // Added an optional find // Operator: looks for '=', '!=', '<', '>' or '~'. // This seems to be a bug because is not searching the literal valid operators. // I guess the operator is validated after the regular expression matches - `(?[${Object.keys(language.tokens.operator_compare.literal)}]{1,2})?` + // Added a optional find + `(?[${Object.keys(language.tokens.operator_compare.literal)}]{1,2})?` + // Added an optional find // Value: A string. '(?(?:(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\/\'"=@%<>{}]*)\\))*' + '(?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]+)' + - '(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]*)\\))*)+)?' + // Added a optional find + '(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]*)\\))*)+)?' + // Added an optional find // A ) character. '(?\\))?' + `(?[${Object.keys(language.tokens.conjunction.literal)}])?`, @@ -124,84 +138,20 @@ export function tokenizerAPI(input: string): ITokens{ ...input.matchAll(re)] .map( ({groups}) => Object.entries(groups) - .map(([key, value]) => ({ - type: key.startsWith('operator_group') ? 'operator_group' : key, - value}) - ) + .map(([key, value]) => ({ + type: key.startsWith('operator_group') ? 'operator_group' : key, + value}) + ) ).flat(); }; -/** - * Check if the input is valid - * @param tokens - * @returns - */ -export function validate(input: string, options): boolean { - // TODO: enhance the validation - - // API regular expression - // self.query_regex = re.compile( - // # A ( character. - // r"(\()?" + - // # Field name: name of the field to look on DB. - // r"([\w.]+)" + - // # Operator: looks for '=', '!=', '<', '>' or '~'. - // rf"([{''.join(self.query_operators.keys())}]{{1,2}})" + - // # Value: A string. - // r"((?:(?:\((?:\[[\[\]\w _\-.,:?\\/'\"=@%<>{}]*]|[\[\]\w _\-.:?\\/'\"=@%<>{}]*)\))*" - // r"(?:\[[\[\]\w _\-.,:?\\/'\"=@%<>{}]*]|[\[\]\w _\-.:?\\/'\"=@%<>{}]+)" - // r"(?:\((?:\[[\[\]\w _\-.,:?\\/'\"=@%<>{}]*]|[\[\]\w _\-.:?\\/'\"=@%<>{}]*)\))*)+)" + - // # A ) character. - // r"(\))?" + - // # Separator: looks for ';', ',' or nothing. - // rf"([{''.join(self.query_separators.keys())}])?" - // ) - - const re = new RegExp( - // A ( character. - '(\\()?' + - // Field name: name of the field to look on DB. - '([\\w.]+)?' + - // Operator: looks for '=', '!=', '<', '>' or '~'. - // This seems to be a bug because is not searching the literal valid operators. - // I guess the operator is validated after the regular expression matches - `([${Object.keys(language.tokens.operator_compare.literal)}]{1,2})?` + - // Value: A string. - '((?:(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\/\'"=@%<>{}]*)\\))*' + - '(?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]+)' + - '(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]*)\\))*)+)?' + - // '([\\w]+)'+ - // A ) character. - '(\\))?' + - `([${Object.keys(language.tokens.conjunction.literal)}])?`, - 'g' - ); - - [...input.matchAll(re)].reduce((accum, [_, operator_group_open, field, operator, value, operator_group_close, conjunction ]) => { - if(!accum){ - return accum; - }; - - return [operator_group_open, field, operator, value, operator_group_close, conjunction] - }, true); - - const errors = []; - - for (let [_, operator_group_open, field, operator, value, operator_group_close, conjunction ] in input.matchAll(re)) { - if(!options.fields.includes(field)){ - errors.push(`Field ${field} is not valid.`) - }; - } - return errors.length === 0; -} - type OptionSuggestionHandler = ( currentValue: string | undefined, { previousField, previousOperatorCompare, }: { previousField: string; previousOperatorCompare: string }, -) => Promise<{ description?: string; label: string; type: string }[]>; +) => Promise<{ description?: string; label: string }[]>; type optionsQL = { suggestions: { @@ -267,7 +217,7 @@ export async function getSuggestionsAPI(tokens: ITokens, options: optionsQL) { if(!lastToken?.type){ return [ // fields - ...(await options.suggestions.field()), + ...(await options.suggestions.field()).map(mapSuggestionCreatorField), { type: 'operator_group', label: '(', @@ -283,7 +233,7 @@ export async function getSuggestionsAPI(tokens: ITokens, options: optionsQL) { ...(await options.suggestions.field()).filter( ({ label }) => label.startsWith(lastToken.value) && label !== lastToken.value, - ), + ).map(mapSuggestionCreatorField), // operators if the input field is exact ...((await options.suggestions.field()).some( ({ label }) => label === lastToken.value, @@ -324,7 +274,7 @@ export async function getSuggestionsAPI(tokens: ITokens, options: optionsQL) { tokens, 'operator_compare', )!.value, - })), + })).map(mapSuggestionCreatorValue), ] : []), ]; @@ -346,7 +296,7 @@ export async function getSuggestionsAPI(tokens: ITokens, options: optionsQL) { tokens, 'operator_compare', )!.value, - })), + })).map(mapSuggestionCreatorValue), ...Object.entries(language.tokens.conjunction.literal).map( ([ conjunction, description]) => ({ type: 'conjunction', @@ -379,13 +329,7 @@ export async function getSuggestionsAPI(tokens: ITokens, options: optionsQL) { conjunction => conjunction === lastToken.value, ) ? [ - ...(await options.suggestions.field()).map( - ({ label, description }) => ({ - type: 'field', - label, - description, - }), - ), + ...(await options.suggestions.field()).map(mapSuggestionCreatorField), ] : []), { @@ -399,9 +343,7 @@ export async function getSuggestionsAPI(tokens: ITokens, options: optionsQL) { if (lastToken.value === '(') { return [ // fields - ...(await options.suggestions.field()).map( - ({ label, description }) => ({ type: 'field', label, description }), - ), + ...(await options.suggestions.field()).map(mapSuggestionCreatorField), ]; } else if (lastToken.value === ')') { return [ @@ -446,9 +388,11 @@ function transformSuggestionsToUI( * @returns */ function getOutput(input: string, options: {implicitQuery?: string} = {}) { + const unifiedQuery = `${options?.implicitQuery ?? ''}${input}`; return { language: AQL.id, - query: `${options?.implicitQuery ?? ''}${input}`, + query: unifiedQuery, + unifiedQuery }; }; @@ -502,7 +446,7 @@ export const AQL = { // Change the input params.setInput(tokens - .filter(value => value) // Ensure the input is rebuilt using tokens with value. + .filter(({ value }) => value) // Ensure the input is rebuilt using tokens with value. // The input tokenization can contain tokens with no value due to the used // regular expression. .map(({ value }) => value) @@ -553,7 +497,7 @@ export const AQL = { output: getOutput(input, params.queryLanguage.parameters), }; }, - transformUnifiedQuery(unifiedQuery) { + transformUnifiedQuery(unifiedQuery: string): string { return unifiedQuery; }, }; From 9c01c8e02c144e0c9e6632fd1ac1c5bdfc3d4312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Tue, 7 Mar 2023 16:50:58 +0100 Subject: [PATCH 08/33] feat(search-bar): Add HAQL - Remove UIQL - Add HAQL query language that is a high-level implementation of AQL - Add the query language interface - Add tests for tokenizer, get suggestions and transformSpecificQLToUnifiedQL method - Add documentation about the language - Syntax - Options - Workflow --- .../search-bar/query-language/haql.md | 279 +++++++++ .../search-bar/query-language/haql.test.tsx | 139 +++++ .../search-bar/query-language/haql.tsx | 572 ++++++++++++++++++ .../search-bar/query-language/index.ts | 16 +- .../search-bar/query-language/uiql.tsx | 71 --- .../agent/components/agents-table.js | 136 ++++- 6 files changed, 1133 insertions(+), 80 deletions(-) create mode 100644 public/components/search-bar/query-language/haql.md create mode 100644 public/components/search-bar/query-language/haql.test.tsx create mode 100644 public/components/search-bar/query-language/haql.tsx delete mode 100644 public/components/search-bar/query-language/uiql.tsx diff --git a/public/components/search-bar/query-language/haql.md b/public/components/search-bar/query-language/haql.md new file mode 100644 index 0000000000..cf1d2b1166 --- /dev/null +++ b/public/components/search-bar/query-language/haql.md @@ -0,0 +1,279 @@ +# Query Language - HAQL + +HAQL (Human API Query Language) is a query language based in the `q` query parameters of the Wazuh API +endpoints. + +Documentation: https://wazuh.com/./user-manual/api/queries.html + +The implementation is adapted to work with the search bar component defined +`public/components/search-bar/index.tsx`. + +## Features +- Suggestions for `fields` (configurable), `operators` and `values` (configurable) +- Support implicit query + +## Language syntax + +### Schema + +``` +???????????? +``` + +### Fields + +Regular expression: /[\\w.]+/ + +Examples: + +``` +field +field.custom +``` + +### Operators + +#### Compare + +- `=` equal to +- `!=` not equal to +- `>` bigger +- `<` smaller +- `~` like + +#### Group + +- `(` open +- `)` close + +#### Logical + +- `and` +- `or` + +### Values + +- Value without spaces can be literal +- Value with spaces should be wrapped by `"`. The `"` can be escaped using `\"`. + +Examples: +``` +value +"custom value" +"custom \" value" +``` + +### Notes + +- The entities can be separated by whitespaces. + +### Examples + +- Simple query + +``` +id=001 +id = 001 +``` + +- Complex query +``` +status=active and os.platform~linux +status = active and os.platform ~ linux +``` + +``` +status!=never_connected and ip~240 or os.platform~linux +status != never_connected and ip ~ 240 or os.platform ~ linux +``` + +- Complex query with group operator +``` +(status!=never_connected and ip~240) or id=001 +( status != never_connected and ip ~ 240 ) or id = 001 +``` + +## Options + +- `implicitQuery`: add an implicit query that is added to the user input. Optional. +Use UQL (Unified Query Language). +This can't be changed by the user. If this is defined, will be displayed as a prepend of the search bar. + +```ts +// language options +// ID is not equal to 000 and . This is defined in UQL that is transformed internally to +// the specific query language. +implicitQuery: 'id!=000;' +``` + +- `suggestions`: define the suggestion handlers. This is required. + + - `field`: method that returns the suggestions for the fields + + ```ts + // language options + field(currentValue) { + return [ + { label: 'configSum', description: 'Config sum' }, + { label: 'dateAdd', description: 'Date add' }, + { label: 'id', description: 'ID' }, + { label: 'ip', description: 'IP address' }, + { label: 'group', description: 'Group' }, + { label: 'group_config_status', description: 'Synced configuration status' }, + { label: 'lastKeepAline', description: 'Date add' }, + { label: 'manager', description: 'Manager' }, + { label: 'mergedSum', description: 'Merged sum' }, + { label: 'name', description: 'Agent name' }, + { label: 'node_name', description: 'Node name' }, + { label: 'os.platform', description: 'Operating system platform' }, + { label: 'status', description: 'Status' }, + { label: 'version', description: 'Version' }, + ] + .map(field => ({ type: 'field', ...field })); + } + ``` + + - `value`: method that returns the suggestion for the values + ```ts + // language options + value: async (currentValue, { previousField }) => { + switch (previousField) { + case 'configSum': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'dateAdd': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'id': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'ip': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'group': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'group_config_status': + return [AGENT_SYNCED_STATUS.SYNCED, AGENT_SYNCED_STATUS.NOT_SYNCED].map( + (status) => ({ + type: 'value', + label: status, + }), + ); + break; + case 'lastKeepAline': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'manager': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'mergedSum': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'name': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'node_name': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'os.platform': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'status': + return UI_ORDER_AGENT_STATUS.map( + (status) => ({ + type: 'value', + label: status, + }), + ); + break; + case 'version': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + default: + return []; + break; + } + } + ``` + +## Language workflow + +```mermaid +graph TD; + user_input[User input]-->tokenizer; + subgraph tokenizer + tokenize_regex[Query language regular expression] + end + + tokenizer-->tokens; + + tokens-->searchBarProps; + subgraph searchBarProps; + searchBarProps_suggestions-->searchBarProps_suggestions_get_last_token_with_value[Get last token with value] + searchBarProps_suggestions_get_last_token_with_value[Get last token with value]-->searchBarProps_suggestions__result[Suggestions] + searchBarProps_suggestions__result[Suggestions]-->EuiSuggestItem + searchBarProps_prepend[prepend]-->searchBarProps_prepend_implicitQuery{implicitQuery} + searchBarProps_prepend_implicitQuery{implicitQuery}-->searchBarProps_prepend_implicitQuery_yes[Yes]-->EuiButton + searchBarProps_prepend_implicitQuery{implicitQuery}-->searchBarProps_prepend_implicitQuery_no[No]-->null + searchBarProps_disableFocusTrap:true[disableFocusTrap = true] + searchBarProps_onItemClick[onItemClickSuggestion onclick handler]-->searchBarProps_onItemClick_suggestion_search[Search suggestion] + searchBarProps_onItemClick[onItemClickSuggestion onclick handler]-->searchBarProps_onItemClick_suggestion_edit_current_token[Edit current token]-->searchBarProps_onItemClick_build_input[Build input] + searchBarProps_onItemClick[onItemClickSuggestion onclick handler]-->searchBarProps_onItemClick_suggestion_add_new_token[Add new token]-->searchBarProps_onItemClick_build_input[Build input] + end + + tokens-->output; + subgraph output[output]; + output_result[implicitFilter + user input] + end + + output-->output_search_bar[Output] +``` \ No newline at end of file diff --git a/public/components/search-bar/query-language/haql.test.tsx b/public/components/search-bar/query-language/haql.test.tsx new file mode 100644 index 0000000000..7193bc8239 --- /dev/null +++ b/public/components/search-bar/query-language/haql.test.tsx @@ -0,0 +1,139 @@ +import { getSuggestions, tokenizer, transformSpecificQLToUnifiedQL } from './haql'; + +describe('Query language - HAQL', () => { + // Tokenize the input + function tokenCreator({type, value}){ + return {type, value}; + }; + + const t = { + opGroup: (value = undefined) => tokenCreator({type: 'operator_group', value}), + opCompare: (value = undefined) => tokenCreator({type: 'operator_compare', value}), + field: (value = undefined) => tokenCreator({type: 'field', value}), + value: (value = undefined) => tokenCreator({type: 'value', value}), + whitespace: (value = undefined) => tokenCreator({type: 'whitespace', value}), + conjunction: (value = undefined) => tokenCreator({type: 'conjunction', value}) + }; + + // Token undefined + const tu = { + opGroup: tokenCreator({type: 'operator_group', value: undefined}), + opCompare: tokenCreator({type: 'operator_compare', value: undefined}), + whitespace: tokenCreator({type: 'whitespace', value: undefined}), + field: tokenCreator({type: 'field', value: undefined}), + value: tokenCreator({type: 'value', value: undefined}), + conjunction: tokenCreator({type: 'conjunction', value: undefined}) + }; + + const tuBlankSerie = [ + tu.opGroup, + tu.whitespace, + tu.field, + tu.whitespace, + tu.opCompare, + tu.whitespace, + tu.value, + tu.whitespace, + tu.opGroup, + tu.whitespace, + tu.conjunction, + tu.whitespace + ]; + + + it.each` + input | tokens + ${''} | ${tuBlankSerie} + ${'f'} | ${[tu.opGroup, tu.whitespace, t.field('f'), tu.whitespace, tu.opCompare, tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, tu.opCompare, tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field='} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value and'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), tu.whitespace, ...tuBlankSerie]} + ${'field=value and '} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), ...tuBlankSerie]} + ${'field=value and field2'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, tu.opCompare, tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value and field2!='} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value and field2!="'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, t.value('"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value and field2!="value'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, t.value('"value'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value and field2!="value"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, t.value('"value"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value and field2!=value2 and'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, t.value('value2'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), tu.whitespace, ...tuBlankSerie]} + `(`Tokenizer API input $input`, ({input, tokens}) => { + expect(tokenizer(input)).toEqual(tokens); + }); + + // Get suggestions + it.each` + input | suggestions + ${''} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }, { description: 'open group', label: '(', type: 'operator_group' }]} + ${'w'} | ${[]} + ${'f'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }]} + ${'field'} | ${[{ description: 'Field2', label: 'field2', type: 'field' }, { description: 'equality', label: '=', type: 'operator_compare' }, { description: 'not equality', label: '!=', type: 'operator_compare' }, { description: 'bigger', label: '>', type: 'operator_compare' }, { description: 'smaller', label: '<', type: 'operator_compare' }, { description: 'like as', label: '~', type: 'operator_compare' }]} + ${'field='} | ${[{ label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }]} + ${'field=v'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: 'and', type: 'conjunction' }, { description: 'or', label: 'or', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=value'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: 'and', type: 'conjunction' }, { description: 'or', label: 'or', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=value and'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }, { description: 'open group', label: '(', type: 'operator_group' }]} + `('Get suggestion from the input: $input', async ({ input, suggestions }) => { + expect( + await getSuggestions(tokenizer(input), { + id: 'aql', + suggestions: { + field(currentValue) { + return [ + { label: 'field', description: 'Field' }, + { label: 'field2', description: 'Field2' }, + ].map(({ label, description }) => ({ + type: 'field', + label, + description, + })); + }, + value(currentValue = '', { previousField }) { + switch (previousField) { + case 'field': + return ['value', 'value2', 'value3', 'value4'] + .filter(value => value.startsWith(currentValue)) + .map(value => ({ type: 'value', label: value })); + break; + case 'field2': + return ['127.0.0.1', '127.0.0.2', '190.0.0.1', '190.0.0.2'] + .filter(value => value.startsWith(currentValue)) + .map(value => ({ type: 'value', label: value })); + break; + default: + return []; + break; + } + }, + }, + }), + ).toEqual(suggestions); + }); + + it.each` + HAQL | UQL + ${'field'} | ${'field'} + ${'field='} | ${'field='} + ${'field=value'} | ${'field=value'} + ${'field=value()'} | ${'field=value()'} + ${'field="custom value"'} | ${'field=custom value'} + ${'field="custom \\"value"'} | ${'field=custom "value'} + ${'field="custom \\"value\\""'} | ${'field=custom "value"'} + ${'field=value and'} | ${'field=value;'} + ${'field="custom value" and'} | ${'field=custom value;'} + ${'(field=value'} | ${'(field=value'} + ${'(field=value)'} | ${'(field=value)'} + ${'(field=value) and'} | ${'(field=value);'} + ${'(field=value) and field2'} | ${'(field=value);field2'} + ${'(field=value) and field2>'} | ${'(field=value);field2>'} + ${'(field=value) and field2>"wrappedcommas"'} | ${'(field=value);field2>wrappedcommas'} + ${'(field=value) and field2>"value with spaces"'} | ${'(field=value);field2>value with spaces'} + ${'field ='} | ${'field='} + ${'field = value'} | ${'field=value'} + ${'field = value or'} | ${'field=value,'} + ${'field = value or field2'} | ${'field=value,field2'} + ${'field = value or field2 <'} | ${'field=value,field2<'} + ${'field = value or field2 < value2'} | ${'field=value,field2 "custom value" '} | ${'(field=value);field2>custom value'} + `('transformSpecificQLToUnifiedQL - HAQL $HAQL', ({HAQL, UQL}) => { + expect(transformSpecificQLToUnifiedQL(HAQL)).toEqual(UQL); + }) +}); diff --git a/public/components/search-bar/query-language/haql.tsx b/public/components/search-bar/query-language/haql.tsx new file mode 100644 index 0000000000..bf45cc9afc --- /dev/null +++ b/public/components/search-bar/query-language/haql.tsx @@ -0,0 +1,572 @@ +import React from 'react'; +import { EuiButtonEmpty, EuiPopover, EuiText, EuiCode } from '@elastic/eui'; +import { tokenizerAPI as tokenizerUQL } from './aql'; +import { pluginPlatform } from '../../../../package.json'; + +/* UI Query language +https://documentation.wazuh.com/current/user-manual/api/queries.html + +// Example of another query language definition +*/ + +type ITokenType = + | 'field' + | 'operator_compare' + | 'operator_group' + | 'value' + | 'conjunction' + | 'whitespace'; +type IToken = { type: ITokenType; value: string }; +type ITokens = IToken[]; + +/* API Query Language +Define the API Query Language to use in the search bar. +It is based in the language used by the q query parameter. +https://documentation.wazuh.com/current/user-manual/api/queries.html + +Use the regular expression of API with some modifications to allow the decomposition of +input in entities that doesn't compose a valid query. It allows get not-completed queries. + +API schema: +??? + +Implemented schema: +???????????? +*/ + +// Language definition +const language = { + // Tokens + tokens: { + field: { + regex: /[\w.]/, + }, + // eslint-disable-next-line camelcase + operator_compare: { + literal: { + '=': 'equality', + '!=': 'not equality', + '>': 'bigger', + '<': 'smaller', + '~': 'like as', + }, + }, + conjunction: { + literal: { + 'and': 'and', + 'or': 'or', + }, + }, + // eslint-disable-next-line camelcase + operator_group: { + literal: { + '(': 'open group', + ')': 'close group', + }, + }, + }, +}; + +// Suggestion mapper by language token type +const suggestionMappingLanguageTokenType = { + field: { iconType: 'kqlField', color: 'tint4' }, + // eslint-disable-next-line camelcase + operator_compare: { iconType: 'kqlOperand', color: 'tint1' }, + value: { iconType: 'kqlValue', color: 'tint0' }, + conjunction: { iconType: 'kqlSelector', color: 'tint3' }, + // eslint-disable-next-line camelcase + operator_group: { iconType: 'tokenDenseVector', color: 'tint3' }, + // eslint-disable-next-line camelcase + function_search: { iconType: 'search', color: 'tint5' }, +}; + + +/** + * Tokenize the input string. Returns an array with the tokens. + * @param input + * @returns + */ +export function tokenizer(input: string): ITokens{ + const re = new RegExp( + // A ( character. + '(?\\()?' + + // Whitespace + '(?\\s+)?' + + // Field name: name of the field to look on DB. + '(?[\\w.]+)?' + // Added an optional find + // Whitespace + '(?\\s+)?' + + // Operator: looks for '=', '!=', '<', '>' or '~'. + // This seems to be a bug because is not searching the literal valid operators. + // I guess the operator is validated after the regular expression matches + `(?[${Object.keys(language.tokens.operator_compare.literal)}]{1,2})?` + // Added an optional find + // Whitespace + '(?\\s+)?' + + // Value: A string. + // Simple value + // Quoted ", "value, "value", "escaped \"quote" + // Escape quoted string with escaping quotes: https://stackoverflow.com/questions/249791/regex-for-quoted-string-with-escaping-quotes + // '(?(?:(?:[^"\\s]+|(?:"(?:[^"\\\\]|\\\\")*"))))?' + + '(?(?:(?:[^"\\s]+|(?:"(?:[^"\\\\]|\\\\")*")|(?:"(?:[^"\\\\]|\\\\")*)|")))?' + + // Whitespace + '(?\\s+)?' + + // A ) character. + '(?\\))?' + + // Whitespace + '(?\\s+)?' + + `(?${Object.keys(language.tokens.conjunction.literal).join('|')})?` + + // Whitespace + '(?\\s+)?', + 'g' + ); + + return [ + ...input.matchAll(re)] + .map( + ({groups}) => Object.entries(groups) + .map(([key, value]) => ({ + type: key.startsWith('operator_group') // Transform operator_group group match + ? 'operator_group' + : (key.startsWith('whitespace') // Transform whitespace group match + ? 'whitespace' + : key), + value}) + ) + ).flat(); +}; + +type OptionSuggestionHandler = ( + currentValue: string | undefined, + { + previousField, + previousOperatorCompare, + }: { previousField: string; previousOperatorCompare: string }, +) => Promise<{ description?: string; label: string; type: string }[]>; + +type optionsQL = { + suggestions: { + field: OptionSuggestionHandler; + value: OptionSuggestionHandler; + }; +}; + +/** + * Get the last token with value + * @param tokens Tokens + * @param tokenType token type to search + * @returns + */ +function getLastTokenWithValue( + tokens: ITokens +): IToken | undefined { + // Reverse the tokens array and use the Array.protorype.find method + const shallowCopyArray = Array.from([...tokens]); + const shallowCopyArrayReversed = shallowCopyArray.reverse(); + const tokenFound = shallowCopyArrayReversed.find( + ({ type, value }) => type !== 'whitespace' && value, + ); + return tokenFound; +} + +/** + * Get the last token with value by type + * @param tokens Tokens + * @param tokenType token type to search + * @returns + */ +function getLastTokenWithValueByType( + tokens: ITokens, + tokenType: ITokenType, +): IToken | undefined { + // Find the last token by type + // Reverse the tokens array and use the Array.protorype.find method + const shallowCopyArray = Array.from([...tokens]); + const shallowCopyArrayReversed = shallowCopyArray.reverse(); + const tokenFound = shallowCopyArrayReversed.find( + ({ type, value }) => type === tokenType && value, + ); + return tokenFound; +} + +/** + * Get the suggestions from the tokens + * @param tokens + * @param language + * @param options + * @returns + */ +export async function getSuggestions(tokens: ITokens, options: optionsQL) { + if (!tokens.length) { + return []; + } + + // Get last token + const lastToken = getLastTokenWithValue(tokens); + + // If it can't get a token with value, then returns fields and open operator group + if(!lastToken?.type){ + return [ + // fields + ...(await options.suggestions.field()), + { + type: 'operator_group', + label: '(', + description: language.tokens.operator_group.literal['('], + } + ]; + }; + + switch (lastToken.type) { + case 'field': + return [ + // fields that starts with the input but is not equals + ...(await options.suggestions.field()).filter( + ({ label }) => + label.startsWith(lastToken.value) && label !== lastToken.value, + ), + // operators if the input field is exact + ...((await options.suggestions.field()).some( + ({ label }) => label === lastToken.value, + ) + ? [ + ...Object.keys(language.tokens.operator_compare.literal).map( + operator => ({ + type: 'operator_compare', + label: operator, + description: + language.tokens.operator_compare.literal[operator], + }), + ), + ] + : []), + ]; + break; + case 'operator_compare': + return [ + ...Object.keys(language.tokens.operator_compare.literal) + .filter( + operator => + operator.startsWith(lastToken.value) && + operator !== lastToken.value, + ) + .map(operator => ({ + type: 'operator_compare', + label: operator, + description: language.tokens.operator_compare.literal[operator], + })), + ...(Object.keys(language.tokens.operator_compare.literal).some( + operator => operator === lastToken.value, + ) + ? [ + ...(await options.suggestions.value(undefined, { + previousField: getLastTokenWithValueByType(tokens, 'field')!.value, + previousOperatorCompare: getLastTokenWithValueByType( + tokens, + 'operator_compare', + )!.value, + })), + ] + : []), + ]; + break; + case 'value': + return [ + ...(lastToken.value + ? [ + { + type: 'function_search', + label: 'Search', + description: 'Run the search query', + }, + ] + : []), + ...(await options.suggestions.value(lastToken.value, { + previousField: getLastTokenWithValueByType(tokens, 'field')!.value, + previousOperatorCompare: getLastTokenWithValueByType( + tokens, + 'operator_compare', + )!.value, + })), + ...Object.entries(language.tokens.conjunction.literal).map( + ([ conjunction, description]) => ({ + type: 'conjunction', + label: conjunction, + description, + }), + ), + { + type: 'operator_group', + label: ')', + description: language.tokens.operator_group.literal[')'], + }, + ]; + break; + case 'conjunction': + return [ + ...Object.keys(language.tokens.conjunction.literal) + .filter( + conjunction => + conjunction.startsWith(lastToken.value) && + conjunction !== lastToken.value, + ) + .map(conjunction => ({ + type: 'conjunction', + label: conjunction, + description: language.tokens.conjunction.literal[conjunction], + })), + // fields if the input field is exact + ...(Object.keys(language.tokens.conjunction.literal).some( + conjunction => conjunction === lastToken.value, + ) + ? [ + ...(await options.suggestions.field()).map( + ({ label, description }) => ({ + type: 'field', + label, + description, + }), + ), + ] + : []), + { + type: 'operator_group', + label: '(', + description: language.tokens.operator_group.literal['('], + }, + ]; + break; + case 'operator_group': + if (lastToken.value === '(') { + return [ + // fields + ...(await options.suggestions.field()).map( + ({ label, description }) => ({ type: 'field', label, description }), + ), + ]; + } else if (lastToken.value === ')') { + return [ + // conjunction + ...Object.keys(language.tokens.conjunction.literal).map( + conjunction => ({ + type: 'conjunction', + label: conjunction, + description: language.tokens.conjunction.literal[conjunction], + }), + ), + ]; + } + break; + default: + return []; + break; + } + + return []; +} + +/** + * Transform the suggestion object to the expected object by EuiSuggestItem + * @param suggestions + * @param options + * @returns + */ +function transformSuggestionsToUI( + suggestions: { type: string; label: string; description?: string }[], + mapSuggestionByLanguageToken: any, +) { + return suggestions.map(({ type, ...rest }) => ({ + type: { ...mapSuggestionByLanguageToken[type] }, + ...rest, + })); +}; + +/** + * Transform the UQL (Unified Query Language) to SpecificQueryLanguage + * @param input + * @returns + */ +export function transformUnifiedQueryToSpecificQueryLanguage(input: string){ + const tokens = tokenizerUQL(input); + return tokens + .filter(({value}) => value) + .map(({type, value}) => type === 'conjunction' + ? value === ';' + ? ' and ' + : ' or ' + : value + ).join(''); +}; + +/** + * Transform the input in SpecificQueryLanguage to UQL (Unified Query Language) + * @param input + * @returns + */ +export function transformSpecificQLToUnifiedQL(input: string){ + const tokens = tokenizer(input); + return tokens + .filter(({type, value}) => type !== 'whitespace' && value) + .map(({type, value}) => { + switch (type) { + case 'value':{ + // Value is wrapped with " + let [ _, extractedValue ] = value.match(/^"(.+)"$/) || [ null, null ]; + // Replace the escape commas (\") by comma (") + extractedValue && (extractedValue = extractedValue.replace(/\\"/g, '"')); + return extractedValue || value; + break; + } + case 'conjunction': + return value === 'and' + ? ';' + : ','; + break; + default: + return value; + break; + } + } + ).join(''); +}; + +/** + * Get the output from the input + * @param input + * @returns + */ +function getOutput(input: string, options: {implicitQuery?: string} = {}) { + const query = `${transformUnifiedQueryToSpecificQueryLanguage(options?.implicitQuery ?? '')}${input}`; + return { + language: HAQL.id, + unifiedQuery: transformSpecificQLToUnifiedQL(query), + query + }; +}; + +export const HAQL = { + id: 'haql', + label: 'HAQL', + description: 'HAQL allows to do queries.', + documentationLink: `https://github.com/wazuh/wazuh-kibana-app/blob/v${pluginPlatform.version.split('.').splice(0,2).join('.')}/public/components/search-bar/query-language/haql.md`, + getConfiguration() { + return { + isOpenPopoverImplicitFilter: false, + }; + }, + async run(input, params) { + // Get the tokens from the input + const tokens: ITokens = tokenizer(input); + + // + const implicitQueryAsSpecificQueryLanguage = params.queryLanguage.parameters.implicitQuery + ? transformUnifiedQueryToSpecificQueryLanguage(params.queryLanguage.parameters.implicitQuery) + : ''; + + return { + searchBarProps: { + // Props that will be used by the EuiSuggest component + // Suggestions + suggestions: transformSuggestionsToUI( + await getSuggestions(tokens, params.queryLanguage.parameters), + suggestionMappingLanguageTokenType, + ), + // Handler to manage when clicking in a suggestion item + onItemClick: item => { + // When the clicked item has the `search` iconType, run the `onSearch` function + if (item.type.iconType === 'search') { + // Execute the search action + params.onSearch(getOutput(input, params.queryLanguage.parameters)); + } else { + // When the clicked item has another iconType + const lastToken: IToken | undefined = getLastTokenWithValue(tokens); + // if the clicked suggestion is of same type of last token + if ( + lastToken && suggestionMappingLanguageTokenType[lastToken.type].iconType === + item.type.iconType + ) { + // replace the value of last token + lastToken.value = item.label; + } else { + // add a whitespace for conjunction + input.length + && !(/\s$/.test(input)) + && item.type.iconType === suggestionMappingLanguageTokenType.conjunction.iconType + && tokens.push({ + type: 'whitespace', + value: ' ' + }); + + // add a new token of the selected type and value + tokens.push({ + type: Object.entries(suggestionMappingLanguageTokenType).find( + ([, { iconType }]) => iconType === item.type.iconType, + )[0], + value: item.type.iconType === suggestionMappingLanguageTokenType.value.iconType + && /\s/.test(item.label) + ? `"${item.label}"` + : item.label, + }); + + // add a whitespace for conjunction + item.type.iconType === suggestionMappingLanguageTokenType.conjunction.iconType + && tokens.push({ + type: 'whitespace', + value: ' ' + }); + }; + + // Change the input + params.setInput(tokens + .filter(value => value) // Ensure the input is rebuilt using tokens with value. + // The input tokenization can contain tokens with no value due to the used + // regular expression. + .map(({ value }) => value) + .join('')); + } + }, + prepend: implicitQueryAsSpecificQueryLanguage ? ( + + params.setQueryLanguageConfiguration(state => ({ + ...state, + isOpenPopoverImplicitFilter: + !state.isOpenPopoverImplicitFilter, + })) + } + iconType='filter' + > + + {implicitQueryAsSpecificQueryLanguage} + + + } + isOpen={ + params.queryLanguage.configuration.isOpenPopoverImplicitFilter + } + closePopover={() => + params.setQueryLanguageConfiguration(state => ({ + ...state, + isOpenPopoverImplicitFilter: false, + })) + } + > + + Implicit query:{' '} + {implicitQueryAsSpecificQueryLanguage} + + This query is added to the input. + + ) : null, + // Disable the focus trap in the EuiInputPopover. + // This causes when using the Search suggestion, the suggestion popover can be closed. + // If this is disabled, then the suggestion popover is open after a short time for this + // use case. + disableFocusTrap: true + }, + output: getOutput(input, params.queryLanguage.parameters), + }; + }, + transformUnifiedQuery: transformUnifiedQueryToSpecificQueryLanguage, +}; diff --git a/public/components/search-bar/query-language/index.ts b/public/components/search-bar/query-language/index.ts index 1895aae4fb..22608fd9f5 100644 --- a/public/components/search-bar/query-language/index.ts +++ b/public/components/search-bar/query-language/index.ts @@ -1,5 +1,5 @@ import { AQL } from './aql'; -import { UIQL } from './uiql'; +import { HAQL } from './haql'; type SearchBarQueryLanguage = { description: string; @@ -7,14 +7,21 @@ type SearchBarQueryLanguage = { id: string; label: string; getConfiguration?: () => any; - run: (input: string | undefined, params: any) => any; - transformUnifiedQuery: (unifiedQuery) => any; + run: (input: string | undefined, params: any) => Promise<{ + searchBarProps: any, + output: { + language: string, + unifiedQuery: string, + query: string + } + }>; + transformUnifiedQuery: (unifiedQuery: string) => string; }; // Register the query languages export const searchBarQueryLanguages: { [key: string]: SearchBarQueryLanguage; -} = [AQL, UIQL].reduce((accum, item) => { +} = [AQL, HAQL].reduce((accum, item) => { if (accum[item.id]) { throw new Error(`Query language with id: ${item.id} already registered.`); } @@ -22,5 +29,4 @@ export const searchBarQueryLanguages: { ...accum, [item.id]: item, }; - ['hola']; }, {}); diff --git a/public/components/search-bar/query-language/uiql.tsx b/public/components/search-bar/query-language/uiql.tsx deleted file mode 100644 index 97b1927f54..0000000000 --- a/public/components/search-bar/query-language/uiql.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React from 'react'; -import { EuiButtonEmpty, EuiPopover, EuiText, EuiCode } from '@elastic/eui'; - -/* UI Query language -https://documentation.wazuh.com/current/user-manual/api/queries.html - -// Example of another query language definition -*/ - -/** - * Get the output from the input - * @param input - * @returns - */ -function getOutput(input: string, options: {implicitQuery?: string} = {}) { - return { - language: UIQL.id, - query: `${options?.implicitQuery ?? ''}${input}`, - }; -}; - -export const UIQL = { - id: 'uiql', - label: 'UIQL', - description: 'UIQL allows to do queries.', - documentationLink: '', - getConfiguration() { - return { - anotherProp: false, - }; - }, - async run(input, params) { - // Get the tokens from the input - return { - searchBarProps: { - // Props that will be used by the EuiSuggest component - // Suggestions - suggestions: [], - // Handler to manage when clicking in a suggestion item - prepend: params.queryLanguage.parameters.implicitQuery ? ( - - params.setQueryLanguageConfiguration(state => ({ - ...state, - anotherProp: !state.anotherProp, - })) - } - iconType='filter' - > - } - isOpen={params.queryLanguage.configuration.anotherProp} - closePopover={() => - params.setQueryLanguageConfiguration(state => ({ - ...state, - anotherProp: false, - })) - } - > - - Implicit UIQL query:{' '} - {params.queryLanguage.parameters.implicitQuery} - - - ) : null, - }, - output: getOutput(input, params.queryLanguage.parameters), - }; - }, -}; diff --git a/public/controllers/agent/components/agents-table.js b/public/controllers/agent/components/agents-table.js index 1fef9c9eb5..0f9ff7640a 100644 --- a/public/controllers/agent/components/agents-table.js +++ b/public/controllers/agent/components/agents-table.js @@ -620,6 +620,7 @@ export const AgentsTable = withErrorBoundary( /> {/** Example implementation */} { + switch (previousField) { + case 'configSum': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'dateAdd': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'id': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'ip': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'group': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'group_config_status': + return [AGENT_SYNCED_STATUS.SYNCED, AGENT_SYNCED_STATUS.NOT_SYNCED].map( + (status) => ({ + type: 'value', + label: status, + }), + ); + break; + case 'lastKeepAline': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'manager': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'mergedSum': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'name': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'node_name': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'os.platform': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'status': + return UI_ORDER_AGENT_STATUS.map( + (status) => ({ + type: 'value', + label: status, + }), + ); + break; + case 'version': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + default: + return []; + break; + } + }, + }, }, ]} onChange={console.log} - onSearch={async ({language, query}) => { + onSearch={async ({language, unifiedQuery}) => { try{ this.setState({isLoading: true}); const response = await this.props.wzReq('GET', '/agents', { params: { limit: this.state.pageSize, offset: 0, - q: query, + q: unifiedQuery, sort: this.buildSortFilter() }}); @@ -973,7 +1101,7 @@ AgentsTable.propTypes = { const getAgentFilterValuesMapToSearchBarSuggestion = async (key, value, params) => { try{ - return (await getAgentFilterValues(key, value, params)).map(label => ({type: 'value', label})); + return (await getAgentFilterValues(key, value, params)).map(label => ({label})); }catch(error){ return []; }; From b6f0d6d76963555704f0e3c5db46f07cc32d34af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Tue, 14 Mar 2023 14:32:49 +0100 Subject: [PATCH 09/33] feat(search-bar): add test to HAQL and AQL query languages - Add tests to HAQL and AQL query languages - Fix suggestions for HAQL when typing as first element a value entity. Now there are no suggestions because the field and operator_compare are missing. - Enhance documentation of HAQL and AQL - Removed unnecesary returns of suggestion handler in the example implementation of search bar on Agents section --- .../__snapshots__/index.test.tsx.snap | 59 ++++++ public/components/search-bar/index.test.tsx | 54 +++++ public/components/search-bar/index.tsx | 6 +- .../__snapshots__/aql.test.tsx.snap | 99 +++++++++ .../__snapshots__/haql.test.tsx.snap | 99 +++++++++ .../search-bar/query-language/aql.md | 6 +- .../search-bar/query-language/aql.test.tsx | 126 +++++++++++- .../search-bar/query-language/aql.tsx | 62 ++++-- .../search-bar/query-language/haql.md | 3 +- .../search-bar/query-language/haql.test.tsx | 127 +++++++++++- .../search-bar/query-language/haql.tsx | 193 ++++++++++++------ .../agent/components/agents-table.js | 7 +- 12 files changed, 745 insertions(+), 96 deletions(-) create mode 100644 public/components/search-bar/__snapshots__/index.test.tsx.snap create mode 100644 public/components/search-bar/index.test.tsx create mode 100644 public/components/search-bar/query-language/__snapshots__/aql.test.tsx.snap create mode 100644 public/components/search-bar/query-language/__snapshots__/haql.test.tsx.snap diff --git a/public/components/search-bar/__snapshots__/index.test.tsx.snap b/public/components/search-bar/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..007974639e --- /dev/null +++ b/public/components/search-bar/__snapshots__/index.test.tsx.snap @@ -0,0 +1,59 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SearchBar component Renders correctly the initial render 1`] = ` +
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+`; diff --git a/public/components/search-bar/index.test.tsx b/public/components/search-bar/index.test.tsx new file mode 100644 index 0000000000..b77cb2baa8 --- /dev/null +++ b/public/components/search-bar/index.test.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { SearchBar } from './index'; + +describe('SearchBar component', () => { + const componentProps = { + defaultMode: 'haql', + input: '', + modes: [ + { + id: 'aql', + implicitQuery: 'id!=000;', + suggestions: { + field(currentValue) { + return []; + }, + value(currentValue, { previousField }){ + return []; + }, + }, + }, + { + id: 'haql', + implicitQuery: 'id!=000;', + suggestions: { + field(currentValue) { + return []; + }, + value(currentValue, { previousField }){ + return []; + }, + }, + }, + ], + /* eslint-disable @typescript-eslint/no-empty-function */ + onChange: () => {}, + onSearch: () => {} + /* eslint-enable @typescript-eslint/no-empty-function */ + }; + + it('Renders correctly the initial render', async () => { + const wrapper = render( + + ); + + /* This test causes a warning about act. This is intentional, because the test pretends to get + the first rendering of the component that doesn't have the component properties coming of the + selected query language */ + expect(wrapper.container).toMatchSnapshot(); + }); +}); \ No newline at end of file diff --git a/public/components/search-bar/index.tsx b/public/components/search-bar/index.tsx index 8b0ebcd1b7..63b1d5a49a 100644 --- a/public/components/search-bar/index.tsx +++ b/public/components/search-bar/index.tsx @@ -119,7 +119,11 @@ export const SearchBar = ({ value={input} onChange={onChangeInput} onKeyPress={onKeyPressHandler} - onInputChange={() => {}} + // eslint-disable-next-line @typescript-eslint/no-empty-function + onInputChange={() => {}} /* This method is run by EuiSuggest when there is a change in + a div wrapper of the input and should be defined. Defining this + property prevents an error. */ + suggestions={[]} isPopoverOpen={ queryLanguageOutputRun.searchBarProps.suggestions.length > 0 && isOpenSuggestionPopover diff --git a/public/components/search-bar/query-language/__snapshots__/aql.test.tsx.snap b/public/components/search-bar/query-language/__snapshots__/aql.test.tsx.snap new file mode 100644 index 0000000000..0ef68d2e9e --- /dev/null +++ b/public/components/search-bar/query-language/__snapshots__/aql.test.tsx.snap @@ -0,0 +1,99 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SearchBar component Renders correctly to match the snapshot of query language 1`] = ` +
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+`; diff --git a/public/components/search-bar/query-language/__snapshots__/haql.test.tsx.snap b/public/components/search-bar/query-language/__snapshots__/haql.test.tsx.snap new file mode 100644 index 0000000000..8636885205 --- /dev/null +++ b/public/components/search-bar/query-language/__snapshots__/haql.test.tsx.snap @@ -0,0 +1,99 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SearchBar component Renders correctly to match the snapshot of query language 1`] = ` +
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+`; diff --git a/public/components/search-bar/query-language/aql.md b/public/components/search-bar/query-language/aql.md index af0d2ccee9..9dcb5bb0e0 100644 --- a/public/components/search-bar/query-language/aql.md +++ b/public/components/search-bar/query-language/aql.md @@ -170,12 +170,12 @@ implicitQuery: 'id!=000;' ```mermaid graph TD; - user_input[User input]-->tokenizerAPI; - subgraph tokenizerAPI + user_input[User input]-->tokenizer; + subgraph tokenizer tokenize_regex[Wazuh API `q` regular expression] end - tokenizerAPI-->tokens; + tokenizer-->tokens; tokens-->searchBarProps; subgraph searchBarProps; diff --git a/public/components/search-bar/query-language/aql.test.tsx b/public/components/search-bar/query-language/aql.test.tsx index a165ef5b75..597d31188a 100644 --- a/public/components/search-bar/query-language/aql.test.tsx +++ b/public/components/search-bar/query-language/aql.test.tsx @@ -1,4 +1,46 @@ -import { getSuggestionsAPI, tokenizerAPI, validate } from './aql'; +import { AQL, getSuggestions, tokenizer } from './aql'; +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { SearchBar } from '../index'; + +describe('SearchBar component', () => { + const componentProps = { + defaultMode: AQL.id, + input: '', + modes: [ + { + id: AQL.id, + implicitQuery: 'id!=000;', + suggestions: { + field(currentValue) { + return []; + }, + value(currentValue, { previousField }){ + return []; + }, + }, + } + ], + /* eslint-disable @typescript-eslint/no-empty-function */ + onChange: () => {}, + onSearch: () => {} + /* eslint-enable @typescript-eslint/no-empty-function */ + }; + + it('Renders correctly to match the snapshot of query language', async () => { + const wrapper = render( + + ); + + await waitFor(() => { + const elementImplicitQuery = wrapper.container.querySelector('.euiCodeBlock__code'); + expect(elementImplicitQuery?.innerHTML).toEqual('id!=000;'); + expect(wrapper.container).toMatchSnapshot(); + }); + }); +}); describe('Query language - AQL', () => { // Tokenize the input @@ -29,7 +71,7 @@ describe('Query language - AQL', () => { ${'(field>2;field2=value2)'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value2' }, { type: 'operator_group', value: ')' }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} ${'(field>2;field2=custom value())'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'custom value()' }, { type: 'operator_group', value: ')' }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} `(`Tokenizer API input $input`, ({input, tokens}) => { - expect(tokenizerAPI(input)).toEqual(tokens); + expect(tokenizer(input)).toEqual(tokens); }); // Get suggestions @@ -48,7 +90,7 @@ describe('Query language - AQL', () => { ${'field=value;field2=127'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { label: '127.0.0.1', type: 'value' }, { label: '127.0.0.2', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} `('Get suggestion from the input: $input', async ({ input, suggestions }) => { expect( - await getSuggestionsAPI(tokenizerAPI(input), { + await getSuggestions(tokenizer(input), { id: 'aql', suggestions: { field(currentValue) { @@ -82,4 +124,82 @@ describe('Query language - AQL', () => { }), ).toEqual(suggestions); }); + + // When a suggestion is clicked, change the input text + it.each` + AQL | clikedSuggestion | changedInput + ${''} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field'}} | ${'field'} + ${'field'} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'field2'} + ${'field'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '='}} | ${'field='} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value'}} | ${'field=value'} + ${'field='} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '!='}} | ${'field!='} + ${'field=value'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2'}} | ${'field=value2'} + ${'field=value'} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: ';'}} | ${'field=value;'} + ${'field=value;'} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'field=value;field2'} + ${'field=value;field2'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>'}} | ${'field=value;field2>'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with spaces'}} | ${'field=with spaces'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with "spaces'}} | ${'field=with "spaces'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: '"value'}} | ${'field="value'} + ${''} | ${{type: { iconType: 'tokenDenseVector', color: 'tint3' }, label: '('}} | ${'('} + ${'('} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field'}} | ${'(field'} + ${'(field'} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'(field2'} + ${'(field'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '='}} | ${'(field='} + ${'(field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value'}} | ${'(field=value'} + ${'(field=value'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2'}} | ${'(field=value2'} + ${'(field=value'} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: ','}} | ${'(field=value,'} + ${'(field=value,'} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'(field=value,field2'} + ${'(field=value,field2'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>'}} | ${'(field=value,field2>'} + ${'(field=value,field2>'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>'}} | ${'(field=value,field2>'} + ${'(field=value,field2>'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '~'}} | ${'(field=value,field2~'} + ${'(field=value,field2>'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2'}} | ${'(field=value,field2>value2'} + ${'(field=value,field2>value2'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value3'}} | ${'(field=value,field2>value3'} + ${'(field=value,field2>value2'} | ${{type: { iconType: 'tokenDenseVector', color: 'tint3' }, label: ')'}} | ${'(field=value,field2>value2)'} + `('click suggestion - AQL $AQL => $changedInput', async ({AQL: currentInput, clikedSuggestion, changedInput}) => { + // Mock input + let input = currentInput; + + const qlOutput = await AQL.run(input, { + setInput: (value: string): void => { input = value; }, + queryLanguage: { + parameters: { + implicitQuery: '', + suggestions: { + field: () => ([]), + value: () => ([]) + } + } + } + }); + qlOutput.searchBarProps.onItemClick(clikedSuggestion); + expect(input).toEqual(changedInput); + }); + + // Transform the external input in UQL (Unified Query Language) to QL + it.each` + UQL | AQL + ${''} | ${''} + ${'field'} | ${'field'} + ${'field='} | ${'field='} + ${'field!='} | ${'field!='} + ${'field>'} | ${'field>'} + ${'field<'} | ${'field<'} + ${'field~'} | ${'field~'} + ${'field=value'} | ${'field=value'} + ${'field=value;'} | ${'field=value;'} + ${'field=value;field2'} | ${'field=value;field2'} + ${'field="'} | ${'field="'} + ${'field=with spaces'} | ${'field=with spaces'} + ${'field=with "spaces'} | ${'field=with "spaces'} + ${'('} | ${'('} + ${'(field'} | ${'(field'} + ${'(field='} | ${'(field='} + ${'(field=value'} | ${'(field=value'} + ${'(field=value,'} | ${'(field=value,'} + ${'(field=value,field2'} | ${'(field=value,field2'} + ${'(field=value,field2>'} | ${'(field=value,field2>'} + ${'(field=value,field2>value2'} | ${'(field=value,field2>value2'} + ${'(field=value,field2>value2)'} | ${'(field=value,field2>value2)'} + `('Transform the external input UQL to QL - UQL $UQL => $AQL', async ({UQL, AQL: changedInput}) => { + expect(AQL.transformUnifiedQuery(UQL)).toEqual(changedInput); + }); }); diff --git a/public/components/search-bar/query-language/aql.tsx b/public/components/search-bar/query-language/aql.tsx index 22d678844f..a93f33df19 100644 --- a/public/components/search-bar/query-language/aql.tsx +++ b/public/components/search-bar/query-language/aql.tsx @@ -70,7 +70,7 @@ const suggestionMappingLanguageTokenType = { }; /** - * Creator of intermediate interfacte of EuiSuggestItem + * Creator of intermediate interface of EuiSuggestItem * @param type * @returns */ @@ -92,7 +92,7 @@ const mapSuggestionCreatorValue = mapSuggestionCreator('value'); * @param input * @returns */ -export function tokenizerAPI(input: string): ITokens{ +export function tokenizer(input: string): ITokens{ // API regular expression // https://github.com/wazuh/wazuh/blob/v4.4.0-rc1/framework/wazuh/core/utils.py#L1242-L1257 // self.query_regex = re.compile( @@ -145,18 +145,31 @@ export function tokenizerAPI(input: string): ITokens{ ).flat(); }; -type OptionSuggestionHandler = ( +type QLOptionSuggestionEntityItem = { + description?: string + label: string +}; + +type QLOptionSuggestionEntityItemTyped = + QLOptionSuggestionEntityItem + & { type: 'operator_group'|'field'|'operator_compare'|'value'|'conjunction' }; + +type SuggestItem = QLOptionSuggestionEntityItem & { + type: { iconType: string, color: string } +}; + +type QLOptionSuggestionHandler = ( currentValue: string | undefined, { previousField, previousOperatorCompare, }: { previousField: string; previousOperatorCompare: string }, -) => Promise<{ description?: string; label: string }[]>; +) => Promise; type optionsQL = { suggestions: { - field: OptionSuggestionHandler; - value: OptionSuggestionHandler; + field: QLOptionSuggestionHandler; + value: QLOptionSuggestionHandler; }; }; @@ -205,7 +218,7 @@ function getLastTokenWithValueByType( * @param options * @returns */ -export async function getSuggestionsAPI(tokens: ITokens, options: optionsQL) { +export async function getSuggestions(tokens: ITokens, options: optionsQL): Promise { if (!tokens.length) { return []; } @@ -366,21 +379,29 @@ export async function getSuggestionsAPI(tokens: ITokens, options: optionsQL) { return []; } +/** + * Transform the suggestion object to the expected object by EuiSuggestItem + * @param param0 + * @returns + */ +export function transformSuggestionToEuiSuggestItem(suggestion: QLOptionSuggestionEntityItemTyped): SuggestItem{ + const { type, ...rest} = suggestion; + return { + type: { ...suggestionMappingLanguageTokenType[type] }, + ...rest + }; +}; + /** * Transform the suggestion object to the expected object by EuiSuggestItem * @param suggestions - * @param options * @returns */ -function transformSuggestionsToUI( - suggestions: { type: string; label: string; description?: string }[], - mapSuggestionByLanguageToken: any, -) { - return suggestions.map(({ type, ...rest }) => ({ - type: { ...mapSuggestionByLanguageToken[type] }, - ...rest, - })); -} +function transformSuggestionsToEuiSuggestItem( + suggestions: QLOptionSuggestionEntityItemTyped[] +): SuggestItem[] { + return suggestions.map(transformSuggestionToEuiSuggestItem); +}; /** * Get the output from the input @@ -408,15 +429,14 @@ export const AQL = { }, async run(input, params) { // Get the tokens from the input - const tokens: ITokens = tokenizerAPI(input); + const tokens: ITokens = tokenizer(input); return { searchBarProps: { // Props that will be used by the EuiSuggest component // Suggestions - suggestions: transformSuggestionsToUI( - await getSuggestionsAPI(tokens, params.queryLanguage.parameters), - suggestionMappingLanguageTokenType, + suggestions: transformSuggestionsToEuiSuggestItem( + await getSuggestions(tokens, params.queryLanguage.parameters) ), // Handler to manage when clicking in a suggestion item onItemClick: item => { diff --git a/public/components/search-bar/query-language/haql.md b/public/components/search-bar/query-language/haql.md index cf1d2b1166..8f5253eda0 100644 --- a/public/components/search-bar/query-language/haql.md +++ b/public/components/search-bar/query-language/haql.md @@ -128,8 +128,7 @@ implicitQuery: 'id!=000;' { label: 'os.platform', description: 'Operating system platform' }, { label: 'status', description: 'Status' }, { label: 'version', description: 'Version' }, - ] - .map(field => ({ type: 'field', ...field })); + ]; } ``` diff --git a/public/components/search-bar/query-language/haql.test.tsx b/public/components/search-bar/query-language/haql.test.tsx index 7193bc8239..eeef8eb91a 100644 --- a/public/components/search-bar/query-language/haql.test.tsx +++ b/public/components/search-bar/query-language/haql.test.tsx @@ -1,5 +1,48 @@ -import { getSuggestions, tokenizer, transformSpecificQLToUnifiedQL } from './haql'; +import { getSuggestions, tokenizer, transformSpecificQLToUnifiedQL, HAQL } from './haql'; +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { SearchBar } from '../index'; +describe('SearchBar component', () => { + const componentProps = { + defaultMode: HAQL.id, + input: '', + modes: [ + { + id: HAQL.id, + implicitQuery: 'id!=000;', + suggestions: { + field(currentValue) { + return []; + }, + value(currentValue, { previousField }){ + return []; + }, + }, + } + ], + /* eslint-disable @typescript-eslint/no-empty-function */ + onChange: () => {}, + onSearch: () => {} + /* eslint-enable @typescript-eslint/no-empty-function */ + }; + + it('Renders correctly to match the snapshot of query language', async () => { + const wrapper = render( + + ); + + await waitFor(() => { + const elementImplicitQuery = wrapper.container.querySelector('.euiCodeBlock__code'); + expect(elementImplicitQuery?.innerHTML).toEqual('id!=000 and '); + expect(wrapper.container).toMatchSnapshot(); + }); + }); +}); + +/* eslint-disable max-len */ describe('Query language - HAQL', () => { // Tokenize the input function tokenCreator({type, value}){ @@ -108,6 +151,7 @@ describe('Query language - HAQL', () => { ).toEqual(suggestions); }); + // Transform specific query language to UQL (Unified Query Language) it.each` HAQL | UQL ${'field'} | ${'field'} @@ -135,5 +179,84 @@ describe('Query language - HAQL', () => { ${'( field = value ) and field2 > "custom value" '} | ${'(field=value);field2>custom value'} `('transformSpecificQLToUnifiedQL - HAQL $HAQL', ({HAQL, UQL}) => { expect(transformSpecificQLToUnifiedQL(HAQL)).toEqual(UQL); - }) + }); + + // When a suggestion is clicked, change the input text + it.each` + HAQL | clikedSuggestion | changedInput + ${''} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field'}} | ${'field'} + ${'field'} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'field2'} + ${'field'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '='}} | ${'field='} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value'}} | ${'field=value'} + ${'field='} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '!='}} | ${'field!='} + ${'field=value'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2'}} | ${'field=value2'} + ${'field=value'} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'and'}} | ${'field=value and '} + ${'field=value and '} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'field=value and field2'} + ${'field=value and field2'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>'}} | ${'field=value and field2>'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with spaces'}} | ${'field="with spaces"'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with "spaces'}} | ${'field="with \\"spaces"'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: '"value'}} | ${'field="\\"value"'} + ${''} | ${{type: { iconType: 'tokenDenseVector', color: 'tint3' }, label: '('}} | ${'('} + ${'('} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field'}} | ${'(field'} + ${'(field'} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'(field2'} + ${'(field'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '='}} | ${'(field='} + ${'(field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value'}} | ${'(field=value'} + ${'(field=value'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2'}} | ${'(field=value2'} + ${'(field=value'} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'or'}} | ${'(field=value or '} + ${'(field=value or '} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'(field=value or field2'} + ${'(field=value or field2'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>'}} | ${'(field=value or field2>'} + ${'(field=value or field2>'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>'}} | ${'(field=value or field2>'} + ${'(field=value or field2>'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '~'}} | ${'(field=value or field2~'} + ${'(field=value or field2>'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2'}} | ${'(field=value or field2>value2'} + ${'(field=value or field2>value2'}| ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value3'}} | ${'(field=value or field2>value3'} + ${'(field=value or field2>value2'}| ${{type: { iconType: 'tokenDenseVector', color: 'tint3' }, label: ')'}} | ${'(field=value or field2>value2)'} + `('click suggestion - HAQL $HAQL => $changedInput', async ({HAQL: currentInput, clikedSuggestion, changedInput}) => { + // Mock input + let input = currentInput; + + const qlOutput = await HAQL.run(input, { + setInput: (value: string): void => { input = value; }, + queryLanguage: { + parameters: { + implicitQuery: '', + suggestions: { + field: () => ([]), + value: () => ([]) + } + } + } + }); + qlOutput.searchBarProps.onItemClick(clikedSuggestion); + expect(input).toEqual(changedInput); + }); + + // Transform the external input in UQL (Unified Query Language) to QL + it.each` + UQL | HAQL + ${''} | ${''} + ${'field'} | ${'field'} + ${'field='} | ${'field='} + ${'field!='} | ${'field!='} + ${'field>'} | ${'field>'} + ${'field<'} | ${'field<'} + ${'field~'} | ${'field~'} + ${'field=value'} | ${'field=value'} + ${'field=value;'} | ${'field=value and '} + ${'field=value;field2'} | ${'field=value and field2'} + ${'field="'} | ${'field="\\""'} + ${'field=with spaces'} | ${'field="with spaces"'} + ${'field=with "spaces'} | ${'field="with \\"spaces"'} + ${'('} | ${'('} + ${'(field'} | ${'(field'} + ${'(field='} | ${'(field='} + ${'(field=value'} | ${'(field=value'} + ${'(field=value,'} | ${'(field=value or '} + ${'(field=value,field2'} | ${'(field=value or field2'} + ${'(field=value,field2>'} | ${'(field=value or field2>'} + ${'(field=value,field2>value2'} | ${'(field=value or field2>value2'} + ${'(field=value,field2>value2)'} | ${'(field=value or field2>value2)'} + `('Transform the external input UQL to QL - UQL $UQL => $HAQL', async ({UQL, HAQL: changedInput}) => { + expect(HAQL.transformUnifiedQuery(UQL)).toEqual(changedInput); + }); + }); diff --git a/public/components/search-bar/query-language/haql.tsx b/public/components/search-bar/query-language/haql.tsx index bf45cc9afc..cda8a12dfc 100644 --- a/public/components/search-bar/query-language/haql.tsx +++ b/public/components/search-bar/query-language/haql.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { EuiButtonEmpty, EuiPopover, EuiText, EuiCode } from '@elastic/eui'; -import { tokenizerAPI as tokenizerUQL } from './aql'; +import { tokenizer as tokenizerUQL } from './aql'; import { pluginPlatform } from '../../../../package.json'; /* UI Query language @@ -38,9 +38,6 @@ Implemented schema: const language = { // Tokens tokens: { - field: { - regex: /[\w.]/, - }, // eslint-disable-next-line camelcase operator_compare: { literal: { @@ -80,6 +77,48 @@ const suggestionMappingLanguageTokenType = { function_search: { iconType: 'search', color: 'tint5' }, }; +/** + * Creator of intermediate interface of EuiSuggestItem + * @param type + * @returns + */ +function mapSuggestionCreator(type: ITokenType ){ + return function({...params}){ + return { + type, + ...params + }; + }; +}; + +const mapSuggestionCreatorField = mapSuggestionCreator('field'); +const mapSuggestionCreatorValue = mapSuggestionCreator('value'); + +/** + * Transform the conjunction to the query language syntax + * @param conjunction + * @returns + */ +function transformQLConjunction(conjunction: string): string{ + // If the value has a whitespace or comma, then + return conjunction === ';' + ? ' and ' + : ' or '; +}; + +/** + * Transform the value to the query language syntax + * @param value + * @returns + */ +function transformQLValue(value: string): string{ + // If the value has a whitespace or comma, then + return /[\s|"]/.test(value) + // Escape the commas (") => (\") and wraps the string with commas ("") + ? `"${value.replace(/"/, '\\"')}"` + // Raw value + : value; +}; /** * Tokenize the input string. Returns an array with the tokens. @@ -135,18 +174,31 @@ export function tokenizer(input: string): ITokens{ ).flat(); }; -type OptionSuggestionHandler = ( +type QLOptionSuggestionEntityItem = { + description?: string + label: string +}; + +type QLOptionSuggestionEntityItemTyped = + QLOptionSuggestionEntityItem + & { type: 'operator_group'|'field'|'operator_compare'|'value'|'conjunction' }; + +type SuggestItem = QLOptionSuggestionEntityItem & { + type: { iconType: string, color: string } +}; + +type QLOptionSuggestionHandler = ( currentValue: string | undefined, { previousField, previousOperatorCompare, }: { previousField: string; previousOperatorCompare: string }, -) => Promise<{ description?: string; label: string; type: string }[]>; +) => Promise; type optionsQL = { suggestions: { - field: OptionSuggestionHandler; - value: OptionSuggestionHandler; + field: QLOptionSuggestionHandler; + value: QLOptionSuggestionHandler; }; }; @@ -195,7 +247,7 @@ function getLastTokenWithValueByType( * @param options * @returns */ -export async function getSuggestions(tokens: ITokens, options: optionsQL) { +export async function getSuggestions(tokens: ITokens, options: optionsQL): Promise { if (!tokens.length) { return []; } @@ -207,7 +259,7 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL) { if(!lastToken?.type){ return [ // fields - ...(await options.suggestions.field()), + ...(await options.suggestions.field()).map(mapSuggestionCreatorField), { type: 'operator_group', label: '(', @@ -223,7 +275,7 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL) { ...(await options.suggestions.field()).filter( ({ label }) => label.startsWith(lastToken.value) && label !== lastToken.value, - ), + ).map(mapSuggestionCreatorField), // operators if the input field is exact ...((await options.suggestions.field()).some( ({ label }) => label === lastToken.value, @@ -241,7 +293,19 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL) { : []), ]; break; - case 'operator_compare': + case 'operator_compare':{ + const previousField = getLastTokenWithValueByType(tokens, 'field')?.value; + const previousOperatorCompare = getLastTokenWithValueByType( + tokens, + 'operator_compare', + )?.value; + + // If there is no a previous field, then no return suggestions because it would be an syntax + // error + if(!previousField){ + return []; + }; + return [ ...Object.keys(language.tokens.operator_compare.literal) .filter( @@ -259,17 +323,27 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL) { ) ? [ ...(await options.suggestions.value(undefined, { - previousField: getLastTokenWithValueByType(tokens, 'field')!.value, - previousOperatorCompare: getLastTokenWithValueByType( - tokens, - 'operator_compare', - )!.value, - })), + previousField, + previousOperatorCompare, + })).map(mapSuggestionCreatorValue), ] : []), ]; break; - case 'value': + } + case 'value':{ + const previousField = getLastTokenWithValueByType(tokens, 'field')?.value; + const previousOperatorCompare = getLastTokenWithValueByType( + tokens, + 'operator_compare', + )?.value; + + // If there is no a previous field or operator_compar, then no return suggestions because + //it would be an syntax error + if(!previousField || !previousOperatorCompare){ + return []; + }; + return [ ...(lastToken.value ? [ @@ -281,12 +355,9 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL) { ] : []), ...(await options.suggestions.value(lastToken.value, { - previousField: getLastTokenWithValueByType(tokens, 'field')!.value, - previousOperatorCompare: getLastTokenWithValueByType( - tokens, - 'operator_compare', - )!.value, - })), + previousField, + previousOperatorCompare, + })).map(mapSuggestionCreatorValue), ...Object.entries(language.tokens.conjunction.literal).map( ([ conjunction, description]) => ({ type: 'conjunction', @@ -301,6 +372,7 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL) { }, ]; break; + } case 'conjunction': return [ ...Object.keys(language.tokens.conjunction.literal) @@ -319,13 +391,7 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL) { conjunction => conjunction === lastToken.value, ) ? [ - ...(await options.suggestions.field()).map( - ({ label, description }) => ({ - type: 'field', - label, - description, - }), - ), + ...(await options.suggestions.field()).map(mapSuggestionCreatorField), ] : []), { @@ -339,9 +405,7 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL) { if (lastToken.value === '(') { return [ // fields - ...(await options.suggestions.field()).map( - ({ label, description }) => ({ type: 'field', label, description }), - ), + ...(await options.suggestions.field()).map(mapSuggestionCreatorField), ]; } else if (lastToken.value === ')') { return [ @@ -364,20 +428,28 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL) { return []; } +/** + * Transform the suggestion object to the expected object by EuiSuggestItem + * @param param0 + * @returns + */ +export function transformSuggestionToEuiSuggestItem(suggestion: QLOptionSuggestionEntityItemTyped): SuggestItem{ + const { type, ...rest} = suggestion; + return { + type: { ...suggestionMappingLanguageTokenType[type] }, + ...rest + }; +}; + /** * Transform the suggestion object to the expected object by EuiSuggestItem * @param suggestions - * @param options * @returns */ -function transformSuggestionsToUI( - suggestions: { type: string; label: string; description?: string }[], - mapSuggestionByLanguageToken: any, -) { - return suggestions.map(({ type, ...rest }) => ({ - type: { ...mapSuggestionByLanguageToken[type] }, - ...rest, - })); +function transformSuggestionsToEuiSuggestItem( + suggestions: QLOptionSuggestionEntityItemTyped[] +): SuggestItem[] { + return suggestions.map(transformSuggestionToEuiSuggestItem); }; /** @@ -389,11 +461,19 @@ export function transformUnifiedQueryToSpecificQueryLanguage(input: string){ const tokens = tokenizerUQL(input); return tokens .filter(({value}) => value) - .map(({type, value}) => type === 'conjunction' - ? value === ';' - ? ' and ' - : ' or ' - : value + .map(({type, value}) => { + switch (type) { + case 'conjunction': + return transformQLConjunction(value); + break; + case 'value': + return transformQLValue(value); + break; + default: + return value; + break; + } + } ).join(''); }; @@ -466,9 +546,8 @@ export const HAQL = { searchBarProps: { // Props that will be used by the EuiSuggest component // Suggestions - suggestions: transformSuggestionsToUI( - await getSuggestions(tokens, params.queryLanguage.parameters), - suggestionMappingLanguageTokenType, + suggestions: transformSuggestionsToEuiSuggestItem( + await getSuggestions(tokens, params.queryLanguage.parameters) ), // Handler to manage when clicking in a suggestion item onItemClick: item => { @@ -487,9 +566,8 @@ export const HAQL = { // replace the value of last token lastToken.value = item.label; } else { - // add a whitespace for conjunction - input.length - && !(/\s$/.test(input)) + // add a whitespace for conjunction + !(/\s$/.test(input)) && item.type.iconType === suggestionMappingLanguageTokenType.conjunction.iconType && tokens.push({ type: 'whitespace', @@ -502,12 +580,11 @@ export const HAQL = { ([, { iconType }]) => iconType === item.type.iconType, )[0], value: item.type.iconType === suggestionMappingLanguageTokenType.value.iconType - && /\s/.test(item.label) - ? `"${item.label}"` + ? transformQLValue(item.label) : item.label, }); - // add a whitespace for conjunction + // add a whitespace for conjunction item.type.iconType === suggestionMappingLanguageTokenType.conjunction.iconType && tokens.push({ type: 'whitespace', diff --git a/public/controllers/agent/components/agents-table.js b/public/controllers/agent/components/agents-table.js index 0f9ff7640a..3de71e4a32 100644 --- a/public/controllers/agent/components/agents-table.js +++ b/public/controllers/agent/components/agents-table.js @@ -643,8 +643,7 @@ export const AgentsTable = withErrorBoundary( { label: 'os.platform', description: 'Operating system platform' }, { label: 'status', description: 'Status' }, { label: 'version', description: 'Version' }, - ] - .map(field => ({ type: 'field', ...field })); + ]; }, value: async (currentValue, { previousField }) => { switch (previousField) { @@ -686,7 +685,6 @@ export const AgentsTable = withErrorBoundary( case 'group_config_status': return [AGENT_SYNCED_STATUS.SYNCED, AGENT_SYNCED_STATUS.NOT_SYNCED].map( (status) => ({ - type: 'value', label: status, }), ); @@ -736,7 +734,6 @@ export const AgentsTable = withErrorBoundary( case 'status': return UI_ORDER_AGENT_STATUS.map( (status) => ({ - type: 'value', label: status, }), ); @@ -817,7 +814,6 @@ export const AgentsTable = withErrorBoundary( case 'group_config_status': return [AGENT_SYNCED_STATUS.SYNCED, AGENT_SYNCED_STATUS.NOT_SYNCED].map( (status) => ({ - type: 'value', label: status, }), ); @@ -867,7 +863,6 @@ export const AgentsTable = withErrorBoundary( case 'status': return UI_ORDER_AGENT_STATUS.map( (status) => ({ - type: 'value', label: status, }), ); From 22676d4ddc7e8ef6412d0e15f86a31836be6c0ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Wed, 15 Mar 2023 12:48:24 +0100 Subject: [PATCH 10/33] feat(search-bar): Rename HAQL query language to WQL - Rename query language HAQL to WQL - Update tests - Remove AQL usage from the implementation in the agents section --- .../__snapshots__/index.test.tsx.snap | 2 +- public/components/search-bar/index.test.tsx | 6 +- .../{haql.test.tsx.snap => wql.test.tsx.snap} | 2 +- .../search-bar/query-language/index.ts | 4 +- .../query-language/{haql.md => wql.md} | 4 +- .../{haql.test.tsx => wql.test.tsx} | 26 ++-- .../query-language/{haql.tsx => wql.tsx} | 12 +- .../agent/components/agents-table.js | 133 +----------------- 8 files changed, 30 insertions(+), 159 deletions(-) rename public/components/search-bar/query-language/__snapshots__/{haql.test.tsx.snap => wql.test.tsx.snap} (99%) rename public/components/search-bar/query-language/{haql.md => wql.md} (98%) rename public/components/search-bar/query-language/{haql.test.tsx => wql.test.tsx} (95%) rename public/components/search-bar/query-language/{haql.tsx => wql.tsx} (99%) diff --git a/public/components/search-bar/__snapshots__/index.test.tsx.snap b/public/components/search-bar/__snapshots__/index.test.tsx.snap index 007974639e..5602512bd0 100644 --- a/public/components/search-bar/__snapshots__/index.test.tsx.snap +++ b/public/components/search-bar/__snapshots__/index.test.tsx.snap @@ -43,7 +43,7 @@ exports[`SearchBar component Renders correctly the initial render 1`] = ` - HAQL + WQL diff --git a/public/components/search-bar/index.test.tsx b/public/components/search-bar/index.test.tsx index b77cb2baa8..be6d332b6b 100644 --- a/public/components/search-bar/index.test.tsx +++ b/public/components/search-bar/index.test.tsx @@ -4,7 +4,7 @@ import { SearchBar } from './index'; describe('SearchBar component', () => { const componentProps = { - defaultMode: 'haql', + defaultMode: 'wql', input: '', modes: [ { @@ -20,7 +20,7 @@ describe('SearchBar component', () => { }, }, { - id: 'haql', + id: 'wql', implicitQuery: 'id!=000;', suggestions: { field(currentValue) { @@ -42,7 +42,7 @@ describe('SearchBar component', () => { const wrapper = render( ); diff --git a/public/components/search-bar/query-language/__snapshots__/haql.test.tsx.snap b/public/components/search-bar/query-language/__snapshots__/wql.test.tsx.snap similarity index 99% rename from public/components/search-bar/query-language/__snapshots__/haql.test.tsx.snap rename to public/components/search-bar/query-language/__snapshots__/wql.test.tsx.snap index 8636885205..f1bad4e5d4 100644 --- a/public/components/search-bar/query-language/__snapshots__/haql.test.tsx.snap +++ b/public/components/search-bar/query-language/__snapshots__/wql.test.tsx.snap @@ -83,7 +83,7 @@ exports[`SearchBar component Renders correctly to match the snapshot of query la - HAQL + WQL diff --git a/public/components/search-bar/query-language/index.ts b/public/components/search-bar/query-language/index.ts index 22608fd9f5..9c0d6e2b9b 100644 --- a/public/components/search-bar/query-language/index.ts +++ b/public/components/search-bar/query-language/index.ts @@ -1,5 +1,5 @@ import { AQL } from './aql'; -import { HAQL } from './haql'; +import { WQL } from './wql'; type SearchBarQueryLanguage = { description: string; @@ -21,7 +21,7 @@ type SearchBarQueryLanguage = { // Register the query languages export const searchBarQueryLanguages: { [key: string]: SearchBarQueryLanguage; -} = [AQL, HAQL].reduce((accum, item) => { +} = [AQL, WQL].reduce((accum, item) => { if (accum[item.id]) { throw new Error(`Query language with id: ${item.id} already registered.`); } diff --git a/public/components/search-bar/query-language/haql.md b/public/components/search-bar/query-language/wql.md similarity index 98% rename from public/components/search-bar/query-language/haql.md rename to public/components/search-bar/query-language/wql.md index 8f5253eda0..7ff72ddc1f 100644 --- a/public/components/search-bar/query-language/haql.md +++ b/public/components/search-bar/query-language/wql.md @@ -1,6 +1,6 @@ -# Query Language - HAQL +# Query Language - WQL -HAQL (Human API Query Language) is a query language based in the `q` query parameters of the Wazuh API +WQL (Wazuh Query Language) is a query language based in the `q` query parameters of the Wazuh API endpoints. Documentation: https://wazuh.com/./user-manual/api/queries.html diff --git a/public/components/search-bar/query-language/haql.test.tsx b/public/components/search-bar/query-language/wql.test.tsx similarity index 95% rename from public/components/search-bar/query-language/haql.test.tsx rename to public/components/search-bar/query-language/wql.test.tsx index eeef8eb91a..be2e42a707 100644 --- a/public/components/search-bar/query-language/haql.test.tsx +++ b/public/components/search-bar/query-language/wql.test.tsx @@ -1,15 +1,15 @@ -import { getSuggestions, tokenizer, transformSpecificQLToUnifiedQL, HAQL } from './haql'; +import { getSuggestions, tokenizer, transformSpecificQLToUnifiedQL, WQL } from './wql'; import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { SearchBar } from '../index'; describe('SearchBar component', () => { const componentProps = { - defaultMode: HAQL.id, + defaultMode: WQL.id, input: '', modes: [ { - id: HAQL.id, + id: WQL.id, implicitQuery: 'id!=000;', suggestions: { field(currentValue) { @@ -43,7 +43,7 @@ describe('SearchBar component', () => { }); /* eslint-disable max-len */ -describe('Query language - HAQL', () => { +describe('Query language - WQL', () => { // Tokenize the input function tokenCreator({type, value}){ return {type, value}; @@ -153,7 +153,7 @@ describe('Query language - HAQL', () => { // Transform specific query language to UQL (Unified Query Language) it.each` - HAQL | UQL + WQL | UQL ${'field'} | ${'field'} ${'field='} | ${'field='} ${'field=value'} | ${'field=value'} @@ -177,13 +177,13 @@ describe('Query language - HAQL', () => { ${'field = value or field2 <'} | ${'field=value,field2<'} ${'field = value or field2 < value2'} | ${'field=value,field2 "custom value" '} | ${'(field=value);field2>custom value'} - `('transformSpecificQLToUnifiedQL - HAQL $HAQL', ({HAQL, UQL}) => { - expect(transformSpecificQLToUnifiedQL(HAQL)).toEqual(UQL); + `('transformSpecificQLToUnifiedQL - WQL $WQL', ({WQL, UQL}) => { + expect(transformSpecificQLToUnifiedQL(WQL)).toEqual(UQL); }); // When a suggestion is clicked, change the input text it.each` - HAQL | clikedSuggestion | changedInput + WQL | clikedSuggestion | changedInput ${''} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field'}} | ${'field'} ${'field'} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'field2'} ${'field'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '='}} | ${'field='} @@ -210,11 +210,11 @@ describe('Query language - HAQL', () => { ${'(field=value or field2>'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2'}} | ${'(field=value or field2>value2'} ${'(field=value or field2>value2'}| ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value3'}} | ${'(field=value or field2>value3'} ${'(field=value or field2>value2'}| ${{type: { iconType: 'tokenDenseVector', color: 'tint3' }, label: ')'}} | ${'(field=value or field2>value2)'} - `('click suggestion - HAQL $HAQL => $changedInput', async ({HAQL: currentInput, clikedSuggestion, changedInput}) => { + `('click suggestion - WQL $WQL => $changedInput', async ({WQL: currentInput, clikedSuggestion, changedInput}) => { // Mock input let input = currentInput; - const qlOutput = await HAQL.run(input, { + const qlOutput = await WQL.run(input, { setInput: (value: string): void => { input = value; }, queryLanguage: { parameters: { @@ -232,7 +232,7 @@ describe('Query language - HAQL', () => { // Transform the external input in UQL (Unified Query Language) to QL it.each` - UQL | HAQL + UQL | WQL ${''} | ${''} ${'field'} | ${'field'} ${'field='} | ${'field='} @@ -255,8 +255,8 @@ describe('Query language - HAQL', () => { ${'(field=value,field2>'} | ${'(field=value or field2>'} ${'(field=value,field2>value2'} | ${'(field=value or field2>value2'} ${'(field=value,field2>value2)'} | ${'(field=value or field2>value2)'} - `('Transform the external input UQL to QL - UQL $UQL => $HAQL', async ({UQL, HAQL: changedInput}) => { - expect(HAQL.transformUnifiedQuery(UQL)).toEqual(changedInput); + `('Transform the external input UQL to QL - UQL $UQL => $WQL', async ({UQL, WQL: changedInput}) => { + expect(WQL.transformUnifiedQuery(UQL)).toEqual(changedInput); }); }); diff --git a/public/components/search-bar/query-language/haql.tsx b/public/components/search-bar/query-language/wql.tsx similarity index 99% rename from public/components/search-bar/query-language/haql.tsx rename to public/components/search-bar/query-language/wql.tsx index cda8a12dfc..970d25020c 100644 --- a/public/components/search-bar/query-language/haql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -517,17 +517,17 @@ export function transformSpecificQLToUnifiedQL(input: string){ function getOutput(input: string, options: {implicitQuery?: string} = {}) { const query = `${transformUnifiedQueryToSpecificQueryLanguage(options?.implicitQuery ?? '')}${input}`; return { - language: HAQL.id, + language: WQL.id, unifiedQuery: transformSpecificQLToUnifiedQL(query), query }; }; -export const HAQL = { - id: 'haql', - label: 'HAQL', - description: 'HAQL allows to do queries.', - documentationLink: `https://github.com/wazuh/wazuh-kibana-app/blob/v${pluginPlatform.version.split('.').splice(0,2).join('.')}/public/components/search-bar/query-language/haql.md`, +export const WQL = { + id: 'wql', + label: 'WQL', + description: 'WQL (Wazuh Query language) allows to do queries.', + documentationLink: `https://github.com/wazuh/wazuh-kibana-app/blob/v${pluginPlatform.version.split('.').splice(0,2).join('.')}/public/components/search-bar/query-language/wql.md`, getConfiguration() { return { isOpenPopoverImplicitFilter: false, diff --git a/public/controllers/agent/components/agents-table.js b/public/controllers/agent/components/agents-table.js index 3de71e4a32..272d8b9c0f 100644 --- a/public/controllers/agent/components/agents-table.js +++ b/public/controllers/agent/components/agents-table.js @@ -620,140 +620,11 @@ export const AgentsTable = withErrorBoundary( /> {/** Example implementation */} { - switch (previousField) { - case 'configSum': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'dateAdd': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'id': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'ip': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'group': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'group_config_status': - return [AGENT_SYNCED_STATUS.SYNCED, AGENT_SYNCED_STATUS.NOT_SYNCED].map( - (status) => ({ - label: status, - }), - ); - break; - case 'lastKeepAline': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'manager': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'mergedSum': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'name': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'node_name': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'os.platform': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'status': - return UI_ORDER_AGENT_STATUS.map( - (status) => ({ - label: status, - }), - ); - break; - case 'version': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - default: - return []; - break; - } - }, - }, - }, - { - id: 'haql', + id: 'wql', implicitQuery: 'id!=000;', suggestions: { field(currentValue) { From 459a9329a8910b32a802ba4d1b20bced340ec308 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Wed, 15 Mar 2023 14:34:13 +0100 Subject: [PATCH 11/33] feat(search-bar): Add more use cases to the tests of WQL query language - Add more use cases to the test of WQL query language - Replace some literals by constants in the WQL query language implementation --- .../search-bar/query-language/wql.test.tsx | 93 ++++++++++++++++++- .../search-bar/query-language/wql.tsx | 21 +++-- 2 files changed, 105 insertions(+), 9 deletions(-) diff --git a/public/components/search-bar/query-language/wql.test.tsx b/public/components/search-bar/query-language/wql.test.tsx index be2e42a707..e30402390d 100644 --- a/public/components/search-bar/query-language/wql.test.tsx +++ b/public/components/search-bar/query-language/wql.test.tsx @@ -91,6 +91,24 @@ describe('Query language - WQL', () => { ${'field'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, tu.opCompare, tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} ${'field='} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} ${'field=value'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=and'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('and'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=or'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('or'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=valueand'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('valueand'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=valueor'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('valueor'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value='} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value='), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value!='} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value!='), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value>'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value>'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value<'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value<'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value~'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value~'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie] /** ~ character is not supported as value in the q query parameter */} + ${'field="'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value and'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value and'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value and value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value and value2"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value or value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value or value2"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value = value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value = value2"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value != value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value != value2"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value > value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value > value2"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value < value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value < value2"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value ~ value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value ~ value2"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie] /** ~ character is not supported as value in the q query parameter */} ${'field=value and'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), tu.whitespace, ...tuBlankSerie]} ${'field=value and '} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), ...tuBlankSerie]} ${'field=value and field2'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, tu.opCompare, tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} @@ -153,12 +171,27 @@ describe('Query language - WQL', () => { // Transform specific query language to UQL (Unified Query Language) it.each` - WQL | UQL + WQL | UQL ${'field'} | ${'field'} ${'field='} | ${'field='} ${'field=value'} | ${'field=value'} ${'field=value()'} | ${'field=value()'} + ${'field=valueand'} | ${'field=valueand'} + ${'field=valueor'} | ${'field=valueor'} + ${'field=value='} | ${'field=value='} + ${'field=value!='} | ${'field=value!='} + ${'field=value>'} | ${'field=value>'} + ${'field=value<'} | ${'field=value<'} + ${'field=value~'} | ${'field=value~' /** ~ character is not supported as value in the q query parameter */} ${'field="custom value"'} | ${'field=custom value'} + ${'field="custom value()"'} | ${'field=custom value()'} + ${'field="value and value2"'} | ${'field=value and value2'} + ${'field="value or value2"'} | ${'field=value or value2'} + ${'field="value = value2"'} | ${'field=value = value2'} + ${'field="value != value2"'} | ${'field=value != value2'} + ${'field="value > value2"'} | ${'field=value > value2'} + ${'field="value < value2"'} | ${'field=value < value2'} + ${'field="value ~ value2"'} | ${'field=value ~ value2' /** ~ character is not supported as value in the q query parameter */} ${'field="custom \\"value"'} | ${'field=custom "value'} ${'field="custom \\"value\\""'} | ${'field=custom "value"'} ${'field=value and'} | ${'field=value;'} @@ -172,22 +205,47 @@ describe('Query language - WQL', () => { ${'(field=value) and field2>"value with spaces"'} | ${'(field=value);field2>value with spaces'} ${'field ='} | ${'field='} ${'field = value'} | ${'field=value'} + ${'field = value()'} | ${'field=value()'} + ${'field = valueand'} | ${'field=valueand'} + ${'field = valueor'} | ${'field=valueor'} + ${'field = value='} | ${'field=value='} + ${'field = value!='} | ${'field=value!='} + ${'field = value>'} | ${'field=value>'} + ${'field = value<'} | ${'field=value<'} + ${'field = value~'} | ${'field=value~' /** ~ character is not supported as value in the q query parameter */} + ${'field = "custom value"'} | ${'field=custom value'} + ${'field = "custom value()"'} | ${'field=custom value()'} + ${'field = "value and value2"'} | ${'field=value and value2'} + ${'field = "value or value2"'} | ${'field=value or value2'} + ${'field = "value = value2"'} | ${'field=value = value2'} + ${'field = "value != value2"'} | ${'field=value != value2'} + ${'field = "value > value2"'} | ${'field=value > value2'} + ${'field = "value < value2"'} | ${'field=value < value2'} + ${'field = "value ~ value2"'} | ${'field=value ~ value2' /** ~ character is not supported as value in the q query parameter */} ${'field = value or'} | ${'field=value,'} ${'field = value or field2'} | ${'field=value,field2'} ${'field = value or field2 <'} | ${'field=value,field2<'} ${'field = value or field2 < value2'} | ${'field=value,field2 "custom value" '} | ${'(field=value);field2>custom value'} - `('transformSpecificQLToUnifiedQL - WQL $WQL', ({WQL, UQL}) => { + `('transformSpecificQLToUnifiedQL - WQL $WQL TO UQL $UQL', ({WQL, UQL}) => { expect(transformSpecificQLToUnifiedQL(WQL)).toEqual(UQL); }); // When a suggestion is clicked, change the input text it.each` - WQL | clikedSuggestion | changedInput + WQL | clikedSuggestion | changedInput ${''} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field'}} | ${'field'} ${'field'} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'field2'} ${'field'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '='}} | ${'field='} ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value'}} | ${'field=value'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value()'}} | ${'field=value()'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'valueand'}} | ${'field=valueand'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'valueor'}} | ${'field=valueor'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value='}} | ${'field=value='} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value!='}} | ${'field=value!='} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value>'}} | ${'field=value>'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value<'}} | ${'field=value<'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value~'}} | ${'field=value~' /** ~ character is not supported as value in the q query parameter */} ${'field='} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '!='}} | ${'field!='} ${'field=value'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2'}} | ${'field=value2'} ${'field=value'} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'and'}} | ${'field=value and '} @@ -195,6 +253,14 @@ describe('Query language - WQL', () => { ${'field=value and field2'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>'}} | ${'field=value and field2>'} ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with spaces'}} | ${'field="with spaces"'} ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with "spaces'}} | ${'field="with \\"spaces"'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with value()'}} | ${'field="with value()"'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with and value'}}| ${'field="with and value"'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with or value'}} | ${'field="with or value"'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with = value'}} | ${'field="with = value"'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with != value'}} | ${'field="with != value"'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with > value'}} | ${'field="with > value"'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with < value'}} | ${'field="with < value"'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with ~ value'}} | ${'field="with ~ value"' /** ~ character is not supported as value in the q query parameter */} ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: '"value'}} | ${'field="\\"value"'} ${''} | ${{type: { iconType: 'tokenDenseVector', color: 'tint3' }, label: '('}} | ${'('} ${'('} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field'}} | ${'(field'} @@ -236,6 +302,14 @@ describe('Query language - WQL', () => { ${''} | ${''} ${'field'} | ${'field'} ${'field='} | ${'field='} + ${'field=()'} | ${'field=()'} + ${'field=valueand'} | ${'field=valueand'} + ${'field=valueor'} | ${'field=valueor'} + ${'field=value='} | ${'field=value='} + ${'field=value!='} | ${'field=value!='} + ${'field=value>'} | ${'field=value>'} + ${'field=value<'} | ${'field=value<'} + ${'field=value~'} | ${'field=value~'} ${'field!='} | ${'field!='} ${'field>'} | ${'field>'} ${'field<'} | ${'field<'} @@ -246,6 +320,12 @@ describe('Query language - WQL', () => { ${'field="'} | ${'field="\\""'} ${'field=with spaces'} | ${'field="with spaces"'} ${'field=with "spaces'} | ${'field="with \\"spaces"'} + ${'field=value ()'} | ${'field="value ()"'} + ${'field=with and value'} | ${'field="with and value"'} + ${'field=with or value'} | ${'field="with or value"'} + ${'field=with = value'} | ${'field="with = value"'} + ${'field=with > value'} | ${'field="with > value"'} + ${'field=with < value'} | ${'field="with < value"'} ${'('} | ${'('} ${'(field'} | ${'(field'} ${'(field='} | ${'(field='} @@ -259,4 +339,11 @@ describe('Query language - WQL', () => { expect(WQL.transformUnifiedQuery(UQL)).toEqual(changedInput); }); + /* The ! and ~ characters can't be part of a value that contains examples. The tests doesn't + include these cases. + + Value examples: + - with != value + - with ~ value + */ }); diff --git a/public/components/search-bar/query-language/wql.tsx b/public/components/search-bar/query-language/wql.tsx index 970d25020c..1202a7cc81 100644 --- a/public/components/search-bar/query-language/wql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -62,6 +62,14 @@ const language = { }, }, }, + equivalencesToUQL:{ + conjunction:{ + literal:{ + 'and': ';', + 'or': ',', + } + } + } }; // Suggestion mapper by language token type @@ -101,9 +109,9 @@ const mapSuggestionCreatorValue = mapSuggestionCreator('value'); */ function transformQLConjunction(conjunction: string): string{ // If the value has a whitespace or comma, then - return conjunction === ';' - ? ' and ' - : ' or '; + return conjunction === language.equivalencesToUQL.conjunction.literal['and'] + ? ` ${language.tokens.conjunction.literal['and']} ` + : ` ${language.tokens.conjunction.literal['or']} `; }; /** @@ -491,15 +499,16 @@ export function transformSpecificQLToUnifiedQL(input: string){ case 'value':{ // Value is wrapped with " let [ _, extractedValue ] = value.match(/^"(.+)"$/) || [ null, null ]; - // Replace the escape commas (\") by comma (") + // Replace the escaped comma (\") by comma (") + // WARN: This could cause a problem with value that contains this sequence \" extractedValue && (extractedValue = extractedValue.replace(/\\"/g, '"')); return extractedValue || value; break; } case 'conjunction': return value === 'and' - ? ';' - : ','; + ? language.equivalencesToUQL.conjunction.literal['and'] + : language.equivalencesToUQL.conjunction.literal['or']; break; default: return value; From 315932a9900d929911d2696a9193c2f2746f2dd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Wed, 15 Mar 2023 14:39:38 +0100 Subject: [PATCH 12/33] feat(search-bar): enhance the documenation of query languages --- .../search-bar/query-language/aql.md | 4 ++- .../search-bar/query-language/wql.md | 30 ++++++++++++------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/public/components/search-bar/query-language/aql.md b/public/components/search-bar/query-language/aql.md index 9dcb5bb0e0..293d2a049e 100644 --- a/public/components/search-bar/query-language/aql.md +++ b/public/components/search-bar/query-language/aql.md @@ -12,10 +12,12 @@ The implementation is adapted to work with the search bar component defined - Suggestions for `fields` (configurable), `operators` and `values` (configurable) - Support implicit query -## Language syntax +# Language syntax Documentation: https://wazuh.com/./user-manual/api/queries.html +# Developer notes + ## Options - `implicitQuery`: add an implicit query that is added to the user input. Optional. diff --git a/public/components/search-bar/query-language/wql.md b/public/components/search-bar/query-language/wql.md index 7ff72ddc1f..4c19b731ca 100644 --- a/public/components/search-bar/query-language/wql.md +++ b/public/components/search-bar/query-language/wql.md @@ -12,15 +12,15 @@ The implementation is adapted to work with the search bar component defined - Suggestions for `fields` (configurable), `operators` and `values` (configurable) - Support implicit query -## Language syntax +# Language syntax -### Schema +## Schema ``` ???????????? ``` -### Fields +## Fields Regular expression: /[\\w.]+/ @@ -31,9 +31,9 @@ field field.custom ``` -### Operators +## Operators -#### Compare +### Compare - `=` equal to - `!=` not equal to @@ -41,15 +41,15 @@ field.custom - `<` smaller - `~` like -#### Group +### Group - `(` open - `)` close -#### Logical +### Conjunction (logical) -- `and` -- `or` +- `and` intersection +- `or` union ### Values @@ -93,6 +93,8 @@ status != never_connected and ip ~ 240 or os.platform ~ linux ( status != never_connected and ip ~ 240 ) or id = 001 ``` +## Developer notes + ## Options - `implicitQuery`: add an implicit query that is added to the user input. Optional. @@ -275,4 +277,12 @@ graph TD; end output-->output_search_bar[Output] -``` \ No newline at end of file +``` + +## Notes + +- The value that contains the following characters: `!`, `~` are not supported by the AQL and this +could cause problems when do the request to the API. +- The value with spaces are wrapped with `"`. If the value contains the `\"` sequence this is +replaced by `"`. This could cause a problem with values that are intended to have the mentioned +sequence. \ No newline at end of file From 4303a1af4b0bf72d790c4aa4b47750c2653db914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Wed, 15 Mar 2023 14:50:05 +0100 Subject: [PATCH 13/33] feat(search-bar): Add a popover title to replicate similar UI to the platform search bar --- public/components/search-bar/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/public/components/search-bar/index.tsx b/public/components/search-bar/index.tsx index 63b1d5a49a..fd1be3cdab 100644 --- a/public/components/search-bar/index.tsx +++ b/public/components/search-bar/index.tsx @@ -4,6 +4,7 @@ import { EuiFormRow, EuiLink, EuiPopover, + EuiPopoverTitle, EuiSpacer, EuiSelect, EuiText, @@ -141,6 +142,7 @@ export const SearchBar = ({ isOpen={isOpenPopoverQueryLanguage} closePopover={onQueryLanguagePopoverSwitch} > + SYNTAX OPTIONS {searchBarQueryLanguages[queryLanguage.id].description} From 5e1485c330e1204769aca21bc9ab2cfabbaa4473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Wed, 15 Mar 2023 14:51:46 +0100 Subject: [PATCH 14/33] feat(search-bar): wrap the user input with group operators when there is an implicit query --- public/components/search-bar/query-language/aql.tsx | 2 +- public/components/search-bar/query-language/wql.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/components/search-bar/query-language/aql.tsx b/public/components/search-bar/query-language/aql.tsx index a93f33df19..9cb38211d5 100644 --- a/public/components/search-bar/query-language/aql.tsx +++ b/public/components/search-bar/query-language/aql.tsx @@ -409,7 +409,7 @@ function transformSuggestionsToEuiSuggestItem( * @returns */ function getOutput(input: string, options: {implicitQuery?: string} = {}) { - const unifiedQuery = `${options?.implicitQuery ?? ''}${input}`; + const unifiedQuery = `${options?.implicitQuery ?? ''}${options?.implicitQuery ? `(${input})` : input}`; return { language: AQL.id, query: unifiedQuery, diff --git a/public/components/search-bar/query-language/wql.tsx b/public/components/search-bar/query-language/wql.tsx index 1202a7cc81..346569c5ec 100644 --- a/public/components/search-bar/query-language/wql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -524,7 +524,7 @@ export function transformSpecificQLToUnifiedQL(input: string){ * @returns */ function getOutput(input: string, options: {implicitQuery?: string} = {}) { - const query = `${transformUnifiedQueryToSpecificQueryLanguage(options?.implicitQuery ?? '')}${input}`; + const query = `${transformUnifiedQueryToSpecificQueryLanguage(options?.implicitQuery ?? '')}${options?.implicitQuery ? `(${input})` : input}`; return { language: WQL.id, unifiedQuery: transformSpecificQLToUnifiedQL(query), From 9983153383cd94165d62929173a434aaf6fc9253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Thu, 16 Mar 2023 16:42:30 +0100 Subject: [PATCH 15/33] feat(search-bar): add implicit query mode to WQL - WQL - add implicit query mode to WQL - enhance query language documentation - renamed transformUnifiedQuery to transformUQLToQL - now wraps the user input if this is defined and there a implicit query string - fix a problem with the value suggestions if there is a previous conjunction - add tests cases - update tests - AQL - enhance query language documentation - renamed transformUnifiedQuery to transformUQLToQL - add warning about the query language implementation is not updated to the last changes in the search bar component - update tests - Search Bar - renamed transformUnifiedQuery to transformUQLToQL --- public/components/search-bar/README.md | 12 +- public/components/search-bar/index.test.tsx | 5 +- public/components/search-bar/index.tsx | 8 +- .../search-bar/query-language/aql.md | 2 + .../search-bar/query-language/aql.test.tsx | 2 +- .../search-bar/query-language/aql.tsx | 2 +- .../search-bar/query-language/index.ts | 2 +- .../search-bar/query-language/wql.md | 85 +++++++++++-- .../search-bar/query-language/wql.test.tsx | 13 +- .../search-bar/query-language/wql.tsx | 119 +++++++++++++----- .../agent/components/agents-table.js | 20 ++- 11 files changed, 210 insertions(+), 60 deletions(-) diff --git a/public/components/search-bar/README.md b/public/components/search-bar/README.md index df007a4ea0..58e557a26c 100644 --- a/public/components/search-bar/README.md +++ b/public/components/search-bar/README.md @@ -26,13 +26,15 @@ Basic usage: // The ID of them should be registered previously. See How to add a new query language documentation. modes={[ { - id: 'aql', + id: 'wql', // specific query language parameters // implicit query. Optional // Set a implicit query that can't be changed by the user. // Use the UQL (Unified Query Language) syntax. - // Each query language implementation must interpret - implicitQuery: 'id!=000;', + implicitQuery: { + query: 'id!=000', + conjunction: ';' + }, suggestions: { field(currentValue) { return [ @@ -202,7 +204,7 @@ type SearchBarQueryLanguage = { query: string } }>; - transformUnifiedQuery: (unifiedQuery: string) => string; + transformUQLToQL: (unifiedQuery: string) => string; }; ``` @@ -221,7 +223,7 @@ where: - `language`: query language ID - `unifiedQuery`: query in unified query syntax - `query`: current query in the specified language -- `transformUnifiedQuery`: method that transform the Unified Query Language to the specific query +- `transformUQLToQL`: method that transforms the UQL (Unified Query Language) to the specific query language. This is used when receives a external input in the Unified Query Language, the returned value is converted to the specific query language to set the new input text of the search bar component. diff --git a/public/components/search-bar/index.test.tsx b/public/components/search-bar/index.test.tsx index be6d332b6b..2f8f2366a4 100644 --- a/public/components/search-bar/index.test.tsx +++ b/public/components/search-bar/index.test.tsx @@ -21,7 +21,10 @@ describe('SearchBar component', () => { }, { id: 'wql', - implicitQuery: 'id!=000;', + implicitQuery: { + query: 'id!=000', + conjunction: ';' + }, suggestions: { field(currentValue) { return []; diff --git a/public/components/search-bar/index.tsx b/public/components/search-bar/index.tsx index fd1be3cdab..e902f553f4 100644 --- a/public/components/search-bar/index.tsx +++ b/public/components/search-bar/index.tsx @@ -12,7 +12,7 @@ import { import { EuiSuggest } from '../eui-suggest'; import { searchBarQueryLanguages } from './query-language'; -type Props = { +type SearchBarProps = { defaultMode?: string; modes: { id: string; [key: string]: any }[]; onChange?: (params: any) => void; @@ -26,7 +26,7 @@ export const SearchBar = ({ onChange, onSearch, ...rest -}: Props) => { +}: SearchBarProps) => { // Query language ID and configuration const [queryLanguage, setQueryLanguage] = useState<{ id: string; @@ -73,10 +73,10 @@ export const SearchBar = ({ }; useEffect(() => { - // React to external changes and set the internal input text. Use the `transformUnifiedQuery` of + // React to external changes and set the internal input text. Use the `transformUQLToQL` of // the query language in use rest.input && setInput( - searchBarQueryLanguages[queryLanguage.id]?.transformUnifiedQuery?.( + searchBarQueryLanguages[queryLanguage.id]?.transformUQLToQL?.( rest.input, ), ); diff --git a/public/components/search-bar/query-language/aql.md b/public/components/search-bar/query-language/aql.md index 293d2a049e..9d144e3b15 100644 --- a/public/components/search-bar/query-language/aql.md +++ b/public/components/search-bar/query-language/aql.md @@ -1,3 +1,5 @@ +**WARNING: The search bar was changed and this language needs some adaptations to work.** + # Query Language - AQL AQL (API Query Language) is a query language based in the `q` query parameters of the Wazuh API diff --git a/public/components/search-bar/query-language/aql.test.tsx b/public/components/search-bar/query-language/aql.test.tsx index 597d31188a..86fc9de5c9 100644 --- a/public/components/search-bar/query-language/aql.test.tsx +++ b/public/components/search-bar/query-language/aql.test.tsx @@ -200,6 +200,6 @@ describe('Query language - AQL', () => { ${'(field=value,field2>value2'} | ${'(field=value,field2>value2'} ${'(field=value,field2>value2)'} | ${'(field=value,field2>value2)'} `('Transform the external input UQL to QL - UQL $UQL => $AQL', async ({UQL, AQL: changedInput}) => { - expect(AQL.transformUnifiedQuery(UQL)).toEqual(changedInput); + expect(AQL.transformUQLToQL(UQL)).toEqual(changedInput); }); }); diff --git a/public/components/search-bar/query-language/aql.tsx b/public/components/search-bar/query-language/aql.tsx index 9cb38211d5..4256cd4540 100644 --- a/public/components/search-bar/query-language/aql.tsx +++ b/public/components/search-bar/query-language/aql.tsx @@ -517,7 +517,7 @@ export const AQL = { output: getOutput(input, params.queryLanguage.parameters), }; }, - transformUnifiedQuery(unifiedQuery: string): string { + transformUQLToQL(unifiedQuery: string): string { return unifiedQuery; }, }; diff --git a/public/components/search-bar/query-language/index.ts b/public/components/search-bar/query-language/index.ts index 9c0d6e2b9b..fc95717fca 100644 --- a/public/components/search-bar/query-language/index.ts +++ b/public/components/search-bar/query-language/index.ts @@ -15,7 +15,7 @@ type SearchBarQueryLanguage = { query: string } }>; - transformUnifiedQuery: (unifiedQuery: string) => string; + transformUQLToQL: (unifiedQuery: string) => string; }; // Register the query languages diff --git a/public/components/search-bar/query-language/wql.md b/public/components/search-bar/query-language/wql.md index 4c19b731ca..b99538b9c7 100644 --- a/public/components/search-bar/query-language/wql.md +++ b/public/components/search-bar/query-language/wql.md @@ -8,19 +8,24 @@ Documentation: https://wazuh.com/./user-manual/api The implementation is adapted to work with the search bar component defined `public/components/search-bar/index.tsx`. -## Features -- Suggestions for `fields` (configurable), `operators` and `values` (configurable) -- Support implicit query - # Language syntax -## Schema +It supports 2 modes: + +- `explicit`: define the field, operator and value +- `implicit`: use a term to search in the available fields + +Theses modes can not be combined. + +## Mode: explicit + +### Schema ``` ???????????? ``` -## Fields +### Fields Regular expression: /[\\w.]+/ @@ -31,9 +36,9 @@ field field.custom ``` -## Operators +### Operators -### Compare +#### Compare - `=` equal to - `!=` not equal to @@ -41,17 +46,17 @@ field.custom - `<` smaller - `~` like -### Group +#### Group - `(` open - `)` close -### Conjunction (logical) +#### Conjunction (logical) - `and` intersection - `or` union -### Values +#### Values - Value without spaces can be literal - Value with spaces should be wrapped by `"`. The `"` can be escaped using `\"`. @@ -93,19 +98,73 @@ status != never_connected and ip ~ 240 or os.platform ~ linux ( status != never_connected and ip ~ 240 ) or id = 001 ``` +## Mode: implicit + +Search the term in the available fields. + +This mode is used when there is no a `field` and `operator` attending to the regular expression +of the **explicit** mode. + +### Examples: + +``` +linux +``` + +If the available fields are `id` and `ip`, then the input will be translated under the hood to the +following UQL syntax: + +``` +id~linux,ip~linux +``` + ## Developer notes +## Features +- Support suggestions for each token entity. `fields` and `values` are customizable. +- Support implicit query. +- Support for search term mode. It enables to search a term in multiple fields. + The query is built under the hoods. This mode requires there are `field` and `operator_compare`. + +### Implicit query + +This a query that can't be added, edited or removed by the user. It is added to the user input. + +### Search term mode + +This mode enables to search in multiple fields. The fields to use must be defined. + +Use an union expression of each field with the like as operation `~`. + +The user input is transformed to something as: +``` +field1~user_input,field2~user_input,field3~user_input +``` + ## Options - `implicitQuery`: add an implicit query that is added to the user input. Optional. + This can't be changed by the user. If this is defined, will be displayed as a prepend of the search bar. + - `query`: query string in UQL (Unified Query Language) Use UQL (Unified Query Language). -This can't be changed by the user. If this is defined, will be displayed as a prepend of the search bar. + - `conjunction`: query string of the conjunction in UQL (Unified Query Language) + ```ts // language options // ID is not equal to 000 and . This is defined in UQL that is transformed internally to // the specific query language. -implicitQuery: 'id!=000;' +implicitQuery: { + query: 'id!=000', + conjunction: ';' +} +``` + +- `searchTermFields`: define the fields used to build the query for the search term mode + +```ts +// language options +searchTermFields: ['id', 'ip'] ``` - `suggestions`: define the suggestion handlers. This is required. diff --git a/public/components/search-bar/query-language/wql.test.tsx b/public/components/search-bar/query-language/wql.test.tsx index e30402390d..89ac5c50ae 100644 --- a/public/components/search-bar/query-language/wql.test.tsx +++ b/public/components/search-bar/query-language/wql.test.tsx @@ -10,7 +10,10 @@ describe('SearchBar component', () => { modes: [ { id: WQL.id, - implicitQuery: 'id!=000;', + implicitQuery: { + query: 'id!=000', + conjunction: ';' + }, suggestions: { field(currentValue) { return []; @@ -249,6 +252,9 @@ describe('Query language - WQL', () => { ${'field='} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '!='}} | ${'field!='} ${'field=value'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2'}} | ${'field=value2'} ${'field=value'} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'and'}} | ${'field=value and '} + ${'field=value and'} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'or'}} | ${'field=value or'} + ${'field=value and'} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'field=value and field2'} + ${'field=value and '} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'or'}} | ${'field=value or '} ${'field=value and '} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'field=value and field2'} ${'field=value and field2'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>'}} | ${'field=value and field2>'} ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with spaces'}} | ${'field="with spaces"'} @@ -269,6 +275,9 @@ describe('Query language - WQL', () => { ${'(field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value'}} | ${'(field=value'} ${'(field=value'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2'}} | ${'(field=value2'} ${'(field=value'} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'or'}} | ${'(field=value or '} + ${'(field=value or'} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'and'}} | ${'(field=value and'} + ${'(field=value or'} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'(field=value or field2'} + ${'(field=value or '} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'and'}} | ${'(field=value and '} ${'(field=value or '} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'(field=value or field2'} ${'(field=value or field2'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>'}} | ${'(field=value or field2>'} ${'(field=value or field2>'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>'}} | ${'(field=value or field2>'} @@ -336,7 +345,7 @@ describe('Query language - WQL', () => { ${'(field=value,field2>value2'} | ${'(field=value or field2>value2'} ${'(field=value,field2>value2)'} | ${'(field=value or field2>value2)'} `('Transform the external input UQL to QL - UQL $UQL => $WQL', async ({UQL, WQL: changedInput}) => { - expect(WQL.transformUnifiedQuery(UQL)).toEqual(changedInput); + expect(WQL.transformUQLToQL(UQL)).toEqual(changedInput); }); /* The ! and ~ characters can't be part of a value that contains examples. The tests doesn't diff --git a/public/components/search-bar/query-language/wql.tsx b/public/components/search-bar/query-language/wql.tsx index 346569c5ec..795417e7d1 100644 --- a/public/components/search-bar/query-language/wql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -203,7 +203,13 @@ type QLOptionSuggestionHandler = ( }: { previousField: string; previousOperatorCompare: string }, ) => Promise; -type optionsQL = { +type OptionsQLImplicitQuery = { + query: string + conjunction: string +} +type OptionsQL = { + implicitQuery?: OptionsQLImplicitQuery + searchTermFields?: string[] suggestions: { field: QLOptionSuggestionHandler; value: QLOptionSuggestionHandler; @@ -216,7 +222,7 @@ type optionsQL = { * @param tokenType token type to search * @returns */ -function getLastTokenWithValue( +function getLastTokenDefined( tokens: ITokens ): IToken | undefined { // Reverse the tokens array and use the Array.protorype.find method @@ -234,7 +240,7 @@ function getLastTokenWithValue( * @param tokenType token type to search * @returns */ -function getLastTokenWithValueByType( +function getLastTokenDefinedByType( tokens: ITokens, tokenType: ITokenType, ): IToken | undefined { @@ -255,13 +261,13 @@ function getLastTokenWithValueByType( * @param options * @returns */ -export async function getSuggestions(tokens: ITokens, options: optionsQL): Promise { +export async function getSuggestions(tokens: ITokens, options: OptionsQL): Promise { if (!tokens.length) { return []; } // Get last token - const lastToken = getLastTokenWithValue(tokens); + const lastToken = getLastTokenDefined(tokens); // If it can't get a token with value, then returns fields and open operator group if(!lastToken?.type){ @@ -302,8 +308,8 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL): Promi ]; break; case 'operator_compare':{ - const previousField = getLastTokenWithValueByType(tokens, 'field')?.value; - const previousOperatorCompare = getLastTokenWithValueByType( + const previousField = getLastTokenDefinedByType(tokens, 'field')?.value; + const previousOperatorCompare = getLastTokenDefinedByType( tokens, 'operator_compare', )?.value; @@ -340,14 +346,14 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL): Promi break; } case 'value':{ - const previousField = getLastTokenWithValueByType(tokens, 'field')?.value; - const previousOperatorCompare = getLastTokenWithValueByType( + const previousField = getLastTokenDefinedByType(tokens, 'field')?.value; + const previousOperatorCompare = getLastTokenDefinedByType( tokens, 'operator_compare', )?.value; - // If there is no a previous field or operator_compar, then no return suggestions because - //it would be an syntax error + /* If there is no a previous field or operator_compare, then no return suggestions because + it would be an syntax error */ if(!previousField || !previousOperatorCompare){ return []; }; @@ -461,11 +467,11 @@ function transformSuggestionsToEuiSuggestItem( }; /** - * Transform the UQL (Unified Query Language) to SpecificQueryLanguage + * Transform the UQL (Unified Query Language) to QL * @param input * @returns */ -export function transformUnifiedQueryToSpecificQueryLanguage(input: string){ +export function transformUQLToQL(input: string){ const tokens = tokenizerUQL(input); return tokens .filter(({value}) => value) @@ -485,13 +491,29 @@ export function transformUnifiedQueryToSpecificQueryLanguage(input: string){ ).join(''); }; +export function shouldUseSearchTerm(tokens: ITokens): boolean{ + return !( + tokens.some(({type, value}) => type === 'operator_compare' && value ) + && tokens.some(({type, value}) => type === 'field' && value ) + ); +}; + +export function transformToSearchTerm(searchTermFields: string[], input: string): string{ + return searchTermFields.map(searchTermField => `${searchTermField}~${input}`).join(','); +}; + /** - * Transform the input in SpecificQueryLanguage to UQL (Unified Query Language) + * Transform the input in QL to UQL (Unified Query Language) * @param input * @returns */ -export function transformSpecificQLToUnifiedQL(input: string){ +export function transformSpecificQLToUnifiedQL(input: string, searchTermFields: string[]){ const tokens = tokenizer(input); + + if(input && searchTermFields && shouldUseSearchTerm(tokens)){ + return transformToSearchTerm(searchTermFields, input); + }; + return tokens .filter(({type, value}) => type !== 'whitespace' && value) .map(({type, value}) => { @@ -523,19 +545,45 @@ export function transformSpecificQLToUnifiedQL(input: string){ * @param input * @returns */ -function getOutput(input: string, options: {implicitQuery?: string} = {}) { - const query = `${transformUnifiedQueryToSpecificQueryLanguage(options?.implicitQuery ?? '')}${options?.implicitQuery ? `(${input})` : input}`; +function getOutput(input: string, options: OptionsQL) { + // Implicit query + const implicitQueryAsUQL = options?.implicitQuery?.query ?? ''; + const implicitQueryAsQL = transformUQLToQL( + implicitQueryAsUQL + ); + + // Implicit query conjunction + const implicitQueryConjunctionAsUQL = options?.implicitQuery?.conjunction ?? ''; + const implicitQueryConjunctionAsQL = transformUQLToQL( + implicitQueryConjunctionAsUQL + ); + + // User input query + const inputQueryAsQL = input; + const inputQueryAsUQL = transformSpecificQLToUnifiedQL( + inputQueryAsQL, + options?.searchTermFields ?? [] + ); + return { language: WQL.id, - unifiedQuery: transformSpecificQLToUnifiedQL(query), - query + unifiedQuery: [ + implicitQueryAsUQL, + implicitQueryAsUQL && inputQueryAsUQL ? implicitQueryConjunctionAsUQL : '', + implicitQueryAsUQL && inputQueryAsUQL ? `(${inputQueryAsUQL})`: inputQueryAsUQL + ].join(''), + query: [ + implicitQueryAsQL, + implicitQueryAsQL && inputQueryAsQL ? implicitQueryConjunctionAsQL : '', + implicitQueryAsQL && inputQueryAsQL ? `(${inputQueryAsQL})`: inputQueryAsQL + ].join('') }; }; export const WQL = { id: 'wql', label: 'WQL', - description: 'WQL (Wazuh Query language) allows to do queries.', + description: 'WQL (Wazuh Query Language) allows to do queries.', documentationLink: `https://github.com/wazuh/wazuh-kibana-app/blob/v${pluginPlatform.version.split('.').splice(0,2).join('.')}/public/components/search-bar/query-language/wql.md`, getConfiguration() { return { @@ -546,11 +594,17 @@ export const WQL = { // Get the tokens from the input const tokens: ITokens = tokenizer(input); - // - const implicitQueryAsSpecificQueryLanguage = params.queryLanguage.parameters.implicitQuery - ? transformUnifiedQueryToSpecificQueryLanguage(params.queryLanguage.parameters.implicitQuery) + // Get the implicit query as query language syntax + const implicitQueryAsQL = params.queryLanguage.parameters.implicitQuery + ? transformUQLToQL( + params.queryLanguage.parameters.implicitQuery.query + + params.queryLanguage.parameters.implicitQuery.conjunction + ) : ''; + // Get the output of query language + const output = getOutput(input, params.queryLanguage.parameters); + return { searchBarProps: { // Props that will be used by the EuiSuggest component @@ -563,10 +617,10 @@ export const WQL = { // When the clicked item has the `search` iconType, run the `onSearch` function if (item.type.iconType === 'search') { // Execute the search action - params.onSearch(getOutput(input, params.queryLanguage.parameters)); + params.onSearch(output); } else { // When the clicked item has another iconType - const lastToken: IToken | undefined = getLastTokenWithValue(tokens); + const lastToken: IToken | undefined = getLastTokenDefined(tokens); // if the clicked suggestion is of same type of last token if ( lastToken && suggestionMappingLanguageTokenType[lastToken.type].iconType === @@ -577,7 +631,10 @@ export const WQL = { } else { // add a whitespace for conjunction !(/\s$/.test(input)) - && item.type.iconType === suggestionMappingLanguageTokenType.conjunction.iconType + && ( + item.type.iconType === suggestionMappingLanguageTokenType.conjunction.iconType + || lastToken?.type === 'conjunction' + ) && tokens.push({ type: 'whitespace', value: ' ' @@ -610,7 +667,7 @@ export const WQL = { .join('')); } }, - prepend: implicitQueryAsSpecificQueryLanguage ? ( + prepend: implicitQueryAsQL ? ( - {implicitQueryAsSpecificQueryLanguage} + {implicitQueryAsQL} } @@ -640,7 +697,7 @@ export const WQL = { > Implicit query:{' '} - {implicitQueryAsSpecificQueryLanguage} + {implicitQueryAsQL} This query is added to the input. @@ -651,8 +708,8 @@ export const WQL = { // use case. disableFocusTrap: true }, - output: getOutput(input, params.queryLanguage.parameters), + output, }; }, - transformUnifiedQuery: transformUnifiedQueryToSpecificQueryLanguage, + transformUQLToQL: transformUQLToQL, }; diff --git a/public/controllers/agent/components/agents-table.js b/public/controllers/agent/components/agents-table.js index 272d8b9c0f..82ce73d88d 100644 --- a/public/controllers/agent/components/agents-table.js +++ b/public/controllers/agent/components/agents-table.js @@ -625,7 +625,25 @@ export const AgentsTable = withErrorBoundary( modes={[ { id: 'wql', - implicitQuery: 'id!=000;', + implicitQuery: { + query: 'id!=000', + conjunction: ';' + }, + searchTermFields: [ + 'configSum', + 'dateAdd', + 'id', + 'group', + 'group_config_status', + 'lastKeepAline', + 'manager', + 'mergedSum', + 'name', + 'node_name', + 'os.platform', + 'status', + 'version' + ], suggestions: { field(currentValue) { return [ From 1eaf94400c83181550a71d6dea07c8d2e5e69937 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Thu, 23 Mar 2023 09:12:07 +0100 Subject: [PATCH 16/33] feat(search-bar): set the width of the syntax options popover --- public/components/search-bar/index.tsx | 94 +++++++++++++------------- 1 file changed, 48 insertions(+), 46 deletions(-) diff --git a/public/components/search-bar/index.tsx b/public/components/search-bar/index.tsx index e902f553f4..2ecf57f858 100644 --- a/public/components/search-bar/index.tsx +++ b/public/components/search-bar/index.tsx @@ -143,52 +143,54 @@ export const SearchBar = ({ closePopover={onQueryLanguagePopoverSwitch} > SYNTAX OPTIONS - - {searchBarQueryLanguages[queryLanguage.id].description} - - {searchBarQueryLanguages[queryLanguage.id].documentationLink && ( - <> - -
- - Documentation - -
- - )} - {modes?.length > 1 && ( - <> - - - ({ - value: id, - text: searchBarQueryLanguages[id].label, - }))} - value={queryLanguage.id} - onChange={(event: React.ChangeEvent) => { - const queryLanguageID: string = event.target.value; - setQueryLanguage({ - id: queryLanguageID, - configuration: - searchBarQueryLanguages[ - queryLanguageID - ]?.getConfiguration?.() || {}, - }); - setInput(''); - }} - aria-label='query-language-selector' - /> - - - )} +
+ + {searchBarQueryLanguages[queryLanguage.id].description} + + {searchBarQueryLanguages[queryLanguage.id].documentationLink && ( + <> + +
+ + Documentation + +
+ + )} + {modes?.length > 1 && ( + <> + + + ({ + value: id, + text: searchBarQueryLanguages[id].label, + }))} + value={queryLanguage.id} + onChange={(event: React.ChangeEvent) => { + const queryLanguageID: string = event.target.value; + setQueryLanguage({ + id: queryLanguageID, + configuration: + searchBarQueryLanguages[ + queryLanguageID + ]?.getConfiguration?.() || {}, + }); + setInput(''); + }} + aria-label='query-language-selector' + /> + + + )} +
} {...queryLanguageOutputRun.searchBarProps} From d9abea0d47bb9707f9e5c2de9187617f532d1edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Thu, 23 Mar 2023 10:47:11 +0100 Subject: [PATCH 17/33] feat(search-bar): unify suggestion descriptions in WQL - Set a width for the syntax options popover - Unify the description in the suggestions of WQL example implementation - Update tests - Fix minor bugs in the WQL example implementation in Agents --- .../search-bar/query-language/aql.test.tsx | 6 ++-- .../search-bar/query-language/aql.tsx | 2 +- .../search-bar/query-language/wql.test.tsx | 4 +-- .../search-bar/query-language/wql.tsx | 4 +-- .../agent/components/agents-table.js | 31 ++++++++++--------- 5 files changed, 24 insertions(+), 23 deletions(-) diff --git a/public/components/search-bar/query-language/aql.test.tsx b/public/components/search-bar/query-language/aql.test.tsx index 86fc9de5c9..a5f7c7d36c 100644 --- a/public/components/search-bar/query-language/aql.test.tsx +++ b/public/components/search-bar/query-language/aql.test.tsx @@ -82,12 +82,12 @@ describe('Query language - AQL', () => { ${'f'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }]} ${'field'} | ${[{ description: 'Field2', label: 'field2', type: 'field' }, { description: 'equality', label: '=', type: 'operator_compare' }, { description: 'not equality', label: '!=', type: 'operator_compare' }, { description: 'bigger', label: '>', type: 'operator_compare' }, { description: 'smaller', label: '<', type: 'operator_compare' }, { description: 'like as', label: '~', type: 'operator_compare' }]} ${'field='} | ${[{ label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }]} - ${'field=v'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} - ${'field=value'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=v'} | ${[{ description: 'run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=value'} | ${[{ description: 'run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} ${'field=value;'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }, { description: 'open group', label: '(', type: 'operator_group' }]} ${'field=value;field2'} | ${[{ description: 'equality', label: '=', type: 'operator_compare' }, { description: 'not equality', label: '!=', type: 'operator_compare' }, { description: 'bigger', label: '>', type: 'operator_compare' }, { description: 'smaller', label: '<', type: 'operator_compare' }, { description: 'like as', label: '~', type: 'operator_compare' }]} ${'field=value;field2='} | ${[{ label: '127.0.0.1', type: 'value' }, { label: '127.0.0.2', type: 'value' }, { label: '190.0.0.1', type: 'value' }, { label: '190.0.0.2', type: 'value' }]} - ${'field=value;field2=127'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { label: '127.0.0.1', type: 'value' }, { label: '127.0.0.2', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=value;field2=127'} | ${[{ description: 'run the search query', label: 'Search', type: 'function_search' }, { label: '127.0.0.1', type: 'value' }, { label: '127.0.0.2', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} `('Get suggestion from the input: $input', async ({ input, suggestions }) => { expect( await getSuggestions(tokenizer(input), { diff --git a/public/components/search-bar/query-language/aql.tsx b/public/components/search-bar/query-language/aql.tsx index 4256cd4540..8c898af3e2 100644 --- a/public/components/search-bar/query-language/aql.tsx +++ b/public/components/search-bar/query-language/aql.tsx @@ -299,7 +299,7 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL): Promi { type: 'function_search', label: 'Search', - description: 'Run the search query', + description: 'run the search query', }, ] : []), diff --git a/public/components/search-bar/query-language/wql.test.tsx b/public/components/search-bar/query-language/wql.test.tsx index 89ac5c50ae..752b9b8451 100644 --- a/public/components/search-bar/query-language/wql.test.tsx +++ b/public/components/search-bar/query-language/wql.test.tsx @@ -132,8 +132,8 @@ describe('Query language - WQL', () => { ${'f'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }]} ${'field'} | ${[{ description: 'Field2', label: 'field2', type: 'field' }, { description: 'equality', label: '=', type: 'operator_compare' }, { description: 'not equality', label: '!=', type: 'operator_compare' }, { description: 'bigger', label: '>', type: 'operator_compare' }, { description: 'smaller', label: '<', type: 'operator_compare' }, { description: 'like as', label: '~', type: 'operator_compare' }]} ${'field='} | ${[{ label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }]} - ${'field=v'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: 'and', type: 'conjunction' }, { description: 'or', label: 'or', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} - ${'field=value'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: 'and', type: 'conjunction' }, { description: 'or', label: 'or', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=v'} | ${[{ description: 'run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: 'and', type: 'conjunction' }, { description: 'or', label: 'or', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=value'} | ${[{ description: 'run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: 'and', type: 'conjunction' }, { description: 'or', label: 'or', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} ${'field=value and'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }, { description: 'open group', label: '(', type: 'operator_group' }]} `('Get suggestion from the input: $input', async ({ input, suggestions }) => { expect( diff --git a/public/components/search-bar/query-language/wql.tsx b/public/components/search-bar/query-language/wql.tsx index 795417e7d1..84758b3ec3 100644 --- a/public/components/search-bar/query-language/wql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -364,7 +364,7 @@ export async function getSuggestions(tokens: ITokens, options: OptionsQL): Promi { type: 'function_search', label: 'Search', - description: 'Run the search query', + description: 'run the search query', }, ] : []), @@ -583,7 +583,7 @@ function getOutput(input: string, options: OptionsQL) { export const WQL = { id: 'wql', label: 'WQL', - description: 'WQL (Wazuh Query Language) allows to do queries.', + description: 'WQL (Wazuh Query Language) offers a human query syntax based on the Wazuh API query language.', documentationLink: `https://github.com/wazuh/wazuh-kibana-app/blob/v${pluginPlatform.version.split('.').splice(0,2).join('.')}/public/components/search-bar/query-language/wql.md`, getConfiguration() { return { diff --git a/public/controllers/agent/components/agents-table.js b/public/controllers/agent/components/agents-table.js index 82ce73d88d..84ac881f60 100644 --- a/public/controllers/agent/components/agents-table.js +++ b/public/controllers/agent/components/agents-table.js @@ -633,9 +633,10 @@ export const AgentsTable = withErrorBoundary( 'configSum', 'dateAdd', 'id', + 'ip', 'group', 'group_config_status', - 'lastKeepAline', + 'lastKeepAlive', 'manager', 'mergedSum', 'name', @@ -647,20 +648,20 @@ export const AgentsTable = withErrorBoundary( suggestions: { field(currentValue) { return [ - { label: 'configSum', description: 'Config sum' }, - { label: 'dateAdd', description: 'Date add' }, - { label: 'id', description: 'ID' }, - { label: 'ip', description: 'IP address' }, - { label: 'group', description: 'Group' }, - { label: 'group_config_status', description: 'Synced configuration status' }, - { label: 'lastKeepAline', description: 'Date add' }, - { label: 'manager', description: 'Manager' }, - { label: 'mergedSum', description: 'Merged sum' }, - { label: 'name', description: 'Agent name' }, - { label: 'node_name', description: 'Node name' }, - { label: 'os.platform', description: 'Operating system platform' }, - { label: 'status', description: 'Status' }, - { label: 'version', description: 'Version' }, + { label: 'configSum', description: 'filter by config sum' }, + { label: 'dateAdd', description: 'filter by date add' }, + { label: 'id', description: 'filter by ID' }, + { label: 'ip', description: 'filter by IP address' }, + { label: 'group', description: 'filter by Group' }, + { label: 'group_config_status', description: 'filter by Synced configuration status' }, + { label: 'lastKeepAlive', description: 'filter by date add' }, + { label: 'manager', description: 'filter by manager' }, + { label: 'mergedSum', description: 'filter by merged sum' }, + { label: 'name', description: 'filter by name' }, + { label: 'node_name', description: 'filter by manager node name' }, + { label: 'os.platform', description: 'filter by operating system platform' }, + { label: 'status', description: 'filter by status' }, + { label: 'version', description: 'filter by version' }, ]; }, value: async (currentValue, { previousField }) => { From c221f97b3f7044282f83a0daf84fdf8b9a343743 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Mon, 10 Apr 2023 16:56:36 +0200 Subject: [PATCH 18/33] feat(search-bar): add enhancements to WQL - WQL - Enhance documentation - Add partial and "expanded" input validation - Add tests --- .../components/eui-suggest/suggest_input.js | 2 +- public/components/search-bar/index.tsx | 3 +- .../search-bar/query-language/wql.md | 171 +++----------- .../search-bar/query-language/wql.test.tsx | 52 +++++ .../search-bar/query-language/wql.tsx | 211 +++++++++++++++++- .../agent/components/agents-table.js | 11 +- 6 files changed, 300 insertions(+), 150 deletions(-) diff --git a/public/components/eui-suggest/suggest_input.js b/public/components/eui-suggest/suggest_input.js index f67149d489..7a4f5df6f2 100644 --- a/public/components/eui-suggest/suggest_input.js +++ b/public/components/eui-suggest/suggest_input.js @@ -53,7 +53,7 @@ export class EuiSuggestInput extends Component { onPopoverFocus, isPopoverOpen, onClosePopover, - disableFocusTrap, + disableFocusTrap = false, ...rest } = this.props; diff --git a/public/components/search-bar/index.tsx b/public/components/search-bar/index.tsx index 2ecf57f858..f4e7fc4038 100644 --- a/public/components/search-bar/index.tsx +++ b/public/components/search-bar/index.tsx @@ -97,6 +97,7 @@ export const SearchBar = ({ configuration: configuration?.(state.configuration) || configuration, })), + setQueryLanguageOutput: setQueryLanguageOutputRun, inputRef, queryLanguage: { configuration: queryLanguage.configuration, @@ -126,7 +127,7 @@ export const SearchBar = ({ property prevents an error. */ suggestions={[]} isPopoverOpen={ - queryLanguageOutputRun.searchBarProps.suggestions.length > 0 && + queryLanguageOutputRun?.searchBarProps?.suggestions?.length > 0 && isOpenSuggestionPopover } onClosePopover={() => setIsOpenSuggestionPopover(false)} diff --git a/public/components/search-bar/query-language/wql.md b/public/components/search-bar/query-language/wql.md index b99538b9c7..2f8c4bb9f6 100644 --- a/public/components/search-bar/query-language/wql.md +++ b/public/components/search-bar/query-language/wql.md @@ -1,6 +1,6 @@ # Query Language - WQL -WQL (Wazuh Query Language) is a query language based in the `q` query parameters of the Wazuh API +WQL (Wazuh Query Language) is a query language based in the `q` query parameter of the Wazuh API endpoints. Documentation: https://wazuh.com/./user-manual/api/queries.html @@ -13,10 +13,12 @@ The implementation is adapted to work with the search bar component defined It supports 2 modes: - `explicit`: define the field, operator and value -- `implicit`: use a term to search in the available fields +- `search term`: use a term to search in the available fields Theses modes can not be combined. +`explicit` mode is enabled when it finds a field and operator tokens. + ## Mode: explicit ### Schema @@ -63,14 +65,14 @@ field.custom Examples: ``` -value -"custom value" -"custom \" value" +value_without_whitespace +"value with whitespaces" +"value with whitespaces and escaped \"quotes\"" ``` ### Notes -- The entities can be separated by whitespaces. +- The tokens can be separated by whitespaces. ### Examples @@ -81,7 +83,7 @@ id=001 id = 001 ``` -- Complex query +- Complex query (logical operator) ``` status=active and os.platform~linux status = active and os.platform ~ linux @@ -92,17 +94,17 @@ status!=never_connected and ip~240 or os.platform~linux status != never_connected and ip ~ 240 or os.platform ~ linux ``` -- Complex query with group operator +- Complex query (logical operators and group operator) ``` (status!=never_connected and ip~240) or id=001 ( status != never_connected and ip ~ 240 ) or id = 001 ``` -## Mode: implicit +## Mode: search term Search the term in the available fields. -This mode is used when there is no a `field` and `operator` attending to the regular expression +This mode is used when there is no a `field` and `operator` according to the regular expression of the **explicit** mode. ### Examples: @@ -132,7 +134,7 @@ This a query that can't be added, edited or removed by the user. It is added to ### Search term mode -This mode enables to search in multiple fields. The fields to use must be defined. +This mode enables to search in multiple fields using a search term. The fields to use must be defined. Use an union expression of each field with the like as operation `~`. @@ -174,21 +176,10 @@ searchTermFields: ['id', 'ip'] ```ts // language options field(currentValue) { + // static or async fetching is allowed return [ - { label: 'configSum', description: 'Config sum' }, - { label: 'dateAdd', description: 'Date add' }, - { label: 'id', description: 'ID' }, - { label: 'ip', description: 'IP address' }, - { label: 'group', description: 'Group' }, - { label: 'group_config_status', description: 'Synced configuration status' }, - { label: 'lastKeepAline', description: 'Date add' }, - { label: 'manager', description: 'Manager' }, - { label: 'mergedSum', description: 'Merged sum' }, - { label: 'name', description: 'Agent name' }, - { label: 'node_name', description: 'Node name' }, - { label: 'os.platform', description: 'Operating system platform' }, - { label: 'status', description: 'Status' }, - { label: 'version', description: 'Version' }, + { label: 'field1', description: 'Description' }, + { label: 'field2', description: 'Description' } ]; } ``` @@ -197,111 +188,13 @@ searchTermFields: ['id', 'ip'] ```ts // language options value: async (currentValue, { previousField }) => { - switch (previousField) { - case 'configSum': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'dateAdd': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'id': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'ip': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'group': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'group_config_status': - return [AGENT_SYNCED_STATUS.SYNCED, AGENT_SYNCED_STATUS.NOT_SYNCED].map( - (status) => ({ - type: 'value', - label: status, - }), - ); - break; - case 'lastKeepAline': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'manager': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'mergedSum': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'name': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'node_name': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'os.platform': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'status': - return UI_ORDER_AGENT_STATUS.map( - (status) => ({ - type: 'value', - label: status, - }), - ); - break; - case 'version': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - default: - return []; - break; - } + // static or async fetching is allowed + // async fetching data + // const response = await fetchData(); + return [ + { label: 'value1' }, + { label: 'value2' } + ] } ``` @@ -315,19 +208,27 @@ graph TD; end tokenizer-->tokens; + tokens-->validate; tokens-->searchBarProps; subgraph searchBarProps; - searchBarProps_suggestions-->searchBarProps_suggestions_get_last_token_with_value[Get last token with value] - searchBarProps_suggestions_get_last_token_with_value[Get last token with value]-->searchBarProps_suggestions__result[Suggestions] + searchBarProps_suggestions[suggestions]-->searchBarProps_suggestions_input_isvalid{Input is valid} + searchBarProps_suggestions_input_isvalid{Is input valid?}-->searchBarProps_suggestions_input_isvalid_success[Yes] + searchBarProps_suggestions_input_isvalid{Is input valid?}-->searchBarProps_suggestions_input_isvalid_fail[No] + searchBarProps_suggestions_input_isvalid_success[Yes]--->searchBarProps_suggestions_get_last_token_with_value[Get last token with value]-->searchBarProps_suggestions__result[Suggestions] searchBarProps_suggestions__result[Suggestions]-->EuiSuggestItem + searchBarProps_suggestions_input_isvalid_fail[No]-->searchBarProps_suggestions_invalid[Invalid with error message] + searchBarProps_suggestions_invalid[Invalid with error message]-->EuiSuggestItem searchBarProps_prepend[prepend]-->searchBarProps_prepend_implicitQuery{implicitQuery} searchBarProps_prepend_implicitQuery{implicitQuery}-->searchBarProps_prepend_implicitQuery_yes[Yes]-->EuiButton searchBarProps_prepend_implicitQuery{implicitQuery}-->searchBarProps_prepend_implicitQuery_no[No]-->null searchBarProps_disableFocusTrap:true[disableFocusTrap = true] - searchBarProps_onItemClick[onItemClickSuggestion onclick handler]-->searchBarProps_onItemClick_suggestion_search[Search suggestion] - searchBarProps_onItemClick[onItemClickSuggestion onclick handler]-->searchBarProps_onItemClick_suggestion_edit_current_token[Edit current token]-->searchBarProps_onItemClick_build_input[Build input] - searchBarProps_onItemClick[onItemClickSuggestion onclick handler]-->searchBarProps_onItemClick_suggestion_add_new_token[Add new token]-->searchBarProps_onItemClick_build_input[Build input] + searchBarProps_onItemClick[onItemClickSuggestion]-->searchBarProps_onItemClick_suggestion_search[Search suggestion] + searchBarProps_onItemClick_suggestion_search[Search suggestion]-->searchBarProps_onItemClick_suggestion_search_run[Run search] + searchBarProps_onItemClick[onItemClickSuggestion]-->searchBarProps_onItemClick_suggestion_edit_current_token[Edit current token]-->searchBarProps_onItemClick_build_input[Build input] + searchBarProps_onItemClick[onItemClickSuggestion]-->searchBarProps_onItemClick_suggestion_add_new_token[Add new token]-->searchBarProps_onItemClick_build_input[Build input] + searchBarProps_onItemClick[onItemClickSuggestion]-->searchBarProps_onItemClick_suggestion_error[Error] + searchBarProps_isInvalid[isInvalid] end tokens-->output; diff --git a/public/components/search-bar/query-language/wql.test.tsx b/public/components/search-bar/query-language/wql.test.tsx index 752b9b8451..03095d54c3 100644 --- a/public/components/search-bar/query-language/wql.test.tsx +++ b/public/components/search-bar/query-language/wql.test.tsx @@ -355,4 +355,56 @@ describe('Query language - WQL', () => { - with != value - with ~ value */ + + // Validate the tokens + it.only.each` + WQL | validationError + ${''} | ${undefined} + ${'field1'} | ${undefined} + ${'field2'} | ${undefined} + ${'field1='} | ${['The value for field "field1" is missing.']} + ${'field2='} | ${['The value for field "field2" is missing.']} + ${'field='} | ${['"field" is not a valid field.']} + ${'custom='} | ${['"custom" is not a valid field.']} + ${'field1=value'} | ${undefined} + ${'field2=value'} | ${undefined} + ${'field=value'} | ${['"field" is not a valid field.']} + ${'custom=value'} | ${['"custom" is not a valid field.']} + ${'field1=value and'} | ${['There is no sentence after conjunction "and".']} + ${'field2=value and'} | ${['There is no sentence after conjunction "and".']} + ${'field=value and'} | ${['"field" is not a valid field.', 'There is no sentence after conjunction "and".']} + ${'custom=value and'} | ${['"custom" is not a valid field.', 'There is no sentence after conjunction "and".']} + ${'field1=value and '} | ${['There is no sentence after conjunction "and".']} + ${'field2=value and '} | ${['There is no sentence after conjunction "and".']} + ${'field=value and '} | ${['"field" is not a valid field.', 'There is no sentence after conjunction "and".']} + ${'custom=value and '} | ${['"custom" is not a valid field.', 'There is no sentence after conjunction "and".']} + ${'field1=value and field2'} | ${['The operator for field \"field2\" is missing.']} + ${'field2=value and field1'} | ${['The operator for field \"field1\" is missing.']} + ${'field1=value and field'} | ${['"field" is not a valid field.']} + ${'field2=value and field'} | ${['"field" is not a valid field.']} + ${'field=value and custom'} | ${['"field" is not a valid field.', '"custom" is not a valid field.']} + ${'('} | ${undefined} + ${'(field'} | ${undefined} + ${'(field='} | ${['"field" is not a valid field.']} + ${'(field=value'} | ${['"field" is not a valid field.']} + ${'(field=value or'} | ${['"field" is not a valid field.', 'There is no sentence after conjunction or.']} + ${'(field=value or '} | ${['"field" is not a valid field.', 'There is no sentence after conjunction or.']} + ${'(field=value or field2'} | ${['"field" is not a valid field.', 'The operator for field \"field2\" is missing.']} + ${'(field=value or field2>'} | ${['"field" is not a valid field.', 'The value for field "field2" is missing.']} + ${'(field=value or field2>value2'}| ${['"field" is not a valid field.']} + `('validate the tokens - WQL $WQL => $validationError', async ({WQL: currentInput, validationError}) => { + + const qlOutput = await WQL.run(currentInput, { + queryLanguage: { + parameters: { + implicitQuery: '', + suggestions: { + field: () => (['field1', 'field2'].map(label => ({label}))), + value: () => ([]) + } + } + } + }); + expect(qlOutput.output.error).toEqual(validationError); + }); }); diff --git a/public/components/search-bar/query-language/wql.tsx b/public/components/search-bar/query-language/wql.tsx index 84758b3ec3..2c59ad2f66 100644 --- a/public/components/search-bar/query-language/wql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -83,6 +83,8 @@ const suggestionMappingLanguageTokenType = { operator_group: { iconType: 'tokenDenseVector', color: 'tint3' }, // eslint-disable-next-line camelcase function_search: { iconType: 'search', color: 'tint5' }, + // eslint-disable-next-line camelcase + validation_error: { iconType: 'alert', color: 'tint2' } }; /** @@ -153,7 +155,6 @@ export function tokenizer(input: string): ITokens{ // Simple value // Quoted ", "value, "value", "escaped \"quote" // Escape quoted string with escaping quotes: https://stackoverflow.com/questions/249791/regex-for-quoted-string-with-escaping-quotes - // '(?(?:(?:[^"\\s]+|(?:"(?:[^"\\\\]|\\\\")*"))))?' + '(?(?:(?:[^"\\s]+|(?:"(?:[^"\\\\]|\\\\")*")|(?:"(?:[^"\\\\]|\\\\")*)|")))?' + // Whitespace '(?\\s+)?' + @@ -168,8 +169,8 @@ export function tokenizer(input: string): ITokens{ ); return [ - ...input.matchAll(re)] - .map( + ...input.matchAll(re) + ].map( ({groups}) => Object.entries(groups) .map(([key, value]) => ({ type: key.startsWith('operator_group') // Transform operator_group group match @@ -252,7 +253,32 @@ function getLastTokenDefinedByType( ({ type, value }) => type === tokenType && value, ); return tokenFound; -} +}; + +/** + * Get the token that is near to a token position of the token type. + * @param tokens + * @param tokenReferencePosition + * @param tokenType + * @param mode + * @returns + */ +function getTokenNearTo( + tokens: ITokens, + tokenType: ITokenType, + mode : 'previous' | 'next' = 'previous', + options : {tokenReferencePosition?: number, tokenFoundShouldHaveValue?: boolean} = {} +): IToken | undefined { + const shallowCopyTokens = Array.from([...tokens]); + const computedShallowCopyTokens = mode === 'previous' + ? shallowCopyTokens.slice(0, options?.tokenReferencePosition || tokens.length).reverse() + : shallowCopyTokens.slice(options?.tokenReferencePosition || 0); + return computedShallowCopyTokens + .find(({type, value}) => + type === tokenType + && (options?.tokenFoundShouldHaveValue ? value : true) + ); +}; /** * Get the suggestions from the tokens @@ -580,6 +606,128 @@ function getOutput(input: string, options: OptionsQL) { }; }; +/** + * Validate the tokens while the user is building the query + * @param tokens + * @param options + * @returns + */ +function validatePartial(tokens: ITokens, options: {field: string[]}): undefined | string{ + // Ensure is not in search term mode + if (!shouldUseSearchTerm(tokens)){ + return tokens.map((token: IToken, index) => { + if(token.value){ + if(token.type === 'field'){ + // Ensure there is a operator next to field to check if the fields is valid or not. + // This allows the user can type the field token and get the suggestions for the field. + const tokenOperatorNearToField = getTokenNearTo( + tokens, + 'operator_compare', + 'next', + { tokenReferencePosition: index, tokenFoundShouldHaveValue: true } + ); + return tokenOperatorNearToField + && !options.field.includes(token.value) + ? `"${token.value}" is not a valid field.` + : undefined; + }; + // Check if the value is allowed + if(token.type === 'value'){ + // Usage of API regular expression + const re = new RegExp( + // Value: A string. + '^(?(?:(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\/\'"=@%<>{}]*)\\))*' + + '(?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]+)' + + '(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]*)\\))*)+)$' + ); + + const match = token.value.match(re); + return match?.groups?.value === token.value + ? undefined + : `"${token.value}" is not a valid value222.`; + } + }; + }) + .filter(t => typeof t !== 'undefined') + .join('\n') || undefined; + } +}; + +/** + * Validate the tokens if they are a valid syntax + * @param tokens + * @param options + * @returns + */ +function validate(tokens: ITokens, options: {field: string[]}): undefined | string[]{ + if (!shouldUseSearchTerm(tokens)){ + const errors = tokens.map((token: IToken, index) => { + const errors = []; + if(token.value){ + if(token.type === 'field'){ + const tokenOperatorNearToField = getTokenNearTo( + tokens, + 'operator_compare', + 'next', + { tokenReferencePosition: index, tokenFoundShouldHaveValue: true } + ); + const tokenValueNearToField = getTokenNearTo( + tokens, + 'value', + 'next', + { tokenReferencePosition: index, tokenFoundShouldHaveValue: true } + ); + if(!options.field.includes(token.value)){ + errors.push(`"${token.value}" is not a valid field.`); + }else if(!tokenOperatorNearToField){ + errors.push(`The operator for field "${token.value}" is missing.`); + }else if(!tokenValueNearToField){ + errors.push(`The value for field "${token.value}" is missing.`); + } + }; + // Check if the value is allowed + if(token.type === 'value'){ + // Usage of API regular expression + const re = new RegExp( + // Value: A string. + '^(?(?:(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\/\'"=@%<>{}]*)\\))*' + + '(?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]+)' + + '(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]*)\\))*)+)$' + ); + + const match = token.value.match(re); + match?.groups?.value !== token.value && errors.push(`"${token.value}" is not a valid value.`); + }; + + // Check if the value is allowed + if(token.type === 'conjunction'){ + + const tokenWhitespaceNearToFieldNext = getTokenNearTo( + tokens, + 'whitespace', + 'next', + { tokenReferencePosition: index } + ); + const tokenFieldNearToFieldNext = getTokenNearTo( + tokens, + 'field', + 'next', + { tokenReferencePosition: index, tokenFoundShouldHaveValue: true } + ); + !tokenWhitespaceNearToFieldNext?.value?.length + && errors.push(`There is no whitespace after conjunction "${token.value}".`); + !tokenFieldNearToFieldNext?.value?.length + && errors.push(`There is no sentence after conjunction "${token.value}".`); + }; + }; + return errors.length ? errors : undefined; + }).filter(errors => errors) + .flat() + return errors.length ? errors : undefined; + }; + return undefined; +}; + export const WQL = { id: 'wql', label: 'WQL', @@ -602,22 +750,59 @@ export const WQL = { ) : ''; + // Validate the user input + const fieldsSuggestion: string[] = await params.queryLanguage.parameters.suggestions.field() + .map(({label}) => label); + + const validationPartial = validatePartial(tokens, { + field: fieldsSuggestion + }); + + const validationStrict = validate(tokens, { + field: fieldsSuggestion + }); + // Get the output of query language - const output = getOutput(input, params.queryLanguage.parameters); + const output = { + ...getOutput(input, params.queryLanguage.parameters), + error: validationStrict + }; + + const onSearch = () => { + if(output?.error){ + params.setQueryLanguageOutput((state) => ({ + ...state, + searchBarProps: { + ...state.searchBarProps, + suggestions: transformSuggestionsToEuiSuggestItem( + output.error.map(error => ({type: 'validation_error', label: 'Invalid', description: error})) + ) + } + })); + }else{ + params.onSearch(output); + }; + }; return { searchBarProps: { // Props that will be used by the EuiSuggest component // Suggestions suggestions: transformSuggestionsToEuiSuggestItem( - await getSuggestions(tokens, params.queryLanguage.parameters) + validationPartial + ? [{ type: 'validation_error', label: 'Invalid', description: validationPartial}] + : await getSuggestions(tokens, params.queryLanguage.parameters) ), // Handler to manage when clicking in a suggestion item onItemClick: item => { + // There is an error, clicking on the item does nothing + if (item.type.iconType === 'alert'){ + return; + }; // When the clicked item has the `search` iconType, run the `onSearch` function if (item.type.iconType === 'search') { // Execute the search action - params.onSearch(output); + onSearch(); } else { // When the clicked item has another iconType const lastToken: IToken | undefined = getLastTokenDefined(tokens); @@ -706,9 +891,17 @@ export const WQL = { // This causes when using the Search suggestion, the suggestion popover can be closed. // If this is disabled, then the suggestion popover is open after a short time for this // use case. - disableFocusTrap: true + disableFocusTrap: true, + // Show the input is invalid + isInvalid: Boolean(validationStrict), + // Define the handler when the a key is pressed while the input is focused + onKeyPress: (event) => { + if (event.key === 'Enter') { + onSearch(); + }; + } }, - output, + output }; }, transformUQLToQL: transformUQLToQL, diff --git a/public/controllers/agent/components/agents-table.js b/public/controllers/agent/components/agents-table.js index 84ac881f60..bee79d1cc0 100644 --- a/public/controllers/agent/components/agents-table.js +++ b/public/controllers/agent/components/agents-table.js @@ -652,8 +652,8 @@ export const AgentsTable = withErrorBoundary( { label: 'dateAdd', description: 'filter by date add' }, { label: 'id', description: 'filter by ID' }, { label: 'ip', description: 'filter by IP address' }, - { label: 'group', description: 'filter by Group' }, - { label: 'group_config_status', description: 'filter by Synced configuration status' }, + { label: 'group', description: 'filter by group' }, + { label: 'group_config_status', description: 'filter by synced configuration status' }, { label: 'lastKeepAlive', description: 'filter by date add' }, { label: 'manager', description: 'filter by manager' }, { label: 'mergedSum', description: 'filter by merged sum' }, @@ -708,7 +708,7 @@ export const AgentsTable = withErrorBoundary( }), ); break; - case 'lastKeepAline': + case 'lastKeepAlive': return await getAgentFilterValuesMapToSearchBarSuggestion( previousField, currentValue, @@ -773,7 +773,10 @@ export const AgentsTable = withErrorBoundary( }, ]} onChange={console.log} - onSearch={async ({language, unifiedQuery}) => { + onSearch={async ({language, unifiedQuery, error}) => { + if(error){ + return; + } try{ this.setState({isLoading: true}); const response = await this.props.wzReq('GET', '/agents', { params: { From cdbbb6d4a3206bc8832278bc3070a91051cfec2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Mon, 10 Apr 2023 17:28:00 +0200 Subject: [PATCH 19/33] feat(search-bar): rename previousField and previousOperatorCompare in WQL --- public/components/search-bar/README.md | 101 ++---------------- public/components/search-bar/index.test.tsx | 4 +- .../search-bar/query-language/wql.md | 2 +- .../search-bar/query-language/wql.test.tsx | 18 ++-- .../search-bar/query-language/wql.tsx | 26 ++--- .../agent/components/agents-table.js | 28 ++--- 6 files changed, 49 insertions(+), 130 deletions(-) diff --git a/public/components/search-bar/README.md b/public/components/search-bar/README.md index 58e557a26c..b017a5871d 100644 --- a/public/components/search-bar/README.md +++ b/public/components/search-bar/README.md @@ -54,108 +54,27 @@ Basic usage: { label: 'version', description: 'Version' }, ]; }, - value: async (currentValue, { previousField }) => { - switch (previousField) { + value: async (currentValue, { field }) => { + switch (field) { case 'configSum': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); + return [ + { label: 'configSum1' }, + { label: 'configSum2' }, + ]; break; case 'dateAdd': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'id': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'ip': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'group': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'group_config_status': - return [AGENT_SYNCED_STATUS.SYNCED, AGENT_SYNCED_STATUS.NOT_SYNCED].map( - (status) => ({ - type: 'value', - label: status, - }), - ); - break; - case 'lastKeepAline': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'manager': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'mergedSum': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'name': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'node_name': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'os.platform': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); + return [ + { label: 'dateAdd1' }, + { label: 'dateAdd2' }, + ]; break; case 'status': return UI_ORDER_AGENT_STATUS.map( (status) => ({ - type: 'value', label: status, }), ); break; - case 'version': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; default: return []; break; diff --git a/public/components/search-bar/index.test.tsx b/public/components/search-bar/index.test.tsx index 2f8f2366a4..31f18f6dda 100644 --- a/public/components/search-bar/index.test.tsx +++ b/public/components/search-bar/index.test.tsx @@ -14,7 +14,7 @@ describe('SearchBar component', () => { field(currentValue) { return []; }, - value(currentValue, { previousField }){ + value(currentValue, { field }){ return []; }, }, @@ -29,7 +29,7 @@ describe('SearchBar component', () => { field(currentValue) { return []; }, - value(currentValue, { previousField }){ + value(currentValue, { field }){ return []; }, }, diff --git a/public/components/search-bar/query-language/wql.md b/public/components/search-bar/query-language/wql.md index 2f8c4bb9f6..ce70e7e6c6 100644 --- a/public/components/search-bar/query-language/wql.md +++ b/public/components/search-bar/query-language/wql.md @@ -187,7 +187,7 @@ searchTermFields: ['id', 'ip'] - `value`: method that returns the suggestion for the values ```ts // language options - value: async (currentValue, { previousField }) => { + value: async (currentValue, { field }) => { // static or async fetching is allowed // async fetching data // const response = await fetchData(); diff --git a/public/components/search-bar/query-language/wql.test.tsx b/public/components/search-bar/query-language/wql.test.tsx index 03095d54c3..4c645a8939 100644 --- a/public/components/search-bar/query-language/wql.test.tsx +++ b/public/components/search-bar/query-language/wql.test.tsx @@ -18,7 +18,7 @@ describe('SearchBar component', () => { field(currentValue) { return []; }, - value(currentValue, { previousField }){ + value(currentValue, { field }){ return []; }, }, @@ -150,8 +150,8 @@ describe('Query language - WQL', () => { description, })); }, - value(currentValue = '', { previousField }) { - switch (previousField) { + value(currentValue = '', { field }) { + switch (field) { case 'field': return ['value', 'value2', 'value3', 'value4'] .filter(value => value.startsWith(currentValue)) @@ -370,10 +370,10 @@ describe('Query language - WQL', () => { ${'field2=value'} | ${undefined} ${'field=value'} | ${['"field" is not a valid field.']} ${'custom=value'} | ${['"custom" is not a valid field.']} - ${'field1=value and'} | ${['There is no sentence after conjunction "and".']} - ${'field2=value and'} | ${['There is no sentence after conjunction "and".']} - ${'field=value and'} | ${['"field" is not a valid field.', 'There is no sentence after conjunction "and".']} - ${'custom=value and'} | ${['"custom" is not a valid field.', 'There is no sentence after conjunction "and".']} + ${'field1=value and'} | ${['There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} + ${'field2=value and'} | ${['There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} + ${'field=value and'} | ${['"field" is not a valid field.', 'There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} + ${'custom=value and'} | ${['"custom" is not a valid field.', 'There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} ${'field1=value and '} | ${['There is no sentence after conjunction "and".']} ${'field2=value and '} | ${['There is no sentence after conjunction "and".']} ${'field=value and '} | ${['"field" is not a valid field.', 'There is no sentence after conjunction "and".']} @@ -387,8 +387,8 @@ describe('Query language - WQL', () => { ${'(field'} | ${undefined} ${'(field='} | ${['"field" is not a valid field.']} ${'(field=value'} | ${['"field" is not a valid field.']} - ${'(field=value or'} | ${['"field" is not a valid field.', 'There is no sentence after conjunction or.']} - ${'(field=value or '} | ${['"field" is not a valid field.', 'There is no sentence after conjunction or.']} + ${'(field=value or'} | ${['"field" is not a valid field.', 'There is no whitespace after conjunction "or".', 'There is no sentence after conjunction "or".']} + ${'(field=value or '} | ${['"field" is not a valid field.', 'There is no sentence after conjunction "or".']} ${'(field=value or field2'} | ${['"field" is not a valid field.', 'The operator for field \"field2\" is missing.']} ${'(field=value or field2>'} | ${['"field" is not a valid field.', 'The value for field "field2" is missing.']} ${'(field=value or field2>value2'}| ${['"field" is not a valid field.']} diff --git a/public/components/search-bar/query-language/wql.tsx b/public/components/search-bar/query-language/wql.tsx index 2c59ad2f66..984ece8c01 100644 --- a/public/components/search-bar/query-language/wql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -199,9 +199,9 @@ type SuggestItem = QLOptionSuggestionEntityItem & { type QLOptionSuggestionHandler = ( currentValue: string | undefined, { - previousField, - previousOperatorCompare, - }: { previousField: string; previousOperatorCompare: string }, + field, + operatorCompare, + }: { field: string; operatorCompare: string }, ) => Promise; type OptionsQLImplicitQuery = { @@ -334,15 +334,15 @@ export async function getSuggestions(tokens: ITokens, options: OptionsQL): Promi ]; break; case 'operator_compare':{ - const previousField = getLastTokenDefinedByType(tokens, 'field')?.value; - const previousOperatorCompare = getLastTokenDefinedByType( + const field = getLastTokenDefinedByType(tokens, 'field')?.value; + const operatorCompare = getLastTokenDefinedByType( tokens, 'operator_compare', )?.value; // If there is no a previous field, then no return suggestions because it would be an syntax // error - if(!previousField){ + if(!field){ return []; }; @@ -363,8 +363,8 @@ export async function getSuggestions(tokens: ITokens, options: OptionsQL): Promi ) ? [ ...(await options.suggestions.value(undefined, { - previousField, - previousOperatorCompare, + field, + operatorCompare, })).map(mapSuggestionCreatorValue), ] : []), @@ -372,15 +372,15 @@ export async function getSuggestions(tokens: ITokens, options: OptionsQL): Promi break; } case 'value':{ - const previousField = getLastTokenDefinedByType(tokens, 'field')?.value; - const previousOperatorCompare = getLastTokenDefinedByType( + const field = getLastTokenDefinedByType(tokens, 'field')?.value; + const operatorCompare = getLastTokenDefinedByType( tokens, 'operator_compare', )?.value; /* If there is no a previous field or operator_compare, then no return suggestions because it would be an syntax error */ - if(!previousField || !previousOperatorCompare){ + if(!field || !operatorCompare){ return []; }; @@ -395,8 +395,8 @@ export async function getSuggestions(tokens: ITokens, options: OptionsQL): Promi ] : []), ...(await options.suggestions.value(lastToken.value, { - previousField, - previousOperatorCompare, + field, + operatorCompare, })).map(mapSuggestionCreatorValue), ...Object.entries(language.tokens.conjunction.literal).map( ([ conjunction, description]) => ({ diff --git a/public/controllers/agent/components/agents-table.js b/public/controllers/agent/components/agents-table.js index 374d357bef..d71608441a 100644 --- a/public/controllers/agent/components/agents-table.js +++ b/public/controllers/agent/components/agents-table.js @@ -655,39 +655,39 @@ export const AgentsTable = withErrorBoundary( { label: 'version', description: 'filter by version' }, ]; }, - value: async (currentValue, { previousField }) => { - switch (previousField) { + value: async (currentValue, { field }) => { + switch (field) { case 'configSum': return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, + field, currentValue, {q: 'id!=000'} ); break; case 'dateAdd': return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, + field, currentValue, {q: 'id!=000'} ); break; case 'id': return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, + field, currentValue, {q: 'id!=000'} ); break; case 'ip': return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, + field, currentValue, {q: 'id!=000'} ); break; case 'group': return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, + field, currentValue, {q: 'id!=000'} ); @@ -701,42 +701,42 @@ export const AgentsTable = withErrorBoundary( break; case 'lastKeepAlive': return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, + field, currentValue, {q: 'id!=000'} ); break; case 'manager': return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, + field, currentValue, {q: 'id!=000'} ); break; case 'mergedSum': return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, + field, currentValue, {q: 'id!=000'} ); break; case 'name': return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, + field, currentValue, {q: 'id!=000'} ); break; case 'node_name': return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, + field, currentValue, {q: 'id!=000'} ); break; case 'os.platform': return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, + field, currentValue, {q: 'id!=000'} ); @@ -750,7 +750,7 @@ export const AgentsTable = withErrorBoundary( break; case 'version': return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, + field, currentValue, {q: 'id!=000'} ); From c300fa36e15ec90ae992f584f515eb5e2edf1c42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Tue, 11 Apr 2023 08:44:56 +0200 Subject: [PATCH 20/33] fix(tests): update snapshot --- .../tables/__snapshots__/table-with-search-bar.test.tsx.snap | 1 + 1 file changed, 1 insertion(+) diff --git a/public/components/common/tables/__snapshots__/table-with-search-bar.test.tsx.snap b/public/components/common/tables/__snapshots__/table-with-search-bar.test.tsx.snap index 43dbaa0096..0c57fd5b26 100644 --- a/public/components/common/tables/__snapshots__/table-with-search-bar.test.tsx.snap +++ b/public/components/common/tables/__snapshots__/table-with-search-bar.test.tsx.snap @@ -108,6 +108,7 @@ exports[`Table With Search Bar component renders correctly to match the snapshot anchorPosition="downLeft" attachToAnchor={true} closePopover={[Function]} + disableFocusTrap={false} display="block" fullWidth={true} id="popover" From ef85c548986f47c2e8defae81b7f52833a24b157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Tue, 11 Apr 2023 10:12:16 +0200 Subject: [PATCH 21/33] fix(search-bar): fix documentation link for WQL --- public/components/search-bar/query-language/wql.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/components/search-bar/query-language/wql.tsx b/public/components/search-bar/query-language/wql.tsx index 984ece8c01..b6a9bbe14e 100644 --- a/public/components/search-bar/query-language/wql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { EuiButtonEmpty, EuiPopover, EuiText, EuiCode } from '@elastic/eui'; import { tokenizer as tokenizerUQL } from './aql'; -import { pluginPlatform } from '../../../../package.json'; +import { PLUGIN_VERSION_SHORT } from '../../../../common/constants'; /* UI Query language https://documentation.wazuh.com/current/user-manual/api/queries.html @@ -732,7 +732,7 @@ export const WQL = { id: 'wql', label: 'WQL', description: 'WQL (Wazuh Query Language) offers a human query syntax based on the Wazuh API query language.', - documentationLink: `https://github.com/wazuh/wazuh-kibana-app/blob/v${pluginPlatform.version.split('.').splice(0,2).join('.')}/public/components/search-bar/query-language/wql.md`, + documentationLink: `https://github.com/wazuh/wazuh-kibana-app/blob/v${PLUGIN_VERSION_SHORT}/public/components/search-bar/query-language/wql.md`, getConfiguration() { return { isOpenPopoverImplicitFilter: false, From 17053c037ce24efc88144dbf788c2421fae0b73d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Tue, 11 Apr 2023 10:57:31 +0200 Subject: [PATCH 22/33] fix(search-bar): remove example usage of SearchBar component in Agents --- .../agent/components/agents-table.js | 196 +----------------- 1 file changed, 2 insertions(+), 194 deletions(-) diff --git a/public/controllers/agent/components/agents-table.js b/public/controllers/agent/components/agents-table.js index d71608441a..432269d1d3 100644 --- a/public/controllers/agent/components/agents-table.js +++ b/public/controllers/agent/components/agents-table.js @@ -41,7 +41,6 @@ import { UI_ERROR_SEVERITIES } from '../../../react-services/error-orchestrator/ import { getErrorOrchestrator } from '../../../react-services/common-services'; import { AgentStatus } from '../../../components/agents/agent_status'; import { AgentSynced } from '../../../components/agents/agent-synced'; -import { SearchBar } from '../../../components/search-bar'; import { compressIPv6 } from '../../../services/ipv6-services'; export const AgentsTable = withErrorBoundary( @@ -63,8 +62,7 @@ export const AgentsTable = withErrorBoundary( isFilterColumnOpen: false, filters: sessionStorage.getItem('agents_preview_selected_options') ? JSON.parse(sessionStorage.getItem('agents_preview_selected_options')) - : [], - query: '' + : [] }; this.suggestions = [ { @@ -212,7 +210,7 @@ export const AgentsTable = withErrorBoundary( this.props.filters && this.props.filters.length ) { - this.setState({ filters: this.props.filters, pageIndex: 0, query: this.props.filters.find(({field}) => field === 'q')?.value || '' }); + this.setState({ filters: this.props.filters, pageIndex: 0 }); this.props.removeFilters(); } } @@ -609,188 +607,6 @@ export const AgentsTable = withErrorBoundary( } placeholder='Filter or search agent' /> - {/** Example implementation */} - { - switch (field) { - case 'configSum': - return await getAgentFilterValuesMapToSearchBarSuggestion( - field, - currentValue, - {q: 'id!=000'} - ); - break; - case 'dateAdd': - return await getAgentFilterValuesMapToSearchBarSuggestion( - field, - currentValue, - {q: 'id!=000'} - ); - break; - case 'id': - return await getAgentFilterValuesMapToSearchBarSuggestion( - field, - currentValue, - {q: 'id!=000'} - ); - break; - case 'ip': - return await getAgentFilterValuesMapToSearchBarSuggestion( - field, - currentValue, - {q: 'id!=000'} - ); - break; - case 'group': - return await getAgentFilterValuesMapToSearchBarSuggestion( - field, - currentValue, - {q: 'id!=000'} - ); - break; - case 'group_config_status': - return [AGENT_SYNCED_STATUS.SYNCED, AGENT_SYNCED_STATUS.NOT_SYNCED].map( - (status) => ({ - label: status, - }), - ); - break; - case 'lastKeepAlive': - return await getAgentFilterValuesMapToSearchBarSuggestion( - field, - currentValue, - {q: 'id!=000'} - ); - break; - case 'manager': - return await getAgentFilterValuesMapToSearchBarSuggestion( - field, - currentValue, - {q: 'id!=000'} - ); - break; - case 'mergedSum': - return await getAgentFilterValuesMapToSearchBarSuggestion( - field, - currentValue, - {q: 'id!=000'} - ); - break; - case 'name': - return await getAgentFilterValuesMapToSearchBarSuggestion( - field, - currentValue, - {q: 'id!=000'} - ); - break; - case 'node_name': - return await getAgentFilterValuesMapToSearchBarSuggestion( - field, - currentValue, - {q: 'id!=000'} - ); - break; - case 'os.platform': - return await getAgentFilterValuesMapToSearchBarSuggestion( - field, - currentValue, - {q: 'id!=000'} - ); - break; - case 'status': - return UI_ORDER_AGENT_STATUS.map( - (status) => ({ - label: status, - }), - ); - break; - case 'version': - return await getAgentFilterValuesMapToSearchBarSuggestion( - field, - currentValue, - {q: 'id!=000'} - ); - break; - default: - return []; - break; - } - }, - }, - }, - ]} - onChange={console.log} - onSearch={async ({language, unifiedQuery, error}) => { - if(error){ - return; - } - try{ - this.setState({isLoading: true}); - const response = await this.props.wzReq('GET', '/agents', { params: { - limit: this.state.pageSize, - offset: 0, - q: unifiedQuery, - sort: this.buildSortFilter() - }}); - - const formatedAgents = response?.data?.data?.affected_items?.map( - this.formatAgent.bind(this) - ); - - this._isMount && this.setState({ - agents: formatedAgents, - totalItems: response?.data?.data?.total_affected_items, - isLoading: false, - }); - }catch(error){ - this.setState({isLoading: false}); - }; - }} - /> { - try{ - return (await getAgentFilterValues(key, value, params)).map(label => ({label})); - }catch(error){ - return []; - }; -}; From cfbf19b04b049ba57d832f2dad90e6c730ba6637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Wed, 12 Apr 2023 09:10:28 +0200 Subject: [PATCH 23/33] fix(search-bar): fix an error using the value suggestions in WQL Fix an error when the last token in the input was a value and used a value suggestion whose label contains whitespaces, the value was not wrapped with quotes. --- public/components/search-bar/query-language/wql.test.tsx | 4 +++- public/components/search-bar/query-language/wql.tsx | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/public/components/search-bar/query-language/wql.test.tsx b/public/components/search-bar/query-language/wql.test.tsx index 4c645a8939..f40d81bf22 100644 --- a/public/components/search-bar/query-language/wql.test.tsx +++ b/public/components/search-bar/query-language/wql.test.tsx @@ -268,6 +268,8 @@ describe('Query language - WQL', () => { ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with < value'}} | ${'field="with < value"'} ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with ~ value'}} | ${'field="with ~ value"' /** ~ character is not supported as value in the q query parameter */} ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: '"value'}} | ${'field="\\"value"'} + ${'field="with spaces"'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value'}} | ${'field=value'} + ${'field="with spaces"'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'other spaces'}} | ${'field="other spaces"'} ${''} | ${{type: { iconType: 'tokenDenseVector', color: 'tint3' }, label: '('}} | ${'('} ${'('} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field'}} | ${'(field'} ${'(field'} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'(field2'} @@ -357,7 +359,7 @@ describe('Query language - WQL', () => { */ // Validate the tokens - it.only.each` + it.each` WQL | validationError ${''} | ${undefined} ${'field1'} | ${undefined} diff --git a/public/components/search-bar/query-language/wql.tsx b/public/components/search-bar/query-language/wql.tsx index b6a9bbe14e..c5ea2d0839 100644 --- a/public/components/search-bar/query-language/wql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -811,8 +811,11 @@ export const WQL = { lastToken && suggestionMappingLanguageTokenType[lastToken.type].iconType === item.type.iconType ) { - // replace the value of last token - lastToken.value = item.label; + // replace the value of last token with the current one. + // if the current token is a value, then transform it + lastToken.value = item.type.iconType === suggestionMappingLanguageTokenType.value.iconType + ? transformQLValue(item.label) + : item.label; } else { // add a whitespace for conjunction !(/\s$/.test(input)) From 929b47bb6e1b742e29b5909b6be9e959181fd1e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Wed, 12 Apr 2023 13:11:39 +0200 Subject: [PATCH 24/33] feat(search-bar): replace search bar in agent selection modal Replace the WzSearchBar component by the new search bar Add comments related to TODO and FIX Enhance request to get the agents data --- .../agents-selection-table.js | 165 +++++++++++++----- 1 file changed, 124 insertions(+), 41 deletions(-) diff --git a/public/controllers/overview/components/overview-actions/agents-selection-table.js b/public/controllers/overview/components/overview-actions/agents-selection-table.js index 99d6277b13..d6b1a90b1b 100644 --- a/public/controllers/overview/components/overview-actions/agents-selection-table.js +++ b/public/controllers/overview/components/overview-actions/agents-selection-table.js @@ -25,18 +25,21 @@ import { LEFT_ALIGNMENT } from '@elastic/eui/lib/services'; import { updateCurrentAgentData } from '../../../../redux/actions/appStateActions'; import store from '../../../../redux/store'; import { GroupTruncate } from '../../../../components/common/util/agent-group-truncate/'; -import { filtersToObject, WzSearchBar } from '../../../../components/wz-search-bar'; import { getAgentFilterValues } from '../../../../controllers/management/components/management/groups/get-agents-filters-values'; import _ from 'lodash'; -import { UI_LOGGER_LEVELS, UI_ORDER_AGENT_STATUS } from '../../../../../common/constants'; +import { AGENT_SYNCED_STATUS, UI_LOGGER_LEVELS, UI_ORDER_AGENT_STATUS } from '../../../../../common/constants'; import { UI_ERROR_SEVERITIES } from '../../../../react-services/error-orchestrator/types'; import { getErrorOrchestrator } from '../../../../react-services/common-services'; import { AgentStatus } from '../../../../components/agents/agent_status'; +import { SearchBar } from '../../../../components/search-bar'; const checkField = field => { return field !== undefined ? field : '-'; }; +const IMPLICIT_QUERY = 'id!=000'; +const IMPLICIT_QUERY_CONJUNCTION = ';'; + export class AgentSelectionTable extends Component { constructor(props) { super(props); @@ -52,7 +55,8 @@ export class AgentSelectionTable extends Component { sortField: 'id', agents: [], selectedOptions: [], - filters: [] + query: '', + input: '' }; this.columns = [ @@ -83,6 +87,11 @@ export class AgentSelectionTable extends Component { show: false, }, isSortable: true, + /* FIX: the ability to add a filter from a hidden group doesn't work. + This is happening in previous versions and could be related to the events order + and stopping the propagation of events. This is handled by + public/components/common/util/agent-group-truncate/group-truncate.tsx file. + */ render: groups => this.renderGroups(groups) }, { @@ -117,21 +126,14 @@ export class AgentSelectionTable extends Component { render: status => , }, ]; - this.suggestions = [ - { type: 'q', label: 'status', description: 'Filter by agent connection status', operators: ['=', '!=',], values: UI_ORDER_AGENT_STATUS }, - { type: 'q', label: 'os.platform', description: 'Filter by operating system platform', operators: ['=', '!=',], values: async (value) => getAgentFilterValues('os.platform', value, { q: 'id!=000'})}, - { type: 'q', label: 'ip', description: 'Filter by agent IP address', operators: ['=', '!=',], values: async (value) => getAgentFilterValues('ip', value, { q: 'id!=000'})}, - { type: 'q', label: 'name', description: 'Filter by agent name', operators: ['=', '!=',], values: async (value) => getAgentFilterValues('name', value, { q: 'id!=000'})}, - { type: 'q', label: 'id', description: 'Filter by agent id', operators: ['=', '!=',], values: async (value) => getAgentFilterValues('id', value, { q: 'id!=000'})}, - { type: 'q', label: 'group', description: 'Filter by agent group', operators: ['=', '!=',], values: async (value) => getAgentFilterValues('group', value, { q: 'id!=000'})}, - { type: 'q', label: 'node_name', description: 'Filter by node name', operators: ['=', '!=',], values: async (value) => getAgentFilterValues('node_name', value, { q: 'id!=000'})}, - { type: 'q', label: 'manager', description: 'Filter by manager', operators: ['=', '!=',], values: async (value) => getAgentFilterValues('manager', value, { q: 'id!=000'})}, - { type: 'q', label: 'version', description: 'Filter by agent version', operators: ['=', '!=',], values: async (value) => getAgentFilterValues('version', value, { q: 'id!=000'})}, - { type: 'q', label: 'configSum', description: 'Filter by agent config sum', operators: ['=', '!=',], values: async (value) => getAgentFilterValues('configSum', value, { q: 'id!=000'})}, - { type: 'q', label: 'mergedSum', description: 'Filter by agent merged sum', operators: ['=', '!=',], values: async (value) => getAgentFilterValues('mergedSum', value, { q: 'id!=000'})}, - { type: 'q', label: 'dateAdd', description: 'Filter by add date', operators: ['=', '!=',], values: async (value) => getAgentFilterValues('dateAdd', value, { q: 'id!=000'})}, - { type: 'q', label: 'lastKeepAlive', description: 'Filter by last keep alive', operators: ['=', '!=',], values: async (value) => getAgentFilterValues('lastKeepAlive', value, { q: 'id!=000'})}, - ]; + + this.selectFields = [ + ...this.columns.filter(({id}) => id !== 'os').map(({id}) => id), + 'os.name', + 'os.uname', + 'os.platform', + 'os.version' + ].join(','); } onChangeItemsPerPage = async itemsPerPage => { @@ -157,7 +159,7 @@ export class AgentSelectionTable extends Component { } async componentDidUpdate(prevProps, prevState) { - if(!(_.isEqual(prevState.filters,this.state.filters))){ + if(prevState.query!== this.state.query){ await this.getItems(); } } @@ -219,14 +221,14 @@ export class AgentSelectionTable extends Component { } buildFilter() { - const { itemsPerPage, pageIndex, filters } = this.state; + const { itemsPerPage, pageIndex, query } = this.state; const filter = { - ...filtersToObject(filters), + q: query || IMPLICIT_QUERY, offset: (pageIndex * itemsPerPage) || 0, - limit: pageIndex * itemsPerPage + itemsPerPage, + limit: itemsPerPage, + select: this.selectFields, ...this.buildSortFilter() }; - filter.q = !filter.q ? `id!=000` : `id!=000;${filter.q}`; return filter; } @@ -577,19 +579,8 @@ export class AgentSelectionTable extends Component { } filterGroupBadge = (group) => { - const { filters } = this.state; - let auxFilters = filters.map( filter => filter.value.match(/group=(.*S?)/)[1] ); - if (filters.length > 0) { - !auxFilters.includes(group) ? - this.setState({ - filters: [...filters, {field: "q", value: `group=${group}`}], - }) : false; - } else { - this.setState({ - filters: [...filters, {field: "q", value: `group=${group}`}], - }) - } - } + this.setState({ input: `group=${group}` }); + }; renderGroups(groups){ return Array.isArray(groups) ? ( @@ -619,11 +610,102 @@ export class AgentSelectionTable extends Component {
- this.setState({filters, pageIndex: 0})} - placeholder="Filter or search agent" + { + const distinct = { + group_config_status: () => [ + AGENT_SYNCED_STATUS.SYNCED, + AGENT_SYNCED_STATUS.NOT_SYNCED + ].map( + status => ({ + label: status, + })), + status: () => UI_ORDER_AGENT_STATUS.map( + status => ({ + label: status, + })), + }; + if(distinct?.[field]){ + return distinct?.[field]?.(); + }; + + if([ + 'configSum', + 'dateAdd', + 'id', + 'ip', + 'group', + 'lastKeepAlive', + 'manager', + 'mergedSum', + 'name', + 'node_name', + 'os.platform', + 'version' + ].includes(field)){ + try{ + return (await getAgentFilterValues( + field, + currentValue, + {q: IMPLICIT_QUERY} + )).map(label => ({label})); + }catch(error){ + return []; + }; + }; + return []; + }, + }, + }, + ]} + onSearch={({unifiedQuery}) => { + // Set the query and reset the page index + this.setState({query: unifiedQuery, pageIndex: 0}); + }} /> @@ -661,6 +743,7 @@ export class AgentSelectionTable extends Component { + {/* TODO: We should consider to use a reusable component instead of building the table with more atomic components. */} {this.renderHeaderCells()} {(this.state.agents.length && ( From 4f175fbdf111092447f6e39591686309247c0fd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Fri, 14 Apr 2023 08:04:13 +0200 Subject: [PATCH 25/33] fix(search-bar): enhance and unify the logic of agent selection modal - Change the sort column or order, or current page or page size doesn't fetch the data after change the state. It was moved to the componentDidUpdate comparing the previous state with the current one. - Enhance how the sort query parameter is built - Enhance the application of external query input in the search bar --- .../agents-selection-table.js | 73 ++++++++----------- 1 file changed, 30 insertions(+), 43 deletions(-) diff --git a/public/controllers/overview/components/overview-actions/agents-selection-table.js b/public/controllers/overview/components/overview-actions/agents-selection-table.js index d6b1a90b1b..55eac4cc23 100644 --- a/public/controllers/overview/components/overview-actions/agents-selection-table.js +++ b/public/controllers/overview/components/overview-actions/agents-selection-table.js @@ -137,11 +137,11 @@ export class AgentSelectionTable extends Component { } onChangeItemsPerPage = async itemsPerPage => { - this._isMounted && this.setState({ itemsPerPage }, async () => await this.getItems()); + this._isMounted && this.setState({ itemsPerPage }); }; onChangePage = async pageIndex => { - this._isMounted && this.setState({ pageIndex }, async () => await this.getItems()); + this._isMounted && this.setState({ pageIndex }); }; async componentDidMount() { @@ -159,7 +159,12 @@ export class AgentSelectionTable extends Component { } async componentDidUpdate(prevProps, prevState) { - if(prevState.query!== this.state.query){ + if(prevState.query!== this.state.query + || prevState.pageIndex !== this.state.pageIndex + || prevState.pageSize !== this.state.pageSize + || prevState.sortField !== this.state.sortField + || prevState.sortDirection !== this.state.sortDirection + ){ await this.getItems(); } } @@ -227,20 +232,17 @@ export class AgentSelectionTable extends Component { offset: (pageIndex * itemsPerPage) || 0, limit: itemsPerPage, select: this.selectFields, - ...this.buildSortFilter() + sort: this.buildSortFilter() }; return filter; } buildSortFilter() { const { sortDirection, sortField } = this.state; - const sortFilter = {}; - if (sortField) { - const direction = sortDirection === 'asc' ? '+' : '-'; - sortFilter['sort'] = direction + (sortField === 'os'? 'os.name,os.version' : sortField); - } + const direction = sortDirection === 'asc' ? '+' : '-'; + const field = sortField === 'os'? 'os.name,os.version' : sortField; - return sortFilter; + return direction + field; } onSort = async prop => { @@ -252,7 +254,7 @@ export class AgentSelectionTable extends Component { ? 'desc' : 'asc'; - this._isMounted && this.setState({ sortField, sortDirection }, async () => await this.getItems()); + this._isMounted && this.setState({ sortField, sortDirection }); }; toggleItem = itemId => { @@ -501,13 +503,6 @@ export class AgentSelectionTable extends Component { return undefined; }; - async onQueryChange(result) { - this._isMounted && - this.setState({ isLoading: true, ...result }, async () => { - await this.getItems(); - }); - } - getSelectedItems(){ return Object.keys(this.state.itemIdToSelectedMap).filter(x => { return (this.state.itemIdToSelectedMap[x] === true) @@ -578,8 +573,16 @@ export class AgentSelectionTable extends Component { ); } + buildQueryWithImplicitQuery(query){ + return [ + IMPLICIT_QUERY, + `(${query})` + ].join(IMPLICIT_QUERY_CONJUNCTION); + } + filterGroupBadge = (group) => { - this.setState({ input: `group=${group}` }); + const query = `group=${group}`; + this.setState({ input: query, query: this.buildQueryWithImplicitQuery(query) }); }; renderGroups(groups){ @@ -673,31 +676,15 @@ export class AgentSelectionTable extends Component { return distinct?.[field]?.(); }; - if([ - 'configSum', - 'dateAdd', - 'id', - 'ip', - 'group', - 'lastKeepAlive', - 'manager', - 'mergedSum', - 'name', - 'node_name', - 'os.platform', - 'version' - ].includes(field)){ - try{ - return (await getAgentFilterValues( - field, - currentValue, - {q: IMPLICIT_QUERY} - )).map(label => ({label})); - }catch(error){ - return []; - }; + try{ + return (await getAgentFilterValues( + field, + currentValue, + {q: IMPLICIT_QUERY} + )).map(label => ({label})); + }catch(error){ + return []; }; - return []; }, }, }, From f571f3f526bcfcde993e0ac541382836d5ed4217 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Fri, 14 Apr 2023 08:35:08 +0200 Subject: [PATCH 26/33] feat(search-bar): replace WzSearchBar by SearchBar in Agents section - Replace the WzSearchBar by SearchBar in the Agents section - Adapt the external filters - Group filter from labels - Rename the name of the sessionStorage property from `agents_preview_selected_options` to `wz_page_agents_search_bar_query` and change the saved value. This is used to communicate from other sections with the search bar. Renaming avoids errors when the users upgrading due to the change in the schema of saved value. - Enhance the building of request parameters to fetch the table data. - Clean: - Remove `removeFilters` props in the `AgentsTable` component. Adapt `AgentsPreview` render. - Remove unused file: public/controllers/overview/components/select-agent.js --- .../agent/components/agents-preview.js | 8 +- .../agent/components/agents-table.js | 262 +++++++--------- .../overview/components/select-agent.js | 295 ------------------ .../controllers/overview/components/stats.js | 8 +- public/controllers/overview/index.js | 3 - 5 files changed, 122 insertions(+), 454 deletions(-) delete mode 100644 public/controllers/overview/components/select-agent.js diff --git a/public/controllers/agent/components/agents-preview.js b/public/controllers/agent/components/agents-preview.js index ed8a36afcc..0fb21bb740 100644 --- a/public/controllers/agent/components/agents-preview.js +++ b/public/controllers/agent/components/agents-preview.js @@ -67,7 +67,7 @@ export const AgentsPreview = compose( loadingAgents: false, loadingSummary: false, showAgentsEvolutionVisualization: true, - agentTableFilters: [], + agentTableFilters: '', agentStatusSummary: { active: '-', disconnected: '-', total: '-', pending: '-', never_connected: '-' }, agentConfiguration: {}, agentsActiveCoverage: 0, @@ -156,9 +156,6 @@ export const AgentsPreview = compose( } } - removeFilters() { - this._isMount && this.setState({ agentTableFilters: [] }); - } showAgent(agent) { agent && this.props.tableProps.showAgent(agent); @@ -167,7 +164,7 @@ export const AgentsPreview = compose( filterAgentByStatus(status) { this._isMount && this.setState({ - agentTableFilters: [{ field: 'q', value: `status=${status}` }], + agentTableFilters: `status=${status}`, }); } onRenderComplete(){ @@ -316,7 +313,6 @@ export const AgentsPreview = compose( this.removeFilters()} wzReq={this.props.tableProps.wzReq} addingNewAgent={this.props.tableProps.addingNewAgent} downloadCsv={this.props.tableProps.downloadCsv} diff --git a/public/controllers/agent/components/agents-table.js b/public/controllers/agent/components/agents-table.js index 432269d1d3..c9d057abe6 100644 --- a/public/controllers/agent/components/agents-table.js +++ b/public/controllers/agent/components/agents-table.js @@ -31,7 +31,6 @@ import { import { getToasts } from '../../../kibana-services'; import { AppNavigate } from '../../../react-services/app-navigate'; import { GroupTruncate } from '../../../components/common/util'; -import { WzSearchBar, filtersToObject } from '../../../components/wz-search-bar'; import { getAgentFilterValues } from '../../../controllers/management/components/management/groups/get-agents-filters-values'; import { WzButtonPermissions } from '../../../components/common/permissions/button'; import { formatUIDate } from '../../../react-services/time-service'; @@ -42,12 +41,56 @@ import { getErrorOrchestrator } from '../../../react-services/common-services'; import { AgentStatus } from '../../../components/agents/agent_status'; import { AgentSynced } from '../../../components/agents/agent-synced'; import { compressIPv6 } from '../../../services/ipv6-services'; +import { SearchBar } from '../../../components/search-bar'; + +const IMPLICIT_QUERY = 'id!=000'; +const IMPLICIT_QUERY_CONJUNCTION = ';'; +const searchBar = { + wql: { + suggestionsValue: [ + { label: 'configSum', description: 'filter by config sum' }, + { label: 'dateAdd', description: 'filter by date add' }, + { label: 'id', description: 'filter by ID' }, + { label: 'ip', description: 'filter by IP address' }, + { label: 'group', description: 'filter by group' }, + { label: 'group_config_status', description: 'filter by synced configuration status' }, + { label: 'lastKeepAlive', description: 'filter by date add' }, + { label: 'manager', description: 'filter by manager' }, + { label: 'mergedSum', description: 'filter by merged sum' }, + { label: 'name', description: 'filter by name' }, + { label: 'node_name', description: 'filter by manager node name' }, + { label: 'os.platform', description: 'filter by operating system platform' }, + { label: 'status', description: 'filter by status' }, + { label: 'version', description: 'filter by version' }, + ], + searchTermFields: [ + 'configSum', + 'dateAdd', + 'id', + 'ip', + 'group', + 'group_config_status', + 'lastKeepAlive', + 'manager', + 'mergedSum', + 'name', + 'node_name', + 'os.name', + 'os.version', + 'status', + 'version' + ] + } +}; export const AgentsTable = withErrorBoundary( class AgentsTable extends Component { _isMount = false; constructor(props) { super(props); + const filterSessionStorage = sessionStorage.getItem('wz_page_agents_search_bar_query') + ? sessionStorage.getItem('wz_page_agents_search_bar_query') + : ''; this.state = { agents: [], isLoading: false, @@ -60,110 +103,9 @@ export const AgentsTable = withErrorBoundary( allSelected: false, purgeModal: false, isFilterColumnOpen: false, - filters: sessionStorage.getItem('agents_preview_selected_options') - ? JSON.parse(sessionStorage.getItem('agents_preview_selected_options')) - : [] + input: typeof filterSessionStorage !== 'undefined' ? filterSessionStorage : '', + query: typeof filterSessionStorage !== 'undefined' ? filterSessionStorage : '' }; - this.suggestions = [ - { - type: 'q', - label: 'status', - description: 'Filter by agent connection status', - operators: ['=', '!='], - values: UI_ORDER_AGENT_STATUS, - }, - { - type: 'q', - label: 'group_config_status', - description: 'Filter by agent synced configuration status', - operators: ['=', '!='], - values: [AGENT_SYNCED_STATUS.SYNCED, AGENT_SYNCED_STATUS.NOT_SYNCED], - }, - { - type: 'q', - label: 'os.platform', - description: 'Filter by operating system platform', - operators: ['=', '!='], - values: async (value) => getAgentFilterValues('os.platform', value, { q: 'id!=000' }), - }, - { - type: 'q', - label: 'ip', - description: 'Filter by agent IP address', - operators: ['=', '!='], - values: async (value) => getAgentFilterValues('ip', value, { q: 'id!=000' }), - }, - { - type: 'q', - label: 'name', - description: 'Filter by agent name', - operators: ['=', '!='], - values: async (value) => getAgentFilterValues('name', value, { q: 'id!=000' }), - }, - { - type: 'q', - label: 'id', - description: 'Filter by agent id', - operators: ['=', '!='], - values: async (value) => getAgentFilterValues('id', value, { q: 'id!=000' }), - }, - { - type: 'q', - label: 'group', - description: 'Filter by agent group', - operators: ['=', '!='], - values: async (value) => getAgentFilterValues('group', value, { q: 'id!=000' }), - }, - { - type: 'q', - label: 'node_name', - description: 'Filter by node name', - operators: ['=', '!='], - values: async (value) => getAgentFilterValues('node_name', value, { q: 'id!=000' }), - }, - { - type: 'q', - label: 'manager', - description: 'Filter by manager', - operators: ['=', '!='], - values: async (value) => getAgentFilterValues('manager', value, { q: 'id!=000' }), - }, - { - type: 'q', - label: 'version', - description: 'Filter by agent version', - operators: ['=', '!='], - values: async (value) => getAgentFilterValues('version', value, { q: 'id!=000' }), - }, - { - type: 'q', - label: 'configSum', - description: 'Filter by agent config sum', - operators: ['=', '!='], - values: async (value) => getAgentFilterValues('configSum', value, { q: 'id!=000' }), - }, - { - type: 'q', - label: 'mergedSum', - description: 'Filter by agent merged sum', - operators: ['=', '!='], - values: async (value) => getAgentFilterValues('mergedSum', value, { q: 'id!=000' }), - }, - { - type: 'q', - label: 'dateAdd', - description: 'Filter by add date', - operators: ['=', '!='], - values: async (value) => getAgentFilterValues('dateAdd', value, { q: 'id!=000' }), - }, - { - type: 'q', - label: 'lastKeepAlive', - description: 'Filter by last keep alive', - operators: ['=', '!='], - values: async (value) => getAgentFilterValues('lastKeepAlive', value, { q: 'id!=000' }), - }, - ]; this.downloadCsv.bind(this); } @@ -186,8 +128,8 @@ export const AgentsTable = withErrorBoundary( componentWillUnmount() { this._isMount = false; - if (sessionStorage.getItem('agents_preview_selected_options')) { - sessionStorage.removeItem('agents_preview_selected_options'); + if (sessionStorage.getItem('wz_page_agents_search_bar_query')) { + sessionStorage.removeItem('wz_page_agents_search_bar_query'); } } @@ -198,7 +140,7 @@ export const AgentsTable = withErrorBoundary( async componentDidUpdate(prevProps, prevState) { if ( - !_.isEqual(prevState.filters, this.state.filters) || + !_.isEqual(prevState.query, this.state.query) || prevState.pageIndex !== this.state.pageIndex || prevState.pageSize !== this.state.pageSize || prevState.sortField !== this.state.sortField || @@ -206,12 +148,9 @@ export const AgentsTable = withErrorBoundary( ) { await this.getItems(); } else if ( - !_.isEqual(prevProps.filters, this.props.filters) && - this.props.filters && - this.props.filters.length + !_.isEqual(prevProps.filters, this.props.filters) ) { - this.setState({ filters: this.props.filters, pageIndex: 0 }); - this.props.removeFilters(); + this.setState({ input: this.props.filters, query: this.buildQueryWithImplicitQuery(this.props.filters), pageIndex: 0 }); } } @@ -219,9 +158,9 @@ export const AgentsTable = withErrorBoundary( try { this._isMount && this.setState({ isLoading: true }); const selectFieldsList = this.defaultColumns - .filter(field => field.field != 'actions') - .map(field => field.field.replace('os_', 'os.')); // "os_name" subfield should be specified as 'os.name' - const selectFields = [...selectFieldsList, 'os.platform', 'os.uname', 'os.version'].join(','); // Add version and uname fields to render the OS icon and version in the table + .filter(field => !['os_name', 'actions'].includes(field.field)) + .map(({field}) => field); + const selectFields = [...selectFieldsList, 'os.name', 'os.platform', 'os.uname', 'os.version'].join(','); // Add os.name, os.platform, os.uname and os.version fields to render the OS icon and version in the table const rawAgents = await this.props.wzReq('GET', '/agents', { params: { ...this.buildFilter(), select: selectFields } }); const formatedAgents = (((rawAgents || {}).data || {}).data || {}).affected_items.map( @@ -251,17 +190,22 @@ export const AgentsTable = withErrorBoundary( } } + buildQueryWithImplicitQuery(query){ + return [ + IMPLICIT_QUERY, + `(${query})` + ].join(IMPLICIT_QUERY_CONJUNCTION); + } buildFilter() { - const { pageIndex, pageSize, filters } = this.state; + const { pageIndex, pageSize, query } = this.state; const filter = { - ...filtersToObject(filters), - offset: pageIndex * pageSize || 0, + q: query || IMPLICIT_QUERY, + offset: (pageIndex * pageSize) || 0, limit: pageSize, sort: this.buildSortFilter(), }; - filter.q = !filter.q ? `id!=000` : `id!=000;${filter.q}`; return filter; } @@ -269,17 +213,12 @@ export const AgentsTable = withErrorBoundary( buildSortFilter() { const { sortField, sortDirection } = this.state; - const field = sortField === 'os_name' ? 'os.name,os.version' : sortField; const direction = sortDirection === 'asc' ? '+' : '-'; + const field = sortField === 'os_name' ? 'os.name,os.version' : sortField; return direction + field; } - buildQFilter() { - const { q } = this.state; - return q === '' ? `id!=000` : `id!=000;${q}`; - } - formatAgent(agent) { const agentVersion = agent.version !== undefined ? agent.version.split(' ')[1] : '-'; const node_name = agent.node_name && agent.node_name !== 'unknown' ? agent.node_name : '-'; @@ -598,14 +537,56 @@ export const AgentsTable = withErrorBoundary( return ( - - this.setState({ filters, pageIndex: 0 }) - } - placeholder='Filter or search agent' + { + const distinct = { + group_config_status: () => [ + AGENT_SYNCED_STATUS.SYNCED, + AGENT_SYNCED_STATUS.NOT_SYNCED + ].map( + status => ({ + label: status, + })), + status: () => UI_ORDER_AGENT_STATUS.map( + status => ({ + label: status, + })), + }; + if(distinct?.[field]){ + return distinct?.[field]?.(); + }; + + try{ + return (await getAgentFilterValues( + field, + currentValue, + {q: IMPLICIT_QUERY} + )).map(label => ({label})); + }catch(error){ + return []; + }; + }, + }, + }, + ]} + onSearch={({unifiedQuery}) => { + // Set the query and reset the page index + this.setState({query: unifiedQuery, pageIndex: 0}); + }} /> @@ -730,19 +711,8 @@ export const AgentsTable = withErrorBoundary( } filterGroupBadge = (group) => { - const { filters } = this.state; - let auxFilters = filters.map((filter) => filter.value.match(/group=(.*S?)/)[1]); - if (filters.length > 0) { - !auxFilters.includes(group) - ? this.setState({ - filters: [...filters, { field: 'q', value: `group=${group}` }], - }) - : false; - } else { - this.setState({ - filters: [...filters, { field: 'q', value: `group=${group}` }], - }); - } + const query = `group=${group}`; + this.setState({ input: query, query: this.buildQueryWithImplicitQuery(query) }); }; renderGroups(groups) { diff --git a/public/controllers/overview/components/select-agent.js b/public/controllers/overview/components/select-agent.js deleted file mode 100644 index 3dfafb9af6..0000000000 --- a/public/controllers/overview/components/select-agent.js +++ /dev/null @@ -1,295 +0,0 @@ -/* - * Wazuh app - React component for alerts stats. - * - * Copyright (C) 2015-2022 Wazuh, Inc. - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 2 of the License, or - * (at your option) any later version. - * - * Find more information about this on the LICENSE file. - */ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutHeader, - EuiButton, - EuiFlexGroup, - EuiTitle, - EuiFlexItem, - EuiBasicTable -} from '@elastic/eui'; -import { WzRequest } from '../../../react-services/wz-request'; -import { withErrorBoundary } from '../../../components/common/hocs'; -import { UI_LOGGER_LEVELS } from '../../../../common/constants'; -import { UI_ERROR_SEVERITIES } from '../../../react-services/error-orchestrator/types'; -import { getErrorOrchestrator } from '../../../react-services/common-services'; - -export const SelectAgent = withErrorBoundary (class SelectAgent extends Component { - constructor(props) { - super(props); - - this.state = { - isFlyoutVisible: false, - isSwitchChecked: true - }; - - this.closeFlyout = this.closeFlyout.bind(this); - this.showFlyout = this.showFlyout.bind(this); - - const selectedOptions = JSON.parse( - sessionStorage.getItem('agents_preview_selected_options') - ); - - this.state = { - agents: [], - isLoading: false, - isProcessing: true, - pageIndex: 0, - pageSize: 10, - q: '', - search: '', - selectedOptions: selectedOptions || [], - sortDirection: 'asc', - sortField: 'id', - totalItems: 0 - }; - } - - async componentDidMount() { - await this.getItems(); - } - - formatAgent(agent) { - const checkField = field => { - return field !== undefined ? field : '-'; - }; - - const agentVersion = - agent.version !== undefined ? agent.version.split(' ')[1] : '.'; - - return { - id: agent, - name: agent.name, - ip: agent.ip, - status: agent.status, - group: checkField(agent.group), - os_name: agent, - version: agentVersion, - dateAdd: agent.dateAdd, - lastKeepAlive: agent.lastKeepAlive, - actions: agent - }; - } - - async getItems() { - try { - const rawAgents = await WzRequest.apiReq('GET', '/agents', this.buildFilter()); - const formattedAgents = (((rawAgents || {}).data || {}).data || {}).items.map( - this.formatAgent.bind(this) - ); - - this.setState({ - agents: formattedAgents, - totalItems: (((rawAgents || {}).data || {}).data || {}).totalItems, - }); - } catch (error) { - const options = { - context: `${SelectAgent.name}.getItems`, - level: UI_LOGGER_LEVELS.ERROR, - severity: UI_ERROR_SEVERITIES.BUSINESS, - error: { - error: error, - message: error.message || error, - title: error.name || error, - }, - }; - getErrorOrchestrator().handleError(options); - } finally { - this.setState({ - isProcessing: false, - isLoading: false, - }); - } - } - - buildSortFilter() { - const { sortField, sortDirection } = this.state; - - const field = sortField === 'os_name' ? '' : sortField; - const direction = sortDirection === 'asc' ? '+' : '-'; - - return direction + field; - } - - buildQFilter() { - const { q } = this.state; - return q === '' ? `id!=000` : `id!=000;${q}`; - } - - buildFilter() { - const { pageIndex, pageSize, search } = this.state; - - const filter = { - offset: pageIndex * pageSize, - limit: pageSize, - q: this.buildQFilter(), - sort: this.buildSortFilter() - }; - - if (search !== '') { - filter.search = search; - } - return filter; - } - - onSwitchChange = () => { - this.setState({ - isSwitchChecked: !this.state.isSwitchChecked - }); - }; - - closeFlyout() { - this.setState({ isFlyoutVisible: false }); - } - - showFlyout() { - this.setState({ isFlyoutVisible: true }); - } - - columns() { - return [ - { - field: 'name', - name: 'Name', - sortable: true, - truncateText: true - }, - { - field: 'ip', - name: 'IP address', - truncateText: true, - sortable: true - }, - { - field: 'group', - name: 'Group(s)', - truncateText: true, - sortable: true - }, - { - field: 'version', - name: 'Version', - width: '100px', - truncateText: true, - sortable: true - }, - { - field: 'dateAdd', - name: 'Registration date', - truncateText: true, - sortable: true - }, - { - field: 'lastKeepAlive', - name: 'Last keep alive', - truncateText: true, - sortable: true - }, - { - field: 'status', - name: 'Status', - truncateText: true, - sortable: true, - render: this.addHealthStatusRender - } - ]; - } - - onTableChange = ({ page = {}, sort = {} }) => { - const { index: pageIndex, size: pageSize } = page; - const { field: sortField, direction: sortDirection } = sort; - this.setState({ - pageIndex, - pageSize, - sortField, - sortDirection, - isProcessing: true, - isLoading: true - }); - }; - - tableRender() { - const { - pageIndex, - pageSize, - totalItems, - agents, - sortField, - sortDirection - } = this.state; - const columns = this.columns(); - const pagination = { - pageIndex: pageIndex, - pageSize: pageSize, - totalItemCount: totalItems, - pageSizeOptions: [10, 25, 50, 100] - }; - const sorting = { - sort: { - field: sortField, - direction: sortDirection - } - }; - const isLoading = this.state.isLoading; - return ( - - - - - - ); - } - - render() { - let flyout; - - const table = this.tableRender(); - - if (this.state.isFlyoutVisible) { - flyout = ( - - - -

Select agent

-
-
- {table} -
- ); - } - - return ( -
- Select agent - - {flyout} -
- ); - } -}); - -SelectAgent.propTypes = { - items: PropTypes.array -}; diff --git a/public/controllers/overview/components/stats.js b/public/controllers/overview/components/stats.js index a9b1786250..46b3f8052d 100644 --- a/public/controllers/overview/components/stats.js +++ b/public/controllers/overview/components/stats.js @@ -33,11 +33,11 @@ export const Stats = withErrorBoundary (class Stats extends Component { goToAgents(status) { if(status){ sessionStorage.setItem( - 'agents_preview_selected_options', - JSON.stringify([{field: 'q', value: `status=${status}`}]) + 'wz_page_agents_search_bar_query', + `status=${status}` ); - }else if(sessionStorage.getItem('agents_preview_selected_options')){ - sessionStorage.removeItem('agents_preview_selected_options'); + }else if(sessionStorage.getItem('wz_page_agents_search_bar_query')){ + sessionStorage.removeItem('wz_page_agents_search_bar_query'); } window.location.href = '#/agents-preview'; } diff --git a/public/controllers/overview/index.js b/public/controllers/overview/index.js index 5650327927..4f3d442c42 100644 --- a/public/controllers/overview/index.js +++ b/public/controllers/overview/index.js @@ -15,7 +15,6 @@ import { WzCurrentOverviewSectionWrapper } from '../../components/common/modules import { WzCurrentAgentsSectionWrapper } from '../../components/common/modules/agents-current-section-wrapper'; import { Mitre } from '../../components/overview' import { Stats } from './components/stats'; -import { SelectAgent } from './components/select-agent'; import { RequirementCard } from './components/requirement-card'; import { getAngularModule } from '../../kibana-services'; @@ -26,7 +25,6 @@ WzCurrentOverviewSectionWrapper.displayName = 'WzCurrentOverviewSectionWrapper'; WzCurrentAgentsSectionWrapper.displayName = 'WzCurrentAgentsSectionWrapper'; Stats.displayName = 'StatsOverview'; Mitre.displayName = 'Mitre'; -SelectAgent.displayName = 'SelectAgent'; RequirementCard.displayName = 'RequirementCard'; app @@ -36,5 +34,4 @@ app .value('WzCurrentAgentsSectionWrapper', WzCurrentAgentsSectionWrapper) .value('StatsOverview', Stats) .value('Mitre', Mitre) - .value('SelectAgent', SelectAgent) .value('RequirementCard', RequirementCard); From d1222dc7eda4698a729049debf6499ed2b25bf1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Fri, 14 Apr 2023 10:04:59 +0200 Subject: [PATCH 27/33] fix(search-bar): restrict the search in Agents section table to the visible fields --- public/controllers/agent/components/agents-table.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/public/controllers/agent/components/agents-table.js b/public/controllers/agent/components/agents-table.js index c9d057abe6..922b57ce5b 100644 --- a/public/controllers/agent/components/agents-table.js +++ b/public/controllers/agent/components/agents-table.js @@ -534,6 +534,15 @@ export const AgentsTable = withErrorBoundary( } filterBarRender() { + // Build the search term fields from the visible columns in the table + const searchTermFields = this.columns() + .filter(({field}) => field !== 'actions') + /* Map the object + If the table column field is `os_name` then adds the os.name and os.version + */ + .map(({field}) => field === 'os_name' ? ['os.name', 'os.version'] : [field]) + .flat() + .sort(); return ( @@ -547,7 +556,7 @@ export const AgentsTable = withErrorBoundary( query: IMPLICIT_QUERY, conjunction: IMPLICIT_QUERY_CONJUNCTION }, - searchTermFields: searchBar.wql.searchTermFields, + searchTermFields, suggestions: { field(currentValue) { return searchBar.wql.suggestionsValue; From fb00c429dfd400d89fc0146ddbbe598fac540f95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Tue, 18 Apr 2023 11:02:13 +0200 Subject: [PATCH 28/33] changelog: add entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2e6fe434c..631ff1c1c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to the Wazuh app project will be documented in this file. ### Changed - Changed of regular expression in RBAC. [#5201](https://github.com/wazuh/wazuh-kibana-app/pull/5201) +- Changed the search bar of agent selector modal and Agents section [#5366](https://github.com/wazuh/wazuh-kibana-app/pull/5366) ### Fixed From 57d11d64430c4ef7913950ded87aa001b2cd026e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Tue, 25 Apr 2023 11:02:54 +0200 Subject: [PATCH 29/33] feat(search-bar): add search function suggestion when the input is empty --- public/components/search-bar/query-language/wql.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/public/components/search-bar/query-language/wql.tsx b/public/components/search-bar/query-language/wql.tsx index c5ea2d0839..e9b9c2ba16 100644 --- a/public/components/search-bar/query-language/wql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -190,7 +190,7 @@ type QLOptionSuggestionEntityItem = { type QLOptionSuggestionEntityItemTyped = QLOptionSuggestionEntityItem - & { type: 'operator_group'|'field'|'operator_compare'|'value'|'conjunction' }; + & { type: 'operator_group'|'field'|'operator_compare'|'value'|'conjunction'|'function_search' }; type SuggestItem = QLOptionSuggestionEntityItem & { type: { iconType: string, color: string } @@ -298,6 +298,12 @@ export async function getSuggestions(tokens: ITokens, options: OptionsQL): Promi // If it can't get a token with value, then returns fields and open operator group if(!lastToken?.type){ return [ + // Search function + { + type: 'function_search', + label: 'Search', + description: 'run the search query', + }, // fields ...(await options.suggestions.field()).map(mapSuggestionCreatorField), { From ce4bc92af7e44cf0e73401441310980b9f353cc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Tue, 25 Apr 2023 11:04:08 +0200 Subject: [PATCH 30/33] fix(search-bar): ensure the query language output changed to trigger the onChange handler --- public/components/search-bar/index.tsx | 50 +++++++++++++++----------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/public/components/search-bar/index.tsx b/public/components/search-bar/index.tsx index f4e7fc4038..a2b771d89e 100644 --- a/public/components/search-bar/index.tsx +++ b/public/components/search-bar/index.tsx @@ -11,6 +11,7 @@ import { } from '@elastic/eui'; import { EuiSuggest } from '../eui-suggest'; import { searchBarQueryLanguages } from './query-language'; +import _ from 'lodash'; type SearchBarProps = { defaultMode?: string; @@ -48,6 +49,8 @@ export const SearchBar = ({ searchBarProps: { suggestions: [] }, output: undefined, }); + // Cache the previous output + const queryLanguageOutputRunPreviousOutput = useRef(queryLanguageOutputRun.output); // Controls when the suggestion popover is open/close const [isOpenSuggestionPopover, setIsOpenSuggestionPopover] = useState(false); @@ -85,31 +88,36 @@ export const SearchBar = ({ useEffect(() => { (async () => { // Set the query language output - setQueryLanguageOutputRun( - await searchBarQueryLanguages[queryLanguage.id].run(input, { - onSearch: _onSearch, - setInput, - closeSuggestionPopover: () => setIsOpenSuggestionPopover(false), - openSuggestionPopover: () => setIsOpenSuggestionPopover(true), - setQueryLanguageConfiguration: (configuration: any) => - setQueryLanguage(state => ({ - ...state, - configuration: - configuration?.(state.configuration) || configuration, - })), - setQueryLanguageOutput: setQueryLanguageOutputRun, - inputRef, - queryLanguage: { - configuration: queryLanguage.configuration, - parameters: modes.find(({ id }) => id === queryLanguage.id), - }, - }), - ); + const queryLanguageOutput = await searchBarQueryLanguages[queryLanguage.id].run(input, { + onSearch: _onSearch, + setInput, + closeSuggestionPopover: () => setIsOpenSuggestionPopover(false), + openSuggestionPopover: () => setIsOpenSuggestionPopover(true), + setQueryLanguageConfiguration: (configuration: any) => + setQueryLanguage(state => ({ + ...state, + configuration: + configuration?.(state.configuration) || configuration, + })), + setQueryLanguageOutput: setQueryLanguageOutputRun, + inputRef, + queryLanguage: { + configuration: queryLanguage.configuration, + parameters: modes.find(({ id }) => id === queryLanguage.id), + }, + }); + queryLanguageOutputRunPreviousOutput.current = { + ...queryLanguageOutputRun.output + }; + setQueryLanguageOutputRun(queryLanguageOutput); })(); }, [input, queryLanguage]); useEffect(() => { - onChange && onChange(queryLanguageOutputRun.output); + onChange + // Ensure the previous output is different to the new one + && !_.isEqual(queryLanguageOutputRun.output, queryLanguageOutputRunPreviousOutput.current) + && onChange(queryLanguageOutputRun.output); }, [queryLanguageOutputRun.output]); const onQueryLanguagePopoverSwitch = () => From b547d985f239dbb26bc39d712e50c8848276c428 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Thu, 27 Apr 2023 08:53:38 +0200 Subject: [PATCH 31/33] feat(search-bar): allow the API query output can be redone when the search term fields changed - Search bar: - Add a dependency to run the query language output - Adapt search bar documentation to the changes - WQL - Create a new parameter called `options` - Moved the `implicitFilter` and `searchTerm` settings to `options` - Update tests - Update documentation --- public/components/search-bar/README.md | 23 +++++++------ public/components/search-bar/index.tsx | 6 ++-- .../search-bar/query-language/wql.md | 29 ++++++++--------- .../search-bar/query-language/wql.test.tsx | 14 ++++---- .../search-bar/query-language/wql.tsx | 32 +++++++++++-------- 5 files changed, 57 insertions(+), 47 deletions(-) diff --git a/public/components/search-bar/README.md b/public/components/search-bar/README.md index b017a5871d..b8e95cef2a 100644 --- a/public/components/search-bar/README.md +++ b/public/components/search-bar/README.md @@ -28,12 +28,15 @@ Basic usage: { id: 'wql', // specific query language parameters - // implicit query. Optional - // Set a implicit query that can't be changed by the user. - // Use the UQL (Unified Query Language) syntax. - implicitQuery: { - query: 'id!=000', - conjunction: ';' + options: { + // implicit query. Optional + // Set a implicit query that can't be changed by the user. + // Use the UQL (Unified Query Language) syntax. + implicitQuery: { + query: 'id!=000', + conjunction: ';' + }, + searchTermFields: ['id', 'ip'] }, suggestions: { field(currentValue) { @@ -90,9 +93,9 @@ Basic usage: // Used to define the internal input. Optional. // This could be used to change the input text from the external components. // Use the UQL (Unified Query Language) syntax. - input="" + input='' // Define the default mode. Optional. If not defined, it will use the first one mode. - defaultMode="" + defaultMode='' > ``` @@ -119,7 +122,7 @@ type SearchBarQueryLanguage = { searchBarProps: any, output: { language: string, - unifiedQuery: string, + apiQuery: string, query: string } }>; @@ -140,7 +143,7 @@ where: customization the properties that will used by the base search bar component and the output used when searching - `output`: - `language`: query language ID - - `unifiedQuery`: query in unified query syntax + - `apiQuery`: API query. - `query`: current query in the specified language - `transformUQLToQL`: method that transforms the UQL (Unified Query Language) to the specific query language. This is used when receives a external input in the Unified Query Language, the returned diff --git a/public/components/search-bar/index.tsx b/public/components/search-bar/index.tsx index a2b771d89e..eb2be4bbd1 100644 --- a/public/components/search-bar/index.tsx +++ b/public/components/search-bar/index.tsx @@ -75,6 +75,8 @@ export const SearchBar = ({ } }; + const selectedQueryLanguageParameters = modes.find(({ id }) => id === queryLanguage.id); + useEffect(() => { // React to external changes and set the internal input text. Use the `transformUQLToQL` of // the query language in use @@ -103,7 +105,7 @@ export const SearchBar = ({ inputRef, queryLanguage: { configuration: queryLanguage.configuration, - parameters: modes.find(({ id }) => id === queryLanguage.id), + parameters: selectedQueryLanguageParameters, }, }); queryLanguageOutputRunPreviousOutput.current = { @@ -111,7 +113,7 @@ export const SearchBar = ({ }; setQueryLanguageOutputRun(queryLanguageOutput); })(); - }, [input, queryLanguage]); + }, [input, queryLanguage, selectedQueryLanguageParameters?.options]); useEffect(() => { onChange diff --git a/public/components/search-bar/query-language/wql.md b/public/components/search-bar/query-language/wql.md index ce70e7e6c6..ef44107efb 100644 --- a/public/components/search-bar/query-language/wql.md +++ b/public/components/search-bar/query-language/wql.md @@ -145,30 +145,29 @@ field1~user_input,field2~user_input,field3~user_input ## Options -- `implicitQuery`: add an implicit query that is added to the user input. Optional. +- `options`: options + + - `implicitQuery`: add an implicit query that is added to the user input. Optional. This can't be changed by the user. If this is defined, will be displayed as a prepend of the search bar. - - `query`: query string in UQL (Unified Query Language) + - `query`: query string in UQL (Unified Query Language) Use UQL (Unified Query Language). - - `conjunction`: query string of the conjunction in UQL (Unified Query Language) + - `conjunction`: query string of the conjunction in UQL (Unified Query Language) + - `searchTermFields`: define the fields used to build the query for the search term mode ```ts // language options -// ID is not equal to 000 and . This is defined in UQL that is transformed internally to -// the specific query language. -implicitQuery: { - query: 'id!=000', - conjunction: ';' +options: { + // ID is not equal to 000 and . This is defined in UQL that is transformed internally to + // the specific query language. + implicitQuery: { + query: 'id!=000', + conjunction: ';' + } + searchTermFields: ['id', 'ip'] } ``` -- `searchTermFields`: define the fields used to build the query for the search term mode - -```ts -// language options -searchTermFields: ['id', 'ip'] -``` - - `suggestions`: define the suggestion handlers. This is required. - `field`: method that returns the suggestions for the fields diff --git a/public/components/search-bar/query-language/wql.test.tsx b/public/components/search-bar/query-language/wql.test.tsx index f40d81bf22..781c24c335 100644 --- a/public/components/search-bar/query-language/wql.test.tsx +++ b/public/components/search-bar/query-language/wql.test.tsx @@ -10,9 +10,11 @@ describe('SearchBar component', () => { modes: [ { id: WQL.id, - implicitQuery: { - query: 'id!=000', - conjunction: ';' + options: { + implicitQuery: { + query: 'id!=000', + conjunction: ';' + }, }, suggestions: { field(currentValue) { @@ -127,7 +129,7 @@ describe('Query language - WQL', () => { // Get suggestions it.each` input | suggestions - ${''} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }, { description: 'open group', label: '(', type: 'operator_group' }]} + ${''} | ${[{ description: 'run the search query', label: 'Search', type: 'function_search' }, { description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }, { description: 'open group', label: '(', type: 'operator_group' }]} ${'w'} | ${[]} ${'f'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }]} ${'field'} | ${[{ description: 'Field2', label: 'field2', type: 'field' }, { description: 'equality', label: '=', type: 'operator_compare' }, { description: 'not equality', label: '!=', type: 'operator_compare' }, { description: 'bigger', label: '>', type: 'operator_compare' }, { description: 'smaller', label: '<', type: 'operator_compare' }, { description: 'like as', label: '~', type: 'operator_compare' }]} @@ -295,7 +297,7 @@ describe('Query language - WQL', () => { setInput: (value: string): void => { input = value; }, queryLanguage: { parameters: { - implicitQuery: '', + options: {}, suggestions: { field: () => ([]), value: () => ([]) @@ -399,7 +401,7 @@ describe('Query language - WQL', () => { const qlOutput = await WQL.run(currentInput, { queryLanguage: { parameters: { - implicitQuery: '', + options: {}, suggestions: { field: () => (['field1', 'field2'].map(label => ({label}))), value: () => ([]) diff --git a/public/components/search-bar/query-language/wql.tsx b/public/components/search-bar/query-language/wql.tsx index e9b9c2ba16..e63db6fa99 100644 --- a/public/components/search-bar/query-language/wql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -209,8 +209,10 @@ type OptionsQLImplicitQuery = { conjunction: string } type OptionsQL = { - implicitQuery?: OptionsQLImplicitQuery - searchTermFields?: string[] + options?: { + implicitQuery?: OptionsQLImplicitQuery + searchTermFields?: string[] + } suggestions: { field: QLOptionSuggestionHandler; value: QLOptionSuggestionHandler; @@ -579,13 +581,13 @@ export function transformSpecificQLToUnifiedQL(input: string, searchTermFields: */ function getOutput(input: string, options: OptionsQL) { // Implicit query - const implicitQueryAsUQL = options?.implicitQuery?.query ?? ''; + const implicitQueryAsUQL = options?.options?.implicitQuery?.query ?? ''; const implicitQueryAsQL = transformUQLToQL( implicitQueryAsUQL ); // Implicit query conjunction - const implicitQueryConjunctionAsUQL = options?.implicitQuery?.conjunction ?? ''; + const implicitQueryConjunctionAsUQL = options?.options?.implicitQuery?.conjunction ?? ''; const implicitQueryConjunctionAsQL = transformUQLToQL( implicitQueryConjunctionAsUQL ); @@ -594,16 +596,18 @@ function getOutput(input: string, options: OptionsQL) { const inputQueryAsQL = input; const inputQueryAsUQL = transformSpecificQLToUnifiedQL( inputQueryAsQL, - options?.searchTermFields ?? [] + options?.options?.searchTermFields ?? [] ); return { language: WQL.id, - unifiedQuery: [ - implicitQueryAsUQL, - implicitQueryAsUQL && inputQueryAsUQL ? implicitQueryConjunctionAsUQL : '', - implicitQueryAsUQL && inputQueryAsUQL ? `(${inputQueryAsUQL})`: inputQueryAsUQL - ].join(''), + apiQuery: { + q: [ + implicitQueryAsUQL, + implicitQueryAsUQL && inputQueryAsUQL ? implicitQueryConjunctionAsUQL : '', + implicitQueryAsUQL && inputQueryAsUQL ? `(${inputQueryAsUQL})`: inputQueryAsUQL + ].join(''), + }, query: [ implicitQueryAsQL, implicitQueryAsQL && inputQueryAsQL ? implicitQueryConjunctionAsQL : '', @@ -749,17 +753,17 @@ export const WQL = { const tokens: ITokens = tokenizer(input); // Get the implicit query as query language syntax - const implicitQueryAsQL = params.queryLanguage.parameters.implicitQuery + const implicitQueryAsQL = params.queryLanguage.parameters?.options?.implicitQuery ? transformUQLToQL( - params.queryLanguage.parameters.implicitQuery.query - + params.queryLanguage.parameters.implicitQuery.conjunction + params.queryLanguage.parameters.options.implicitQuery.query + + params.queryLanguage.parameters.options.implicitQuery.conjunction ) : ''; - // Validate the user input const fieldsSuggestion: string[] = await params.queryLanguage.parameters.suggestions.field() .map(({label}) => label); + // Validate the user input const validationPartial = validatePartial(tokens, { field: fieldsSuggestion }); From a3c3681dae1cabd3bf23e188a19f266f75eb5e46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Thu, 27 Apr 2023 16:44:09 +0200 Subject: [PATCH 32/33] feat(search-bar): enhance the validation of value token in WQL --- .../search-bar/query-language/wql.test.tsx | 5 ++ .../search-bar/query-language/wql.tsx | 71 +++++++++++++------ 2 files changed, 54 insertions(+), 22 deletions(-) diff --git a/public/components/search-bar/query-language/wql.test.tsx b/public/components/search-bar/query-language/wql.test.tsx index 781c24c335..3ec1f1d61f 100644 --- a/public/components/search-bar/query-language/wql.test.tsx +++ b/public/components/search-bar/query-language/wql.test.tsx @@ -374,6 +374,11 @@ describe('Query language - WQL', () => { ${'field2=value'} | ${undefined} ${'field=value'} | ${['"field" is not a valid field.']} ${'custom=value'} | ${['"custom" is not a valid field.']} + ${'field1=value!test'} | ${['"value!test" is not a valid value. Invalid characters found: !']} + ${'field1=value&test'} | ${['"value&test" is not a valid value. Invalid characters found: &']} + ${'field1=value!value&test'} | ${['"value!value&test" is not a valid value. Invalid characters found: !, &']} + ${'field1=value!value!test'} | ${['"value!value!test" is not a valid value. Invalid characters found: !']} + ${'field1=value!value!t$&st'} | ${['"value!value!t$&st" is not a valid value. Invalid characters found: !, $, &']} ${'field1=value and'} | ${['There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} ${'field2=value and'} | ${['There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} ${'field=value and'} | ${['"field" is not a valid field.', 'There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} diff --git a/public/components/search-bar/query-language/wql.tsx b/public/components/search-bar/query-language/wql.tsx index e63db6fa99..6935f50102 100644 --- a/public/components/search-bar/query-language/wql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -616,6 +616,51 @@ function getOutput(input: string, options: OptionsQL) { }; }; +/** + * Validate the token value + * @param token + * @returns + */ +function validateTokenValue(token: IToken): string | undefined { + // Usage of API regular expression + const re = new RegExp( + // Value: A string. + '^(?(?:(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\/\'"=@%<>{}]*)\\))*' + + '(?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]+)' + + '(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]*)\\))*)+)$' + ); + + /* WARN: the validation for the value token is complex, this supports some characters in + certain circumstances. + + Ideally a character validation helps to the user to identify the problem in the query, but + as the original regular expression is so complex, the logic to get this can be + complicated. + + The original regular expression has a common schema of allowed characters, these and other + characters of the original regular expression can be used to check each character. This + approach can identify some invalid characters despite this is not the ideal way. + + The ideal solution will be check each subset of the complex regex against the allowed + characters. + */ + + const invalidCharacters: string[] = token.value.split('') + .filter((character) => !(new RegExp('[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}\\(\\)]').test(character))) + .filter((value, index, array) => array.indexOf(value) === index); + + const match = token.value.match(re); + return match?.groups?.value === token.value + ? undefined + : [ + `"${token.value}" is not a valid value.`, + ...(invalidCharacters.length + ? [`Invalid characters found: ${invalidCharacters.join(', ')}`] + : [] + ) + ].join(' '); +}; + /** * Validate the tokens while the user is building the query * @param tokens @@ -643,18 +688,7 @@ function validatePartial(tokens: ITokens, options: {field: string[]}): undefined }; // Check if the value is allowed if(token.type === 'value'){ - // Usage of API regular expression - const re = new RegExp( - // Value: A string. - '^(?(?:(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\/\'"=@%<>{}]*)\\))*' + - '(?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]+)' + - '(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]*)\\))*)+)$' - ); - - const match = token.value.match(re); - return match?.groups?.value === token.value - ? undefined - : `"${token.value}" is not a valid value222.`; + return validateTokenValue(token); } }; }) @@ -697,21 +731,14 @@ function validate(tokens: ITokens, options: {field: string[]}): undefined | stri }; // Check if the value is allowed if(token.type === 'value'){ - // Usage of API regular expression - const re = new RegExp( - // Value: A string. - '^(?(?:(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\/\'"=@%<>{}]*)\\))*' + - '(?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]+)' + - '(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]*)\\))*)+)$' - ); + const validationError = validateTokenValue(token); - const match = token.value.match(re); - match?.groups?.value !== token.value && errors.push(`"${token.value}" is not a valid value.`); + validationError && errors.push(validationError); }; // Check if the value is allowed if(token.type === 'conjunction'){ - + const tokenWhitespaceNearToFieldNext = getTokenNearTo( tokens, 'whitespace', From 6678f6cf3c09322138aa0ee3eef6e4391aa006a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Wed, 3 May 2023 10:23:09 +0200 Subject: [PATCH 33/33] feat(search-bar): adapt the replacement of search bar in `Agents` and `Explore agent` views - adapt the replacement of search bar in `Agents` and `Explore agent` views - enhance the `Agents` section - session storage management - display an `Update` button instead of `Refresh` one when the input changed - display always the table pagination - filter out the date fields from the search term fields in WQL (`dateAdd` and `lastKeepAlive`) - add a mechanism to redo the same request when using the search bar - enhance the management of the visible table columns - enhance the `Explore agent` section - restrict the search term fields to the visibles in the table - add a mechanism to redo the same request when using the search bar --- .../agent/components/agents-table.js | 161 ++++++++++-------- .../agents-selection-table.js | 57 +++---- 2 files changed, 111 insertions(+), 107 deletions(-) diff --git a/public/controllers/agent/components/agents-table.js b/public/controllers/agent/components/agents-table.js index 922b57ce5b..b242544d7f 100644 --- a/public/controllers/agent/components/agents-table.js +++ b/public/controllers/agent/components/agents-table.js @@ -62,26 +62,10 @@ const searchBar = { { label: 'os.platform', description: 'filter by operating system platform' }, { label: 'status', description: 'filter by status' }, { label: 'version', description: 'filter by version' }, - ], - searchTermFields: [ - 'configSum', - 'dateAdd', - 'id', - 'ip', - 'group', - 'group_config_status', - 'lastKeepAlive', - 'manager', - 'mergedSum', - 'name', - 'node_name', - 'os.name', - 'os.version', - 'status', - 'version' ] } }; +const removeColumnsSearchTermFields = ['actions', 'dateAdd', 'lastKeepAlive']; export const AgentsTable = withErrorBoundary( class AgentsTable extends Component { @@ -89,8 +73,7 @@ export const AgentsTable = withErrorBoundary( constructor(props) { super(props); const filterSessionStorage = sessionStorage.getItem('wz_page_agents_search_bar_query') - ? sessionStorage.getItem('wz_page_agents_search_bar_query') - : ''; + ?? undefined; this.state = { agents: [], isLoading: false, @@ -104,11 +87,34 @@ export const AgentsTable = withErrorBoundary( purgeModal: false, isFilterColumnOpen: false, input: typeof filterSessionStorage !== 'undefined' ? filterSessionStorage : '', - query: typeof filterSessionStorage !== 'undefined' ? filterSessionStorage : '' + query: typeof filterSessionStorage !== 'undefined' ? filterSessionStorage : IMPLICIT_QUERY, + queryChange: typeof filterSessionStorage !== 'undefined' ? filterSessionStorage : IMPLICIT_QUERY, + refreshTime: Date.now(), + selectedColumns: window.localStorage.getItem('columnsSelectedTableAgent') + ? JSON.parse(window.localStorage.getItem('columnsSelectedTableAgent')) + : this.defaultColumns.map(item => ({...item})), + }; + this.state.searchBarWQLOptions = { + implicitQuery: { + query: IMPLICIT_QUERY, + conjunction: IMPLICIT_QUERY_CONJUNCTION + }, + searchTermFields: this.getSearchTermFieldsFromAvailableColumns(this.state.selectedColumns), }; this.downloadCsv.bind(this); } + getSearchTermFieldsFromAvailableColumns(columns){ + return columns + .filter(({field, show}) => !removeColumnsSearchTermFields.includes(field) && show) + /* Map the object + If the table column field is `os_name` then adds the os.name and os.version + */ + .map(({field}) => field === 'os_name' ? ['os.name', 'os.version'] : [field]) + .flat() + .sort(); + } + onTableChange = ({ page = {}, sort = {} }) => { const { index: pageIndex, size: pageSize } = page; const { field: sortField, direction: sortDirection } = sort; @@ -118,6 +124,7 @@ export const AgentsTable = withErrorBoundary( pageSize, sortField, sortDirection, + refreshTime: Date.now() }); }; @@ -140,17 +147,13 @@ export const AgentsTable = withErrorBoundary( async componentDidUpdate(prevProps, prevState) { if ( - !_.isEqual(prevState.query, this.state.query) || - prevState.pageIndex !== this.state.pageIndex || - prevState.pageSize !== this.state.pageSize || - prevState.sortField !== this.state.sortField || - prevState.sortDirection !== this.state.sortDirection + !_.isEqual(prevState.refreshTime, this.state.refreshTime) ) { await this.getItems(); } else if ( !_.isEqual(prevProps.filters, this.props.filters) ) { - this.setState({ input: this.props.filters, query: this.buildQueryWithImplicitQuery(this.props.filters), pageIndex: 0 }); + this.setState({ input: this.props.filters, query: this.buildQueryWithImplicitQuery(this.props.filters), pageIndex: 0, refreshTime: Date.now() }); } } @@ -389,6 +392,16 @@ export const AgentsTable = withErrorBoundary( setTableColumnsSelected(data) { window.localStorage.setItem('columnsSelectedTableAgent', JSON.stringify(data)); + this.setState({ + selectedColumns: data, + searchBarWQLOptions: { + implicitQuery: { + query: IMPLICIT_QUERY, + conjunction: IMPLICIT_QUERY_CONJUNCTION + }, + searchTermFields: this.getSearchTermFieldsFromAvailableColumns(data), + } + }); } // Columns with the property truncateText: true won't wrap the text @@ -534,15 +547,6 @@ export const AgentsTable = withErrorBoundary( } filterBarRender() { - // Build the search term fields from the visible columns in the table - const searchTermFields = this.columns() - .filter(({field}) => field !== 'actions') - /* Map the object - If the table column field is `os_name` then adds the os.name and os.version - */ - .map(({field}) => field === 'os_name' ? ['os.name', 'os.version'] : [field]) - .flat() - .sort(); return ( @@ -552,11 +556,7 @@ export const AgentsTable = withErrorBoundary( modes={[ { id: 'wql', - implicitQuery: { - query: IMPLICIT_QUERY, - conjunction: IMPLICIT_QUERY_CONJUNCTION - }, - searchTermFields, + options: this.state.searchBarWQLOptions, suggestions: { field(currentValue) { return searchBar.wql.suggestionsValue; @@ -592,50 +592,63 @@ export const AgentsTable = withErrorBoundary( }, }, ]} - onSearch={({unifiedQuery}) => { + onChange={({apiQuery}) => { // Set the query and reset the page index - this.setState({query: unifiedQuery, pageIndex: 0}); + this.setState({queryChange: apiQuery.q, pageIndex: 0}); + }} + onSearch={({apiQuery}) => { + // Set the query, reset the page index and update the refreshTime + this.setState({queryChange: apiQuery.q, query: apiQuery.q, pageIndex: 0, refreshTime: Date.now()}); }} /> - this.reloadAgents()} - > - Refresh - + {this.state.queryChange === this.state.query ? + ( + this.reloadAgents()} + > + Refresh + + ) + : + ( + this.setState({query: this.state.queryChange}, () => this.reloadAgents())} + > + Update + + ) + } ); } selectColumnsRender() { - const columnsSelected = this.getTableColumnsSelected(); - const onChange = (optionId) => { - let item = columnsSelected.find((item) => item.field === optionId); - item.show = !item.show; - this.setTableColumnsSelected(columnsSelected); - this.forceUpdate(); + const results = this.state.selectedColumns.map(item => item.field === optionId ? {...item, show: !item.show} : item); + this.setTableColumnsSelected([...results]); }; - const options = () => { - return columnsSelected.map((item) => { - return { - id: item.field, - label: item.name, - checked: item.show, - }; - }); - }; + const options = this.state.selectedColumns.map((item) => { + return { + id: item.field, + label: item.name, + checked: item.show, + }; + }); return this.state.isFilterColumnOpen ? ( 15 - ? { - pageIndex: pageIndex, - pageSize: pageSize, - totalItemCount: totalItems, - pageSizeOptions: [15, 25, 50, 100], - } - : false; + const columns = this.state.selectedColumns + .filter(({show}) => show).map(({field}) => this.defaultColumns.find((column) => column.field === field)); + const pagination = { + pageIndex: pageIndex, + pageSize: pageSize, + totalItemCount: totalItems, + pageSizeOptions: [15, 25, 50, 100], + }; const sorting = { sort: { field: sortField, diff --git a/public/controllers/overview/components/overview-actions/agents-selection-table.js b/public/controllers/overview/components/overview-actions/agents-selection-table.js index 55eac4cc23..c319ecb19c 100644 --- a/public/controllers/overview/components/overview-actions/agents-selection-table.js +++ b/public/controllers/overview/components/overview-actions/agents-selection-table.js @@ -56,7 +56,8 @@ export class AgentSelectionTable extends Component { agents: [], selectedOptions: [], query: '', - input: '' + input: '', + refreshTime: Date.now() }; this.columns = [ @@ -134,14 +135,30 @@ export class AgentSelectionTable extends Component { 'os.platform', 'os.version' ].join(','); + + this.searchBarWQLOptions = { + implicitQuery: { + query: IMPLICIT_QUERY, + conjunction: IMPLICIT_QUERY_CONJUNCTION + }, + searchTermFields: [ + 'id', + 'name', + 'group', + 'version', + 'os.name', + 'os.version', + 'status', + ] + }; } onChangeItemsPerPage = async itemsPerPage => { - this._isMounted && this.setState({ itemsPerPage }); + this._isMounted && this.setState({ itemsPerPage, refreshTime: Date.now() }); }; onChangePage = async pageIndex => { - this._isMounted && this.setState({ pageIndex }); + this._isMounted && this.setState({ pageIndex, refreshTime: Date.now() }); }; async componentDidMount() { @@ -159,12 +176,7 @@ export class AgentSelectionTable extends Component { } async componentDidUpdate(prevProps, prevState) { - if(prevState.query!== this.state.query - || prevState.pageIndex !== this.state.pageIndex - || prevState.pageSize !== this.state.pageSize - || prevState.sortField !== this.state.sortField - || prevState.sortDirection !== this.state.sortDirection - ){ + if(prevState.refreshTime !== this.state.refreshTime){ await this.getItems(); } } @@ -254,7 +266,7 @@ export class AgentSelectionTable extends Component { ? 'desc' : 'asc'; - this._isMounted && this.setState({ sortField, sortDirection }); + this._isMounted && this.setState({ sortField, sortDirection, refreshTime: Date.now() }); }; toggleItem = itemId => { @@ -619,26 +631,7 @@ export class AgentSelectionTable extends Component { modes={[ { id: 'wql', - implicitQuery: { - query: IMPLICIT_QUERY, - conjunction: IMPLICIT_QUERY_CONJUNCTION - }, - searchTermFields: [ - 'configSum', - 'dateAdd', - 'id', - 'ip', - 'group', - 'group_config_status', - 'lastKeepAlive', - 'manager', - 'mergedSum', - 'name', - 'node_name', - 'os.platform', - 'status', - 'version' - ], + options: this.searchBarWQLOptions, suggestions: { field(currentValue) { return [ @@ -689,9 +682,9 @@ export class AgentSelectionTable extends Component { }, }, ]} - onSearch={({unifiedQuery}) => { + onSearch={({apiQuery}) => { // Set the query and reset the page index - this.setState({query: unifiedQuery, pageIndex: 0}); + this.setState({query: apiQuery.q, pageIndex: 0, refreshTime: Date.now()}); }} />