[Osquery] Refactor ECS editor field (#130582)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Patryk Kopyciński 2022-04-29 14:53:50 +02:00 committed by GitHub
parent d124a4d17c
commit e1bd7da1d1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 914 additions and 1115 deletions

View file

@ -48,7 +48,7 @@ const TabComponent = (props: TabProps) => {
return (
<TabContent>
<OsqueryAction agentId={metadata?.info?.agent?.id} />
<OsqueryAction agentId={metadata?.info?.agent?.id} hideAgentsField />
</TabContent>
);
}, [OsqueryAction, loading, metadata]);

View file

@ -0,0 +1,39 @@
/*
* 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 { 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;
export const convertECSMappingToObject = (
ecsMapping: Array<{
key: string;
result: {
type: string;
value: string;
};
}>
): Record<string, { field?: string; value?: string }> =>
reduce(
ecsMapping,
(acc, value) => {
if (!isEmpty(value?.key) && !isEmpty(value.result?.type) && !isEmpty(value.result?.value)) {
acc[value.key] = {
[value.result.type]: value.result.value,
};
}
return acc;
},
{} as Record<string, { field?: string; value?: string }>
);

View file

@ -31,7 +31,10 @@ describe('ALL - Delete ECS Mappings', () => {
}).click();
cy.contains('Custom key/value pairs.').should('exist');
cy.contains('Hours of uptime').should('exist');
cy.react('EuiButtonIcon', { props: { id: 'labels-trash' } }).click();
cy.react('ECSComboboxFieldComponent', { props: { field: { value: 'labels' } } })
.parents('[data-test-subj="ECSMappingEditorForm"]')
.react('EuiButtonIcon', { props: { iconType: 'trash' } })
.click();
cy.react('EuiButton').contains('Update query').click();
cy.wait(5000);

View file

@ -61,10 +61,8 @@ describe('ALL - Live Query', () => {
}).should('exist');
cy.react(RESULTS_TABLE_CELL_WRRAPER, {
props: { id: 'osquery.days.number', index: 2 },
}).within(() => {
cy.get('.euiToolTipAnchor').within(() => {
cy.get('svg').should('exist');
});
});
})
.react('EuiIconTip', { props: { type: 'indexMapping' } })
.should('exist');
});
});

View file

@ -55,7 +55,7 @@ describe('ALL - Packs', () => {
cy.react('List').first().click();
findAndClickButton('Add query');
cy.contains('Attach next query');
getSavedQueriesDropdown().click().type(`${SAVED_QUERY_ID}{downArrow}{enter}`);
getSavedQueriesDropdown().type(`${SAVED_QUERY_ID}{downArrow}{enter}`);
cy.react('EuiFormRow', { props: { label: 'Interval (s)' } })
.click()
.clear()
@ -92,7 +92,7 @@ describe('ALL - Packs', () => {
findAndClickButton('Add query');
cy.contains('Attach next query');
cy.contains('ID must be unique').should('not.exist');
getSavedQueriesDropdown().click().type(`${SAVED_QUERY_ID}{downArrow}{enter}`);
getSavedQueriesDropdown().type(`${SAVED_QUERY_ID}{downArrow}{enter}`);
cy.contains('ID must be unique').should('exist');
cy.react('EuiFlyoutFooter').react('EuiButtonEmpty').contains('Cancel').click();
});
@ -170,7 +170,7 @@ describe('ALL - Packs', () => {
findAndClickButton('Add query');
getSavedQueriesDropdown().click().type('Multiple {downArrow} {enter}');
getSavedQueriesDropdown().type('Multiple {downArrow} {enter}');
cy.contains('Custom key/value pairs');
cy.contains('Days of uptime');
cy.contains('List of keywords used to tag each');
@ -178,7 +178,7 @@ describe('ALL - Packs', () => {
cy.contains('Client network address.');
cy.contains('Total uptime seconds');
getSavedQueriesDropdown().click().type('NOMAPPING {downArrow} {enter}');
getSavedQueriesDropdown().type('NOMAPPING {downArrow} {enter}');
cy.contains('Custom key/value pairs').should('not.exist');
cy.contains('Days of uptime').should('not.exist');
cy.contains('List of keywords used to tag each').should('not.exist');
@ -186,7 +186,7 @@ describe('ALL - Packs', () => {
cy.contains('Client network address.').should('not.exist');
cy.contains('Total uptime seconds').should('not.exist');
getSavedQueriesDropdown().click().type('ONE_MAPPING {downArrow} {enter}');
getSavedQueriesDropdown().type('ONE_MAPPING {downArrow} {enter}');
cy.contains('Name of the continent');
cy.contains('Seconds of uptime');

View file

@ -60,7 +60,7 @@ describe('T1 Analyst - READ + runSavedQueries ', () => {
cy.waitForReact(1000);
cy.contains('New live query').should('not.be.disabled').click();
selectAllAgents();
getSavedQueriesDropdown().click().type(`${SAVED_QUERY_ID}{downArrow} {enter}`);
getSavedQueriesDropdown().type(`${SAVED_QUERY_ID}{downArrow} {enter}`);
cy.contains('select * from uptime');
submitQuery();
checkResults();

View file

@ -104,7 +104,10 @@ describe('T2 Analyst - READ + Write Live/Saved + runSavedQueries ', () => {
}).click();
cy.contains('Custom key/value pairs.').should('exist');
cy.contains('Hours of uptime').should('exist');
cy.react('EuiButtonIcon', { props: { id: 'labels-trash' } }).click();
cy.react('ECSComboboxFieldComponent', { props: { field: { value: 'labels' } } })
.parents('[data-test-subj="ECSMappingEditorForm"]')
.react('EuiButtonIcon', { props: { iconType: 'trash' } })
.click();
cy.react('EuiButton').contains('Update query').click();
cy.wait(5000);

View file

@ -8,17 +8,15 @@
import { LIVE_QUERY_EDITOR } from '../screens/live_query';
export const DEFAULT_QUERY = 'select * from processes;';
export const BIG_QUERY = 'select * from processes, users;';
export const BIG_QUERY = 'select * from processes, users limit 200;';
export const selectAllAgents = () => {
cy.react('EuiComboBox', { props: { placeholder: 'Select agents or groups' } })
.find('input')
.should('not.be.disabled');
cy.react('EuiComboBox', { props: { placeholder: 'Select agents or groups' } }).click();
cy.react('AgentsTable').find('input').should('not.be.disabled');
cy.react('AgentsTable EuiComboBox', {
props: { placeholder: 'Select agents or groups' },
}).click();
cy.react('EuiFilterSelectItem').contains('All agents').should('exist');
cy.react('EuiComboBox', { props: { placeholder: 'Select agents or groups' } }).type(
'{downArrow}{enter}{esc}'
);
cy.react('AgentsTable EuiComboBox').type('{downArrow}{enter}{esc}');
cy.contains('1 agent selected.');
};
@ -27,12 +25,11 @@ export const inputQuery = (query: string) => cy.get(LIVE_QUERY_EDITOR).type(quer
export const submitQuery = () => cy.contains('Submit').click();
export const checkResults = () =>
cy.getBySel('dataGridRowCell', { timeout: 60000 }).should('have.lengthOf.above', 0);
cy.getBySel('dataGridRowCell', { timeout: 120000 }).should('have.lengthOf.above', 0);
export const typeInECSFieldInput = (text: string) =>
cy.getBySel('ECS-field-input').click().type(text);
export const typeInECSFieldInput = (text: string) => cy.getBySel('ECS-field-input').type(text);
export const typeInOsqueryFieldInput = (text: string) =>
cy.react('OsqueryColumnFieldComponent').first().react('ResultComboBox').click().type(text);
cy.react('OsqueryColumnFieldComponent').first().react('ResultComboBox').type(text);
export const findFormFieldByRowsLabelAndType = (label: string, text: string) => {
cy.react('EuiFormRow', { props: { label } }).type(text);

View file

@ -9,7 +9,7 @@ import { flatten, reverse, uniqBy } from 'lodash/fp';
import { useQuery } from 'react-query';
import { i18n } from '@kbn/i18n';
import { firstValueFrom } from 'rxjs';
import { lastValueFrom } from 'rxjs';
import {
createFilter,
getInspectResponse,
@ -68,7 +68,7 @@ export const useActionResults = ({
return useQuery(
['actionResults', { actionId }],
async () => {
const responseData = await firstValueFrom(
const responseData = await lastValueFrom(
data.search.search<ActionResultsRequestOptions, ActionResultsStrategyResponse>(
{
actionId,

View file

@ -8,7 +8,7 @@
import { useQuery } from 'react-query';
import { i18n } from '@kbn/i18n';
import { firstValueFrom } from 'rxjs';
import { lastValueFrom } from 'rxjs';
import { createFilter } from '../common/helpers';
import { useKibana } from '../common/lib/kibana';
import {
@ -37,7 +37,7 @@ export const useActionDetails = ({ actionId, filterQuery, skip = false }: UseAct
return useQuery(
['actionDetails', { actionId, filterQuery }],
async () => {
const responseData = await firstValueFrom(
const responseData = await lastValueFrom(
data.search.search<ActionDetailsRequestOptions, ActionDetailsStrategyResponse>(
{
actionId,

View file

@ -8,7 +8,7 @@
import { useQuery } from 'react-query';
import { i18n } from '@kbn/i18n';
import { firstValueFrom } from 'rxjs';
import { lastValueFrom } from 'rxjs';
import {
createFilter,
generateTablePaginationOptions,
@ -60,7 +60,7 @@ export const useAllActions = ({
return useQuery(
['actions', { activePage, direction, limit, sortField }],
async () => {
const responseData = await firstValueFrom(
const responseData = await lastValueFrom(
data.search.search<ActionsRequestOptions, ActionsStrategyResponse>(
{
factoryQueryType: OsqueryQueries.actions,

View file

@ -6,13 +6,13 @@
*/
import { find } from 'lodash/fp';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { EuiComboBox, EuiHealth, EuiHighlight, EuiSpacer } from '@elastic/eui';
import deepEqual from 'fast-deep-equal';
import useDebounce from 'react-use/lib/useDebounce';
import { useAllAgents } from './use_all_agents';
import { useAgentGroups } from './use_agent_groups';
import { useOsqueryPolicies } from './use_osquery_policies';
import { AgentGrouper } from './agent_grouper';
import {
getNumAgentsInGrouping,
@ -61,19 +61,16 @@ const AgentsTableComponent: React.FC<AgentsTableProps> = ({ agentSelection, onCh
);
// grouping related
const osqueryPolicyData = useOsqueryPolicies();
const {
loading: groupsLoading,
totalCount: totalNumAgents,
groups,
isLoading: groupsLoading,
data: agentGroupsData,
isFetched: groupsFetched,
} = useAgentGroups(osqueryPolicyData);
const grouper = useMemo(() => new AgentGrouper(), []);
} = useAgentGroups();
const {
isLoading: agentsLoading,
data: agents,
isFetched: agentsFetched,
} = useAllAgents(osqueryPolicyData, debouncedSearchValue, {
} = useAllAgents(debouncedSearchValue, {
perPage,
});
@ -96,7 +93,7 @@ const AgentsTableComponent: React.FC<AgentsTableProps> = ({ agentSelection, onCh
selectedGroups: SelectedGroups;
} = generateAgentSelection(selection);
if (newAgentSelection.allAgentsSelected) {
setNumAgentsSelected(totalNumAgents);
setNumAgentsSelected(agentGroupsData?.totalCount ?? 0);
} else {
const checkAgent = generateAgentCheck(selectedGroups);
setNumAgentsSelected(
@ -105,14 +102,14 @@ const AgentsTableComponent: React.FC<AgentsTableProps> = ({ agentSelection, onCh
// add the number of agents added via policy and platform groups
getNumAgentsInGrouping(selectedGroups) -
// subtract the number of agents double counted by policy/platform selections
getNumOverlapped(selectedGroups, groups.overlap)
getNumOverlapped(selectedGroups, agentGroupsData?.groups?.overlap ?? {})
);
}
onChange(newAgentSelection);
setSelectedOptions(selection);
},
[groups, onChange, totalNumAgents]
[agentGroupsData, onChange]
);
useEffect(() => {
@ -154,26 +151,18 @@ const AgentsTableComponent: React.FC<AgentsTableProps> = ({ agentSelection, onCh
}, [agentSelection, onSelection, options, selectedOptions]);
useEffect(() => {
if (agentsFetched && groupsFetched) {
if (agentsFetched && groupsFetched && agentGroupsData) {
const grouper = new AgentGrouper();
// update the groups when groups or agents have changed
grouper.setTotalAgents(totalNumAgents);
grouper.updateGroup(AGENT_GROUP_KEY.Platform, groups.platforms);
grouper.updateGroup(AGENT_GROUP_KEY.Policy, groups.policies);
grouper.setTotalAgents(agentGroupsData?.totalCount);
grouper.updateGroup(AGENT_GROUP_KEY.Platform, agentGroupsData?.groups.platforms);
grouper.updateGroup(AGENT_GROUP_KEY.Policy, agentGroupsData?.groups.policies);
// @ts-expect-error update types
grouper.updateGroup(AGENT_GROUP_KEY.Agent, agents);
const newOptions = grouper.generateOptions();
setOptions(newOptions);
setOptions((prevOptions) => (!deepEqual(prevOptions, newOptions) ? newOptions : prevOptions));
}
}, [
groups.platforms,
groups.policies,
totalNumAgents,
groupsLoading,
agents,
agentsFetched,
groupsFetched,
grouper,
]);
}, [groupsLoading, agents, agentsFetched, groupsFetched, agentGroupsData]);
const renderOption = useCallback((option, searchVal, contentClassName) => {
const { label, value } = option;
@ -202,6 +191,7 @@ const AgentsTableComponent: React.FC<AgentsTableProps> = ({ agentSelection, onCh
return (
<div>
<EuiComboBox
data-test-subj="agentSelection"
placeholder={SELECT_AGENT_LABEL}
isLoading={modifyingSearch || groupsLoading || agentsLoading}
options={options}
@ -218,4 +208,6 @@ const AgentsTableComponent: React.FC<AgentsTableProps> = ({ agentSelection, onCh
);
};
AgentsTableComponent.displayName = 'AgentsTable';
export const AgentsTable = React.memo(AgentsTableComponent);

View file

@ -38,7 +38,17 @@ interface Aggs extends estypes.AggregationsTermsAggregateBase {
buckets: AggregationDataPoint[];
}
export const processAggregations = (aggs: Record<string, estypes.AggregationsAggregate>) => {
export const processAggregations = (
aggs: Record<string, estypes.AggregationsAggregate> | undefined
) => {
if (!aggs) {
return {
platforms: [],
overlap: {},
policies: [],
};
}
const platforms: Group[] = [];
const overlap: Overlap = {};
const platformTerms = aggs.platforms as Aggs;

View file

@ -4,10 +4,9 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useState } from 'react';
import { useQuery } from 'react-query';
import { i18n } from '@kbn/i18n';
import { firstValueFrom } from 'rxjs';
import { lastValueFrom } from 'rxjs';
import { useKibana } from '../common/lib/kibana';
import { useAgentPolicies } from './use_agent_policies';
@ -19,28 +18,27 @@ import {
import { processAggregations } from './helpers';
import { generateTablePaginationOptions } from '../common/helpers';
import { Overlap, Group } from './types';
import { useErrorToast } from '../common/hooks/use_error_toast';
import { useOsqueryPolicies } from './use_osquery_policies';
interface UseAgentGroups {
osqueryPolicies: string[];
osqueryPoliciesLoading: boolean;
}
export const useAgentGroups = ({ osqueryPolicies, osqueryPoliciesLoading }: UseAgentGroups) => {
export const useAgentGroups = () => {
const { data } = useKibana().services;
const setErrorToast = useErrorToast();
const { data: osqueryPolicies, isFetched: isOsqueryPoliciesFetched } = useOsqueryPolicies();
const { agentPoliciesLoading, agentPolicyById } = useAgentPolicies(osqueryPolicies);
const [platforms, setPlatforms] = useState<Group[]>([]);
const [policies, setPolicies] = useState<Group[]>([]);
const [loading, setLoading] = useState(true);
const [overlap, setOverlap] = useState<Overlap>(() => ({}));
const [totalCount, setTotalCount] = useState<number>(0);
const { isFetched } = useQuery(
return useQuery<
AgentsStrategyResponse,
unknown,
{
totalCount: number;
groups: ReturnType<typeof processAggregations>;
}
>(
['agentGroups'],
async () => {
const responseData = await firstValueFrom(
const responseData = await lastValueFrom(
data.search.search<AgentsRequestOptions, AgentsStrategyResponse>(
{
filterQuery: { terms: { policy_id: osqueryPolicies } },
@ -76,32 +74,54 @@ export const useAgentGroups = ({ osqueryPolicies, osqueryPoliciesLoading }: UseA
)
);
if (responseData.rawResponse.aggregations) {
const {
platforms: newPlatforms,
overlap: newOverlap,
policies: newPolicies,
} = processAggregations(responseData.rawResponse.aggregations);
setPlatforms(newPlatforms);
setOverlap(newOverlap);
setPolicies(
newPolicies.map((p) => {
const name = agentPolicyById[p.id]?.name ?? p.name;
return {
...p,
name,
};
})
);
}
setLoading(false);
setTotalCount(responseData.totalCount);
return responseData;
},
{
enabled: !osqueryPoliciesLoading && !agentPoliciesLoading,
select: (response) => {
const { platforms, overlap, policies } = processAggregations(
response.rawResponse.aggregations
);
return {
totalCount: response.totalCount,
groups: {
platforms,
overlap,
policies: policies.map((p) => {
const name = agentPolicyById[p.id]?.name ?? p.name;
return {
...p,
name,
};
}),
},
};
},
placeholderData: {
totalCount: 0,
edges: [],
pageInfo: {
activePage: 1,
fakeTotalCount: 100,
showMorePagesIndicator: true,
},
rawResponse: {
took: 0,
timed_out: false,
_shards: {
failed: 0,
successful: 0,
total: 0,
},
hits: {
hits: [],
},
},
},
refetchOnWindowFocus: false,
keepPreviousData: true,
enabled: isOsqueryPoliciesFetched && !agentPoliciesLoading,
onSuccess: () => setErrorToast(),
onError: (error) =>
setErrorToast(error as Error, {
@ -111,15 +131,4 @@ export const useAgentGroups = ({ osqueryPolicies, osqueryPoliciesLoading }: UseA
}),
}
);
return {
isFetched,
loading,
totalCount,
groups: {
platforms,
policies,
overlap,
},
};
};

View file

@ -11,11 +11,7 @@ import { useQuery } from 'react-query';
import { GetAgentsResponse } from '@kbn/fleet-plugin/common';
import { useErrorToast } from '../common/hooks/use_error_toast';
import { useKibana } from '../common/lib/kibana';
interface UseAllAgents {
osqueryPolicies: string[];
osqueryPoliciesLoading: boolean;
}
import { useOsqueryPolicies } from './use_osquery_policies';
interface RequestOptions {
perPage?: number;
@ -23,22 +19,24 @@ interface RequestOptions {
}
// TODO: break out the paginated vs all cases into separate hooks
export const useAllAgents = (
{ osqueryPolicies, osqueryPoliciesLoading }: UseAllAgents,
searchValue = '',
opts: RequestOptions = { perPage: 9000 }
) => {
export const useAllAgents = (searchValue = '', opts: RequestOptions = { perPage: 9000 }) => {
const { perPage } = opts;
const { http } = useKibana().services;
const setErrorToast = useErrorToast();
const { data: osqueryPolicies, isFetched } = useOsqueryPolicies();
return useQuery<GetAgentsResponse>(
['agents', osqueryPolicies, searchValue, perPage],
() => {
let kuery = `(${osqueryPolicies.map((p) => `policy_id:${p}`).join(' or ')})`;
let kuery = '';
if (searchValue) {
kuery += ` and (local_metadata.host.hostname:*${searchValue}* or local_metadata.elastic.agent.id:*${searchValue}*)`;
if (osqueryPolicies?.length) {
kuery = `(${osqueryPolicies.map((p) => `policy_id:${p}`).join(' or ')})`;
if (searchValue) {
kuery += ` and (local_metadata.host.hostname:*${searchValue}* or local_metadata.elastic.agent.id:*${searchValue}*)`;
}
}
return http.get(`/internal/osquery/fleet_wrapper/agents`, {
@ -51,7 +49,7 @@ export const useAllAgents = (
{
// @ts-expect-error update types
select: (data) => data?.agents || [],
enabled: !osqueryPoliciesLoading && osqueryPolicies.length > 0,
enabled: isFetched && !!osqueryPolicies?.length,
onSuccess: () => setErrorToast(),
onError: (error) =>
// @ts-expect-error update types

View file

@ -7,7 +7,6 @@
import { uniq } from 'lodash';
import { useQuery } from 'react-query';
import { useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { useKibana } from '../common/lib/kibana';
import { useErrorToast } from '../common/hooks/use_error_toast';
@ -16,7 +15,7 @@ export const useOsqueryPolicies = () => {
const { http } = useKibana().services;
const setErrorToast = useErrorToast();
const { isLoading: osqueryPoliciesLoading, data: osqueryPolicies = [] } = useQuery(
return useQuery(
['osqueryPolicies'],
() =>
http.get<{ items: Array<{ policy_id: string }> }>(
@ -33,9 +32,4 @@ export const useOsqueryPolicies = () => {
}),
}
);
return useMemo(
() => ({ osqueryPoliciesLoading, osqueryPolicies }),
[osqueryPoliciesLoading, osqueryPolicies]
);
};

View file

@ -8,22 +8,21 @@
import {
EuiButton,
EuiButtonEmpty,
EuiSteps,
EuiSpacer,
EuiFlexGroup,
EuiFlexItem,
EuiAccordion,
EuiAccordionProps,
} from '@elastic/eui';
import { EuiContainedStepProps } from '@elastic/eui/src/components/steps/steps';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation } from 'react-query';
import deepMerge from 'deepmerge';
import styled from 'styled-components';
import { pickBy, isEmpty } from 'lodash';
import { pickBy, isEmpty, map } from 'lodash';
import { convertECSMappingToObject } from '../../../common/schemas/common/utils';
import { UseField, Form, FormData, useForm, useFormData, FIELD_TYPES } from '../../shared_imports';
import { AgentsTableField } from './agents_table_field';
import { LiveQueryQueryField } from './live_query_query_field';
@ -33,16 +32,13 @@ import { queryFieldValidation } from '../../common/validations';
import { fieldValidators } from '../../shared_imports';
import { SavedQueryFlyout } from '../../saved_queries';
import { useErrorToast } from '../../common/hooks/use_error_toast';
import {
ECSMappingEditorField,
ECSMappingEditorFieldRef,
} from '../../packs/queries/lazy_ecs_mapping_editor_field';
import { ECSMappingEditorField } from '../../packs/queries/lazy_ecs_mapping_editor_field';
import { SavedQueriesDropdown } from '../../saved_queries/saved_queries_dropdown';
const FORM_ID = 'liveQueryForm';
const StyledEuiAccordion = styled(EuiAccordion)`
${({ isDisabled }: { isDisabled: boolean }) => isDisabled && 'display: none;'}
${({ isDisabled }: { isDisabled?: boolean }) => isDisabled && 'display: none;'}
.euiAccordion__button {
color: ${({ theme }) => theme.eui.euiColorPrimary};
}
@ -55,27 +51,26 @@ const GhostFormField = () => <></>;
type FormType = 'simple' | 'steps';
interface LiveQueryFormProps {
defaultValue?: Partial<FormData> | undefined;
defaultValue?: Partial<FormData>;
onSuccess?: () => void;
agentsField?: boolean;
queryField?: boolean;
ecsMappingField?: boolean;
formType?: FormType;
enabled?: boolean;
hideAgentsField?: boolean;
addToTimeline?: (payload: { query: [string, string]; isIcon?: true }) => React.ReactElement;
}
const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
defaultValue,
onSuccess,
agentsField = true,
queryField = true,
ecsMappingField = true,
formType = 'steps',
enabled = true,
hideAgentsField = false,
addToTimeline,
}) => {
const ecsFieldRef = useRef<ECSMappingEditorFieldRef>();
const permissions = useKibana().services.application.capabilities.osquery;
const { http } = useKibana().services;
const [advancedContentState, setAdvancedContentState] =
@ -136,7 +131,7 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
],
},
ecs_mapping: {
defaultValue: {},
defaultValue: [],
type: FIELD_TYPES.JSON,
validations: [],
},
@ -146,18 +141,9 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
id: FORM_ID,
schema: formSchema,
onSubmit: async (formData, isValid) => {
const ecsFieldValue = await ecsFieldRef?.current?.validate();
if (isValid && (!ecsMappingField || !!ecsFieldValue)) {
if (isValid) {
try {
await mutateAsync(
pickBy(
{
...formData,
...(isEmpty(ecsFieldValue) ? {} : { ecs_mapping: ecsFieldValue }),
},
(value) => !isEmpty(value)
)
);
await mutateAsync(pickBy(formData, (value) => !isEmpty(value)));
// eslint-disable-next-line no-empty
} catch (e) {}
}
@ -165,8 +151,16 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
options: {
stripEmptyFields: false,
},
serializer: ({ savedQueryId, ...formData }) =>
pickBy({ ...formData, saved_query_id: savedQueryId }, (value) => !isEmpty(value)),
// eslint-disable-next-line @typescript-eslint/naming-convention
serializer: ({ savedQueryId, ecs_mapping, ...formData }) =>
pickBy(
{
...formData,
saved_query_id: savedQueryId,
ecs_mapping: convertECSMappingToObject(ecs_mapping),
},
(value) => !isEmpty(value)
),
defaultValue: deepMerge(
{
agentSelection: {
@ -177,12 +171,13 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
},
query: '',
savedQueryId: null,
ecs_mapping: [],
},
defaultValue ?? {}
),
});
const { setFieldValue, submit, isSubmitting } = form;
const { updateFieldValues, setFieldValue, submit, isSubmitting } = form;
const actionId = useMemo(() => data?.actions[0].action_id, [data?.actions]);
const agentIds = useMemo(() => data?.actions[0].agents, [data?.actions]);
@ -207,13 +202,12 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
const queryValueProvided = useMemo(() => !!query?.length, [query]);
const queryStatus = useMemo(() => {
if (!agentSelected) return 'disabled';
if (isError || !form.getFields().query.isValid) return 'danger';
if (isError || !form.getFields().query?.isValid) return 'danger';
if (isLoading) return 'loading';
if (isSuccess) return 'complete';
return 'incomplete';
}, [agentSelected, isError, isLoading, isSuccess, form]);
}, [isError, isLoading, isSuccess, form]);
const resultsStatus = useMemo(
() => (queryStatus === 'complete' ? 'incomplete' : 'disabled'),
@ -223,19 +217,28 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
const handleSavedQueryChange = useCallback(
(savedQuery) => {
if (savedQuery) {
setFieldValue('query', savedQuery.query);
setFieldValue('savedQueryId', savedQuery.savedQueryId);
updateFieldValues({
query: savedQuery.query,
savedQueryId: savedQuery.savedQueryId,
ecs_mapping: savedQuery.ecs_mapping
? map(savedQuery.ecs_mapping, (value, key) => ({
key,
result: {
type: Object.keys(value)[0],
value: Object.values(value)[0],
},
}))
: [],
});
if (!isEmpty(savedQuery.ecs_mapping)) {
setFieldValue('ecs_mapping', savedQuery.ecs_mapping);
setAdvancedContentState('open');
} else {
setFieldValue('ecs_mapping', {});
}
} else {
setFieldValue('savedQueryId', null);
}
},
[setFieldValue]
[setFieldValue, updateFieldValues]
);
const commands = useMemo(
@ -251,10 +254,9 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
const queryComponentProps = useMemo(
() => ({
disabled: queryStatus === 'disabled',
commands,
}),
[queryStatus, commands]
[commands]
);
const flyoutFormDefaultValue = useMemo(
@ -275,9 +277,8 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
);
const isSavedQueryDisabled = useMemo(
() =>
queryComponentProps.disabled || !permissions.runSavedQueries || !permissions.readSavedQueries,
[permissions.readSavedQueries, permissions.runSavedQueries, queryComponentProps.disabled]
() => !permissions.runSavedQueries || !permissions.readSavedQueries,
[permissions.readSavedQueries, permissions.runSavedQueries]
);
const queryFieldStepContent = useMemo(
@ -314,16 +315,9 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
forceState={advancedContentState}
onToggle={handleToggle}
buttonContent="Advanced"
isDisabled={queryComponentProps.disabled}
>
<EuiSpacer size="xs" />
<UseField
path="ecs_mapping"
component={ECSMappingEditorField}
query={query}
fieldRef={ecsFieldRef}
euiFieldProps={ecsFieldProps}
/>
<ECSMappingEditorField euiFieldProps={ecsFieldProps} />
</StyledEuiAccordion>
</>
) : (
@ -372,7 +366,6 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
ecsMappingField,
advancedContentState,
handleToggle,
query,
ecsFieldProps,
formType,
agentSelected,
@ -399,70 +392,38 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
[actionId, agentIds, data?.actions, addToTimeline]
);
const formSteps: EuiContainedStepProps[] = useMemo(
() => [
{
title: i18n.translate('xpack.osquery.liveQueryForm.steps.agentsStepHeading', {
defaultMessage: 'Select agents',
}),
children: <UseField path="agentSelection" component={AgentsTableField} />,
status: agentSelected ? 'complete' : 'incomplete',
},
{
title: i18n.translate('xpack.osquery.liveQueryForm.steps.queryStepHeading', {
defaultMessage: 'Enter query',
}),
children: queryFieldStepContent,
status: queryStatus,
},
{
title: i18n.translate('xpack.osquery.liveQueryForm.steps.resultsStepHeading', {
defaultMessage: 'Check results',
}),
children: resultsStepContent,
status: resultsStatus,
},
],
[agentSelected, queryFieldStepContent, queryStatus, resultsStepContent, resultsStatus]
);
const simpleForm = useMemo(
() => (
<EuiFlexGroup direction="column">
<UseField
path="agentSelection"
component={agentsField ? AgentsTableField : GhostFormField}
/>
<EuiFlexItem>{queryFieldStepContent}</EuiFlexItem>
<EuiFlexItem>{resultsStepContent}</EuiFlexItem>
</EuiFlexGroup>
),
[agentsField, queryFieldStepContent, resultsStepContent]
);
useEffect(() => {
if (defaultValue?.agentSelection) {
setFieldValue('agentSelection', defaultValue?.agentSelection);
if (defaultValue) {
updateFieldValues({
agentSelection: defaultValue.agentSelection,
query: defaultValue.query,
savedQueryId: defaultValue.savedQueryId,
ecs_mapping: defaultValue.ecs_mapping
? map(defaultValue.ecs_mapping, (value, key) => ({
key,
result: {
type: Object.keys(value)[0],
value: Object.values(value)[0],
},
}))
: undefined,
});
}
if (defaultValue?.query) {
setFieldValue('query', defaultValue?.query);
}
// TODO: Set query and ECS mapping from savedQueryId object
if (defaultValue?.savedQueryId) {
setFieldValue('savedQueryId', defaultValue?.savedQueryId);
}
if (!isEmpty(defaultValue?.ecs_mapping)) {
setFieldValue('ecs_mapping', defaultValue?.ecs_mapping);
}
}, [defaultValue, setFieldValue]);
}, [defaultValue, updateFieldValues]);
return (
<>
<Form form={form}>
{formType === 'steps' ? <EuiSteps steps={formSteps} /> : simpleForm}
<EuiFlexGroup direction="column">
<EuiFlexItem>
<UseField
path="agentSelection"
component={!hideAgentsField ? AgentsTableField : GhostFormField}
/>
</EuiFlexItem>
<EuiFlexItem>{queryFieldStepContent}</EuiFlexItem>
<EuiFlexItem>{resultsStepContent}</EuiFlexItem>
</EuiFlexGroup>
<UseField path="savedQueryId" component={GhostFormField} />
</Form>
{showSavedQueryFlyout ? (

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 { FIELD_TYPES, FormSchema } from '../../shared_imports';
export const formSchema: FormSchema = {
agents: {
type: FIELD_TYPES.MULTI_SELECT,
},
query: {
type: FIELD_TYPES.TEXTAREA,
validations: [],
},
};

View file

@ -28,6 +28,7 @@ interface LiveQueryProps {
ecsMappingField?: boolean;
enabled?: boolean;
formType?: 'steps' | 'simple';
hideAgentsField?: boolean;
addToTimeline?: (payload: { query: [string, string]; isIcon?: true }) => React.ReactElement;
}
@ -40,11 +41,11 @@ const LiveQueryComponent: React.FC<LiveQueryProps> = ({
savedQueryId,
// eslint-disable-next-line @typescript-eslint/naming-convention
ecs_mapping,
agentsField,
queryField,
ecsMappingField,
formType,
enabled,
hideAgentsField,
addToTimeline,
}) => {
const { data: hasActionResultsPrivileges, isLoading } = useActionResultsPrivileges();
@ -108,13 +109,13 @@ const LiveQueryComponent: React.FC<LiveQueryProps> = ({
return (
<LiveQueryForm
agentsField={agentId ? !agentId : agentsField}
queryField={queryField}
ecsMappingField={ecsMappingField}
defaultValue={defaultValue}
onSuccess={onSuccess}
formType={formType}
enabled={enabled}
hideAgentsField={hideAgentsField}
addToTimeline={addToTimeline}
/>
);

View file

@ -6,14 +6,11 @@
*/
import React, { lazy, Suspense } from 'react';
import type {
ECSMappingEditorFieldProps,
ECSMappingEditorFieldRef,
} from './ecs_mapping_editor_field';
import type { ECSMappingEditorFieldProps } from './ecs_mapping_editor_field';
const LazyECSMappingEditorField = lazy(() => import('./ecs_mapping_editor_field'));
export type { ECSMappingEditorFieldProps, ECSMappingEditorFieldRef };
export type { ECSMappingEditorFieldProps };
export const ECSMappingEditorField = (props: ECSMappingEditorFieldProps) => (
<Suspense fallback={null}>
<LazyECSMappingEditorField {...props} />

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { isEmpty } from 'lodash';
import { map } from 'lodash';
import {
EuiFlyout,
EuiTitle,
@ -19,17 +19,17 @@ import {
EuiButton,
EuiText,
} from '@elastic/eui';
import React, { useCallback, useMemo, useState, useRef } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { CodeEditorField } from '../../saved_queries/form/code_editor_field';
import { Form, getUseField, Field, useFormData } from '../../shared_imports';
import { Form, getUseField, Field } from '../../shared_imports';
import { PlatformCheckBoxGroupField } from './platform_checkbox_group_field';
import { ALL_OSQUERY_VERSIONS_OPTIONS } from './constants';
import { UsePackQueryFormProps, PackFormData, usePackQueryForm } from './use_pack_query_form';
import { SavedQueriesDropdown } from '../../saved_queries/saved_queries_dropdown';
import { ECSMappingEditorField, ECSMappingEditorFieldRef } from './lazy_ecs_mapping_editor_field';
import { ECSMappingEditorField } from './lazy_ecs_mapping_editor_field';
const CommonUseField = getUseField({ component: Field });
@ -46,70 +46,46 @@ const QueryFlyoutComponent: React.FC<QueryFlyoutProps> = ({
onSave,
onClose,
}) => {
const ecsFieldRef = useRef<ECSMappingEditorFieldRef>();
const [isEditMode] = useState(!!defaultValue);
const { form } = usePackQueryForm({
uniqueQueryIds,
defaultValue,
handleSubmit: async (payload, isValid) => {
const ecsFieldValue = await ecsFieldRef?.current?.validate();
const isEcsFieldValueValid =
ecsFieldValue &&
Object.values(ecsFieldValue).every((field) => !isEmpty(Object.values(field)[0]));
return new Promise((resolve) => {
if (isValid && isEcsFieldValueValid) {
onSave({
...payload,
...(isEmpty(ecsFieldValue) ? {} : { ecs_mapping: ecsFieldValue }),
});
handleSubmit: async (payload, isValid) =>
new Promise((resolve) => {
if (isValid) {
onSave(payload);
onClose();
}
resolve();
});
},
}),
});
const { submit, setFieldValue, reset, isSubmitting, validate } = form;
const [{ query }] = useFormData({
form,
watch: ['query'],
});
const { submit, isSubmitting, updateFieldValues } = form;
const handleSetQueryValue = useCallback(
(savedQuery) => {
reset();
if (savedQuery) {
setFieldValue('id', savedQuery.id);
setFieldValue('query', savedQuery.query);
if (savedQuery.description) {
setFieldValue('description', savedQuery.description);
}
if (savedQuery.interval) {
setFieldValue('interval', savedQuery.interval);
}
if (savedQuery.platform) {
setFieldValue('platform', savedQuery.platform);
}
if (savedQuery.version) {
setFieldValue('version', [savedQuery.version]);
}
if (savedQuery.ecs_mapping) {
setFieldValue('ecs_mapping', savedQuery.ecs_mapping);
}
updateFieldValues({
id: savedQuery.id,
query: savedQuery.query,
description: savedQuery.description,
platform: savedQuery.platform,
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],
},
})) ?? [],
});
}
validate();
},
[reset, validate, setFieldValue]
[updateFieldValues]
);
/* Avoids accidental closing of the flyout when the user clicks outside of the flyout */
const maskProps = useMemo(() => ({ onClick: () => ({}) }), []);
@ -190,12 +166,7 @@ const QueryFlyoutComponent: React.FC<QueryFlyoutProps> = ({
<EuiSpacer />
<EuiFlexGroup>
<EuiFlexItem>
<CommonUseField
path="ecs_mapping"
component={ECSMappingEditorField}
query={query}
fieldRef={ecsFieldRef}
/>
<ECSMappingEditorField />
</EuiFlexItem>
</EuiFlexGroup>
</Form>

View file

@ -71,7 +71,7 @@ export const createFormSchema = (ids: Set<string>) => ({
validations: [],
},
ecs_mapping: {
defaultValue: {},
defaultValue: [],
type: FIELD_TYPES.JSON,
validations: [],
},

View file

@ -5,11 +5,12 @@
* 2.0.
*/
import { isArray, isEmpty, xor } from 'lodash';
import { isArray, isEmpty, xor, map } from 'lodash';
import uuid from 'uuid';
import { produce } from 'immer';
import { useMemo } from 'react';
import { convertECSMappingToObject } from '../../../common/schemas/common/utils';
import { FormConfig, useForm } from '../../shared_imports';
import { createFormSchema } from './schema';
@ -37,11 +38,14 @@ export interface PackFormData {
platform?: string | undefined;
version?: string | undefined;
ecs_mapping?:
| Record<
string,
{
field: string;
}
| Array<
Record<
string,
{
field?: string;
value?: string;
}
>
>
| undefined;
}
@ -76,7 +80,7 @@ export const usePackQueryForm = ({
id: '',
query: '',
interval: 3600,
ecs_mapping: {},
ecs_mapping: [],
},
// @ts-expect-error update types
serializer: (payload) =>
@ -100,6 +104,9 @@ export const usePackQueryForm = ({
if (isEmpty(draft.ecs_mapping)) {
delete draft.ecs_mapping;
} else {
// @ts-expect-error update types
draft.ecs_mapping = convertECSMappingToObject(payload.ecs_mapping);
}
return draft;
@ -114,7 +121,17 @@ export const usePackQueryForm = ({
interval: payload.interval,
platform: payload.platform,
version: payload.version ? [payload.version] : [],
ecs_mapping: payload.ecs_mapping ?? {},
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

View file

@ -109,11 +109,7 @@ const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({
]);
const [columns, setColumns] = useState<EuiDataGridColumn[]>([]);
const {
data: allResultsData,
isFetched,
isLoading,
} = useAllResults({
const { data: allResultsData, isLoading } = useAllResults({
actionId,
activePage: pagination.pageIndex,
limit: pagination.pageSize,
@ -361,20 +357,44 @@ const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({
setIsLive(() => {
if (!agentIds?.length || expired) return false;
return !!(aggregations.totalResponded !== agentIds?.length);
return !!(
aggregations.totalResponded !== agentIds?.length ||
allResultsData?.totalCount !== aggregations?.totalRowCount ||
(allResultsData?.totalCount && !allResultsData?.edges.length)
);
}),
[agentIds?.length, aggregations.failed, aggregations.totalResponded, expired]
[
agentIds?.length,
aggregations.totalResponded,
aggregations?.totalRowCount,
allResultsData?.edges.length,
allResultsData?.totalCount,
expired,
]
);
if (!hasActionResultsPrivileges) {
return (
<EuiCallOut title="Missing privileges" color="danger" iconType="alert">
<EuiCallOut
title={
<FormattedMessage
id="xpack.osquery.liveQuery.permissionDeniedPromptTitle"
defaultMessage="Permission denied"
/>
}
color="danger"
iconType="alert"
>
<p>
{'Your user role doesnt have index read permissions on the '}
<EuiCode>logs-{OSQUERY_INTEGRATION_NAME}.result*</EuiCode>
{
'index. Access to this index is required to view osquery results. Administrators can update role permissions in Stack Management > Roles.'
}
<FormattedMessage
id="xpack.osquery.liveQuery.permissionDeniedPromptBody"
defaultMessage="To view query results, ask your administrator to update your user role to have index {read} privileges on the {logs} index."
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
values={{
read: <EuiCode>read</EuiCode>,
logs: <EuiCode>logs-{OSQUERY_INTEGRATION_NAME}.result*</EuiCode>,
}}
/>
</p>
</EuiCallOut>
);
@ -388,13 +408,12 @@ const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({
<>
{isLive && <EuiProgress color="primary" size="xs" />}
{isFetched && !allResultsData?.edges.length && !aggregations?.totalRowCount ? (
{!allResultsData?.edges.length ? (
<>
<EuiCallOut title={generateEmptyDataMessage(aggregations.totalResponded)} />
<EuiSpacer />
</>
) : (
// @ts-expect-error update types
<DataContext.Provider value={allResultsData?.edges}>
<EuiDataGrid
data-test-subj="osqueryResultsTable"

View file

@ -8,7 +8,7 @@
import { useQuery } from 'react-query';
import { i18n } from '@kbn/i18n';
import { firstValueFrom } from 'rxjs';
import { lastValueFrom } from 'rxjs';
import {
createFilter,
generateTablePaginationOptions,
@ -62,7 +62,7 @@ export const useAllResults = ({
return useQuery(
['allActionResults', { actionId, activePage, limit, sort }],
async () => {
const responseData = await firstValueFrom(
const responseData = await lastValueFrom(
data.search.search<ResultsRequestOptions, ResultsStrategyResponse>(
{
actionId,

View file

@ -13,12 +13,12 @@ import {
EuiFlexItem,
EuiSpacer,
} from '@elastic/eui';
import React, { useRef } from 'react';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { useRouterNavigate } from '../../../common/lib/kibana';
import { Form } from '../../../shared_imports';
import { SavedQueryForm, SavedQueryFormRefObject } from '../../../saved_queries/form';
import { SavedQueryForm } from '../../../saved_queries/form';
import { useSavedQueryForm } from '../../../saved_queries/form/use_saved_query_form';
interface EditSavedQueryFormProps {
@ -32,19 +32,17 @@ const EditSavedQueryFormComponent: React.FC<EditSavedQueryFormProps> = ({
handleSubmit,
viewMode,
}) => {
const savedQueryFormRef = useRef<SavedQueryFormRefObject>(null);
const savedQueryListProps = useRouterNavigate('saved_queries');
const { form } = useSavedQueryForm({
defaultValue,
savedQueryFormRef,
handleSubmit,
});
const { submit, isSubmitting } = form;
return (
<Form form={form}>
<SavedQueryForm ref={savedQueryFormRef} viewMode={viewMode} hasPlayground />
<SavedQueryForm viewMode={viewMode} hasPlayground />
{!viewMode && (
<>
<EuiBottomBar>

View file

@ -13,12 +13,12 @@ import {
EuiFlexItem,
EuiSpacer,
} from '@elastic/eui';
import React, { useRef } from 'react';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { useRouterNavigate } from '../../../common/lib/kibana';
import { Form } from '../../../shared_imports';
import { SavedQueryForm, SavedQueryFormRefObject } from '../../../saved_queries/form';
import { SavedQueryForm } from '../../../saved_queries/form';
import { useSavedQueryForm } from '../../../saved_queries/form/use_saved_query_form';
interface NewSavedQueryFormProps {
@ -30,19 +30,17 @@ const NewSavedQueryFormComponent: React.FC<NewSavedQueryFormProps> = ({
defaultValue,
handleSubmit,
}) => {
const savedQueryFormRef = useRef<SavedQueryFormRefObject>(null);
const savedQueryListProps = useRouterNavigate('saved_queries');
const { form } = useSavedQueryForm({
defaultValue,
savedQueryFormRef,
handleSubmit,
});
const { submit, isSubmitting, isValid } = form;
return (
<Form form={form}>
<SavedQueryForm ref={savedQueryFormRef} hasPlayground isValid={isValid} />
<SavedQueryForm hasPlayground isValid={isValid} />
<EuiBottomBar>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>

View file

@ -13,25 +13,15 @@ import {
EuiText,
EuiButtonEmpty,
} from '@elastic/eui';
import React, {
useCallback,
useMemo,
useRef,
forwardRef,
useImperativeHandle,
useState,
} from 'react';
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 { PlatformCheckBoxGroupField } from '../../packs/queries/platform_checkbox_group_field';
import { Field, getUseField, UseField, useFormData } from '../../shared_imports';
import { Field, getUseField, UseField } from '../../shared_imports';
import { CodeEditorField } from './code_editor_field';
import {
ECSMappingEditorField,
ECSMappingEditorFieldRef,
} from '../../packs/queries/lazy_ecs_mapping_editor_field';
import { ECSMappingEditorField } from '../../packs/queries/lazy_ecs_mapping_editor_field';
import { PlaygroundFlyout } from './playground_flyout';
export const CommonUseField = getUseField({ component: Field });
@ -41,131 +31,112 @@ interface SavedQueryFormProps {
hasPlayground?: boolean;
isValid?: boolean;
}
export interface SavedQueryFormRefObject {
validateEcsMapping: ECSMappingEditorFieldRef['validate'];
}
const SavedQueryFormComponent = forwardRef<SavedQueryFormRefObject, SavedQueryFormProps>(
({ viewMode, hasPlayground, isValid }, ref) => {
const [playgroundVisible, setPlaygroundVisible] = useState(false);
const ecsFieldRef = useRef<ECSMappingEditorFieldRef>();
const SavedQueryFormComponent: React.FC<SavedQueryFormProps> = ({
viewMode,
hasPlayground,
isValid,
}) => {
const [playgroundVisible, setPlaygroundVisible] = useState(false);
const euiFieldProps = useMemo(
() => ({
isDisabled: !!viewMode,
const euiFieldProps = useMemo(
() => ({
isDisabled: !!viewMode,
}),
[viewMode]
);
const handleHidePlayground = useCallback(() => setPlaygroundVisible(false), []);
const handleTogglePlayground = useCallback(
() => setPlaygroundVisible((prevValue) => !prevValue),
[]
);
const intervalEuiFieldProps = useMemo(
() => ({
append: 's',
...euiFieldProps,
}),
[euiFieldProps]
);
const versionEuiFieldProps = useMemo(
() => ({
noSuggestions: false,
singleSelection: { asPlainText: true },
placeholder: i18n.translate('xpack.osquery.pack.queriesTable.osqueryVersionAllLabel', {
defaultMessage: 'ALL',
}),
[viewMode]
);
options: ALL_OSQUERY_VERSIONS_OPTIONS,
onCreateOption: undefined,
...euiFieldProps,
}),
[euiFieldProps]
);
const [{ query }] = useFormData({ watch: ['query'] });
const handleHidePlayground = useCallback(() => setPlaygroundVisible(false), []);
const handleTogglePlayground = useCallback(
() => setPlaygroundVisible((prevValue) => !prevValue),
[]
);
useImperativeHandle(
ref,
() => ({
validateEcsMapping: () => {
if (ecsFieldRef.current) {
return ecsFieldRef.current.validate();
}
return Promise.resolve(false);
},
}),
[]
);
return (
<>
<CommonUseField path="id" euiFieldProps={euiFieldProps} />
<EuiSpacer />
<CommonUseField path="description" euiFieldProps={euiFieldProps} />
<EuiSpacer />
<UseField path="query" component={CodeEditorField} euiFieldProps={euiFieldProps} />
<EuiSpacer size="xl" />
return (
<>
<CommonUseField path="id" euiFieldProps={euiFieldProps} />
<EuiSpacer />
<CommonUseField path="description" euiFieldProps={euiFieldProps} />
<EuiSpacer />
<UseField path="query" component={CodeEditorField} euiFieldProps={euiFieldProps} />
<EuiSpacer size="xl" />
<EuiFlexGroup>
<EuiFlexItem>
<ECSMappingEditorField />
</EuiFlexItem>
</EuiFlexGroup>
{!viewMode && hasPlayground && (
<EuiFlexGroup>
<EuiFlexItem>
<CommonUseField
path="ecs_mapping"
component={ECSMappingEditorField}
query={query}
fieldRef={ecsFieldRef}
/>
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="play" onClick={handleTogglePlayground}>
Test configuration
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
{!viewMode && hasPlayground && (
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="play" onClick={handleTogglePlayground}>
Test configuration
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
)}
<EuiSpacer size="xl" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.osquery.savedQueries.form.packConfigSection.title"
defaultMessage="Pack configuration"
/>
</h5>
</EuiTitle>
<EuiText color="subdued">
)}
<EuiSpacer size="xl" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.osquery.savedQueries.form.packConfigSection.description"
defaultMessage="The options listed below are optional and are only applied when the query is assigned to a pack."
id="xpack.osquery.savedQueries.form.packConfigSection.title"
defaultMessage="Pack configuration"
/>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<EuiFlexGroup>
<EuiFlexItem>
<CommonUseField
path="interval"
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
euiFieldProps={{ append: 's', ...euiFieldProps }}
</h5>
</EuiTitle>
<EuiText color="subdued">
<FormattedMessage
id="xpack.osquery.savedQueries.form.packConfigSection.description"
defaultMessage="The options listed below are optional and are only applied when the query is assigned to a pack."
/>
<EuiSpacer size="m" />
<CommonUseField
path="version"
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
euiFieldProps={{
noSuggestions: false,
singleSelection: { asPlainText: true },
placeholder: i18n.translate(
'xpack.osquery.pack.queriesTable.osqueryVersionAllLabel',
{
defaultMessage: 'ALL',
}
),
options: ALL_OSQUERY_VERSIONS_OPTIONS,
onCreateOption: undefined,
...euiFieldProps,
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<CommonUseField path="platform" component={PlatformCheckBoxGroupField} />
</EuiFlexItem>
</EuiFlexGroup>
{playgroundVisible && (
<PlaygroundFlyout
enabled={isValid !== undefined ? isValid : true}
onClose={handleHidePlayground}
/>
)}
</>
);
}
);
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<EuiFlexGroup>
<EuiFlexItem>
<CommonUseField path="interval" euiFieldProps={intervalEuiFieldProps} />
<EuiSpacer size="m" />
<CommonUseField path="version" euiFieldProps={versionEuiFieldProps} />
</EuiFlexItem>
<EuiFlexItem>
<CommonUseField path="platform" component={PlatformCheckBoxGroupField} />
</EuiFlexItem>
</EuiFlexGroup>
{playgroundVisible && (
<PlaygroundFlyout
enabled={isValid !== undefined ? isValid : true}
onClose={handleHidePlayground}
/>
)}
</>
);
};
SavedQueryFormComponent.displayName = 'SavedQueryForm';
export const SavedQueryForm = React.memo(SavedQueryFormComponent);

View file

@ -6,7 +6,7 @@
*/
import { EuiFlyout, EuiFlyoutHeader, EuiTitle, EuiFlyoutBody } from '@elastic/eui';
import React from 'react';
import React, { useMemo } from 'react';
import styled from 'styled-components';
import { FormattedMessage } from '@kbn/i18n-react';
@ -26,11 +26,14 @@ interface PlaygroundFlyoutProps {
}
const PlaygroundFlyoutComponent: React.FC<PlaygroundFlyoutProps> = ({ enabled, onClose }) => {
// eslint-disable-next-line @typescript-eslint/naming-convention
const [{ query, ecs_mapping, id }] = useFormData({
const [{ query, ecs_mapping: ecsMapping, id }, formDataSerializer] = useFormData({
watch: ['query', 'ecs_mapping', 'savedQueryId'],
});
/* recalculate the form data when ecs_mapping changes */
// eslint-disable-next-line react-hooks/exhaustive-deps
const serializedFormData = useMemo(() => formDataSerializer(), [ecsMapping, formDataSerializer]);
return (
<EuiFlyout type="push" size="m" onClose={onClose}>
<StyledEuiFlyoutHeader hasBorder>
@ -48,7 +51,7 @@ const PlaygroundFlyoutComponent: React.FC<PlaygroundFlyoutProps> = ({ enabled, o
enabled={enabled && query !== ''}
formType="simple"
query={query}
ecs_mapping={ecs_mapping}
ecs_mapping={serializedFormData.ecs_mapping}
savedQueryId={id}
queryField={false}
ecsMappingField={false}

View file

@ -8,27 +8,22 @@
import { isArray, isEmpty, map } from 'lodash';
import uuid from 'uuid';
import { produce } from 'immer';
import { RefObject, useMemo } from 'react';
import { useMemo } from 'react';
import { convertECSMappingToObject } from '../../../common/schemas/common/utils';
import { useForm } from '../../shared_imports';
import { createFormSchema } from '../../packs/queries/schema';
import { PackFormData } from '../../packs/queries/use_pack_query_form';
import { useSavedQueries } from '../use_saved_queries';
import { SavedQueryFormRefObject } from '.';
const SAVED_QUERY_FORM_ID = 'savedQueryForm';
interface UseSavedQueryFormProps {
defaultValue?: unknown;
handleSubmit: (payload: unknown) => Promise<void>;
savedQueryFormRef: RefObject<SavedQueryFormRefObject>;
}
export const useSavedQueryForm = ({
defaultValue,
handleSubmit,
savedQueryFormRef,
}: UseSavedQueryFormProps) => {
export const useSavedQueryForm = ({ defaultValue, handleSubmit }: UseSavedQueryFormProps) => {
const { data } = useSavedQueries({});
const ids: string[] = useMemo<string[]>(
() => map(data?.saved_objects, 'attributes.id') ?? [],
@ -50,14 +45,9 @@ export const useSavedQueryForm = ({
id: SAVED_QUERY_FORM_ID + uuid.v4(),
schema: formSchema,
onSubmit: async (formData, isValid) => {
const ecsFieldValue = await savedQueryFormRef?.current?.validateEcsMapping();
if (isValid && !!ecsFieldValue) {
if (isValid) {
try {
await handleSubmit({
...formData,
ecs_mapping: ecsFieldValue,
});
await handleSubmit(formData);
// eslint-disable-next-line no-empty
} catch (e) {}
}
@ -82,9 +72,12 @@ export const useSavedQueryForm = ({
}
}
if (isEmpty(draft.ecs_mapping)) {
if (isEmpty(payload.ecs_mapping)) {
// @ts-expect-error update types
delete draft.ecs_mapping;
} else {
// @ts-expect-error update types
draft.ecs_mapping = convertECSMappingToObject(payload.ecs_mapping);
}
// @ts-expect-error update types
@ -103,7 +96,16 @@ export const useSavedQueryForm = ({
interval: payload.interval ?? 3600,
platform: payload.platform,
version: payload.version ? [payload.version] : [],
ecs_mapping: payload.ecs_mapping ?? {},
ecs_mapping:
(!isEmpty(payload.ecs_mapping) &&
map(payload.ecs_mapping, (value, key) => ({
key,
result: {
type: Object.keys(value)[0],
value: Object.values(value)[0],
},
}))) ??
[],
};
},
});

View file

@ -12,7 +12,6 @@ import { SimpleSavedObject } from '@kbn/core/public';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import styled from 'styled-components';
import deepEqual from 'fast-deep-equal';
import { useSavedQueries } from './use_saved_queries';
import { useFormData } from '../shared_imports';
@ -47,10 +46,7 @@ const SavedQueriesDropdownComponent: React.FC<SavedQueriesDropdownProps> = ({
}) => {
const [selectedOptions, setSelectedOptions] = useState([]);
// eslint-disable-next-line @typescript-eslint/naming-convention
const [{ query, ecs_mapping, savedQueryId }] = useFormData({
watch: ['ecs_mapping', 'query', 'savedQueryId'],
});
const [{ savedQueryId }] = useFormData();
const { data } = useSavedQueries({});
@ -122,15 +118,11 @@ const SavedQueriesDropdownComponent: React.FC<SavedQueriesDropdownProps> = ({
if (
selectedOptions.length &&
// @ts-expect-error update types
(selectedOptions[0].value.savedQueryId !== savedQueryId ||
// @ts-expect-error update types
selectedOptions[0].value.query !== query ||
// @ts-expect-error update types
!deepEqual(selectedOptions[0].value.ecs_mapping, ecs_mapping))
selectedOptions[0].value.savedQueryId !== savedQueryId
) {
setSelectedOptions([]);
}
}, [ecs_mapping, query, savedQueryId, selectedOptions]);
}, [savedQueryId, selectedOptions]);
return (
<EuiFormRow

View file

@ -17,12 +17,12 @@ import {
EuiButtonEmpty,
EuiButton,
} from '@elastic/eui';
import React, { useCallback, useRef } from 'react';
import React, { useCallback } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { Form } from '../shared_imports';
import { useSavedQueryForm } from './form/use_saved_query_form';
import { SavedQueryForm, SavedQueryFormRefObject } from './form';
import { SavedQueryForm } from './form';
import { useCreateSavedQuery } from './use_create_saved_query';
interface AddQueryFlyoutProps {
@ -38,7 +38,6 @@ const SavedQueryFlyoutComponent: React.FC<AddQueryFlyoutProps> = ({
onClose,
isExternal,
}) => {
const savedQueryFormRef = useRef<SavedQueryFormRefObject>(null);
const createSavedQueryMutation = useCreateSavedQuery({ withRedirect: false });
const handleSubmit = useCallback(
@ -48,7 +47,6 @@ const SavedQueryFlyoutComponent: React.FC<AddQueryFlyoutProps> = ({
const { form } = useSavedQueryForm({
defaultValue,
savedQueryFormRef,
handleSubmit,
});
const { submit, isSubmitting } = form;
@ -74,7 +72,7 @@ const SavedQueryFlyoutComponent: React.FC<AddQueryFlyoutProps> = ({
</EuiFlyoutHeader>
<EuiFlyoutBody>
<Form form={form}>
<SavedQueryForm ref={savedQueryFormRef} />
<SavedQueryForm />
</Form>
</EuiFlyoutBody>
<EuiFlyoutFooter>

View file

@ -8,6 +8,7 @@
import { EuiErrorBoundary, EuiLoadingContent, EuiEmptyPrompt, EuiCode } from '@elastic/eui';
import React, { useMemo } from 'react';
import { QueryClientProvider } from 'react-query';
import { CoreStart } from '@kbn/core/public';
import {
AGENT_STATUS_ERROR,
EMPTY_PROMPT,
@ -22,16 +23,19 @@ import { queryClient } from '../../query_client';
import { OsqueryIcon } from '../../components/osquery_icon';
import { KibanaThemeProvider } from '../../shared_imports';
import { useIsOsqueryAvailable } from './use_is_osquery_available';
import { StartPlugins } from '../../types';
interface OsqueryActionProps {
agentId?: string;
formType: 'steps' | 'simple';
hideAgentsField?: boolean;
addToTimeline?: (payload: { query: [string, string]; isIcon?: true }) => React.ReactElement;
}
const OsqueryActionComponent: React.FC<OsqueryActionProps> = ({
agentId,
formType = 'simple',
hideAgentsField,
addToTimeline,
}) => {
const permissions = useKibana().services.application.capabilities.osquery;
@ -103,18 +107,37 @@ const OsqueryActionComponent: React.FC<OsqueryActionProps> = ({
);
}
return <LiveQuery formType={formType} agentId={agentId} addToTimeline={addToTimeline} />;
return (
<LiveQuery
formType={formType}
agentId={agentId}
hideAgentsField={hideAgentsField}
addToTimeline={addToTimeline}
/>
);
};
export const OsqueryAction = React.memo(OsqueryActionComponent);
// @ts-expect-error update types
const OsqueryActionWrapperComponent = ({ services, agentId, formType, addToTimeline }) => (
type OsqueryActionWrapperProps = { services: CoreStart & StartPlugins } & OsqueryActionProps;
const OsqueryActionWrapperComponent: React.FC<OsqueryActionWrapperProps> = ({
services,
agentId,
formType,
hideAgentsField = false,
addToTimeline,
}) => (
<KibanaThemeProvider theme$={services.theme.theme$}>
<KibanaContextProvider services={services}>
<EuiErrorBoundary>
<QueryClientProvider client={queryClient}>
<OsqueryAction agentId={agentId} formType={formType} addToTimeline={addToTimeline} />
<OsqueryAction
agentId={agentId}
formType={formType}
hideAgentsField={hideAgentsField}
addToTimeline={addToTimeline}
/>
</QueryClientProvider>
</EuiErrorBoundary>
</KibanaContextProvider>

View file

@ -23,6 +23,8 @@ export {
Form,
FormDataProvider,
UseArray,
ArrayItem,
FormArrayField,
UseField,
UseMultiFields,
useForm,

View file

@ -21965,9 +21965,6 @@
"xpack.osquery.liveQueryDetails.viewLiveQueriesHistoryTitle": "Afficher l'historique des recherches en direct",
"xpack.osquery.liveQueryForm.form.saveForLaterButtonLabel": "Enregistrer pour plus tard",
"xpack.osquery.liveQueryForm.form.submitButtonLabel": "Envoyer",
"xpack.osquery.liveQueryForm.steps.agentsStepHeading": "Sélectionner les agents",
"xpack.osquery.liveQueryForm.steps.queryStepHeading": "Entrer la recherche",
"xpack.osquery.liveQueryForm.steps.resultsStepHeading": "Vérifier les résultats",
"xpack.osquery.liveQueryResults.table.agentColumnTitle": "agent",
"xpack.osquery.liveQueryResults.table.fieldMappedLabel": "Le champ est mappé à",
"xpack.osquery.newLiveQuery.pageTitle": "Nouvelle recherche en direct",

View file

@ -22118,9 +22118,6 @@
"xpack.osquery.liveQueryDetails.viewLiveQueriesHistoryTitle": "ライブクエリ履歴を表示",
"xpack.osquery.liveQueryForm.form.saveForLaterButtonLabel": "後で使用するために保存する",
"xpack.osquery.liveQueryForm.form.submitButtonLabel": "送信",
"xpack.osquery.liveQueryForm.steps.agentsStepHeading": "エージェントを選択",
"xpack.osquery.liveQueryForm.steps.queryStepHeading": "クエリを入力",
"xpack.osquery.liveQueryForm.steps.resultsStepHeading": "結果を確認",
"xpack.osquery.liveQueryResults.table.agentColumnTitle": "エージェント",
"xpack.osquery.liveQueryResults.table.fieldMappedLabel": "フィールドは以下にマッピングされています",
"xpack.osquery.newLiveQuery.pageTitle": "新しいライブクエリ",

View file

@ -22149,9 +22149,6 @@
"xpack.osquery.liveQueryDetails.viewLiveQueriesHistoryTitle": "查看实时查询历史记录",
"xpack.osquery.liveQueryForm.form.saveForLaterButtonLabel": "保存,以后继续",
"xpack.osquery.liveQueryForm.form.submitButtonLabel": "提交",
"xpack.osquery.liveQueryForm.steps.agentsStepHeading": "选择代理",
"xpack.osquery.liveQueryForm.steps.queryStepHeading": "输入查询",
"xpack.osquery.liveQueryForm.steps.resultsStepHeading": "检查结果",
"xpack.osquery.liveQueryResults.table.agentColumnTitle": "代理",
"xpack.osquery.liveQueryResults.table.fieldMappedLabel": "字段已映射到",
"xpack.osquery.newLiveQuery.pageTitle": "新建实时查询",