[Osquery] Refactor to React hooks form (#138501)

This commit is contained in:
Tomasz Ciecierski 2022-08-23 13:31:15 +02:00 committed by GitHub
parent 0212338e4e
commit 767cb6b1c1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 1274 additions and 1135 deletions

View file

@ -6,14 +6,7 @@
*/
import { isEmpty, reduce } from 'lodash';
export const convertECSMappingToArray = (ecsMapping: Record<string, object> | undefined) =>
ecsMapping
? Object.entries(ecsMapping).map((item) => ({
key: item[0],
value: item[1],
}))
: undefined;
import type { ECSMapping } from './schemas';
export const convertECSMappingToObject = (
ecsMapping: Array<{
@ -23,7 +16,7 @@ export const convertECSMappingToObject = (
value: string;
};
}>
): Record<string, { field?: string; value?: string }> =>
): ECSMapping =>
reduce(
ecsMapping,
(acc, value) => {

View file

@ -8,7 +8,6 @@
import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver';
import { login } from '../../tasks/login';
import {
checkResults,
findAndClickButton,
findFormFieldByRowsLabelAndType,
inputQuery,
@ -80,7 +79,8 @@ describe('Alert Event Details', () => {
cy.contains('1 agent selected.');
inputQuery('select * from uptime;');
submitQuery();
checkResults();
cy.contains('Results');
cy.contains('Add to timeline investigation');
cy.contains('Save for later').click();
cy.contains('Save query');
cy.get('.euiButtonEmpty--flushLeft').contains('Cancel').click();

View file

@ -32,10 +32,9 @@ describe('ALL - Edit saved query', () => {
}).click();
cy.contains('Custom key/value pairs.').should('exist');
cy.contains('Hours of uptime').should('exist');
cy.react('ECSComboboxFieldComponent', { props: { field: { value: 'labels' } } })
.parents('[data-test-subj="ECSMappingEditorForm"]')
.react('EuiButtonIcon', { props: { iconType: 'trash' } })
.click();
cy.react('ECSMappingEditorForm').within(() => {
cy.react('EuiButtonIcon', { props: { iconType: 'trash' } }).click();
});
cy.react('PlatformCheckBoxGroupField').within(() => {
cy.react('EuiCheckbox', {

View file

@ -41,6 +41,28 @@ describe('ALL - Live Query', () => {
runKbnArchiverScript(ArchiverMethod.UNLOAD, 'example_pack');
});
it('should validate the form', () => {
cy.contains('New live query').click();
submitQuery();
cy.contains('Agents is a required field');
cy.contains('Query is a required field');
selectAllAgents();
inputQuery('select * from uptime; ');
submitQuery();
cy.contains('Agents is a required field').should('not.exist');
cy.contains('Query is a required field').should('not.exist');
checkResults();
getAdvancedButton().click();
typeInOsqueryFieldInput('days{downArrow}{enter}');
submitQuery();
cy.contains('ECS field is required.');
typeInECSFieldInput('message{downArrow}{enter}');
submitQuery();
cy.contains('ECS field is required.').should('not.exist');
checkResults();
});
it('should run query and enable ecs mapping', () => {
const cmd = Cypress.platform === 'darwin' ? '{meta}{enter}' : '{ctrl}{enter}';
cy.contains('New live query').click();
@ -82,7 +104,7 @@ describe('ALL - Live Query', () => {
cy.contains('New live query').click();
selectAllAgents();
cy.react('SavedQueriesDropdown').type('NOMAPPING{downArrow}{enter}');
cy.getReact('SavedQueriesDropdown').getCurrentState().should('have.length', 1);
// cy.getReact('SavedQueriesDropdown').getCurrentState().should('have.length', 1); // TODO do we need it?
inputQuery('{selectall}{backspace}{selectall}{backspace}select * from users');
cy.wait(1000);
submitQuery();

View file

@ -77,6 +77,7 @@ describe('ALL - Packs', () => {
cy.contains('Attach next query');
inputQuery('select * from uptime');
findFormFieldByRowsLabelAndType('ID', SAVED_QUERY_ID);
cy.react('EuiFlyoutFooter').react('EuiButton').contains('Save').click();
cy.contains('ID must be unique').should('exist');
findFormFieldByRowsLabelAndType('ID', NEW_QUERY_NAME);
cy.contains('ID must be unique').should('not.exist');
@ -95,6 +96,7 @@ describe('ALL - Packs', () => {
cy.contains('Attach next query');
cy.contains('ID must be unique').should('not.exist');
getSavedQueriesDropdown().type(`${SAVED_QUERY_ID}{downArrow}{enter}`);
cy.react('EuiFlyoutFooter').react('EuiButton').contains('Save').click();
cy.contains('ID must be unique').should('exist');
cy.react('EuiFlyoutFooter').react('EuiButtonEmpty').contains('Cancel').click();
});

View file

@ -20,7 +20,6 @@ describe('Admin', () => {
cy.contains('New live query').click();
selectAllAgents();
inputQuery('select * from uptime; ');
cy.wait(500);
submitQuery();
checkResults();
});

View file

@ -94,6 +94,7 @@ describe('T1 Analyst - READ + runSavedQueries ', () => {
cy.contains('New live query').click();
selectAllAgents();
cy.get(LIVE_QUERY_EDITOR).should('not.exist');
cy.contains('Submit').should('be.disabled');
submitQuery();
cy.contains('Query is a required field');
});
});

View file

@ -101,15 +101,15 @@ describe('T2 Analyst - READ + Write Live/Saved + runSavedQueries ', () => {
it('to click the edit button and edit pack', () => {
navigateTo('/app/osquery/saved_queries');
cy.getBySel('pagination-button-next').click();
cy.react('CustomItemAction', {
props: { index: 1, item: { attributes: { id: SAVED_QUERY_ID } } },
}).click();
cy.contains('Custom key/value pairs.').should('exist');
cy.contains('Hours of uptime').should('exist');
cy.react('ECSComboboxFieldComponent', { props: { field: { value: 'labels' } } })
.parents('[data-test-subj="ECSMappingEditorForm"]')
.react('EuiButtonIcon', { props: { iconType: 'trash' } })
.click();
cy.react('ECSMappingEditorForm').within(() => {
cy.react('EuiButtonIcon', { props: { iconType: 'trash' } }).click();
});
cy.react('EuiButton').contains('Update query').click();
cy.wait(5000);

View file

@ -25,7 +25,10 @@ export const clearInputQuery = () =>
export const inputQuery = (query: string) => cy.get(LIVE_QUERY_EDITOR).type(query);
export const submitQuery = () => cy.contains('Submit').click();
export const submitQuery = () => {
cy.wait(1000); // wait for the validation to trigger - cypress is way faster than users ;)
cy.contains('Submit').click();
};
export const checkResults = () =>
cy.getBySel('dataGridRowCell', { timeout: 120000 }).should('have.lengthOf.above', 0);

View file

@ -35,12 +35,13 @@ import { AGENT_GROUP_KEY } from './types';
interface AgentsTableProps {
agentSelection: AgentSelection;
onChange: (payload: AgentSelection) => void;
error?: string;
}
const perPage = 10;
const DEBOUNCE_DELAY = 300; // ms
const AgentsTableComponent: React.FC<AgentsTableProps> = ({ agentSelection, onChange }) => {
const AgentsTableComponent: React.FC<AgentsTableProps> = ({ agentSelection, onChange, error }) => {
// search related
const [searchValue, setSearchValue] = useState<string>('');
const [modifyingSearch, setModifyingSearch] = useState<boolean>(false);
@ -185,7 +186,7 @@ const AgentsTableComponent: React.FC<AgentsTableProps> = ({ agentSelection, onCh
return (
<div>
<EuiFormRow label={AGENT_SELECTION_LABEL} fullWidth>
<EuiFormRow label={AGENT_SELECTION_LABEL} fullWidth isInvalid={!!error} error={error}>
<EuiComboBox
data-test-subj="agentSelection"
placeholder={SELECT_AGENT_LABEL}

View file

@ -15,7 +15,6 @@ import type {
} from '../../common/search_strategy';
import type { ESQuery } from '../../common/typed_json';
import type { ArrayItem } from '../shared_imports';
export const createFilter = (filterQuery: ESQuery | string | undefined) =>
isString(filterQuery) ? filterQuery : JSON.stringify(filterQuery);
@ -44,7 +43,7 @@ export const getInspectResponse = <T extends FactoryQueryTypes>(
response != null ? [JSON.stringify(response.rawResponse, null, 2)] : prevResponse?.response,
});
export const prepareEcsFieldsToValidate = (ecsMapping: ArrayItem[]): string[] =>
export const prepareEcsFieldsToValidate = (ecsMapping: Array<{ id: string }>): string[] =>
ecsMapping
?.map((_: unknown, index: number) => [
`ecs_mapping[${index}].result.value`,

View file

@ -1,18 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import type { FormData, ValidationFunc } from '../shared_imports';
import { fieldValidators } from '../shared_imports';
export const queryFieldValidation: ValidationFunc<FormData, string, string> =
fieldValidators.emptyField(
i18n.translate('xpack.osquery.pack.queryFlyoutForm.emptyQueryError', {
defaultMessage: 'Query is a required field',
})
);

View file

@ -7,12 +7,12 @@
import React, { useEffect, useState } from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import 'brace/theme/tomorrow';
import type { EuiCodeEditorProps } from '../shared_imports';
import { EuiCodeEditor } from '../shared_imports';
import './osquery_mode';
import 'brace/theme/tomorrow';
const EDITOR_SET_OPTIONS = {
enableBasicAutocompletion: true,

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { VersionField } from './version_field';
export { QueryDescriptionField } from './query_description_field';
export { IntervalField } from './interval_field';
export { QueryIdField } from './query_id_field';
export type { FormField } from './types';

View file

@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useMemo } from 'react';
import { useController } from 'react-hook-form';
import type { EuiFieldNumberProps } from '@elastic/eui';
import { EuiFieldNumber, EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
const intervalFieldValidations = {
required: {
message: i18n.translate('xpack.osquery.pack.queryFlyoutForm.intervalFieldMinNumberError', {
defaultMessage: 'A positive interval value is required',
}),
value: true,
},
min: {
message: i18n.translate('xpack.osquery.pack.queryFlyoutForm.intervalFieldMinNumberError', {
defaultMessage: 'A positive interval value is required',
}),
value: 1,
},
max: {
message: i18n.translate('xpack.osquery.pack.queryFlyoutForm.intervalFieldMaxNumberError', {
defaultMessage: 'An interval value must be lower than {than}',
values: { than: 604800 },
}),
value: 604800,
},
};
interface IntervalFieldProps {
euiFieldProps?: Record<string, unknown>;
}
const IntervalFieldComponent = ({ euiFieldProps }: IntervalFieldProps) => {
const {
field: { onChange, value },
fieldState: { error },
} = useController({
name: 'interval',
defaultValue: 3600,
rules: {
...intervalFieldValidations,
},
});
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const numberValue = e.target.valueAsNumber ? e.target.valueAsNumber : 0;
onChange(numberValue);
},
[onChange]
);
const hasError = useMemo(() => !!error?.message, [error?.message]);
return (
<EuiFormRow
label={i18n.translate('xpack.osquery.pack.queryFlyoutForm.intervalFieldLabel', {
defaultMessage: 'Interval (s)',
})}
error={error?.message}
isInvalid={hasError}
fullWidth
>
<EuiFieldNumber
isInvalid={hasError}
value={value as EuiFieldNumberProps['value']}
onChange={handleChange}
fullWidth
type="number"
data-test-subj="input"
{...euiFieldProps}
/>
</EuiFormRow>
);
};
export const IntervalField = React.memo(IntervalFieldComponent);

View file

@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import { useController } from 'react-hook-form';
import { EuiFieldText, EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
interface QueryDescriptionFieldProps {
euiFieldProps?: Record<string, unknown>;
}
const QueryDescriptionFieldComponentn = ({ euiFieldProps }: QueryDescriptionFieldProps) => {
const {
field: { onChange, value, name: fieldName },
fieldState: { error },
} = useController({
name: 'description',
defaultValue: '',
});
const hasError = useMemo(() => !!error?.message, [error?.message]);
return (
<EuiFormRow
label={i18n.translate('xpack.osquery.pack.form.descriptionFieldLabel', {
defaultMessage: 'Description (optional)',
})}
error={error?.message}
isInvalid={hasError}
fullWidth
>
<EuiFieldText
isInvalid={hasError}
onChange={onChange}
value={value}
name={fieldName}
fullWidth
data-test-subj="input"
{...euiFieldProps}
/>
</EuiFormRow>
);
};
export const QueryDescriptionField = React.memo(QueryDescriptionFieldComponentn);

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import { useController } from 'react-hook-form';
import { EuiFieldText, EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { createFormIdFieldValidations } from '../packs/queries/validations';
interface QueryIdFieldProps {
idSet?: Set<string>;
euiFieldProps?: Record<string, unknown>;
}
const QueryIdFieldComponentn = ({ idSet, euiFieldProps }: QueryIdFieldProps) => {
const {
field: { onChange, value, name: fieldName },
fieldState: { error },
} = useController({
name: 'id',
defaultValue: '',
rules: idSet && createFormIdFieldValidations(idSet),
});
const hasError = useMemo(() => !!error?.message, [error?.message]);
return (
<EuiFormRow
label={i18n.translate('xpack.osquery.pack.queryFlyoutForm.idFieldLabel', {
defaultMessage: 'ID',
})}
error={error?.message}
isInvalid={hasError}
fullWidth
>
<EuiFieldText
isInvalid={hasError}
onChange={onChange}
value={value}
name={fieldName}
fullWidth
data-test-subj="input"
{...euiFieldProps}
/>
</EuiFormRow>
);
};
export const QueryIdField = React.memo(QueryIdFieldComponentn);

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type React from 'react';
import type { ReactNode } from 'react';
export interface FormField<T> {
name: string;
onChange: (data: T) => void;
value: T;
onBlur?: () => void;
}
export interface FormFieldProps<T> {
name: string;
label: string | Element;
labelAppend?: ReactNode;
helpText?: string | (() => React.ReactNode);
idAria?: string;
euiFieldProps?: Record<string, unknown>;
defaultValue?: T;
required?: boolean;
rules?: Record<string, unknown>;
}

View file

@ -0,0 +1,88 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import type { EuiComboBoxOptionOption } from '@elastic/eui';
import { EuiFormRow, EuiComboBox, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { useController } from 'react-hook-form';
import { FormattedMessage } from '@kbn/i18n-react';
interface VersionFieldProps {
euiFieldProps?: Record<string, unknown>;
}
const VersionFieldComponent = ({ euiFieldProps = {} }: VersionFieldProps) => {
const {
field: { onChange, value },
fieldState: { error },
} = useController({
name: 'version',
defaultValue: [],
rules: {},
});
const onCreateComboOption = useCallback(
(newValue: string) => {
const result = [...(value as string[]), newValue];
onChange(result);
},
[onChange, value]
);
const onComboChange = useCallback(
(options: EuiComboBoxOptionOption[]) => {
onChange(options.map((option) => option.label));
},
[onChange]
);
const hasError = useMemo(() => !!error?.message, [error?.message]);
return (
<EuiFormRow
label={
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<FormattedMessage
id="xpack.osquery.pack.queryFlyoutForm.versionFieldLabel"
defaultMessage="Minimum Osquery version"
/>
</EuiFlexItem>
</EuiFlexGroup>
}
labelAppend={
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued">
<FormattedMessage
id="xpack.osquery.queryFlyoutForm.versionFieldOptionalLabel"
defaultMessage="(optional)"
/>
</EuiText>
</EuiFlexItem>
}
error={error?.message}
isInvalid={hasError}
fullWidth
>
<EuiComboBox
isInvalid={hasError}
noSuggestions
placeholder={i18n.translate('xpack.osquery.comboBoxField.placeHolderText', {
defaultMessage: 'Type and then hit "ENTER"',
})}
selectedOptions={value.map((v: string) => ({ label: v }))}
onCreateOption={onCreateComboOption}
onChange={onComboChange}
fullWidth
data-test-subj="input"
{...euiFieldProps}
/>
</EuiFormRow>
);
};
export const VersionField = React.memo(VersionFieldComponent);

View file

@ -5,27 +5,47 @@
* 2.0.
*/
import React, { useCallback } from 'react';
import type { FieldHook } from '../../shared_imports';
import React from 'react';
import { useController } from 'react-hook-form';
import { isEmpty } from 'lodash';
import { i18n } from '@kbn/i18n';
import { AgentsTable } from '../../agents/agents_table';
import type { AgentSelection } from '../../agents/types';
interface AgentsTableFieldProps {
field: FieldHook<AgentSelection>;
}
const checkAgentsLength = (agentsSelection: AgentSelection) => {
if (!isEmpty(agentsSelection)) {
const isValid = !!(
agentsSelection.allAgentsSelected ||
agentsSelection.agents?.length ||
agentsSelection.platformsSelected?.length ||
agentsSelection.policiesSelected?.length
);
const AgentsTableFieldComponent: React.FC<AgentsTableFieldProps> = ({ field }) => {
const { value, setValue } = field;
const handleChange = useCallback(
(props) => {
if (props !== value) {
return setValue(props);
}
return !isValid
? i18n.translate('xpack.osquery.pack.queryFlyoutForm.osqueryAgentsMissingErrorMessage', {
defaultMessage: 'Agents is a required field',
})
: undefined;
}
return i18n.translate('xpack.osquery.pack.queryFlyoutForm.osqueryAgentsMissingErrorMessage', {
defaultMessage: 'Agents is a required field',
});
};
const AgentsTableFieldComponent: React.FC<{}> = () => {
const {
field: { onChange, value },
fieldState: { error },
} = useController({
name: 'agentSelection',
rules: {
validate: checkAgentsLength,
},
[value, setValue]
);
defaultValue: {},
});
return <AgentsTable agentSelection={value} onChange={handleChange} />;
return <AgentsTable agentSelection={value} onChange={onChange} error={error?.message} />;
};
export const AgentsTableField = React.memo(AgentsTableFieldComponent);

View file

@ -19,27 +19,47 @@ import {
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import { useForm as useHookForm, FormProvider } from 'react-hook-form';
import { pickBy, isEmpty, map, find } from 'lodash';
import { isEmpty, map, find, pickBy } from 'lodash';
import { i18n } from '@kbn/i18n';
import type { SavedQuerySOFormData } from '../../saved_queries/form/use_saved_query_form';
import type {
EcsMappingFormField,
EcsMappingSerialized,
} from '../../packs/queries/ecs_mapping_editor_field';
import { defaultEcsFormData } from '../../packs/queries/ecs_mapping_editor_field';
import { convertECSMappingToObject } from '../../../common/schemas/common/utils';
import type { FormData } from '../../shared_imports';
import { UseField, Form, useForm, useFormData } from '../../shared_imports';
import { AgentsTableField } from './agents_table_field';
import { LiveQueryQueryField } from './live_query_query_field';
import { useKibana } from '../../common/lib/kibana';
import { ResultTabs } from '../../routes/saved_queries/edit/tabs';
import { SavedQueryFlyout } from '../../saved_queries';
import { ECSMappingEditorField } from '../../packs/queries/lazy_ecs_mapping_editor_field';
import { SavedQueriesDropdown } from '../../saved_queries/saved_queries_dropdown';
import { liveQueryFormSchema } from './schema';
import { usePacks } from '../../packs/use_packs';
import { PackQueriesStatusTable } from './pack_queries_status_table';
import { useCreateLiveQuery } from '../use_create_live_query_action';
import { useLiveQueryDetails } from '../../actions/use_live_query_details';
import type { AgentSelection } from '../../agents/types';
import { LiveQueryQueryField } from './live_query_query_field';
import { AgentsTableField } from './agents_table_field';
import { PacksComboBoxField } from './packs_combobox_field';
import { savedQueryDataSerializer } from '../../saved_queries/form/use_saved_query_form';
const FORM_ID = 'liveQueryForm';
export interface LiveQueryFormFields {
query?: string;
agentSelection: AgentSelection;
savedQueryId?: string | null;
ecs_mapping: EcsMappingFormField[];
packId: string[];
}
interface DefaultLiveQueryFormFields {
query?: string;
agentSelection?: AgentSelection;
savedQueryId?: string | null;
ecs_mapping?: EcsMappingSerialized;
packId?: string;
}
const StyledEuiCard = styled(EuiCard)`
padding: 16px 92px 16px 16px !important;
@ -86,12 +106,10 @@ const StyledEuiAccordion = styled(EuiAccordion)`
}
`;
const GhostFormField = () => <></>;
type FormType = 'simple' | 'steps';
interface LiveQueryFormProps {
defaultValue?: Partial<FormData>;
defaultValue?: DefaultLiveQueryFormFields;
onSuccess?: () => void;
queryField?: boolean;
ecsMappingField?: boolean;
@ -118,6 +136,22 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
[permissions]
);
const hooksForm = useHookForm<LiveQueryFormFields>({
defaultValues: {
ecs_mapping: [defaultEcsFormData],
},
});
const {
handleSubmit,
watch,
setValue,
resetField,
clearErrors,
getFieldState,
register,
formState: { isSubmitting, errors },
} = hooksForm;
const canRunSingleQuery = useMemo(
() =>
!!(
@ -133,6 +167,8 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
const [queryType, setQueryType] = useState<string>('query');
const [isLive, setIsLive] = useState(false);
const queryState = getFieldState('query');
const watchedValues = watch();
const handleShowSaveQueryFlyout = useCallback(() => setShowSavedQueryFlyout(true), []);
const handleCloseSaveQueryFlyout = useCallback(() => setShowSavedQueryFlyout(false), []);
@ -150,75 +186,22 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
isLive,
});
const { form } = useForm({
id: FORM_ID,
schema: liveQueryFormSchema,
onSubmit: async (formData, isValid) => {
if (isValid) {
try {
// @ts-expect-error update types
await mutateAsync(formData);
// eslint-disable-next-line no-empty
} catch (e) {}
}
},
options: {
stripEmptyFields: false,
},
serializer: ({
savedQueryId,
// eslint-disable-next-line @typescript-eslint/naming-convention
ecs_mapping,
packId,
...formData
}) =>
pickBy(
{
...formData,
pack_id: packId?.length ? packId[0] : undefined,
saved_query_id: savedQueryId,
ecs_mapping: convertECSMappingToObject(ecs_mapping),
},
(value) => !isEmpty(value)
),
});
const { updateFieldValues, setFieldValue, submit, isSubmitting } = form;
const actionId = useMemo(() => liveQueryDetails?.action_id, [liveQueryDetails?.action_id]);
const agentIds = useMemo(() => liveQueryDetails?.agents, [liveQueryDetails?.agents]);
const [
{ agentSelection, ecs_mapping: ecsMapping, query, savedQueryId, packId },
formDataSerializer,
] = useFormData({
form,
});
/* recalculate the form data when ecs_mapping changes */
// eslint-disable-next-line react-hooks/exhaustive-deps
const serializedFormData = useMemo(() => formDataSerializer(), [ecsMapping, formDataSerializer]);
useEffect(() => {
register('savedQueryId');
}, [register]);
const agentSelected = useMemo(
() =>
agentSelection &&
!!(
agentSelection.allAgentsSelected ||
agentSelection.agents?.length ||
agentSelection.platformsSelected?.length ||
agentSelection.policiesSelected?.length
),
[agentSelection]
);
const queryValueProvided = useMemo(() => !!query?.length, [query]);
const { packId } = watchedValues;
const queryStatus = useMemo(() => {
if (isError || !form.getFields().query?.isValid) return 'danger';
if (isError || queryState.invalid) return 'danger';
if (isLoading) return 'loading';
if (isSuccess) return 'complete';
return 'incomplete';
}, [isError, isLoading, isSuccess, form]);
}, [isError, isLoading, isSuccess, queryState]);
const resultsStatus = useMemo(
() => (queryStatus === 'complete' ? 'incomplete' : 'disabled'),
@ -228,39 +211,66 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
const handleSavedQueryChange = useCallback(
(savedQuery) => {
if (savedQuery) {
updateFieldValues({
query: savedQuery.query,
savedQueryId: savedQuery.savedQueryId,
ecs_mapping: savedQuery.ecs_mapping
setValue('query', savedQuery.query);
setValue('savedQueryId', savedQuery.savedQueryId);
setValue(
'ecs_mapping',
!isEmpty(savedQuery.ecs_mapping)
? map(savedQuery.ecs_mapping, (value, key) => ({
key,
result: {
type: Object.keys(value)[0],
value: Object.values(value)[0],
value: Object.values(value)[0] as string,
},
}))
: [],
});
: [defaultEcsFormData]
);
if (!isEmpty(savedQuery.ecs_mapping)) {
setAdvancedContentState('open');
}
} else {
setFieldValue('savedQueryId', null);
setValue('savedQueryId', null);
}
},
[setFieldValue, updateFieldValues]
[setValue]
);
const onSubmit = useCallback(
// not sure why, but submitOnCmdEnter doesn't have proper form values so I am passing them in manually
async (values: LiveQueryFormFields = watchedValues) => {
const serializedData = pickBy(
{
agentSelection: values.agentSelection,
saved_query_id: values.savedQueryId,
query: values.query,
pack_id: packId?.length ? packId[0] : undefined,
...(values.ecs_mapping
? { ecs_mapping: convertECSMappingToObject(values.ecs_mapping) }
: {}),
},
(value) => !isEmpty(value)
);
if (isEmpty(errors)) {
try {
// @ts-expect-error update types
await mutateAsync(serializedData);
// eslint-disable-next-line no-empty
} catch (e) {}
}
},
[errors, mutateAsync, packId, watchedValues]
);
const commands = useMemo(
() => [
{
name: 'submitOnCmdEnter',
bindKey: { win: 'ctrl+enter', mac: 'cmd+enter' },
exec: () => submit(),
// @ts-expect-error update types - explanation in onSubmit()
exec: () => handleSubmit(onSubmit)(watchedValues),
},
],
[submit]
[handleSubmit, onSubmit, watchedValues]
);
const queryComponentProps = useMemo(
@ -270,9 +280,9 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
[commands]
);
const flyoutFormDefaultValue = useMemo(
() => ({ savedQueryId, query, ecs_mapping: serializedFormData.ecs_mapping }),
[savedQueryId, serializedFormData.ecs_mapping, query]
const serializedData: SavedQuerySOFormData = useMemo(
() => savedQueryDataSerializer(watchedValues),
[watchedValues]
);
const handleToggle = useCallback((isOpen) => {
@ -306,12 +316,7 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
{formType === 'steps' && queryType !== 'pack' && (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
disabled={
!permissions.writeSavedQueries ||
!agentSelected ||
!queryValueProvided ||
resultsStatus === 'disabled'
}
disabled={!permissions.writeSavedQueries || resultsStatus === 'disabled'}
onClick={handleShowSaveQueryFlyout}
>
<FormattedMessage
@ -324,15 +329,8 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
<EuiFlexItem grow={false}>
<EuiButton
id="submit-button"
disabled={
!enabled ||
!agentSelected ||
(queryType === 'query' && !queryValueProvided) ||
(queryType === 'pack' &&
(!packId || !selectedPackData?.attributes.queries.length)) ||
isSubmitting
}
onClick={submit}
disabled={!enabled || isSubmitting}
onClick={handleSubmit(onSubmit)}
>
<FormattedMessage
id="xpack.osquery.liveQueryForm.form.submitButtonLabel"
@ -344,25 +342,22 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
</EuiFlexItem>
),
[
agentSelected,
enabled,
formType,
handleShowSaveQueryFlyout,
isSubmitting,
packId,
permissions.writeSavedQueries,
queryType,
queryValueProvided,
permissions.writeSavedQueries,
resultsStatus,
selectedPackData,
submit,
handleShowSaveQueryFlyout,
enabled,
isSubmitting,
handleSubmit,
onSubmit,
]
);
const queryFieldStepContent = useMemo(
() => (
<>
{queryField ? (
{queryField && (
<>
{!isSavedQueryDisabled && (
<>
@ -372,20 +367,10 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
/>
</>
)}
<UseField path="savedQueryId" component={GhostFormField} />
<UseField
path="query"
component={LiveQueryQueryField}
componentProps={queryComponentProps}
/>
</>
) : (
<>
<UseField path="savedQueryId" component={GhostFormField} />
<UseField path="query" component={GhostFormField} />
<LiveQueryQueryField {...queryComponentProps} queryType={queryType} />
</>
)}
{ecsMappingField ? (
{ecsMappingField && (
<>
<EuiSpacer size="m" />
<StyledEuiAccordion
@ -398,20 +383,19 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
<ECSMappingEditorField euiFieldProps={ecsFieldProps} />
</StyledEuiAccordion>
</>
) : (
<UseField path="ecs_mapping" component={GhostFormField} />
)}
</>
),
[
queryField,
queryComponentProps,
isSavedQueryDisabled,
handleSavedQueryChange,
queryComponentProps,
queryType,
ecsMappingField,
advancedContentState,
handleToggle,
ecsFieldProps,
isSavedQueryDisabled,
]
);
@ -422,7 +406,7 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
singleQueryDetails?.action_id ? (
<ResultTabs
actionId={singleQueryDetails?.action_id}
ecsMapping={serializedFormData.ecs_mapping}
ecsMapping={serializedData.ecs_mapping}
endDate={singleQueryDetails?.expiration}
agentIds={singleQueryDetails?.agents}
addToTimeline={addToTimeline}
@ -432,7 +416,7 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
singleQueryDetails?.action_id,
singleQueryDetails?.expiration,
singleQueryDetails?.agents,
serializedFormData.ecs_mapping,
serializedData.ecs_mapping,
addToTimeline,
]
);
@ -440,9 +424,7 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
useEffect(() => {
if (defaultValue) {
if (defaultValue.agentSelection) {
updateFieldValues({
agentSelection: defaultValue.agentSelection,
});
setValue('agentSelection', defaultValue.agentSelection);
}
if (defaultValue?.packId && canRunPacks) {
@ -451,19 +433,18 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
if (!isPackDataFetched) return;
const selectedPackOption = find(packsData?.data, ['id', defaultValue.packId]);
if (selectedPackOption) {
updateFieldValues({
packId: [defaultValue.packId],
});
setValue('packId', [defaultValue.packId]);
}
return;
}
if (defaultValue?.query && canRunSingleQuery) {
updateFieldValues({
query: defaultValue.query,
savedQueryId: defaultValue.savedQueryId,
ecs_mapping: defaultValue.ecs_mapping
setValue('query', defaultValue.query);
setValue('savedQueryId', defaultValue.savedQueryId);
setValue(
'ecs_mapping',
!isEmpty(defaultValue.ecs_mapping)
? map(defaultValue.ecs_mapping, (value, key) => ({
key,
result: {
@ -471,8 +452,8 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
value: Object.values(value)[0],
},
}))
: undefined,
});
: [defaultEcsFormData]
);
return;
}
@ -485,14 +466,7 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
return setQueryType('pack');
}
}
}, [
canRunPacks,
canRunSingleQuery,
defaultValue,
isPackDataFetched,
packsData?.data,
updateFieldValues,
]);
}, [canRunPacks, canRunSingleQuery, defaultValue, isPackDataFetched, packsData?.data, setValue]);
const queryCardSelectable = useMemo(
() => ({
@ -516,11 +490,20 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
setIsLive(() => !(liveQueryDetails?.status === 'completed'));
}, [liveQueryDetails?.status]);
useEffect(() => cleanupLiveQuery(), [queryType, packId, cleanupLiveQuery]);
useEffect(() => {
cleanupLiveQuery();
if (!defaultValue) {
resetField('packId');
resetField('query');
resetField('ecs_mapping');
resetField('savedQueryId');
clearErrors();
}
}, [queryType, cleanupLiveQuery, resetField, setValue, clearErrors, defaultValue]);
return (
<>
<Form form={form}>
<FormProvider {...hooksForm}>
<EuiFlexGroup direction="column">
{queryField && (
<EuiFlexItem>
@ -572,25 +555,23 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
</EuiFormRow>
</EuiFlexItem>
)}
{!hideAgentsField ? (
{!hideAgentsField && (
<EuiFlexItem>
<UseField path="agentSelection" component={AgentsTableField} />
<AgentsTableField />
</EuiFlexItem>
) : (
<UseField path="agentSelection" component={GhostFormField} />
)}
{queryType === 'pack' ? (
<>
<EuiFlexItem>
<UseField
path="packId"
component={PacksComboBoxField}
<PacksComboBoxField
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
euiFieldProps={{ packsData: packsData?.data }}
fieldProps={{ packsData: packsData?.data }}
queryType={queryType}
/>
</EuiFlexItem>
{submitButtonContent}
<EuiSpacer />
{liveQueryDetails?.queries?.length ||
selectedPackData?.attributes?.queries?.length ? (
<>
@ -598,6 +579,7 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
<PackQueriesStatusTable
actionId={actionId}
agentIds={agentIds}
// @ts-expect-error version string !+ string[]
data={liveQueryDetails?.queries ?? selectedPackData?.attributes?.queries}
addToTimeline={addToTimeline}
/>
@ -613,12 +595,13 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
</>
)}
</EuiFlexGroup>
</Form>
</FormProvider>
{showSavedQueryFlyout ? (
<SavedQueryFlyout
isExternal={!!addToTimeline}
onClose={handleCloseSaveQueryFlyout}
defaultValue={flyoutFormDefaultValue}
defaultValue={serializedData}
/>
) : null}
</>

View file

@ -6,12 +6,15 @@
*/
import { EuiCodeBlock, EuiFormRow } from '@elastic/eui';
import React, { useCallback } from 'react';
import React from 'react';
import styled from 'styled-components';
import type { EuiCodeEditorProps, FieldHook } from '../../shared_imports';
import { useController } from 'react-hook-form';
import { i18n } from '@kbn/i18n';
import type { EuiCodeEditorProps } from '../../shared_imports';
import { OsqueryEditor } from '../../editor';
import { useKibana } from '../../common/lib/kibana';
import { MAX_QUERY_LENGTH } from '../../packs/queries/validations';
const StyledEuiCodeBlock = styled(EuiCodeBlock)`
min-height: 100px;
@ -19,30 +22,44 @@ const StyledEuiCodeBlock = styled(EuiCodeBlock)`
interface LiveQueryQueryFieldProps {
disabled?: boolean;
field: FieldHook<string>;
commands?: EuiCodeEditorProps['commands'];
queryType: string;
}
const LiveQueryQueryFieldComponent: React.FC<LiveQueryQueryFieldProps> = ({
disabled,
field,
commands,
queryType,
}) => {
const permissions = useKibana().services.application.capabilities.osquery;
const { value, setValue, errors } = field;
const error = errors[0]?.message;
const handleEditorChange = useCallback(
(newValue) => {
setValue(newValue);
const {
field: { onChange, value },
fieldState: { error },
} = useController({
name: 'query',
rules: {
required: {
message: i18n.translate('xpack.osquery.pack.queryFlyoutForm.emptyQueryError', {
defaultMessage: 'Query is a required field',
}),
value: queryType === 'query',
},
maxLength: {
message: i18n.translate('xpack.osquery.liveQuery.queryForm.largeQueryError', {
defaultMessage: 'Query is too large (max {maxLength} characters)',
values: { maxLength: MAX_QUERY_LENGTH },
}),
value: MAX_QUERY_LENGTH,
},
},
[setValue]
);
defaultValue: '',
});
return (
<EuiFormRow
isInvalid={typeof error === 'string'}
error={error}
isInvalid={!!error?.message}
error={error?.message}
fullWidth
isDisabled={!permissions.writeLiveQueries || disabled}
>
@ -56,7 +73,7 @@ const LiveQueryQueryFieldComponent: React.FC<LiveQueryQueryFieldProps> = ({
{value}
</StyledEuiCodeBlock>
) : (
<OsqueryEditor defaultValue={value} onChange={handleEditorChange} commands={commands} />
<OsqueryEditor defaultValue={value} onChange={onChange} commands={commands} />
)}
</EuiFormRow>
);

View file

@ -12,8 +12,7 @@ import type { EuiComboBoxOptionOption } from '@elastic/eui';
import { EuiFormRow, EuiComboBox, EuiTextColor, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import styled from 'styled-components';
import type { FieldHook } from '../../shared_imports';
import { VALIDATION_TYPES } from '../../shared_imports';
import { useController } from 'react-hook-form';
import type { PackSavedObject } from '../../packs/types';
const TextTruncate = styled.div`
@ -21,13 +20,12 @@ const TextTruncate = styled.div`
text-overflow: ellipsis;
`;
interface Props {
field: FieldHook<string[]>;
euiFieldProps?: {
interface PackComboBoxFieldProps {
fieldProps?: {
packsData?: PackSavedObject[];
};
idAria?: string;
[key: string]: unknown;
queryType: string;
}
interface PackOption {
@ -36,48 +34,53 @@ interface PackOption {
description?: string;
}
export const PacksComboBoxField = ({ field, euiFieldProps = {}, idAria, ...rest }: Props) => {
export const PacksComboBoxField = ({
queryType,
fieldProps = {},
idAria,
...rest
}: PackComboBoxFieldProps) => {
const {
field: { value, onChange },
fieldState,
} = useController({
name: 'packId',
rules: {
required: {
message: i18n.translate(
'xpack.osquery.pack.queryFlyoutForm.osqueryPackMissingErrorMessage',
{
defaultMessage: 'Pack is a required field',
}
),
value: queryType === 'pack',
},
},
defaultValue: [],
});
const error = fieldState.error?.message;
const [selectedOptions, setSelectedOptions] = useState<
Array<EuiComboBoxOptionOption<PackOption>>
>([]);
// Errors for the comboBox value (the "array")
const errorMessageField = field.getErrorsMessages();
// Errors for comboBox option added (the array "item")
const errorMessageArrayItem = field.getErrorsMessages({
validationType: VALIDATION_TYPES.ARRAY_ITEM,
});
const isInvalid = field.errors.length
? errorMessageField !== null || errorMessageArrayItem !== null
: false;
// Concatenate error messages.
const errorMessage =
errorMessageField && errorMessageArrayItem
? `${errorMessageField}, ${errorMessageArrayItem}`
: errorMessageField
? errorMessageField
: errorMessageArrayItem;
const handlePackChange = useCallback(
(newSelectedOptions) => {
if (!newSelectedOptions.length) {
setSelectedOptions(newSelectedOptions);
field.setValue([]);
onChange([]);
return;
}
setSelectedOptions(newSelectedOptions);
field.setValue([newSelectedOptions[0].value?.id]);
onChange([newSelectedOptions[0].value?.id]);
},
[field]
[onChange]
);
const packOptions = useMemo<Array<EuiComboBoxOptionOption<PackOption>>>(
() =>
euiFieldProps?.packsData?.map((packSO) => ({
fieldProps?.packsData?.map((packSO) => ({
label: packSO.attributes.name ?? '',
value: {
id: packSO.id,
@ -85,20 +88,11 @@ export const PacksComboBoxField = ({ field, euiFieldProps = {}, idAria, ...rest
description: packSO.attributes.description,
},
})) ?? [],
[euiFieldProps?.packsData]
);
const onSearchComboChange = useCallback(
(value: string) => {
if (value !== undefined) {
field.clearErrors(VALIDATION_TYPES.ARRAY_ITEM);
}
},
[field]
[fieldProps?.packsData]
);
const renderOption = useCallback(
({ value }) => (
({ value: option }) => (
<EuiFlexGroup
gutterSize="none"
direction="column"
@ -106,11 +100,11 @@ export const PacksComboBoxField = ({ field, euiFieldProps = {}, idAria, ...rest
justifyContent="flexStart"
>
<EuiFlexItem>
<strong>{value.name}</strong>
<strong>{option?.name}</strong>
</EuiFlexItem>
<EuiFlexItem>
<TextTruncate>
<EuiTextColor color="subdued">{value.description}</EuiTextColor>
<EuiTextColor color="subdued">{option?.description}</EuiTextColor>
</TextTruncate>
</EuiFlexItem>
</EuiFlexGroup>
@ -119,22 +113,22 @@ export const PacksComboBoxField = ({ field, euiFieldProps = {}, idAria, ...rest
);
useEffect(() => {
if (field.value.length) {
const packOption = find(packOptions, ['value.id', field.value[0]]);
if (value?.length) {
const packOption = find(packOptions, ['value.id', value[0]]);
if (packOption) {
setSelectedOptions([packOption]);
}
}
}, [field.value, packOptions]);
}, [value, packOptions]);
return (
<EuiFormRow
label={field.label}
labelAppend={field.labelAppend}
helpText={typeof field.helpText === 'function' ? field.helpText() : field.helpText}
error={errorMessage}
isInvalid={isInvalid}
label={i18n.translate('xpack.osquery.liveQuery.queryForm.packQueryTypeLabel', {
defaultMessage: `Pack`,
})}
error={error}
isInvalid={!!error}
fullWidth
// eslint-disable-next-line react-perf/jsx-no-new-array-as-prop
describedByIds={idAria ? [idAria] : undefined}
@ -146,7 +140,6 @@ export const PacksComboBoxField = ({ field, euiFieldProps = {}, idAria, ...rest
})}
selectedOptions={selectedOptions}
onChange={handlePackChange}
onSearchChange={onSearchComboChange}
data-test-subj="select-live-pack"
fullWidth
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
@ -154,7 +147,7 @@ export const PacksComboBoxField = ({ field, euiFieldProps = {}, idAria, ...rest
renderOption={renderOption}
options={packOptions}
rowHeight={60}
{...euiFieldProps}
{...fieldProps}
/>
</EuiFormRow>
);

View file

@ -1,57 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const MAX_QUERY_LENGTH = 2000;
import { i18n } from '@kbn/i18n';
import { FIELD_TYPES } from '../../shared_imports';
import { queryFieldValidation } from '../../common/validations';
import { fieldValidators } from '../../shared_imports';
export const liveQueryFormSchema = {
agentSelection: {
defaultValue: {
agents: [],
allAgentsSelected: false,
platformsSelected: [],
policiesSelected: [],
},
type: FIELD_TYPES.JSON,
validations: [],
},
savedQueryId: {
type: FIELD_TYPES.TEXT,
validations: [],
},
query: {
defaultValue: '',
type: FIELD_TYPES.TEXT,
validations: [
{
validator: fieldValidators.maxLengthField({
length: MAX_QUERY_LENGTH,
message: i18n.translate('xpack.osquery.liveQuery.queryForm.largeQueryError', {
defaultMessage: 'Query is too large (max {maxLength} characters)',
values: { maxLength: MAX_QUERY_LENGTH },
}),
}),
},
{ validator: queryFieldValidation },
],
},
packId: {
label: i18n.translate('xpack.osquery.packs.dropdown.searchFieldLabel', {
defaultMessage: `Pack`,
}),
type: FIELD_TYPES.COMBO_BOX,
defaultValue: [],
},
ecs_mapping: {
defaultValue: [],
type: FIELD_TYPES.JSON,
},
};

View file

@ -10,6 +10,7 @@ import { EuiCode, EuiLoadingContent, EuiEmptyPrompt } from '@elastic/eui';
import React, { useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import type { EcsMappingSerialized } from '../packs/queries/ecs_mapping_editor_field';
import { LiveQueryForm } from './form';
import { useActionResultsPrivileges } from '../action_results/use_action_privileges';
import { OSQUERY_INTEGRATION_NAME } from '../../common';
@ -23,7 +24,7 @@ interface LiveQueryProps {
onSuccess?: () => void;
query?: string;
savedQueryId?: string;
ecs_mapping?: unknown;
ecs_mapping?: EcsMappingSerialized;
agentsField?: boolean;
queryField?: boolean;
ecsMappingField?: boolean;

View file

@ -93,6 +93,7 @@ const QueriesFieldComponent: React.FC<QueriesFieldProps> = ({
if (updatedQuery.ecs_mapping) {
draft[showEditQueryFlyout].ecs_mapping = updatedQuery.ecs_mapping;
} else {
// @ts-expect-error update types
delete draft[showEditQueryFlyout].ecs_mapping;
}
@ -231,6 +232,7 @@ const QueriesFieldComponent: React.FC<QueriesFieldProps> = ({
{showEditQueryFlyout != null && showEditQueryFlyout >= 0 && (
<QueryFlyout
uniqueQueryIds={uniqueQueryIds}
// @ts-expect-error update types
defaultValue={field.value[showEditQueryFlyout]}
onSave={handleEditQuery}
onClose={handleHideEditFlyout}

View file

@ -39,27 +39,32 @@ import { i18n } from '@kbn/i18n';
import styled from 'styled-components';
import deepEqual from 'fast-deep-equal';
import { prepareEcsFieldsToValidate } from '../../common/helpers';
import { useController, useFieldArray, useFormContext, useWatch } from 'react-hook-form';
import type { FormField } from '../../form/types';
import ECSSchema from '../../common/schemas/ecs/v8.4.0.json';
import osquerySchema from '../../common/schemas/osquery/v5.4.0.json';
import { FieldIcon } from '../../common/lib/kibana';
import type { FieldHook, ValidationFuncArg, ArrayItem, FormArrayField } from '../../shared_imports';
import {
FIELD_TYPES,
getFieldValidityAndErrorMessage,
useFormData,
Field,
getUseField,
fieldValidators,
UseMultiFields,
UseArray,
useFormContext,
} from '../../shared_imports';
import type { FormArrayField } from '../../shared_imports';
import { OsqueryIcon } from '../../components/osquery_icon';
import { removeMultilines } from '../../../common/utils/build_query/remove_multilines';
import { prepareEcsFieldsToValidate } from '../../common/helpers';
export const CommonUseField = getUseField({ component: Field });
export interface EcsMappingFormField {
key: string;
result: {
type: string;
value: string;
};
}
export type EcsMappingSerialized = Record<
string,
{
field?: string;
value?: string;
}
>;
const typeMap = {
binary: 'binary',
@ -80,17 +85,6 @@ const typeMap = {
constant_keyword: 'string',
};
const StyledEuiSuperSelect = styled(EuiSuperSelect)`
min-width: 70px;
border-radius: 6px 0 0 6px;
.euiIcon {
padding: 0;
width: 18px;
background: none;
}
`;
// @ts-expect-error update types
const ResultComboBox = styled(EuiComboBox)`
&.euiComboBox {
@ -103,6 +97,17 @@ const ResultComboBox = styled(EuiComboBox)`
}
`;
const StyledEuiSuperSelect = styled(EuiSuperSelect)`
min-width: 70px;
border-radius: 6px 0 0 6px;
.euiIcon {
padding: 0;
width: 18px;
background: none;
}
`;
const StyledFieldIcon = styled(FieldIcon)`
width: 32px;
@ -144,31 +149,32 @@ const ECSSchemaOptions = ECSSchema.map((ecs) => ({
type ECSSchemaOption = typeof ECSSchemaOptions[0];
interface ECSComboboxFieldProps {
field: FieldHook<string>;
interface ECSComboboxFieldProps extends FormField<string> {
euiFieldProps: EuiComboBoxProps<ECSSchemaOption>;
idAria?: string;
error?: string;
}
const ECSComboboxFieldComponent: React.FC<ECSComboboxFieldProps> = ({
field,
euiFieldProps = {},
idAria,
onChange,
value,
error,
}) => {
const { setValue } = field;
const [selectedOptions, setSelected] = useState<Array<EuiComboBoxOptionOption<ECSSchemaOption>>>(
[]
);
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
const describedByIds = useMemo(() => (idAria ? [idAria] : []), [idAria]);
const [formData] = useFormData();
const { ecs_mapping: watchedEcsMapping } = useWatch() as unknown as {
ecs_mapping: EcsMappingFormField[];
};
const handleChange = useCallback(
(newSelectedOptions) => {
setSelected(newSelectedOptions);
setValue(newSelectedOptions[0]?.label ?? '');
onChange(newSelectedOptions[0]?.label ?? '');
},
[setValue]
[onChange]
);
// TODO: Create own component for this.
@ -230,37 +236,36 @@ const ECSComboboxFieldComponent: React.FC<ECSComboboxFieldProps> = ({
}, [selectedOptions]);
const availableECSSchemaOptions = useMemo(() => {
const currentFormECSFieldValues = map(formData.ecs_mapping, 'key');
const currentFormECSFieldValues = map(watchedEcsMapping, 'key');
return ECSSchemaOptions.filter(({ label }) => !currentFormECSFieldValues.includes(label));
}, [formData.ecs_mapping]);
}, [watchedEcsMapping]);
useEffect(() => {
// @ts-expect-error update types
setSelected(() => {
if (!field.value.length) return [];
if (!value?.length) return [];
const selectedOption = find(ECSSchemaOptions, ['label', field.value]);
const selectedOption = find(ECSSchemaOptions, ['label', value]);
return selectedOption
? [selectedOption]
: [
{
label: field.value,
label: value,
value: {
value: field.value,
value,
},
},
];
});
}, [field.value]);
}, [value]);
return (
<EuiFormRow
label={field.label}
helpText={helpText}
error={errorMessage}
isInvalid={isInvalid}
error={error}
isInvalid={!!error}
fullWidth
describedByIds={describedByIds}
isDisabled={euiFieldProps.isDisabled}
@ -329,29 +334,72 @@ const OSQUERY_COLUMN_VALUE_TYPE_OPTIONS = [
const EMPTY_ARRAY: EuiComboBoxOptionOption[] = [];
interface OsqueryColumnFieldProps {
resultType: FieldHook<string>;
resultValue: FieldHook<string | string[]>;
euiFieldProps: EuiComboBoxProps<OsquerySchemaOption>;
item: ArrayItem;
item: EcsMappingFormField;
index: number;
idAria?: string;
isLastItem: boolean;
}
const OsqueryColumnFieldComponent: React.FC<OsqueryColumnFieldProps> = ({
resultType,
resultValue,
euiFieldProps = {},
euiFieldProps,
idAria,
item,
index,
isLastItem,
}) => {
const osqueryResultFieldValidator = (
value: string,
ecsMappingFormData: EcsMappingFormField[]
): string | undefined => {
const currentMapping = ecsMappingFormData[index];
if (!value.length && currentMapping.key.length) {
return i18n.translate(
'xpack.osquery.pack.queryFlyoutForm.osqueryResultFieldRequiredErrorMessage',
{
defaultMessage: 'Value field is required.',
}
);
}
if (!value.length || currentMapping.result.type !== 'field') return;
const osqueryColumnExists = find(euiFieldProps.options, [
'label',
isArray(value) ? value[0] : value,
]);
return !osqueryColumnExists
? i18n.translate(
'xpack.osquery.pack.queryFlyoutForm.osqueryResultFieldValueMissingErrorMessage',
{
defaultMessage: 'The current query does not return a {columnName} field',
values: {
columnName: value,
},
}
)
: undefined;
};
const { setValue } = useFormContext();
const { ecs_mapping: watchedEcsMapping } = useWatch() as unknown as {
ecs_mapping: EcsMappingFormField[];
};
const { field: resultField, fieldState: resultFieldState } = useController({
name: `ecs_mapping.${index}.result.value`,
rules: {
validate: (data) => osqueryResultFieldValidator(data, watchedEcsMapping),
},
defaultValue: '',
});
const itemPath = `ecs_mapping.${index}`;
const resultValue = item.result;
const inputRef = useRef<HTMLInputElement>();
const { setValue } = resultValue;
const { value: typeValue, setValue: setType } = resultType;
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(resultValue);
const [selectedOptions, setSelected] = useState<OsquerySchemaOption[]>([]);
const describedByIds = useMemo(() => (idAria ? [idAria] : []), [idAria]);
const [selectedOptions, setSelected] = useState<
Array<EuiComboBoxOptionOption<OsquerySchemaOption>>
>([]);
const [formData] = useFormData();
const renderOsqueryOption = useCallback(
(option, searchValue, contentClassName) => (
@ -365,7 +413,6 @@ const OsqueryColumnFieldComponent: React.FC<OsqueryColumnFieldProps> = ({
{option.value.suggestion_label}
</StyledFieldSpan>
</EuiFlexItem>
<DescriptionWrapper grow={false}>
<StyledFieldSpan className="euiSuggestItem__description euiSuggestItem__description">
{option.value.description}
@ -376,37 +423,44 @@ const OsqueryColumnFieldComponent: React.FC<OsqueryColumnFieldProps> = ({
[]
);
const handleChange = useCallback(
const handleKeyChange = useCallback(
(newSelectedOptions) => {
setSelected(newSelectedOptions);
setValue(
resultField.onChange(
isArray(newSelectedOptions)
? map(newSelectedOptions, 'label')
: newSelectedOptions[0]?.label ?? ''
);
},
[setValue, setSelected]
[resultField]
);
const isSingleSelection = useMemo(() => {
const ecsKey = get(formData, item.path)?.key;
if (ecsKey?.length && typeValue === 'value') {
const ecsKeySchemaOption = find(ECSSchemaOptions, ['label', ecsKey]);
const ecsData = get(watchedEcsMapping, `${index}`);
if (ecsData?.key?.length && item.result.type === 'value') {
const ecsKeySchemaOption = find(ECSSchemaOptions, ['label', ecsData?.key]);
return ecsKeySchemaOption?.value?.normalization !== 'array';
}
return !!ecsKey?.length;
}, [typeValue, formData, item.path]);
if (!ecsData?.key?.length && isLastItem) {
return true;
}
return !!ecsData?.key?.length;
}, [index, isLastItem, item.result.type, watchedEcsMapping]);
const onTypeChange = useCallback(
(newType) => {
if (newType !== typeValue) {
setType(newType);
setValue(newType === 'value' && isSingleSelection === false ? [] : '');
if (newType !== item.result.type) {
setValue(`${itemPath}.result.type`, newType);
setValue(
`${itemPath}.result.value`,
newType === 'value' && isSingleSelection === false ? [] : ''
);
}
},
[typeValue, setType, setValue, isSingleSelection]
[isSingleSelection, item.result.type, itemPath, setValue]
);
const handleCreateOption = useCallback(
@ -416,19 +470,19 @@ const OsqueryColumnFieldComponent: React.FC<OsqueryColumnFieldProps> = ({
if (!trimmedNewOption.length) return;
if (isSingleSelection === false) {
setValue([trimmedNewOption]);
if (resultValue.value.length) {
setValue([...castArray(resultValue.value), trimmedNewOption]);
setValue(`${itemPath}.result.value`, [trimmedNewOption]);
if (item.result.value.length) {
setValue(`${itemPath}.result.value`, [...castArray(resultValue.value), trimmedNewOption]);
} else {
setValue([trimmedNewOption]);
setValue(`${itemPath}.result.value`, [trimmedNewOption]);
}
inputRef.current?.blur();
} else {
setValue(trimmedNewOption);
setValue(`${itemPath}.result.value`, trimmedNewOption);
}
},
[isSingleSelection, resultValue.value, setValue]
[isSingleSelection, item.result.value.length, itemPath, resultValue.value, setValue]
);
const Prepend = useMemo(
@ -436,7 +490,7 @@ const OsqueryColumnFieldComponent: React.FC<OsqueryColumnFieldProps> = ({
<StyledEuiSuperSelect
disabled={euiFieldProps.isDisabled}
options={OSQUERY_COLUMN_VALUE_TYPE_OPTIONS}
valueOfSelected={typeValue || OSQUERY_COLUMN_VALUE_TYPE_OPTIONS[0].value}
valueOfSelected={item.result.type || OSQUERY_COLUMN_VALUE_TYPE_OPTIONS[0].value}
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
popoverProps={{
panelStyle: {
@ -446,29 +500,33 @@ const OsqueryColumnFieldComponent: React.FC<OsqueryColumnFieldProps> = ({
onChange={onTypeChange}
/>
),
[euiFieldProps.isDisabled, onTypeChange, typeValue]
[euiFieldProps.isDisabled, item.result.type, onTypeChange]
);
useEffect(() => {
if (isSingleSelection && isArray(resultValue.value)) {
setValue(resultValue.value.join(' '));
setValue(`${itemPath}.result.value`, resultValue.value.join(' '));
}
if (!isSingleSelection && !isArray(resultValue.value)) {
setValue(resultValue.value.length ? [resultValue.value] : []);
const value = resultValue.value.length ? [resultValue.value] : [];
setValue(`${itemPath}.result.value`, value);
}
}, [isSingleSelection, resultValue.value, setValue]);
}, [index, isSingleSelection, itemPath, resultValue, resultValue.value, setValue]);
useEffect(() => {
setSelected(() => {
// @ts-expect-error hard to type to satisfy TS, but it represents proper types
setSelected((_: OsquerySchemaOption[]): OsquerySchemaOption[] | Array<{ label: string }> => {
if (!resultValue.value.length) return [];
// Static array values
if (isArray(resultValue.value)) {
return resultValue.value.map((value) => ({ label: value }));
return resultValue.value.map((value) => ({ label: value })) as OsquerySchemaOption[];
}
const selectedOption = find(euiFieldProps?.options, ['label', resultValue.value]);
const selectedOption = find(euiFieldProps?.options, ['label', resultValue.value]) as
| OsquerySchemaOption
| undefined;
return selectedOption ? [selectedOption] : [{ label: resultValue.value }];
});
@ -476,10 +534,9 @@ const OsqueryColumnFieldComponent: React.FC<OsqueryColumnFieldProps> = ({
return (
<EuiFormRow
// @ts-expect-error update types
helpText={selectedOptions[0]?.value?.description}
error={errorMessage}
isInvalid={isInvalid}
error={resultFieldState.error?.message}
isInvalid={!!resultFieldState.error?.message?.length}
fullWidth
describedByIds={describedByIds}
isDisabled={euiFieldProps.isDisabled}
@ -488,20 +545,26 @@ const OsqueryColumnFieldComponent: React.FC<OsqueryColumnFieldProps> = ({
<EuiFlexItem grow={false}>{Prepend}</EuiFlexItem>
<EuiFlexItem>
<ResultComboBox
onBlur={resultField.onBlur}
value={resultField.value}
name={resultField.name}
error={resultFieldState.error?.message}
// eslint-disable-next-line react/jsx-no-bind, react-perf/jsx-no-new-function-as-prop
inputRef={(ref: HTMLInputElement) => {
inputRef.current = ref;
}}
fullWidth
selectedOptions={selectedOptions}
onChange={handleChange}
onChange={handleKeyChange}
onCreateOption={handleCreateOption}
renderOption={renderOsqueryOption}
rowHeight={32}
isClearable
{...euiFieldProps}
singleSelection={isSingleSelection ? SINGLE_SELECTION : false}
options={(typeValue === 'field' && euiFieldProps.options) || EMPTY_ARRAY}
options={(item.result.type === 'field' && euiFieldProps.options) || EMPTY_ARRAY}
idAria={idAria}
helpText={selectedOptions[0]?.value?.description}
{...euiFieldProps}
/>
</EuiFlexItem>
</EuiFlexGroup>
@ -518,177 +581,76 @@ export interface ECSMappingEditorFieldProps {
interface ECSMappingEditorFormProps {
isDisabled?: boolean;
osquerySchemaOptions: OsquerySchemaOption[];
item: ArrayItem;
isLastItem?: boolean;
item: EcsMappingFormField;
index: number;
isLastItem: boolean;
onAppend: (ecs_mapping: EcsMappingFormField[]) => void;
onDelete?: FormArrayField['removeItem'];
}
const ecsFieldValidator = (
args: ValidationFuncArg<ECSMappingEditorFormData, ECSMappingEditorFormData['key']> & {
customData: {
value: {
editForm: boolean;
};
};
}
) => {
const editForm: boolean = args.customData.value?.editForm;
const rootPath = args.path.split('.')[0];
const fieldRequiredError = fieldValidators.emptyField(
i18n.translate('xpack.osquery.pack.queryFlyoutForm.ecsFieldRequiredErrorMessage', {
defaultMessage: 'ECS field is required.',
})
)(args);
if (
fieldRequiredError &&
// @ts-expect-error update types
((!editForm && args.formData[`${rootPath}.result.value`]?.length) || editForm)
) {
return fieldRequiredError;
}
return undefined;
export const defaultEcsFormData = {
key: '',
result: {
type: 'field',
value: '',
},
};
const osqueryResultFieldValidator = async (
args: ValidationFuncArg<ECSMappingEditorFormData, ECSMappingEditorFormData['value']['value']> & {
customData: {
value: {
editForm: boolean;
osquerySchemaOptions: OsquerySchemaOption[];
};
};
}
) => {
const rootPath = args.path.split('.')[0];
const { editForm, osquerySchemaOptions } = args.customData.value;
const fieldRequiredError = fieldValidators.emptyField(
i18n.translate('xpack.osquery.pack.queryFlyoutForm.osqueryResultFieldRequiredErrorMessage', {
defaultMessage: 'Value is required.',
})
)(args);
// @ts-expect-error update types
if (fieldRequiredError && ((!editForm && args.formData[`${rootPath}.key`]?.length) || editForm)) {
return fieldRequiredError;
}
// @ts-expect-error update types
if (!args.value?.length || args.formData[`${rootPath}.result.type`] !== 'field') return;
const osqueryColumnExists = find(osquerySchemaOptions, [
'label',
isArray(args.value) ? args.value[0] : args.value,
]);
return !osqueryColumnExists
? {
code: 'ERR_FIELD_FORMAT',
path: args.path,
message: i18n.translate(
'xpack.osquery.pack.queryFlyoutForm.osqueryResultFieldValueMissingErrorMessage',
{
defaultMessage: 'The current query does not return a {columnName} field',
values: {
columnName: args.value,
},
}
),
}
: undefined;
};
interface ECSMappingEditorFormData {
key: string;
value: {
field?: string;
value?: string;
};
}
export const ECSMappingEditorForm: React.FC<ECSMappingEditorFormProps> = ({
isDisabled,
osquerySchemaOptions,
item,
isLastItem,
index,
onDelete,
}) => {
const ecsFieldValidator = (value: string, ecsMapping: EcsMappingFormField[]) => {
const ecsCurrentMapping = ecsMapping[index].result.value;
return !value.length && ecsCurrentMapping.length
? i18n.translate('xpack.osquery.pack.queryFlyoutForm.ecsFieldRequiredErrorMessage', {
defaultMessage: 'ECS field is required.',
})
: undefined;
};
const { ecs_mapping: ecsMapping } = useWatch() as unknown as {
ecs_mapping: EcsMappingFormField[];
};
const { field: ECSField, fieldState: ECSFieldState } = useController({
name: `ecs_mapping.${index}.key`,
rules: {
validate: (value: string) => ecsFieldValidator(value, ecsMapping),
},
defaultValue: '',
});
const MultiFields = useMemo(
() => (
<UseMultiFields
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
fields={{
resultType: {
path: `${item.path}.result.type`,
config: {
valueChangeDebounceTime: 300,
defaultValue: OSQUERY_COLUMN_VALUE_TYPE_OPTIONS[0].value,
type: FIELD_TYPES.COMBO_BOX,
fieldsToValidateOnChange: [`${item.path}.key`, `${item.path}.result.value`],
},
},
resultValue: {
path: `${item.path}.result.value`,
validationData: {
osquerySchemaOptions,
editForm: !isLastItem,
},
readDefaultValueOnForm: !item.isNew,
config: {
valueChangeDebounceTime: 300,
type: FIELD_TYPES.COMBO_BOX,
fieldsToValidateOnChange: [`${item.path}.key`, `${item.path}.result.value`],
validations: [
{
// @ts-expect-error update types
validator: osqueryResultFieldValidator,
},
],
},
},
}}
>
{(fields) => (
<OsqueryColumnField
{...fields}
item={item}
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
euiFieldProps={{
// @ts-expect-error update types
options: osquerySchemaOptions,
isDisabled,
}}
/>
)}
</UseMultiFields>
<div>
<OsqueryColumnField
item={item}
index={index}
isLastItem={isLastItem}
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
euiFieldProps={{
// @ts-expect-error update types
options: osquerySchemaOptions,
isDisabled,
}}
/>
</div>
),
[item, osquerySchemaOptions, isLastItem, isDisabled]
[item, index, isLastItem, osquerySchemaOptions, isDisabled]
);
const ecsComboBoxEuiFieldProps = useMemo(() => ({ isDisabled }), [isDisabled]);
const validationData = useMemo(() => ({ editForm: !isLastItem }), [isLastItem]);
const config = useMemo(
() => ({
valueChangeDebounceTime: 300,
fieldsToValidateOnChange: [`${item.path}.key`, `${item.path}.result.value`],
validations: [
{
validator: ecsFieldValidator,
},
],
}),
[item.path]
);
const handleDeleteClick = useCallback(() => {
if (onDelete) {
onDelete(item.id);
onDelete(index);
}
}, [item.id, onDelete]);
}, [index, onDelete]);
return (
<>
@ -696,14 +658,13 @@ export const ECSMappingEditorForm: React.FC<ECSMappingEditorFormProps> = ({
<EuiFlexItem>
<EuiFlexGroup alignItems="flexStart" gutterSize="s" wrap>
<EuiFlexItem>
<CommonUseField
path={`${item.path}.key`}
component={ECSComboboxField}
<ECSComboboxField
onChange={ECSField.onChange}
onBlur={ECSField.onBlur}
value={ECSField.value}
name={ECSField.name}
error={ECSFieldState.error?.message}
euiFieldProps={ecsComboBoxEuiFieldProps}
validationData={validationData}
readDefaultValueOnForm={!item.isNew}
// @ts-expect-error update types
config={config}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
@ -764,22 +725,26 @@ interface OsqueryColumn {
export const ECSMappingEditorField = React.memo(
({ euiFieldProps }: ECSMappingEditorFieldProps) => {
const lastItemPath = useRef<string>();
const onAdd = useRef<FormArrayField['addItem']>();
const itemsList = useRef<ArrayItem[]>([]);
const [osquerySchemaOptions, setOsquerySchemaOptions] = useState<OsquerySchemaOption[]>([]);
const [{ query, ...formData }, formDataSerializer, isMounted] = useFormData();
const { trigger } = useFormContext();
const { fields, append, remove } = useFieldArray<{ ecs_mapping: EcsMappingFormField[] }>({
name: 'ecs_mapping',
});
const { validateFields } = useFormContext();
const itemsList = useRef<Array<{ id: string }>>([]);
const [osquerySchemaOptions, setOsquerySchemaOptions] = useState<OsquerySchemaOption[]>([]);
const { query, ...formData } = useWatch() as unknown as {
query: string;
ecs_mapping: EcsMappingFormField[];
};
useEffect(() => {
// Additional 'suspended' validation of osquery ecs fields. fieldsToValidateOnChange doesn't work because it happens before the osquerySchema gets updated.
const fieldsToValidate = prepareEcsFieldsToValidate(itemsList.current);
const fieldsToValidate = prepareEcsFieldsToValidate(fields);
// it is always at least 2 - empty fields
if (fieldsToValidate.length > 2) {
setTimeout(() => validateFields(fieldsToValidate), 0);
setTimeout(async () => await trigger('ecs_mapping'), 0);
}
}, [query, validateFields]);
}, [fields, query, trigger]);
useEffect(() => {
if (!query?.length) {
@ -1013,32 +978,23 @@ export const ECSMappingEditorField = React.memo(
}, [query]);
useLayoutEffect(() => {
if (isMounted) {
if (!lastItemPath.current && onAdd.current) {
onAdd.current();
const ecsList = formData?.ecs_mapping;
const lastEcs = formData?.ecs_mapping?.[itemsList?.current.length - 1];
return;
}
if (euiFieldProps?.isDisabled) {
return;
}
const itemKey = get(formData, `${lastItemPath.current}.key`);
if (itemKey) {
const serializedFormData = formDataSerializer();
const itemValue =
serializedFormData.ecs_mapping &&
(serializedFormData.ecs_mapping[`${itemKey}`]?.field ||
serializedFormData.ecs_mapping[`${itemKey}`]?.value);
if (itemValue && onAdd.current) {
onAdd.current();
}
}
// we skip appending on remove
if (itemsList?.current?.length < ecsList?.length) {
return;
}
}, [euiFieldProps?.isDisabled, formData, formDataSerializer, isMounted, onAdd]);
// // list contains ecs already, and the last item has values provided
if (
ecsList?.length === itemsList.current.length &&
lastEcs?.key?.length &&
lastEcs?.result?.value?.length
) {
return append(defaultEcsFormData);
}
}, [append, euiFieldProps?.isDisabled, formData]);
return (
<>
@ -1080,28 +1036,24 @@ export const ECSMappingEditorField = React.memo(
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<UseArray path="ecs_mapping">
{({ items, addItem, removeItem }) => {
lastItemPath.current = items[items.length - 1]?.path;
onAdd.current = addItem;
itemsList.current = items;
return (
<>
{items.map((item, index) => (
<ECSMappingEditorForm
key={item.id}
osquerySchemaOptions={osquerySchemaOptions}
item={item}
isLastItem={index === items.length - 1}
onDelete={removeItem}
isDisabled={!!euiFieldProps?.isDisabled}
/>
))}
</>
);
}}
</UseArray>
{fields.map((item, index, array) => {
itemsList.current = array;
return (
<div key={item.id}>
<ECSMappingEditorForm
osquerySchemaOptions={osquerySchemaOptions}
item={item}
index={index}
onAppend={append}
isLastItem={index === array.length - 1}
onDelete={remove}
isDisabled={!!euiFieldProps?.isDisabled}
/>
</div>
);
})}
</>
);
},

View file

@ -11,23 +11,22 @@ import type { EuiCheckboxGroupOption } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiCheckboxGroup } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import type { FieldHook } from '../../shared_imports';
import { getFieldValidityAndErrorMessage } from '../../shared_imports';
import { useController } from 'react-hook-form';
import { i18n } from '@kbn/i18n';
import type { FormFieldProps } from '../../form/types';
import { PlatformIcon } from './platforms/platform_icon';
interface Props {
field: FieldHook<string>;
euiFieldProps?: Record<string, unknown>;
idAria?: string;
[key: string]: unknown;
}
type Props = Omit<FormFieldProps<string[]>, 'name' | 'label'>;
export const PlatformCheckBoxGroupField = ({
field,
euiFieldProps = {},
idAria,
...rest
}: Props) => {
export const PlatformCheckBoxGroupField = (props: Props) => {
const { euiFieldProps = {}, idAria, helpText, ...rest } = props;
const {
field: { onChange, value },
fieldState: { error },
} = useController({
name: 'platform',
defaultValue: [],
});
const options = useMemo(
() => [
{
@ -82,17 +81,16 @@ export const PlatformCheckBoxGroupField = ({
[]
);
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
const [checkboxIdToSelectedMap, setCheckboxIdToSelectedMap] = useState<Record<string, boolean>>(
() =>
(options as EuiCheckboxGroupOption[]).reduce((acc, option) => {
acc[option.id] = isEmpty(field.value) ? true : field.value?.includes(option.id) ?? false;
acc[option.id] = isEmpty(value) ? true : value?.includes(option.id) ?? false;
return acc;
}, {} as Record<string, boolean>)
);
const onChange = useCallback(
const handleChange = useCallback(
(optionId: string) => {
const newCheckboxIdToSelectedMap = {
...checkboxIdToSelectedMap,
@ -100,11 +98,13 @@ export const PlatformCheckBoxGroupField = ({
};
setCheckboxIdToSelectedMap(newCheckboxIdToSelectedMap);
field.setValue(() =>
Object.keys(pickBy(newCheckboxIdToSelectedMap, (value) => value === true)).join(',')
onChange(
Object.keys(
pickBy(newCheckboxIdToSelectedMap, (checkboxValue) => checkboxValue === true)
).join(',')
);
},
[checkboxIdToSelectedMap, field]
[checkboxIdToSelectedMap, onChange]
);
const describedByIds = useMemo(() => (idAria ? [idAria] : []), [idAria]);
@ -112,19 +112,23 @@ export const PlatformCheckBoxGroupField = ({
useEffect(() => {
setCheckboxIdToSelectedMap(() =>
(options as EuiCheckboxGroupOption[]).reduce((acc, option) => {
acc[option.id] = isEmpty(field.value) ? true : field.value?.includes(option.id) ?? false;
acc[option.id] = isEmpty(value) ? true : value?.includes(option.id) ?? false;
return acc;
}, {} as Record<string, boolean>)
);
}, [field.value, options]);
}, [value, options]);
const hasError = useMemo(() => !!error?.message, [error?.message]);
return (
<EuiFormRow
label={field.label}
helpText={typeof field.helpText === 'function' ? field.helpText() : field.helpText}
error={errorMessage}
isInvalid={isInvalid}
label={i18n.translate('xpack.osquery.pack.queryFlyoutForm.platformFieldLabel', {
defaultMessage: 'Platform',
})}
helpText={typeof helpText === 'function' ? helpText() : helpText}
error={error?.message}
isInvalid={hasError}
fullWidth
describedByIds={describedByIds}
{...rest}
@ -132,7 +136,7 @@ export const PlatformCheckBoxGroupField = ({
<EuiCheckboxGroup
idToSelectedMap={checkboxIdToSelectedMap}
options={options}
onChange={onChange}
onChange={handleChange}
data-test-subj="input"
{...euiFieldProps}
/>

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import { map } from 'lodash';
import {
EuiFlyout,
EuiTitle,
@ -17,28 +16,33 @@ import {
EuiFlexItem,
EuiButtonEmpty,
EuiButton,
EuiText,
} from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { FormProvider } from 'react-hook-form';
import { isEmpty, map } from 'lodash';
import { QueryIdField, IntervalField } from '../../form';
import { defaultEcsFormData } from './ecs_mapping_editor_field';
import { CodeEditorField } from '../../saved_queries/form/code_editor_field';
import { Form, getUseField, Field } from '../../shared_imports';
import { PlatformCheckBoxGroupField } from './platform_checkbox_group_field';
import { ALL_OSQUERY_VERSIONS_OPTIONS } from './constants';
import type { UsePackQueryFormProps, PackQueryFormData } from './use_pack_query_form';
import type {
UsePackQueryFormProps,
PackQueryFormData,
PackSOQueryFormData,
} from './use_pack_query_form';
import { usePackQueryForm } from './use_pack_query_form';
import { SavedQueriesDropdown } from '../../saved_queries/saved_queries_dropdown';
import { ECSMappingEditorField } from './lazy_ecs_mapping_editor_field';
import { useKibana } from '../../common/lib/kibana';
const CommonUseField = getUseField({ component: Field });
import { VersionField } from '../../form';
interface QueryFlyoutProps {
uniqueQueryIds: string[];
defaultValue?: UsePackQueryFormProps['defaultValue'] | undefined;
onSave: (payload: PackQueryFormData) => Promise<void>;
onSave: (payload: PackSOQueryFormData) => void;
onClose: () => void;
}
@ -50,45 +54,48 @@ const QueryFlyoutComponent: React.FC<QueryFlyoutProps> = ({
}) => {
const permissions = useKibana().services.application.capabilities.osquery;
const [isEditMode] = useState(!!defaultValue);
const { form } = usePackQueryForm({
const { serializer, idSet, ...hooksForm } = usePackQueryForm({
uniqueQueryIds,
defaultValue,
handleSubmit: async (payload, isValid) =>
new Promise((resolve) => {
if (isValid) {
onSave(payload);
onClose();
}
resolve();
}),
});
const { submit, isSubmitting, updateFieldValues } = form;
const {
handleSubmit,
formState: { isSubmitting },
setValue,
clearErrors,
} = hooksForm;
const onSubmit = (payload: PackQueryFormData) => {
const serializedData: PackSOQueryFormData = serializer(payload);
onSave(serializedData);
onClose();
};
const handleSetQueryValue = useCallback(
(savedQuery) => {
if (savedQuery) {
updateFieldValues({
id: savedQuery.id,
query: savedQuery.query,
description: savedQuery.description,
platform: savedQuery.platform ? savedQuery.platform : 'linux,windows,darwin',
version: savedQuery.version,
interval: savedQuery.interval,
// @ts-expect-error update types
ecs_mapping:
map(savedQuery.ecs_mapping, (value, key) => ({
key,
result: {
type: Object.keys(value)[0],
value: Object.values(value)[0],
},
})) ?? [],
});
clearErrors('id');
setValue('id', savedQuery.id);
setValue('query', savedQuery.query);
// setValue('description', savedQuery.description); // TODO do we need it?
setValue('platform', savedQuery.platform ? savedQuery.platform : 'linux,windows,darwin');
setValue('version', savedQuery.version ? [savedQuery.version] : []);
setValue('interval', savedQuery.interval);
setValue(
'ecs_mapping',
!isEmpty(savedQuery.ecs_mapping)
? map(savedQuery.ecs_mapping, (value, key) => ({
key,
result: {
type: Object.keys(value)[0],
value: Object.values(value)[0] as string,
},
}))
: [defaultEcsFormData]
);
}
},
[updateFieldValues]
[clearErrors, setValue]
);
/* Avoids accidental closing of the flyout when the user clicks outside of the flyout */
const maskProps = useMemo(() => ({ onClick: () => ({}) }), []);
@ -119,37 +126,25 @@ const QueryFlyoutComponent: React.FC<QueryFlyoutProps> = ({
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<Form form={form}>
<FormProvider {...hooksForm}>
{!isEditMode && permissions.readSavedQueries ? (
<>
<SavedQueriesDropdown onChange={handleSetQueryValue} />
<EuiSpacer />
</>
) : null}
<CommonUseField path="id" />
<QueryIdField idSet={idSet} />
<EuiSpacer />
<CommonUseField path="query" component={CodeEditorField} />
<CodeEditorField />
<EuiSpacer />
<EuiFlexGroup>
<EuiFlexItem>
<CommonUseField
path="interval"
<IntervalField
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
euiFieldProps={{ append: 's' }}
/>
<EuiSpacer />
<CommonUseField
path="version"
labelAppend={
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued">
<FormattedMessage
id="xpack.osquery.queryFlyoutForm.versionFieldOptionalLabel"
defaultMessage="(optional)"
/>
</EuiText>
</EuiFlexItem>
}
<VersionField
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
euiFieldProps={{
noSuggestions: false,
@ -163,7 +158,7 @@ const QueryFlyoutComponent: React.FC<QueryFlyoutProps> = ({
/>
</EuiFlexItem>
<EuiFlexItem>
<CommonUseField path="platform" component={PlatformCheckBoxGroupField} />
<PlatformCheckBoxGroupField />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
@ -172,7 +167,7 @@ const QueryFlyoutComponent: React.FC<QueryFlyoutProps> = ({
<ECSMappingEditorField />
</EuiFlexItem>
</EuiFlexGroup>
</Form>
</FormProvider>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
@ -185,7 +180,7 @@ const QueryFlyoutComponent: React.FC<QueryFlyoutProps> = ({
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton isLoading={isSubmitting} onClick={submit} fill>
<EuiButton isLoading={isSubmitting} onClick={handleSubmit(onSubmit)} fill>
<FormattedMessage
id="xpack.osquery.queryFlyoutForm.saveButtonLabel"
defaultMessage="Save"

View file

@ -1,77 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { FIELD_TYPES } from '../../shared_imports';
import {
createIdFieldValidations,
intervalFieldValidations,
queryFieldValidation,
} from './validations';
export const createFormSchema = (ids: Set<string>) => ({
id: {
type: FIELD_TYPES.TEXT,
label: i18n.translate('xpack.osquery.pack.queryFlyoutForm.idFieldLabel', {
defaultMessage: 'ID',
}),
validations: createIdFieldValidations(ids).map((validator) => ({ validator })),
},
description: {
type: FIELD_TYPES.TEXT,
label: i18n.translate('xpack.osquery.pack.queryFlyoutForm.descriptionFieldLabel', {
defaultMessage: 'Description (optional)',
}),
validations: [],
},
query: {
type: FIELD_TYPES.TEXT,
label: i18n.translate('xpack.osquery.pack.queryFlyoutForm.queryFieldLabel', {
defaultMessage: 'Query',
}),
validations: [{ validator: queryFieldValidation }],
},
interval: {
defaultValue: 3600,
type: FIELD_TYPES.NUMBER,
label: i18n.translate('xpack.osquery.pack.queryFlyoutForm.intervalFieldLabel', {
defaultMessage: 'Interval (s)',
}),
validations: intervalFieldValidations,
},
platform: {
type: FIELD_TYPES.TEXT,
label: i18n.translate('xpack.osquery.pack.queryFlyoutForm.platformFieldLabel', {
defaultMessage: 'Platform',
}),
validations: [],
},
version: {
defaultValue: [],
type: FIELD_TYPES.COMBO_BOX,
label: (
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<FormattedMessage
id="xpack.osquery.pack.queryFlyoutForm.versionFieldLabel"
defaultMessage="Minimum Osquery version"
/>
</EuiFlexItem>
</EuiFlexGroup>
) as unknown as string,
validations: [],
},
ecs_mapping: {
defaultValue: [],
type: FIELD_TYPES.JSON,
},
});

View file

@ -5,134 +5,113 @@
* 2.0.
*/
import { isArray, isEmpty, xor, map } from 'lodash';
import uuid from 'uuid';
import { isArray, isEmpty, map, xor } from 'lodash';
import { useForm as useHookForm } from 'react-hook-form';
import type { Draft } from 'immer';
import { produce } from 'immer';
import { useMemo } from 'react';
import type { ECSMapping } from '../../../common/schemas/common';
import { convertECSMappingToObject } from '../../../common/schemas/common/utils';
import type { FormConfig } from '../../shared_imports';
import { useForm } from '../../shared_imports';
import { createFormSchema } from './schema';
const FORM_ID = 'editQueryFlyoutForm';
import type { EcsMappingFormField } from './ecs_mapping_editor_field';
import { defaultEcsFormData } from './ecs_mapping_editor_field';
export interface UsePackQueryFormProps {
uniqueQueryIds: string[];
defaultValue?: PackQueryFormData | undefined;
handleSubmit: FormConfig<PackQueryFormData, PackQueryFormData>['onSubmit'];
defaultValue?: PackSOQueryFormData | undefined;
}
export interface PackSOQueryFormData {
id: string;
query: string;
interval: number;
interval: string;
platform?: string | undefined;
version?: string | undefined;
ecs_mapping?: PackQuerySOECSMapping[] | undefined;
ecs_mapping?: PackQuerySOECSMapping[];
}
export type PackQuerySOECSMapping = Array<{ field: string; value: string }>;
export interface PackQueryFormData {
id?: string;
id: string;
description?: string;
query: string;
interval?: number;
interval: number;
platform?: string | undefined;
version?: string | undefined;
ecs_mapping?: ECSMapping;
version?: string[] | undefined;
ecs_mapping: EcsMappingFormField[];
}
export type PackQueryECSMapping = Record<
string,
{
field?: string;
value?: string;
}
>;
const deserializer = (payload: PackSOQueryFormData): PackQueryFormData =>
({
id: payload.id,
query: payload.query,
interval: payload.interval ? parseInt(payload.interval, 10) : 3600,
platform: payload.platform,
version: payload.version ? [payload.version] : [],
ecs_mapping: !isEmpty(payload.ecs_mapping)
? !isArray(payload.ecs_mapping)
? map(payload.ecs_mapping as unknown as PackQuerySOECSMapping, (value, key) => ({
key,
result: {
type: Object.keys(value)[0],
value: Object.values(value)[0],
},
}))
: payload.ecs_mapping
: [defaultEcsFormData],
} as PackQueryFormData);
export const usePackQueryForm = ({
uniqueQueryIds,
defaultValue,
handleSubmit,
}: UsePackQueryFormProps) => {
const serializer = (payload: PackQueryFormData): PackSOQueryFormData =>
// @ts-expect-error update types
produce<PackQueryFormData>(payload, (draft: Draft<PackSOQueryFormData>) => {
if (isArray(draft.platform)) {
if (draft.platform.length) {
draft.platform.join(',');
} else {
delete draft.platform;
}
}
if (isArray(draft.version)) {
if (!draft.version.length) {
delete draft.version;
} else {
draft.version = draft.version[0];
}
}
if (draft.interval) {
draft.interval = draft.interval + '';
}
if (isEmpty(draft.ecs_mapping)) {
delete draft.ecs_mapping;
} else {
// @ts-expect-error update types
draft.ecs_mapping = convertECSMappingToObject(payload.ecs_mapping);
}
return draft;
});
export const usePackQueryForm = ({ uniqueQueryIds, defaultValue }: UsePackQueryFormProps) => {
const idSet = useMemo<Set<string>>(
() => new Set<string>(xor(uniqueQueryIds, defaultValue?.id ? [defaultValue.id] : [])),
[uniqueQueryIds, defaultValue]
);
const formSchema = useMemo<ReturnType<typeof createFormSchema>>(
() => createFormSchema(idSet),
[idSet]
);
return useForm<PackSOQueryFormData, PackQueryFormData>({
id: FORM_ID + uuid.v4(),
onSubmit: async (formData, isValid) => {
if (isValid && handleSubmit) {
// @ts-expect-error update types
return handleSubmit(formData, isValid);
}
},
options: {
stripEmptyFields: true,
},
// @ts-expect-error update types
defaultValue: defaultValue || {
id: '',
query: '',
interval: 3600,
ecs_mapping: [],
},
// @ts-expect-error update types
serializer: (payload) =>
produce(payload, (draft) => {
if (isArray(draft.platform)) {
draft.platform.join(',');
}
if (isArray(draft.version)) {
if (!draft.version.length) {
delete draft.version;
} else {
draft.version = draft.version[0];
}
}
if (isEmpty(draft.ecs_mapping)) {
delete draft.ecs_mapping;
} else {
// @ts-expect-error update types
draft.ecs_mapping = convertECSMappingToObject(payload.ecs_mapping);
}
return draft;
}),
// @ts-expect-error update types
deserializer: (payload) => {
if (!payload) return {} as PackQueryFormData;
return {
id: payload.id,
query: payload.query,
interval: payload.interval,
platform: payload.platform,
version: payload.version ? [payload.version] : [],
ecs_mapping: !isArray(payload.ecs_mapping)
? map(payload.ecs_mapping, (value, key) => ({
key,
result: {
// @ts-expect-error update types
type: Object.keys(value)[0],
// @ts-expect-error update types
value: Object.values(value)[0],
},
}))
: payload.ecs_mapping,
};
},
// @ts-expect-error update types
schema: formSchema,
});
return {
serializer,
idSet,
...useHookForm<PackQueryFormData>({
defaultValues: defaultValue
? deserializer(defaultValue)
: {
id: '',
query: '',
interval: 3600,
ecs_mapping: [defaultEcsFormData],
},
}),
};
};

View file

@ -6,12 +6,11 @@
*/
import { i18n } from '@kbn/i18n';
import type { FormData, ValidationFunc } from '../../shared_imports';
import type { FormData, ValidationConfig, ValidationFunc } from '../../shared_imports';
import { fieldValidators } from '../../shared_imports';
export { queryFieldValidation } from '../../common/validations';
export const MAX_QUERY_LENGTH = 2000;
const idPattern = /^[a-zA-Z0-9-_]+$/;
// still used in Packs
export const idSchemaValidation: ValidationFunc<FormData, string, string> = ({ value }) => {
const valueIsValid = idPattern.test(value);
if (!valueIsValid) {
@ -23,47 +22,39 @@ export const idSchemaValidation: ValidationFunc<FormData, string, string> = ({ v
}
};
export const idHookSchemaValidation = (value: string) => {
const valueIsValid = idPattern.test(value);
if (!valueIsValid) {
return i18n.translate('xpack.osquery.pack.queryFlyoutForm.invalidIdError', {
defaultMessage: 'Characters must be alphanumeric, _, or -',
});
}
};
const createUniqueIdValidation = (ids: Set<string>) => {
const uniqueIdCheck: ValidationFunc<FormData, string, string> = ({ value }) => {
const uniqueIdCheck = (value: string) => {
if (ids.has(value)) {
return {
message: i18n.translate('xpack.osquery.pack.queryFlyoutForm.uniqueIdError', {
defaultMessage: 'ID must be unique',
}),
};
return i18n.translate('xpack.osquery.pack.queryFlyoutForm.uniqueIdError', {
defaultMessage: 'ID must be unique',
});
}
};
return uniqueIdCheck;
};
export const createIdFieldValidations = (ids: Set<string>) => [
fieldValidators.emptyField(
i18n.translate('xpack.osquery.pack.queryFlyoutForm.emptyIdError', {
export const createFormIdFieldValidations = (ids: Set<string>) => ({
required: {
message: i18n.translate('xpack.osquery.pack.queryFlyoutForm.emptyIdError', {
defaultMessage: 'ID is required',
})
),
idSchemaValidation,
createUniqueIdValidation(ids),
];
}),
value: true,
},
validate: (text: string) => {
const isPatternValid = idHookSchemaValidation(text);
const isUnique = createUniqueIdValidation(ids)(text);
export const intervalFieldValidations: Array<ValidationConfig<FormData, string, number>> = [
{
validator: fieldValidators.numberGreaterThanField({
than: 0,
message: i18n.translate('xpack.osquery.pack.queryFlyoutForm.intervalFieldMinNumberError', {
defaultMessage: 'A positive interval value is required',
}),
}),
return isPatternValid || isUnique;
},
{
validator: fieldValidators.numberSmallerThanField({
than: 604800,
message: ({ than }) =>
i18n.translate('xpack.osquery.pack.queryFlyoutForm.intervalFieldMaxNumberError', {
defaultMessage: 'An interval value must be lower than {than}',
values: { than },
}),
}),
},
];
});

View file

@ -6,24 +6,6 @@
*/
import type { SavedObject } from '@kbn/core/public';
import type { PackQueryFormData } from './queries/use_pack_query_form';
import type { PackQueryECSMapping } from './queries/use_pack_query_form';
export interface IQueryPayload {
attributes?: {
name: string;
id: string;
};
}
export interface PackItemQuery {
id: string;
name: string;
interval: number;
query: string;
platform?: string;
version?: string;
ecs_mapping?: PackQueryECSMapping[];
}
export type PackSavedObject = SavedObject<{
name: string;

View file

@ -28,6 +28,7 @@ import { FormattedMessage } from '@kbn/i18n-react';
import React, { createContext, useEffect, useState, useCallback, useContext, useMemo } from 'react';
import { pagePathGetters } from '@kbn/fleet-plugin/public';
import type { ECSMapping } from '../../common/schemas/common';
import { useAllResults } from './use_all_results';
import type { ResultEdges } from '../../common/search_strategy';
import { Direction } from '../../common/search_strategy';
@ -48,7 +49,7 @@ interface ResultsTableComponentProps {
actionId: string;
selectedAgent?: string;
agentIds?: string[];
ecsMapping?: Record<string, string>;
ecsMapping?: ECSMapping;
endDate?: string;
startDate?: string;
addToTimeline?: (payload: { query: [string, string]; isIcon?: true }) => React.ReactElement;
@ -186,10 +187,8 @@ const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({
if (!ecsMapping) return;
return reduce(
(acc, [key, value]) => {
// @ts-expect-error update types
(acc: Record<string, string[]>, [key, value]) => {
if (value?.field) {
// @ts-expect-error update types
acc[value?.field] = [...(acc[value?.field] ?? []), key];
}
@ -202,7 +201,6 @@ const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({
const getHeaderDisplay = useCallback(
(columnName: string) => {
// @ts-expect-error update types
if (ecsMappingConfig && ecsMappingConfig[columnName]) {
return (
<>
@ -217,12 +215,9 @@ const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({
/>
{`:`}
<ul>
{
// @ts-expect-error update types
ecsMappingConfig[columnName].map((fieldName) => (
<li key={fieldName}>{fieldName}</li>
))
}
{ecsMappingConfig[columnName].map((fieldName) => (
<li key={fieldName}>{fieldName}</li>
))}
</ul>
</>
}

View file

@ -16,14 +16,17 @@ import {
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import type { PackQueryFormData } from '../../../packs/queries/use_pack_query_form';
import { FormProvider } from 'react-hook-form';
import { useRouterNavigate } from '../../../common/lib/kibana';
import { Form } from '../../../shared_imports';
import { SavedQueryForm } from '../../../saved_queries/form';
import type {
SavedQueryFormData,
SavedQuerySOFormData,
} from '../../../saved_queries/form/use_saved_query_form';
import { useSavedQueryForm } from '../../../saved_queries/form/use_saved_query_form';
interface EditSavedQueryFormProps {
defaultValue?: PackQueryFormData;
defaultValue?: SavedQuerySOFormData;
handleSubmit: (payload: unknown) => Promise<void>;
viewMode?: boolean;
}
@ -35,15 +38,28 @@ const EditSavedQueryFormComponent: React.FC<EditSavedQueryFormProps> = ({
}) => {
const savedQueryListProps = useRouterNavigate('saved_queries');
const { form } = useSavedQueryForm({
const hooksForm = useSavedQueryForm({
defaultValue,
handleSubmit,
});
const { submit, isSubmitting } = form;
const {
serializer,
idSet,
handleSubmit: formSubmit,
formState: { isSubmitting },
} = hooksForm;
const onSubmit = (payload: SavedQueryFormData) => {
const serializedData = serializer(payload);
try {
handleSubmit(serializedData);
// eslint-disable-next-line no-empty
} catch (e) {}
};
return (
<Form form={form}>
<SavedQueryForm viewMode={viewMode} hasPlayground />
<FormProvider {...hooksForm}>
<SavedQueryForm viewMode={viewMode} hasPlayground idSet={idSet} />
{!viewMode && (
<>
<EuiBottomBar>
@ -65,7 +81,7 @@ const EditSavedQueryFormComponent: React.FC<EditSavedQueryFormProps> = ({
fill
size="m"
iconType="save"
onClick={submit}
onClick={formSubmit(onSubmit)}
>
<FormattedMessage
id="xpack.osquery.editSavedQuery.form.updateQueryButtonLabel"
@ -82,7 +98,7 @@ const EditSavedQueryFormComponent: React.FC<EditSavedQueryFormProps> = ({
<EuiSpacer size="xxl" />
</>
)}
</Form>
</FormProvider>
);
};

View file

@ -9,6 +9,7 @@ import { EuiTabbedContent, EuiNotificationBadge } from '@elastic/eui';
import React, { useMemo } from 'react';
import type { ReactElement } from 'react';
import type { ECSMapping } from '../../../../common/schemas/common';
import { ResultsTable } from '../../../results/results_table';
import { ActionResultsSummary } from '../../../action_results/action_results_summary';
@ -16,7 +17,7 @@ interface ResultTabsProps {
actionId: string;
agentIds?: string[];
startDate?: string;
ecsMapping?: Record<string, string>;
ecsMapping?: ECSMapping;
failedAgentsCount?: number;
endDate?: string;
addToTimeline?: (payload: { query: [string, string]; isIcon?: true }) => ReactElement;

View file

@ -15,15 +15,19 @@ import {
} from '@elastic/eui';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { FormProvider } from 'react-hook-form';
import type { PackQueryFormData } from '../../../packs/queries/use_pack_query_form';
import { isEmpty } from 'lodash';
import { useRouterNavigate } from '../../../common/lib/kibana';
import { Form } from '../../../shared_imports';
import { SavedQueryForm } from '../../../saved_queries/form';
import type {
SavedQuerySOFormData,
SavedQueryFormData,
} from '../../../saved_queries/form/use_saved_query_form';
import { useSavedQueryForm } from '../../../saved_queries/form/use_saved_query_form';
interface NewSavedQueryFormProps {
defaultValue?: PackQueryFormData;
defaultValue?: SavedQuerySOFormData;
handleSubmit: (payload: unknown) => Promise<void>;
}
@ -33,15 +37,24 @@ const NewSavedQueryFormComponent: React.FC<NewSavedQueryFormProps> = ({
}) => {
const savedQueryListProps = useRouterNavigate('saved_queries');
const { form } = useSavedQueryForm({
const hooksForm = useSavedQueryForm({
defaultValue,
handleSubmit,
});
const { submit, isSubmitting, isValid } = form;
const {
serializer,
idSet,
handleSubmit: formSubmit,
formState: { isSubmitting, errors },
} = hooksForm;
const onSubmit = (payload: SavedQueryFormData) => {
const serializedData = serializer(payload);
handleSubmit(serializedData);
};
return (
<Form form={form}>
<SavedQueryForm hasPlayground isValid={isValid} />
<FormProvider {...hooksForm}>
<SavedQueryForm hasPlayground isValid={isEmpty(errors)} idSet={idSet} />
<EuiBottomBar>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
@ -61,7 +74,7 @@ const NewSavedQueryFormComponent: React.FC<NewSavedQueryFormProps> = ({
fill
size="m"
iconType="save"
onClick={submit}
onClick={formSubmit(onSubmit)}
>
<FormattedMessage
id="xpack.osquery.addSavedQuery.form.saveQueryButtonLabel"
@ -76,7 +89,7 @@ const NewSavedQueryFormComponent: React.FC<NewSavedQueryFormProps> = ({
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
</Form>
</FormProvider>
);
};

View file

@ -10,9 +10,11 @@ import { EuiCodeBlock, EuiFormRow } from '@elastic/eui';
import React from 'react';
import styled from 'styled-components';
import { useController } from 'react-hook-form';
import { i18n } from '@kbn/i18n';
import { MAX_QUERY_LENGTH } from '../../packs/queries/validations';
import { OsquerySchemaLink } from '../../components/osquery_schema_link';
import { OsqueryEditor } from '../../editor';
import type { FieldHook } from '../../shared_imports';
const StyledEuiCodeBlock = styled(EuiCodeBlock)`
min-height: 100px;
@ -20,20 +22,47 @@ const StyledEuiCodeBlock = styled(EuiCodeBlock)`
interface CodeEditorFieldProps {
euiFieldProps?: Record<string, unknown>;
field: FieldHook<string>;
labelAppend?: string;
helpText?: string;
}
const CodeEditorFieldComponent: React.FC<CodeEditorFieldProps> = ({ euiFieldProps, field }) => {
const { value, label, labelAppend, helpText, setValue, errors } = field;
const error = errors[0]?.message;
const CodeEditorFieldComponent: React.FC<CodeEditorFieldProps> = ({
euiFieldProps,
labelAppend,
helpText,
}) => {
const {
field: { onChange, value },
fieldState: { error },
} = useController({
name: 'query',
rules: {
required: {
message: i18n.translate('xpack.osquery.pack.queryFlyoutForm.emptyQueryError', {
defaultMessage: 'Query is a required field',
}),
value: true,
},
maxLength: {
message: i18n.translate('xpack.osquery.liveQuery.queryForm.largeQueryError', {
defaultMessage: 'Query is too large (max {maxLength} characters)',
values: { maxLength: MAX_QUERY_LENGTH },
}),
value: MAX_QUERY_LENGTH,
},
},
defaultValue: '',
});
return (
<EuiFormRow
label={label}
label={i18n.translate('xpack.osquery.savedQuery.queryEditorLabel', {
defaultMessage: 'Query',
})}
labelAppend={!isEmpty(labelAppend) ? labelAppend : <OsquerySchemaLink />}
helpText={helpText}
isInvalid={typeof error === 'string'}
error={error}
isInvalid={!!error?.message}
error={error?.message}
fullWidth
>
{euiFieldProps?.isDisabled ? (
@ -46,7 +75,7 @@ const CodeEditorFieldComponent: React.FC<CodeEditorFieldProps> = ({ euiFieldProp
{value}
</StyledEuiCodeBlock>
) : (
<OsqueryEditor defaultValue={value} onChange={setValue} />
<OsqueryEditor defaultValue={value} onChange={onChange} />
)}
</EuiFormRow>
);

View file

@ -17,25 +17,25 @@ import React, { useCallback, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { ALL_OSQUERY_VERSIONS_OPTIONS } from '../../packs/queries/constants';
import { IntervalField, QueryIdField, QueryDescriptionField, VersionField } from '../../form';
import { PlatformCheckBoxGroupField } from '../../packs/queries/platform_checkbox_group_field';
import { Field, getUseField, UseField } from '../../shared_imports';
import { CodeEditorField } from './code_editor_field';
import { ALL_OSQUERY_VERSIONS_OPTIONS } from '../../packs/queries/constants';
import { ECSMappingEditorField } from '../../packs/queries/lazy_ecs_mapping_editor_field';
import { PlaygroundFlyout } from './playground_flyout';
export const CommonUseField = getUseField({ component: Field });
import { CodeEditorField } from './code_editor_field';
interface SavedQueryFormProps {
viewMode?: boolean;
hasPlayground?: boolean;
isValid?: boolean;
idSet?: Set<string>;
}
const SavedQueryFormComponent: React.FC<SavedQueryFormProps> = ({
viewMode,
hasPlayground,
isValid,
idSet,
}) => {
const [playgroundVisible, setPlaygroundVisible] = useState(false);
@ -77,11 +77,11 @@ const SavedQueryFormComponent: React.FC<SavedQueryFormProps> = ({
return (
<>
<CommonUseField path="id" euiFieldProps={euiFieldProps} />
<QueryIdField idSet={idSet} euiFieldProps={euiFieldProps} />
<EuiSpacer />
<CommonUseField path="description" euiFieldProps={euiFieldProps} />
<QueryDescriptionField euiFieldProps={euiFieldProps} />
<EuiSpacer />
<UseField path="query" component={CodeEditorField} euiFieldProps={euiFieldProps} />
<CodeEditorField euiFieldProps={euiFieldProps} />
<EuiSpacer size="xl" />
<EuiFlexGroup>
<EuiFlexItem>
@ -119,16 +119,12 @@ const SavedQueryFormComponent: React.FC<SavedQueryFormProps> = ({
<EuiSpacer />
<EuiFlexGroup>
<EuiFlexItem>
<CommonUseField path="interval" euiFieldProps={intervalEuiFieldProps} />
<IntervalField euiFieldProps={intervalEuiFieldProps} />
<EuiSpacer size="m" />
<CommonUseField path="version" euiFieldProps={versionEuiFieldProps} />
<VersionField euiFieldProps={versionEuiFieldProps} />
</EuiFlexItem>
<EuiFlexItem>
<CommonUseField
path="platform"
component={PlatformCheckBoxGroupField}
euiFieldProps={euiFieldProps}
/>
<PlatformCheckBoxGroupField euiFieldProps={euiFieldProps} />
</EuiFlexItem>
</EuiFlexGroup>
{playgroundVisible && (

View file

@ -10,8 +10,8 @@ import React, { useMemo } from 'react';
import styled from 'styled-components';
import { FormattedMessage } from '@kbn/i18n-react';
import { useFormContext } from 'react-hook-form';
import { LiveQuery } from '../../live_queries';
import { useFormData } from '../../shared_imports';
const StyledEuiFlyoutHeader = styled(EuiFlyoutHeader)`
&.euiFlyoutHeader.euiFlyoutHeader--hasBorder {
@ -26,11 +26,13 @@ interface PlaygroundFlyoutProps {
}
const PlaygroundFlyoutComponent: React.FC<PlaygroundFlyoutProps> = ({ enabled, onClose }) => {
const [{ query, ecs_mapping: ecsMapping, id }, formDataSerializer] = useFormData();
// @ts-expect-error update types
const { serializer, watch } = useFormContext();
const watchedValues = watch();
const { query, ecs_mapping: ecsMapping, id } = watchedValues;
/* recalculate the form data when ecs_mapping changes */
// eslint-disable-next-line react-hooks/exhaustive-deps
const serializedFormData = useMemo(() => formDataSerializer(), [ecsMapping, formDataSerializer]);
const serializedFormData = useMemo(() => serializer(watchedValues), [ecsMapping]);
return (
<EuiFlyout type="push" size="m" onClose={onClose}>

View file

@ -5,105 +5,98 @@
* 2.0.
*/
import { useForm as useHookForm } from 'react-hook-form';
import { isArray, isEmpty, map } from 'lodash';
import uuid from 'uuid';
import { produce } from 'immer';
import type { Draft } from 'immer';
import produce from 'immer';
import { useMemo } from 'react';
import type { ECSMapping } from '../../../common/schemas/common';
import { convertECSMappingToObject } from '../../../common/schemas/common/utils';
import { useForm } from '../../shared_imports';
import { createFormSchema } from '../../packs/queries/schema';
import type {
PackQueryECSMapping,
PackQueryFormData,
} from '../../packs/queries/use_pack_query_form';
import type { EcsMappingFormField } from '../../packs/queries/ecs_mapping_editor_field';
import { defaultEcsFormData } from '../../packs/queries/ecs_mapping_editor_field';
import { useSavedQueries } from '../use_saved_queries';
const SAVED_QUERY_FORM_ID = 'savedQueryForm';
interface ReturnFormData {
export interface SavedQuerySOFormData {
id?: string;
description?: string;
query: string;
query?: string;
interval?: string;
platform?: string;
version?: string | undefined;
ecs_mapping?: ECSMapping | undefined;
}
export interface SavedQueryFormData {
id?: string;
description?: string;
query?: string;
interval?: number;
platform?: string;
version?: string[];
ecs_mapping?: PackQueryECSMapping[] | undefined;
ecs_mapping: EcsMappingFormField[];
}
interface UseSavedQueryFormProps {
defaultValue?: PackQueryFormData;
handleSubmit: (payload: unknown) => Promise<void>;
defaultValue?: SavedQuerySOFormData;
}
export const useSavedQueryForm = ({ defaultValue, handleSubmit }: UseSavedQueryFormProps) => {
const deserializer = (payload: SavedQuerySOFormData): SavedQueryFormData => ({
id: payload.id,
description: payload.description,
query: payload.query,
interval: payload.interval ? parseInt(payload.interval, 10) : 3600,
platform: payload.platform,
version: payload.version ? [payload.version] : [],
ecs_mapping: !isEmpty(payload.ecs_mapping)
? (map(payload.ecs_mapping, (value, key: string) => ({
key,
result: {
type: Object.keys(value)[0],
value: Object.values(value)[0],
},
})) as unknown as EcsMappingFormField[])
: [defaultEcsFormData],
});
export const savedQueryDataSerializer = (payload: SavedQueryFormData): SavedQuerySOFormData =>
// @ts-expect-error update types
produce<SavedQueryFormData>(payload, (draft: Draft<SavedQuerySOFormData>) => {
if (isArray(draft.version)) {
if (!draft.version.length) {
draft.version = '';
} else {
draft.version = draft.version[0];
}
}
if (isArray(draft.platform) && !draft.platform.length) {
delete draft.platform;
}
draft.ecs_mapping = convertECSMappingToObject(payload.ecs_mapping);
if (draft.interval) {
draft.interval = draft.interval + '';
}
return draft;
});
export const useSavedQueryForm = ({ defaultValue }: UseSavedQueryFormProps) => {
const { data } = useSavedQueries({});
const ids: string[] = useMemo<string[]>(() => map(data, 'attributes.id') ?? [], [data]);
const ids: string[] = useMemo<string[]>(() => map(data?.data, 'attributes.id') ?? [], [data]);
const idSet = useMemo<Set<string>>(() => {
const res = new Set<string>(ids);
if (defaultValue && defaultValue.id) res.delete(defaultValue.id);
return res;
}, [ids, defaultValue]);
const formSchema = useMemo(() => createFormSchema(idSet), [idSet]);
return useForm<PackQueryFormData, ReturnFormData>({
id: SAVED_QUERY_FORM_ID + uuid.v4(),
schema: formSchema,
onSubmit: async (formData, isValid) => {
if (isValid) {
try {
await handleSubmit(formData);
// eslint-disable-next-line no-empty
} catch (e) {}
}
},
defaultValue,
// @ts-expect-error update types
serializer: (payload) =>
produce(payload, (draft) => {
if (isArray(draft.version)) {
if (!draft.version.length) {
// @ts-expect-error update types
draft.version = '';
} else {
// @ts-expect-error update types
draft.version = draft.version[0];
}
}
if (isEmpty(payload.ecs_mapping)) {
delete draft.ecs_mapping;
} else {
// @ts-expect-error update types
draft.ecs_mapping = convertECSMappingToObject(payload.ecs_mapping);
}
// @ts-expect-error update types
draft.interval = draft.interval + '';
return draft;
}),
deserializer: (payload) => {
if (!payload) return {} as ReturnFormData;
return {
id: payload.id,
description: payload.description,
query: payload.query,
interval: payload.interval ?? 3600,
platform: payload.platform,
version: payload.version ? [payload.version] : [],
ecs_mapping: (!isEmpty(payload.ecs_mapping)
? map(payload.ecs_mapping, (value, key: string) => ({
key,
result: {
type: Object.keys(value)[0],
value: Object.values(value)[0],
},
}))
: ([] as PackQueryECSMapping[])) as PackQueryECSMapping[],
};
},
});
return {
serializer: savedQueryDataSerializer,
idSet,
...useHookForm<SavedQueryFormData>({
defaultValues: defaultValue ? deserializer(defaultValue) : {},
}),
};
};

View file

@ -9,11 +9,11 @@ import { find } from 'lodash/fp';
import { EuiCodeBlock, EuiFormRow, EuiComboBox, EuiTextColor } from '@elastic/eui';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import { useWatch } from 'react-hook-form';
import { QUERIES_DROPDOWN_LABEL, QUERIES_DROPDOWN_SEARCH_FIELD_LABEL } from './constants';
import { OsquerySchemaLink } from '../components/osquery_schema_link';
import { useSavedQueries } from './use_saved_queries';
import { useFormData } from '../shared_imports';
import type { SavedQuerySO } from '../routes/saved_queries/list';
const TextTruncate = styled.div`
@ -49,10 +49,9 @@ const SavedQueriesDropdownComponent: React.FC<SavedQueriesDropdownProps> = ({
disabled,
onChange,
}) => {
const savedQueryId = useWatch({ name: 'savedQueryId' });
const [selectedOptions, setSelectedOptions] = useState<SelectedOption[]>([]);
const [{ savedQueryId }] = useFormData();
const { data } = useSavedQueries({});
const queryOptions = useMemo(

View file

@ -17,17 +17,18 @@ import {
EuiButtonEmpty,
EuiButton,
} from '@elastic/eui';
import { FormProvider } from 'react-hook-form';
import React, { useCallback } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { Form } from '../shared_imports';
import type { SavedQuerySOFormData, SavedQueryFormData } from './form/use_saved_query_form';
import { useSavedQueryForm } from './form/use_saved_query_form';
import { SavedQueryForm } from './form';
import { useCreateSavedQuery } from './use_create_saved_query';
import type { PackQueryFormData } from '../packs/queries/use_pack_query_form';
interface AddQueryFlyoutProps {
defaultValue: PackQueryFormData;
defaultValue: SavedQuerySOFormData;
onClose: () => void;
isExternal?: boolean;
}
@ -41,18 +42,24 @@ const SavedQueryFlyoutComponent: React.FC<AddQueryFlyoutProps> = ({
}) => {
const createSavedQueryMutation = useCreateSavedQuery({ withRedirect: false });
const handleSubmit = useCallback(
async (payload) => {
await createSavedQueryMutation.mutateAsync(payload).then(() => onClose());
},
[createSavedQueryMutation, onClose]
);
const { form } = useSavedQueryForm({
const hooksForm = useSavedQueryForm({
defaultValue,
handleSubmit,
});
const { submit, isSubmitting } = form;
const {
serializer,
idSet,
handleSubmit,
formState: { isSubmitting },
} = hooksForm;
const onSubmit = useCallback(
async (payload: SavedQueryFormData) => {
const serializedData = serializer(payload);
// TODO CHECK THIS
// @ts-expect-error update types
await createSavedQueryMutation.mutateAsync(serializedData).then(() => onClose());
},
[createSavedQueryMutation, onClose, serializer]
);
return (
<EuiPortal>
@ -74,9 +81,9 @@ const SavedQueryFlyoutComponent: React.FC<AddQueryFlyoutProps> = ({
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<Form form={form}>
<SavedQueryForm />
</Form>
<FormProvider {...hooksForm}>
<SavedQueryForm idSet={idSet} />
</FormProvider>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
@ -89,7 +96,7 @@ const SavedQueryFlyoutComponent: React.FC<AddQueryFlyoutProps> = ({
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton isLoading={isSubmitting} onClick={submit} fill>
<EuiButton isLoading={isSubmitting} onClick={handleSubmit(onSubmit)} fill>
<FormattedMessage
id="xpack.osquery.pack.queryFlyoutForm.saveButtonLabel"
defaultMessage="Save"

View file

@ -40,7 +40,7 @@ export const useUpdateSavedQuery = ({ savedQueryId }: UseUpdateSavedQueryProps)
toastMessage: error.body.message,
});
},
onSuccess: (payload: SavedQuerySO) => {
onSuccess: (payload: { data: SavedQuerySO }) => {
queryClient.invalidateQueries([SAVED_QUERIES_ID]);
queryClient.invalidateQueries([SAVED_QUERY_ID, { savedQueryId }]);
navigateToApp(PLUGIN_ID, { path: pagePathGetters.saved_queries() });
@ -48,7 +48,7 @@ export const useUpdateSavedQuery = ({ savedQueryId }: UseUpdateSavedQueryProps)
i18n.translate('xpack.osquery.editSavedQuery.successToastMessageText', {
defaultMessage: 'Successfully updated "{savedQueryName}" query',
values: {
savedQueryName: payload.attributes?.id ?? '',
savedQueryName: payload.data.attributes?.id ?? '',
},
})
);

View file

@ -23657,7 +23657,7 @@
"xpack.osquery.pack.queriesTable.deleteActionAriaLabel": "Supprimer {queryName}",
"xpack.osquery.pack.queriesTable.editActionAriaLabel": "Modifier {queryName}",
"xpack.osquery.pack.queryFlyoutForm.intervalFieldMaxNumberError": "La valeur d'intervalle doit être inférieure à {than}",
"xpack.osquery.pack.queryFlyoutForm.osqueryResultFieldValueMissingErrorMessage": "La recherche en cours ne retourne pas de champ {columnName}",
"xpack.osquery.pack.queryFlyoutForm.osqueryResultFieldRequiredErrorMessage": "Valeur obligatoire.",
"xpack.osquery.pack.table.activatedSuccessToastMessageText": "Le pack \"{packName}\" a bien été activé.",
"xpack.osquery.pack.table.deactivatedSuccessToastMessageText": "Le pack \"{packName}\" a bien été désactivé.",
"xpack.osquery.pack.table.deleteQueriesButtonLabel": "Supprimer {queriesCount, plural, one {# recherche} other {# recherches}}",
@ -23820,7 +23820,6 @@
"xpack.osquery.pack.queriesTable.viewResultsColumnTitle": "Afficher les résultats",
"xpack.osquery.pack.queryFlyoutForm.cancelButtonLabel": "Annuler",
"xpack.osquery.pack.queryFlyoutForm.deleteECSMappingRowButtonAriaLabel": "Supprimer la ligne de mapping ECS",
"xpack.osquery.pack.queryFlyoutForm.descriptionFieldLabel": "Description (facultative)",
"xpack.osquery.pack.queryFlyoutForm.ecsFieldRequiredErrorMessage": "Le champ ECS est requis.",
"xpack.osquery.pack.queryFlyoutForm.emptyIdError": "L'ID est requis",
"xpack.osquery.pack.queryFlyoutForm.emptyQueryError": "La recherche est requise",
@ -23830,12 +23829,11 @@
"xpack.osquery.pack.queryFlyoutForm.invalidIdError": "Les caractères doivent être alphanumériques, _ ou -",
"xpack.osquery.pack.queryFlyoutForm.mappingEcsFieldLabel": "Champ ECS",
"xpack.osquery.pack.queryFlyoutForm.mappingValueFieldLabel": "Valeur",
"xpack.osquery.pack.queryFlyoutForm.osqueryResultFieldRequiredErrorMessage": "Valeur obligatoire.",
"xpack.osquery.pack.queryFlyoutForm.osqueryResultFieldValueMissingErrorMessage": "La recherche en cours ne retourne pas de champ {columnName}",
"xpack.osquery.pack.queryFlyoutForm.platformFieldLabel": "Plateforme",
"xpack.osquery.pack.queryFlyoutForm.platformLinusLabel": "macOS",
"xpack.osquery.pack.queryFlyoutForm.platformMacOSLabel": "Linux",
"xpack.osquery.pack.queryFlyoutForm.platformWindowsLabel": "Windows",
"xpack.osquery.pack.queryFlyoutForm.queryFieldLabel": "Recherche",
"xpack.osquery.pack.queryFlyoutForm.saveButtonLabel": "Enregistrer",
"xpack.osquery.pack.queryFlyoutForm.uniqueIdError": "L'ID doit être unique",
"xpack.osquery.pack.queryFlyoutForm.versionFieldLabel": "Version Osquery minimale",
@ -23851,7 +23849,6 @@
"xpack.osquery.packList.prePackagedPacks.emptyPromptTitle.emptyPromptMessage": "Un pack est un ensemble de requêtes pouvant être planifié. Chargez des packs prédéfinis ou créez vos propres packs.",
"xpack.osquery.packList.prePackagedPacks.loadButtonLabel": "Charger les pack prédéfinis Elastic",
"xpack.osquery.packList.prePackagedPacks.updateButtonLabel": "Mettre à jour les pack prédéfinis Elastic",
"xpack.osquery.packs.dropdown.searchFieldLabel": "Pack",
"xpack.osquery.packs.dropdown.searchFieldPlaceholder": "Rechercher un pack à exécuter",
"xpack.osquery.packs.table.activeColumnTitle": "Actif",
"xpack.osquery.packs.table.createdByColumnTitle": "Créé par",

View file

@ -23637,7 +23637,7 @@
"xpack.osquery.pack.queriesTable.deleteActionAriaLabel": "{queryName}を削除",
"xpack.osquery.pack.queriesTable.editActionAriaLabel": "{queryName}を編集",
"xpack.osquery.pack.queryFlyoutForm.intervalFieldMaxNumberError": "間隔値は{than}よりも小さくなければなりません",
"xpack.osquery.pack.queryFlyoutForm.osqueryResultFieldValueMissingErrorMessage": "現在のクエリは{columnName}フィールドを返しません",
"xpack.osquery.pack.queryFlyoutForm.osqueryResultFieldRequiredErrorMessage": "値が必要です。",
"xpack.osquery.pack.table.activatedSuccessToastMessageText": "\"{packName}\"が正常にアクティブ化されました",
"xpack.osquery.pack.table.deactivatedSuccessToastMessageText": "\"{packName}\"が正常に非アクティブ化されました",
"xpack.osquery.pack.table.deleteQueriesButtonLabel": "{queriesCount, plural, other {# 個のクエリ}}を削除",
@ -23800,7 +23800,6 @@
"xpack.osquery.pack.queriesTable.viewResultsColumnTitle": "結果を表示",
"xpack.osquery.pack.queryFlyoutForm.cancelButtonLabel": "キャンセル",
"xpack.osquery.pack.queryFlyoutForm.deleteECSMappingRowButtonAriaLabel": "ECSマッピング行を削除",
"xpack.osquery.pack.queryFlyoutForm.descriptionFieldLabel": "説明(オプション)",
"xpack.osquery.pack.queryFlyoutForm.ecsFieldRequiredErrorMessage": "ECSフィールドは必須です。",
"xpack.osquery.pack.queryFlyoutForm.emptyIdError": "IDが必要です",
"xpack.osquery.pack.queryFlyoutForm.emptyQueryError": "クエリは必須フィールドです",
@ -23810,12 +23809,11 @@
"xpack.osquery.pack.queryFlyoutForm.invalidIdError": "文字は英数字、_、または-でなければなりません",
"xpack.osquery.pack.queryFlyoutForm.mappingEcsFieldLabel": "ECSフィールド",
"xpack.osquery.pack.queryFlyoutForm.mappingValueFieldLabel": "値",
"xpack.osquery.pack.queryFlyoutForm.osqueryResultFieldRequiredErrorMessage": "値が必要です。",
"xpack.osquery.pack.queryFlyoutForm.osqueryResultFieldValueMissingErrorMessage": "現在のクエリは{columnName}フィールドを返しません",
"xpack.osquery.pack.queryFlyoutForm.platformFieldLabel": "プラットフォーム",
"xpack.osquery.pack.queryFlyoutForm.platformLinusLabel": "macOS",
"xpack.osquery.pack.queryFlyoutForm.platformMacOSLabel": "Linux",
"xpack.osquery.pack.queryFlyoutForm.platformWindowsLabel": "Windows",
"xpack.osquery.pack.queryFlyoutForm.queryFieldLabel": "クエリ",
"xpack.osquery.pack.queryFlyoutForm.saveButtonLabel": "保存",
"xpack.osquery.pack.queryFlyoutForm.uniqueIdError": "IDは一意でなければなりません",
"xpack.osquery.pack.queryFlyoutForm.versionFieldLabel": "最低Osqueryバージョン",
@ -23831,7 +23829,6 @@
"xpack.osquery.packList.prePackagedPacks.emptyPromptTitle.emptyPromptMessage": "パックはスケジュールできるクエリのセットです。事前構築済みパックを読み込むか、独自のパックを作成します。",
"xpack.osquery.packList.prePackagedPacks.loadButtonLabel": "Elastic事前構築済みパックを読み込む",
"xpack.osquery.packList.prePackagedPacks.updateButtonLabel": "Elastic事前構築済みパックの更新",
"xpack.osquery.packs.dropdown.searchFieldLabel": "パック",
"xpack.osquery.packs.dropdown.searchFieldPlaceholder": "実行するパックを検索",
"xpack.osquery.packs.table.activeColumnTitle": "アクティブ",
"xpack.osquery.packs.table.createdByColumnTitle": "作成者",

View file

@ -23665,7 +23665,7 @@
"xpack.osquery.pack.queriesTable.deleteActionAriaLabel": "删除 {queryName}",
"xpack.osquery.pack.queriesTable.editActionAriaLabel": "编辑 {queryName}",
"xpack.osquery.pack.queryFlyoutForm.intervalFieldMaxNumberError": "间隔值必须小于 {than}",
"xpack.osquery.pack.queryFlyoutForm.osqueryResultFieldValueMissingErrorMessage": "当前查询不返回 {columnName} 字段",
"xpack.osquery.pack.queryFlyoutForm.osqueryResultFieldRequiredErrorMessage": "“值”必填。",
"xpack.osquery.pack.table.activatedSuccessToastMessageText": "已成功激活“{packName}”包",
"xpack.osquery.pack.table.deactivatedSuccessToastMessageText": "已成功停用“{packName}”包",
"xpack.osquery.pack.table.deleteQueriesButtonLabel": "删除 {queriesCount, plural, other {# 个查询}}",
@ -23828,7 +23828,6 @@
"xpack.osquery.pack.queriesTable.viewResultsColumnTitle": "查看结果",
"xpack.osquery.pack.queryFlyoutForm.cancelButtonLabel": "取消",
"xpack.osquery.pack.queryFlyoutForm.deleteECSMappingRowButtonAriaLabel": "删除 ECS 映射行",
"xpack.osquery.pack.queryFlyoutForm.descriptionFieldLabel": "描述(可选)",
"xpack.osquery.pack.queryFlyoutForm.ecsFieldRequiredErrorMessage": "ECS 字段必填。",
"xpack.osquery.pack.queryFlyoutForm.emptyIdError": "“ID”必填",
"xpack.osquery.pack.queryFlyoutForm.emptyQueryError": "“查询”是必填字段",
@ -23838,12 +23837,11 @@
"xpack.osquery.pack.queryFlyoutForm.invalidIdError": "字符必须是数字字母、_ 或 -",
"xpack.osquery.pack.queryFlyoutForm.mappingEcsFieldLabel": "ECS 字段",
"xpack.osquery.pack.queryFlyoutForm.mappingValueFieldLabel": "值",
"xpack.osquery.pack.queryFlyoutForm.osqueryResultFieldRequiredErrorMessage": "“值”必填。",
"xpack.osquery.pack.queryFlyoutForm.osqueryResultFieldValueMissingErrorMessage": "当前查询不返回 {columnName} 字段",
"xpack.osquery.pack.queryFlyoutForm.platformFieldLabel": "平台",
"xpack.osquery.pack.queryFlyoutForm.platformLinusLabel": "macOS",
"xpack.osquery.pack.queryFlyoutForm.platformMacOSLabel": "Linux",
"xpack.osquery.pack.queryFlyoutForm.platformWindowsLabel": "Windows",
"xpack.osquery.pack.queryFlyoutForm.queryFieldLabel": "查询",
"xpack.osquery.pack.queryFlyoutForm.saveButtonLabel": "保存",
"xpack.osquery.pack.queryFlyoutForm.uniqueIdError": "ID 必须唯一",
"xpack.osquery.pack.queryFlyoutForm.versionFieldLabel": "最低 Osquery 版本",
@ -23859,7 +23857,6 @@
"xpack.osquery.packList.prePackagedPacks.emptyPromptTitle.emptyPromptMessage": "包是您可以计划的一组查询。加载预构建包或创建您自己的预构建包。",
"xpack.osquery.packList.prePackagedPacks.loadButtonLabel": "加载 Elastic 预构建包",
"xpack.osquery.packList.prePackagedPacks.updateButtonLabel": "更新 Elastic 预构建包",
"xpack.osquery.packs.dropdown.searchFieldLabel": "包",
"xpack.osquery.packs.dropdown.searchFieldPlaceholder": "搜索要运行的包",
"xpack.osquery.packs.table.activeColumnTitle": "活动",
"xpack.osquery.packs.table.createdByColumnTitle": "创建者",