[Security Solution] Add dataViewId to filter actions (#177946)

## Summary

This PR fixes a bug related to the filter edition:
https://github.com/elastic/kibana/issues/164406

Filter actions were missing the `meta.index` value, which needs to be
assigned to the dataView id being used. When the filter is edited, the
filter component retrieves the index pattern from the dataView saved
object.

The `meta.index` value has been added to all the "Filter in/out" actions
using the `CellActions` metadata object.

Thanks @angorayc for catching this and implementing the fix

### Screenshots

Before:


![before](6e60cc1f-7811-4c97-8da0-95b688dd3d96)

After:


![after](abaf740f-6ec0-4263-8455-d9f14dc3e423)

---------

Co-authored-by: Angela Chuang <yi-chun.chuang@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Angela Chuang <6295984+angorayc@users.noreply.github.com>
This commit is contained in:
Sergi Massaneda 2024-03-06 11:57:41 +01:00 committed by GitHub
parent 4953a9b158
commit 406b24c6a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 367 additions and 928 deletions

View file

@ -12,6 +12,7 @@ const field = 'field.name';
const value = 'the-value';
const numberValue = 123;
const booleanValue = true;
const dataViewId = 'mock-data-view-id';
describe('createFilter', () => {
it.each([
@ -19,7 +20,7 @@ describe('createFilter', () => {
{ caseName: 'number array', caseValue: [numberValue], query: numberValue.toString() },
{ caseName: 'boolean array', caseValue: [booleanValue], query: booleanValue.toString() },
])('should return filter with $caseName value', ({ caseValue, query = value }) => {
expect(createFilter({ key: field, value: caseValue, negate: false })).toEqual({
expect(createFilter({ key: field, value: caseValue, negate: false, dataViewId })).toEqual({
meta: {
type: 'phrase',
key: field,
@ -27,6 +28,7 @@ describe('createFilter', () => {
params: {
query,
},
index: dataViewId,
},
query: {
match_phrase: {
@ -43,7 +45,7 @@ describe('createFilter', () => {
{ caseName: 'number array', caseValue: [numberValue], query: numberValue.toString() },
{ caseName: 'boolean array', caseValue: [booleanValue], query: booleanValue.toString() },
])('should return negate filter with $caseName value', ({ caseValue, query = value }) => {
expect(createFilter({ key: field, value: caseValue, negate: true })).toEqual({
expect(createFilter({ key: field, value: caseValue, negate: true, dataViewId })).toEqual({
meta: {
type: 'phrase',
key: field,
@ -51,6 +53,7 @@ describe('createFilter', () => {
params: {
query,
},
index: dataViewId,
},
query: {
match_phrase: {
@ -67,19 +70,20 @@ describe('createFilter', () => {
{ caseName: 'negated', negate: true },
])('should return combined filter with multiple $caseName values', ({ negate }) => {
const value2 = 'the-value2';
expect(createFilter({ key: field, value: [value, value2], negate })).toEqual({
expect(createFilter({ key: field, value: [value, value2], negate, dataViewId })).toEqual({
meta: {
type: 'combined',
relation: 'AND',
key: field,
negate,
index: dataViewId,
params: [
{
meta: { type: 'phrase', key: field, params: { query: value } },
meta: { type: 'phrase', key: field, params: { query: value }, index: dataViewId },
query: { match_phrase: { [field]: { query: value } } },
},
{
meta: { type: 'phrase', key: field, params: { query: value2 } },
meta: { type: 'phrase', key: field, params: { query: value2 }, index: dataViewId },
query: { match_phrase: { [field]: { query: value2 } } },
},
],
@ -90,13 +94,14 @@ describe('createFilter', () => {
it.each([{ caseName: 'empty array', caseValue: [] }])(
'should return exist filter with $caseName value',
({ caseValue }) => {
expect(createFilter({ key: field, value: caseValue, negate: false })).toEqual({
expect(createFilter({ key: field, value: caseValue, negate: false, dataViewId })).toEqual({
query: {
exists: {
field,
},
},
meta: {
index: dataViewId,
key: field,
negate: false,
type: 'exists',
@ -109,13 +114,14 @@ describe('createFilter', () => {
it.each([{ caseName: 'empty array', caseValue: [] }])(
'should return negate exist filter with $caseName value',
({ caseValue }) => {
expect(createFilter({ key: field, value: caseValue, negate: true })).toEqual({
expect(createFilter({ key: field, value: caseValue, negate: true, dataViewId })).toEqual({
query: {
exists: {
field,
},
},
meta: {
index: dataViewId,
key: field,
negate: true,
type: 'exists',

View file

@ -18,8 +18,16 @@ import { DefaultActionsSupportedValue } from '../types';
export const isEmptyFilterValue = (value: Array<string | number | boolean>) =>
value.length === 0 || value.every((v) => v === '');
const createExistsFilter = ({ key, negate }: { key: string; negate: boolean }): ExistsFilter => ({
meta: { key, negate, type: FILTERS.EXISTS, value: 'exists' },
const createExistsFilter = ({
key,
negate,
dataViewId,
}: {
key: string;
negate: boolean;
dataViewId?: string;
}): ExistsFilter => ({
meta: { key, negate, type: FILTERS.EXISTS, value: 'exists', index: dataViewId },
query: { exists: { field: key } },
});
@ -27,12 +35,15 @@ const createPhraseFilter = ({
key,
negate,
value,
dataViewId,
}: {
value: string | number | boolean;
key: string;
negate?: boolean;
dataViewId?: string;
}): PhraseFilter => ({
meta: {
index: dataViewId,
key,
negate,
type: FILTERS.PHRASE,
@ -45,17 +56,20 @@ const createCombinedFilter = ({
values,
key,
negate,
dataViewId,
}: {
values: DefaultActionsSupportedValue;
key: string;
negate: boolean;
dataViewId?: string;
}): CombinedFilter => ({
meta: {
index: dataViewId,
key,
negate,
type: FILTERS.COMBINED,
relation: BooleanRelation.AND,
params: values.map((value) => createPhraseFilter({ key, value })),
params: values.map((value) => createPhraseFilter({ key, value, dataViewId })),
},
});
@ -63,18 +77,20 @@ export const createFilter = ({
key,
value,
negate,
dataViewId,
}: {
key: string;
value: DefaultActionsSupportedValue;
negate: boolean;
dataViewId?: string;
}): Filter => {
if (value.length === 0) {
return createExistsFilter({ key, negate });
return createExistsFilter({ key, negate, dataViewId });
}
if (value.length > 1) {
return createCombinedFilter({ key, negate, values: value });
return createCombinedFilter({ key, negate, values: value, dataViewId });
} else {
return createPhraseFilter({ key, negate, value: value[0] });
return createPhraseFilter({ key, negate, value: value[0], dataViewId });
}
};

View file

@ -20,6 +20,7 @@ jest.mock('./create_filter', () => ({
const fieldName = 'user.name';
const value = 'the value';
const dataViewId = 'mockDataViewId';
const mockWarningToast = jest.fn();
@ -96,6 +97,7 @@ describe('createFilterInActionFactory', () => {
key: fieldName,
value: [value],
negate: false,
dataViewId,
});
});
@ -113,6 +115,7 @@ describe('createFilterInActionFactory', () => {
key: fieldName,
value: [value],
negate: false,
dataViewId,
});
});
@ -126,7 +129,12 @@ describe('createFilterInActionFactory', () => {
},
],
});
expect(mockCreateFilter).toHaveBeenCalledWith({ key: fieldName, value: [], negate: true });
expect(mockCreateFilter).toHaveBeenCalledWith({
key: fieldName,
value: [],
negate: true,
dataViewId,
});
});
it('should create negate filter query with undefined value', async () => {
@ -143,6 +151,7 @@ describe('createFilterInActionFactory', () => {
key: fieldName,
value: [],
negate: true,
dataViewId,
});
});
@ -156,7 +165,12 @@ describe('createFilterInActionFactory', () => {
},
],
});
expect(mockCreateFilter).toHaveBeenCalledWith({ key: fieldName, value: [''], negate: true });
expect(mockCreateFilter).toHaveBeenCalledWith({
key: fieldName,
value: [''],
negate: true,
dataViewId,
});
});
it('should create negate filter query with empty array value', async () => {
@ -169,7 +183,12 @@ describe('createFilterInActionFactory', () => {
},
],
});
expect(mockCreateFilter).toHaveBeenCalledWith({ key: fieldName, value: [], negate: true });
expect(mockCreateFilter).toHaveBeenCalledWith({
key: fieldName,
value: [],
negate: true,
dataViewId,
});
});
it('should notify the user when value type is unsupported', async () => {

View file

@ -8,6 +8,7 @@
import { i18n } from '@kbn/i18n';
import type { FilterManager, KBN_FIELD_TYPES } from '@kbn/data-plugin/public';
import { NotificationsStart } from '@kbn/core-notifications-browser';
import { createFilter, isEmptyFilterValue } from './create_filter';
import { FILTER_CELL_ACTION_TYPE } from '../../constants';
import { createCellActionFactory } from '../factory';
@ -46,13 +47,15 @@ export const createFilterInActionFactory = createCellActionFactory(
isTypeSupportedByDefaultActions(field.type as KBN_FIELD_TYPES)
);
},
execute: async ({ data }) => {
execute: async ({ data, metadata }) => {
const field = data[0]?.field;
const rawValue = data[0]?.value;
const dataViewId = typeof metadata?.dataViewId === 'string' ? metadata.dataViewId : undefined;
const value = filterOutNullableValues(valueToArray(rawValue));
if (isValueSupportedByDefaultActions(value)) {
addFilterIn({ filterManager, fieldName: field.name, value });
addFilterIn({ filterManager, fieldName: field.name, value, dataViewId });
} else {
toasts.addWarning({
title: ACTION_INCOMPATIBLE_VALUE_WARNING,
@ -66,16 +69,19 @@ export const addFilterIn = ({
filterManager,
fieldName,
value,
dataViewId,
}: {
filterManager: FilterManager | undefined;
fieldName: string;
value: DefaultActionsSupportedValue;
dataViewId?: string;
}) => {
if (filterManager != null) {
const filter = createFilter({
key: fieldName,
value,
negate: isEmptyFilterValue(value),
dataViewId,
});
filterManager.addFilters(filter);
}

View file

@ -20,6 +20,7 @@ jest.mock('./create_filter', () => ({
const fieldName = 'user.name';
const value = 'the value';
const dataViewId = 'mockDataViewId';
const mockWarningToast = jest.fn();
@ -95,6 +96,7 @@ describe('createFilterOutAction', () => {
key: fieldName,
value: [value],
negate: true,
dataViewId,
});
});
@ -112,6 +114,7 @@ describe('createFilterOutAction', () => {
key: fieldName,
value: [value],
negate: true,
dataViewId,
});
});
@ -125,7 +128,12 @@ describe('createFilterOutAction', () => {
},
],
});
expect(mockCreateFilter).toHaveBeenCalledWith({ key: fieldName, value: [], negate: false });
expect(mockCreateFilter).toHaveBeenCalledWith({
key: fieldName,
value: [],
negate: false,
dataViewId,
});
});
it('should create filter query with undefined value', async () => {
@ -142,6 +150,7 @@ describe('createFilterOutAction', () => {
key: fieldName,
value: [],
negate: false,
dataViewId,
});
});
@ -155,7 +164,12 @@ describe('createFilterOutAction', () => {
},
],
});
expect(mockCreateFilter).toHaveBeenCalledWith({ key: fieldName, value: [''], negate: false });
expect(mockCreateFilter).toHaveBeenCalledWith({
key: fieldName,
value: [''],
negate: false,
dataViewId,
});
});
it('should create negate filter query with empty array value', async () => {
@ -168,7 +182,12 @@ describe('createFilterOutAction', () => {
},
],
});
expect(mockCreateFilter).toHaveBeenCalledWith({ key: fieldName, value: [], negate: false });
expect(mockCreateFilter).toHaveBeenCalledWith({
key: fieldName,
value: [],
negate: false,
dataViewId,
});
});
it('should notify the user when value type is unsupported', async () => {

View file

@ -39,7 +39,6 @@ export const createFilterOutActionFactory = createCellActionFactory(
getDisplayNameTooltip: () => FILTER_OUT,
isCompatible: async ({ data }) => {
const field = data[0]?.field;
return (
data.length === 1 && // TODO Add support for multiple values
!!field.name &&
@ -47,9 +46,11 @@ export const createFilterOutActionFactory = createCellActionFactory(
);
},
execute: async ({ data }) => {
execute: async ({ data, metadata }) => {
const field = data[0]?.field;
const rawValue = data[0]?.value;
const dataViewId = typeof metadata?.dataViewId === 'string' ? metadata.dataViewId : undefined;
const value = filterOutNullableValues(valueToArray(rawValue));
if (isValueSupportedByDefaultActions(value)) {
@ -57,6 +58,7 @@ export const createFilterOutActionFactory = createCellActionFactory(
filterManager,
fieldName: field.name,
value,
dataViewId,
});
} else {
toasts.addWarning({
@ -71,16 +73,19 @@ export const addFilterOut = ({
filterManager,
fieldName,
value,
dataViewId,
}: {
filterManager: FilterManager | undefined;
fieldName: string;
value: DefaultActionsSupportedValue;
dataViewId?: string;
}) => {
if (filterManager != null) {
const filter = createFilter({
key: fieldName,
value,
negate: !isEmptyFilterValue(value),
dataViewId,
});
filterManager.addFilters(filter);
}

View file

@ -39,6 +39,6 @@ export const makeActionContext = (
},
],
nodeRef: {} as MutableRefObject<HTMLElement>,
metadata: undefined,
metadata: { dataViewId: 'mockDataViewId' },
...override,
});

View file

@ -670,12 +670,14 @@ export const UnifiedDataTable = ({
: undefined,
[cellActionsTriggerId, isPlainRecord, visibleColumns, dataView]
);
const cellActionsMetadata = useMemo(() => ({ dataViewId: dataView.id }), [dataView]);
const columnsCellActions = useDataGridColumnsCellActions({
fields: cellActionsFields,
getCellValue,
triggerId: cellActionsTriggerId,
dataGridRef,
metadata: cellActionsMetadata,
});
const {

View file

@ -57,6 +57,7 @@ window.matchMedia = jest.fn().mockImplementation((query) => {
removeListener: jest.fn(),
};
});
const dataViewId = 'security-solution-default';
export const TestCellRenderer: React.FC<DeprecatedCellValueElementProps> = ({ columnId, data }) => (
<>
@ -185,6 +186,7 @@ describe('DataTable', () => {
fields: [timestampFieldSpec],
getCellValue: expect.any(Function),
metadata: {
dataViewId,
scopeId: 'table-test',
},
dataGridRef: expect.any(Object),

View file

@ -170,8 +170,15 @@ export const DataTableComponent = React.memo<DataTableProps>(
const dataTable = useShallowEqualSelector<DataTableModel, DataTableState>(
(state) => getDataTable(state, id) ?? tableDefaults
);
const { columns, selectedEventIds, showCheckboxes, sort, isLoading, defaultColumns } =
dataTable;
const {
columns,
selectedEventIds,
showCheckboxes,
sort,
isLoading,
defaultColumns,
dataViewId,
} = dataTable;
const columnHeaders = memoizedGetColumnHeaders(columns, browserFields, isEventRenderedView);
@ -339,7 +346,7 @@ export const DataTableComponent = React.memo<DataTableProps>(
[dispatch, id]
);
const cellActionsMetadata = useMemo(() => ({ scopeId: id }), [id]);
const cellActionsMetadata = useMemo(() => ({ scopeId: id, dataViewId }), [dataViewId, id]);
const cellActionsFields = useMemo<UseDataGridColumnsCellActionsProps['fields']>(
() =>
cellActionsTriggerId

View file

@ -59,6 +59,7 @@ const MOCK_DATA = {
],
pageParams: [undefined],
};
const MOCK_DATA_VIEW_ID = 'dataViewId';
jest.mock('../../hooks/use_filter', () => ({
useSetFilter: () => ({
@ -82,6 +83,7 @@ describe('ContainerNameWidget component', () => {
globalFilter={GLOBAL_FILTER}
groupedBy={CONTAINER_IMAGE_NAME}
countBy={ENTRY_LEADER_ENTITY_ID}
dataViewId={MOCK_DATA_VIEW_ID}
/>
));

View file

@ -45,6 +45,7 @@ export interface ContainerNameWidgetDeps {
globalFilter: GlobalFilter;
groupedBy: string;
countBy?: string;
dataViewId?: string;
}
interface FilterButtons {
@ -57,6 +58,7 @@ interface CopyButtons {
}
export const ContainerNameWidget = ({
dataViewId,
widgetKey,
indexPattern,
globalFilter,
@ -117,6 +119,7 @@ export const ContainerNameWidget = ({
ownFocus: false,
showTooltip: true,
value: aggData.key as string,
dataViewId,
});
});
})
@ -135,13 +138,14 @@ export const ContainerNameWidget = ({
ownFocus: false,
showTooltip: true,
value: aggData.key as string,
dataViewId,
});
});
})
.flat() || [],
};
return result;
}, [data, getFilterForValueButton, getFilterOutValueButton, filterManager]);
}, [data?.pages, getFilterForValueButton, dataViewId, filterManager, getFilterOutValueButton]);
const copyToClipboardButtons = useMemo((): CopyButtons => {
const result: CopyButtons = {

View file

@ -29,6 +29,8 @@ jest.mock('../container_name_widget', () => ({
ContainerNameWidget: () => <div>{'Mock Container Name widget'}</div>,
}));
const dataViewId = 'dataViewId';
const renderWithRouter = (
initialEntries: MemoryRouterProps['initialEntries'] = ['/kubernetes']
) => {
@ -64,6 +66,7 @@ const renderWithRouter = (
endDate: '2022-06-09T17:52:15.532Z',
}}
renderSessionsView={jest.fn()}
dataViewId={dataViewId}
/>
</MemoryRouter>
);

View file

@ -47,6 +47,7 @@ import {
import { ContainerNameWidget } from '../container_name_widget';
const KubernetesSecurityRoutesComponent = ({
dataViewId,
filter,
indexPattern,
globalFilter,
@ -158,6 +159,7 @@ const KubernetesSecurityRoutesComponent = ({
<EuiFlexGroup css={styles.widgetsBottomSpacing}>
<EuiFlexItem>
<PercentWidget
dataViewId={dataViewId}
title={
<>
<EuiText size="xs" css={styles.percentageChartTitle}>
@ -250,6 +252,7 @@ const KubernetesSecurityRoutesComponent = ({
shouldHideFilter: true,
},
}}
dataViewId={dataViewId}
groupedBy={ENTRY_LEADER_USER_ID}
countBy={ENTRY_LEADER_ENTITY_ID}
onReduce={onReduceRootAggs}
@ -259,6 +262,7 @@ const KubernetesSecurityRoutesComponent = ({
</EuiFlexItem>
<EuiFlexItem grow={false} css={styles.rightWidgetsGroup}>
<ContainerNameWidget
dataViewId={dataViewId}
widgetKey="containerNameSessions"
indexPattern={indexPattern}
globalFilter={globalFilter}

View file

@ -35,6 +35,8 @@ const DATA_VALUE_MAP = {
},
};
const MOCK_DATA_VIEW_ID = 'dataViewId';
jest.mock('../../hooks/use_filter', () => ({
useSetFilter: () => ({
getFilterForValueButton: jest.fn(),
@ -54,6 +56,7 @@ describe('PercentWidget component', () => {
<PercentWidget
title={TITLE}
dataValueMap={DATA_VALUE_MAP}
dataViewId={MOCK_DATA_VIEW_ID}
widgetKey="percentWidget"
globalFilter={GLOBAL_FILTER}
groupedBy={ENTRY_LEADER_INTERACTIVE}

View file

@ -33,6 +33,7 @@ export interface PercentWidgetDeps {
groupedBy: string;
countBy?: string;
onReduce: (result: AggregateResult) => Record<string, number>;
dataViewId?: string;
}
interface FilterButtons {
@ -49,6 +50,7 @@ export const PercentWidget = ({
groupedBy,
countBy,
onReduce,
dataViewId,
}: PercentWidgetDeps) => {
const [hoveredFilter, setHoveredFilter] = useState<number | null>(null);
const styles = useStyles();
@ -92,6 +94,7 @@ export const PercentWidget = ({
ownFocus: false,
showTooltip: true,
value: [groupedByValue],
dataViewId,
})
);
result.filterOutButtons.push(
@ -104,13 +107,14 @@ export const PercentWidget = ({
ownFocus: false,
showTooltip: true,
value: [groupedByValue],
dataViewId,
})
);
}
});
return result;
}, [dataValueMap, filterManager, getFilterForValueButton, getFilterOutValueButton]);
}, [dataValueMap, dataViewId, filterManager, getFilterForValueButton, getFilterOutValueButton]);
return (
<div css={styles.container}>

View file

@ -38,6 +38,7 @@ export interface KubernetesSecurityDeps {
renderSessionsView: (sessionsFilterQuery: string | undefined) => JSX.Element;
indexPattern?: IndexPattern;
globalFilter: GlobalFilter;
dataViewId?: string;
}
export interface KubernetesSecurityStart {

View file

@ -8,12 +8,11 @@
import type {
EuiDataGridCellValueElementProps,
EuiDataGridColumn,
EuiDataGridColumnCellActionProps,
EuiDataGridControlColumn,
} from '@elastic/eui';
import type { IFieldSubType } from '@kbn/es-query';
import type { FieldBrowserOptions } from '@kbn/triggers-actions-ui-plugin/public';
import type { ComponentType, JSXElementConstructor, ReactNode } from 'react';
import type { ComponentType, JSXElementConstructor } from 'react';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import type { SortColumnTable } from '@kbn/securitysolution-data-table';
import type { OnRowSelected, SetEventsDeleted, SetEventsLoading } from '..';
@ -24,50 +23,6 @@ export type ColumnHeaderType = 'not-filtered' | 'text-filter';
/** Uniquely identifies a column */
export type ColumnId = string;
/**
* A `DataTableCellAction` function accepts `data`, where each row of data is
* represented as a `TimelineNonEcsData[]`. For example, `data[0]` would
* contain a `TimelineNonEcsData[]` with the first row of data.
*
* A `DataTableCellAction` returns a function that has access to all the
* `EuiDataGridColumnCellActionProps`, _plus_ access to `data`,
* which enables code like the following example to be written:
*
* Example:
* ```
* ({ data }: { data: TimelineNonEcsData[][] }) => ({ rowIndex, columnId, Component }) => {
* const value = getMappedNonEcsValue({
* data: data[rowIndex], // access a specific row's values
* fieldName: columnId,
* });
*
* return (
* <Component onClick={() => alert(`row ${rowIndex} col ${columnId} has value ${value}`)} iconType="heart">
* {'Love it'}
* </Component>
* );
* };
* ```
*/
export type DataTableCellAction = ({
browserFields,
data,
ecsData,
header,
pageSize,
scopeId,
closeCellPopover,
}: {
browserFields: BrowserFields;
/** each row of data is represented as one TimelineNonEcsData[] */
data: TimelineNonEcsData[][];
ecsData: Ecs[];
header?: ColumnHeaderOptions;
pageSize: number;
scopeId: string;
closeCellPopover?: () => void;
}) => (props: EuiDataGridColumnCellActionProps) => ReactNode;
/** The specification of a column header */
export type ColumnHeaderOptions = Pick<
EuiDataGridColumn,

View file

@ -51,8 +51,10 @@ export const createFilterInCellActionFactory = ({
);
},
execute: async ({ data, metadata }) => {
const field = data[0]?.field;
const fieldName = data[0]?.field.name;
const rawValue = data[0]?.value;
const dataViewId = metadata?.dataViewId;
const value = filterOutNullableValues(valueToArray(rawValue));
if (!isValueSupportedByDefaultActions(value)) {
@ -62,7 +64,7 @@ export const createFilterInCellActionFactory = ({
return;
}
if (!field) return;
if (!fieldName) return;
// if negateFilters is true we have to perform the opposite operation, we can just execute filterOut with the same params
const addFilter = metadata?.negateFilters === true ? addFilterOut : addFilterIn;
@ -73,17 +75,9 @@ export const createFilterInCellActionFactory = ({
TimelineId.active
)?.filterManager;
addFilter({
filterManager: timelineFilterManager,
fieldName: field.name,
value,
});
addFilter({ filterManager: timelineFilterManager, fieldName, value, dataViewId });
} else {
addFilter({
filterManager,
fieldName: field.name,
value,
});
addFilter({ filterManager, fieldName, value, dataViewId });
}
},
});

View file

@ -52,8 +52,9 @@ export const createFilterOutCellActionFactory = ({
);
},
execute: async ({ data, metadata }) => {
const field = data[0]?.field;
const fieldName = data[0]?.field.name;
const rawValue = data[0]?.value;
const dataViewId = metadata?.dataViewId;
const value = filterOutNullableValues(valueToArray(rawValue));
if (!isValueSupportedByDefaultActions(value)) {
@ -63,7 +64,7 @@ export const createFilterOutCellActionFactory = ({
return;
}
if (!field) return;
if (!fieldName) return;
// if negateFilters is true we have to perform the opposite operation, we can just execute filterIn with the same params
const addFilter = metadata?.negateFilters === true ? addFilterIn : addFilterOut;
@ -74,17 +75,9 @@ export const createFilterOutCellActionFactory = ({
TimelineId.active
)?.filterManager;
addFilter({
filterManager: timelineFilterManager,
fieldName: field.name,
value,
});
addFilter({ filterManager: timelineFilterManager, fieldName, value, dataViewId });
} else {
addFilter({
filterManager,
fieldName: field.name,
value,
});
addFilter({ filterManager, fieldName, value, dataViewId });
}
},
});

View file

@ -73,6 +73,8 @@ export const createFilterLensAction = ({
execute: async ({ data }) => {
const field = data[0]?.columnMeta?.field;
const rawValue = data[0]?.value;
const mayBeDataViewId = data[0]?.columnMeta?.sourceParams?.indexPatternId;
const dataViewId = typeof mayBeDataViewId === 'string' ? mayBeDataViewId : undefined;
const value = filterOutNullableValues(valueToArray(rawValue));
if (!isValueSupportedByDefaultActions(value)) {
@ -93,7 +95,7 @@ export const createFilterLensAction = ({
? timeline.filterManager
: dataService.query.filterManager;
addFilter({ filterManager, fieldName: field, value });
addFilter({ filterManager, fieldName: field, value, dataViewId });
},
});
};

View file

@ -47,6 +47,8 @@ export interface SecurityMetadata extends Record<string, unknown> {
* an "and" query to the main data provider
*/
andFilters?: AndFilter[];
dataViewId?: string;
}
export interface SecurityCellActionExecutionContext extends CellActionExecutionContext {

View file

@ -36,6 +36,7 @@ import {
import { useDiscoverInTimelineContext } from '../../common/components/discover_in_timeline/use_discover_in_timeline_context';
import { useShowTimeline } from '../../common/utils/timeline/use_show_timeline';
import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features';
import { useSourcererDataView } from '../../common/containers/sourcerer';
export interface SendToTimelineButtonProps {
asEmptyButton: boolean;
@ -59,6 +60,7 @@ export const SendToTimelineButton: React.FunctionComponent<SendToTimelineButtonP
const { showAssistantOverlay } = useAssistantContext();
const [isTimelineBottomBarVisible] = useShowTimeline();
const { discoverStateContainer } = useDiscoverInTimelineContext();
const { dataViewId: timelineDataViewId } = useSourcererDataView(SourcererScopeName.timeline);
const isEsqlTabInTimelineDisabled = useIsExperimentalFeatureEnabled('timelineEsqlTabDisabled');
@ -167,6 +169,7 @@ export const SendToTimelineButton: React.FunctionComponent<SendToTimelineButtonP
alias: dataProviders[0].name,
key: 'query',
value: dataProviders[0].kqlQuery,
index: timelineDataViewId ?? undefined,
},
query: JSON.parse(dataProviders[0].kqlQuery),
};
@ -211,8 +214,9 @@ export const SendToTimelineButton: React.FunctionComponent<SendToTimelineButtonP
timeRange,
keepDataView,
dispatch,
clearTimeline,
discoverStateContainer,
clearTimeline,
timelineDataViewId,
defaultDataView.id,
signalIndexName,
]);

View file

@ -0,0 +1,104 @@
/*
* 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 from 'react';
import { render } from '@testing-library/react';
import { SecurityCellActionsTrigger } from '../../../actions/constants';
import { CellActionsMode, SecurityCellActions } from '.';
import { CellActions } from '@kbn/cell-actions';
const MockCellActions = CellActions as jest.Mocked<typeof CellActions>;
jest.mock('@kbn/cell-actions', () => ({
...jest.requireActual('@kbn/cell-actions'),
CellActions: jest.fn(() => <div data-test-subj="cell-actions-component" />),
}));
const mockFieldSpec = { someFieldSpec: 'theFieldSpec' };
const mockGetFieldSpec = jest.fn((_: string) => mockFieldSpec);
const mockUseGetFieldSpec = jest.fn((_: unknown) => mockGetFieldSpec);
jest.mock('../../hooks/use_get_field_spec', () => ({
useGetFieldSpec: (param: unknown) => mockUseGetFieldSpec(param),
}));
const mockDataViewId = 'security-default-dataview-id';
const mockUseDataViewId = jest.fn((_: unknown) => mockDataViewId);
jest.mock('../../hooks/use_data_view_id', () => ({
useDataViewId: (param: unknown) => mockUseDataViewId(param),
}));
const defaultProps = {
triggerId: SecurityCellActionsTrigger.DEFAULT,
mode: CellActionsMode.INLINE,
};
const mockData = [{ field: 'fieldName', value: 'fieldValue' }];
const mockMetadata = { someMetadata: 'value' };
describe('SecurityCellActions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render CellActions component when data is not empty', () => {
const result = render(
<SecurityCellActions {...defaultProps} data={mockData}>
<div />
</SecurityCellActions>
);
expect(result.queryByTestId('cell-actions-component')).toBeInTheDocument();
});
it('should render children without CellActions component when data is empty', () => {
const result = render(
<SecurityCellActions {...defaultProps} data={[]}>
<div>{'Test Children'}</div>
</SecurityCellActions>
);
expect(result.queryByTestId('cell-actions-component')).not.toBeInTheDocument();
expect(result.queryByText('Test Children')).toBeInTheDocument();
});
it('should render CellActions component with correct props', () => {
render(
<SecurityCellActions {...defaultProps} data={mockData}>
<div />
</SecurityCellActions>
);
expect(MockCellActions).toHaveBeenCalledWith(expect.objectContaining(defaultProps), {});
});
it('should render CellActions with the correct field spec in the data', () => {
render(
<SecurityCellActions {...defaultProps} data={mockData}>
<div />
</SecurityCellActions>
);
expect(MockCellActions).toHaveBeenCalledWith(
expect.objectContaining({
data: [{ field: mockFieldSpec, value: 'fieldValue' }],
}),
{}
);
});
it('should render CellActions with the correct dataViewId in the metadata', () => {
render(
<SecurityCellActions {...defaultProps} data={mockData} metadata={mockMetadata}>
<div />
</SecurityCellActions>
);
expect(MockCellActions).toHaveBeenCalledWith(
expect.objectContaining({
metadata: { ...mockMetadata, dataViewId: mockDataViewId },
}),
{}
);
});
});

View file

@ -17,6 +17,7 @@ import type { SecurityMetadata } from '../../../actions/types';
import { SecurityCellActionsTrigger, SecurityCellActionType } from '../../../actions/constants';
import { SourcererScopeName } from '../../store/sourcerer/model';
import { useGetFieldSpec } from '../../hooks/use_get_field_spec';
import { useDataViewId } from '../../hooks/use_data_view_id';
// bridge exports for convenience
export * from '@kbn/cell-actions';
@ -54,10 +55,12 @@ export const useDataGridColumnsSecurityCellActions: UseDataGridColumnsCellAction
export const SecurityCellActions: React.FC<SecurityCellActionsProps> = ({
sourcererScopeId = SourcererScopeName.default,
data,
metadata,
children,
...props
}) => {
const getFieldSpec = useGetFieldSpec(sourcererScopeId);
const dataViewId = useDataViewId(sourcererScopeId);
// Make a dependency key to prevent unnecessary re-renders when data object is defined inline
// It is necessary because the data object is an array or an object and useMemo would always re-render
const dependencyKey = JSON.stringify(data);
@ -74,8 +77,10 @@ export const SecurityCellActions: React.FC<SecurityCellActionsProps> = ({
[dependencyKey, getFieldSpec]
);
const metadataWithDataView = useMemo(() => ({ ...metadata, dataViewId }), [dataViewId, metadata]);
return fieldData.length > 0 ? (
<CellActions data={fieldData} {...props}>
<CellActions data={fieldData} metadata={metadataWithDataView} {...props}>
{children}
</CellActions>
) : (

View file

@ -13,6 +13,10 @@ import type { DataProvider } from '../../../../common/types/timeline';
jest.mock('../../lib/kibana');
jest.mock('../../hooks/use_selector');
const mockUseDataViewId = jest.fn();
jest.mock('../../hooks/use_data_view_id', () => ({
useDataViewId: (scope: string) => mockUseDataViewId(scope),
}));
jest.mock('../../containers/sourcerer', () => ({
useSourcererDataView: jest.fn().mockReturnValue({ browserFields: {} }),
}));
@ -31,6 +35,7 @@ describe('useHoverActionItems', () => {
dataProvider: [{} as DataProvider],
defaultFocusedButtonRef: null,
field: 'kibana.alert.rule.name',
scopeId: 'timeline-test',
handleHoverActionClicked: jest.fn(),
hideAddToTimeline: false,
hideTopN: false,
@ -87,6 +92,14 @@ describe('useHoverActionItems', () => {
});
});
test('should call getDataViewId with the correct scope', () => {
renderHook(() => {
const defaultFocusedButtonRef = useRef(null);
return useHoverActionItems({ ...defaultProps, defaultFocusedButtonRef });
});
expect(mockUseDataViewId).toHaveBeenCalledWith('timeline');
});
test('should return overflowActionItems', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => {

View file

@ -13,7 +13,7 @@ import { isEmpty } from 'lodash';
import { FilterManager } from '@kbn/data-plugin/public';
import { useDispatch } from 'react-redux';
import { isActiveTimeline } from '../../../helpers';
import { getSourcererScopeId, isActiveTimeline } from '../../../helpers';
import { timelineSelectors } from '../../../timelines/store';
import { useKibana } from '../../lib/kibana';
import { allowTopN } from '../drag_and_drop/helpers';
@ -22,6 +22,7 @@ import { TimelineId } from '../../../../common/types/timeline';
import { ShowTopNButton } from './actions/show_top_n';
import { addProvider } from '../../../timelines/store/actions';
import { useDeepEqualSelector } from '../../hooks/use_selector';
import { useDataViewId } from '../../hooks/use_data_view_id';
export interface UseHoverActionItemsProps {
dataProvider?: DataProvider | DataProvider[];
dataType?: string;
@ -85,6 +86,8 @@ export const useHoverActionItems = ({
const kibana = useKibana();
const dispatch = useDispatch();
const { timelines, uiSettings } = kibana.services;
const dataViewId = useDataViewId(getSourcererScopeId(scopeId ?? ''));
// Common actions used by the alert table and alert flyout
const {
getAddToTimelineButton,
@ -193,6 +196,7 @@ export const useHoverActionItems = ({
ownFocus,
showTooltip: enableOverflowButton ? false : true,
value: values,
dataViewId,
})}
</div>
) : null,
@ -207,6 +211,7 @@ export const useHoverActionItems = ({
onClick: handleHoverActionClicked,
showTooltip: enableOverflowButton ? false : true,
value: values,
dataViewId,
})}
</div>
) : null,
@ -294,6 +299,7 @@ export const useHoverActionItems = ({
stKeyboardEvent,
toggleColumn,
values,
dataViewId,
]
) as JSX.Element[];

View file

@ -0,0 +1,34 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import { TestProviders } from '../mock';
import { SourcererScopeName } from '../store/sourcerer/model';
import { DEFAULT_DATA_VIEW_ID } from '../../../common/constants';
import { useDataViewId } from './use_data_view_id';
import * as sourcererSelectors from '../store/sourcerer/selectors';
describe('useDataViewId', () => {
it.each(Object.values(SourcererScopeName))(
'should return the data view id for %s scope',
(scope) => {
const { result } = renderHook(useDataViewId, { initialProps: scope, wrapper: TestProviders });
expect(result.current).toEqual(DEFAULT_DATA_VIEW_ID); // mocked value
}
);
it('should return undefined if dataViewId selector returns null', () => {
jest
.spyOn(sourcererSelectors, 'sourcererScopeSelectedDataViewId')
.mockImplementationOnce(() => null);
const { result } = renderHook(useDataViewId, {
initialProps: SourcererScopeName.default,
wrapper: TestProviders,
});
expect(result.current).toEqual(undefined);
});
});

View file

@ -0,0 +1,18 @@
/*
* 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 { useSelector } from 'react-redux';
import { sourcererScopeSelectedDataViewId } from '../store/sourcerer/selectors';
import type { SourcererScopeName } from '../store/sourcerer/model';
import type { State } from '../store';
export const useDataViewId = (scopeId: SourcererScopeName): string | undefined => {
const dataViewId = useSelector((state: State) =>
sourcererScopeSelectedDataViewId(state, scopeId)
);
return dataViewId ?? undefined;
};

View file

@ -1,60 +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 React from 'react';
import { render } from '@testing-library/react';
import type { TimelineNonEcsData } from '@kbn/timelines-plugin/common';
import { getAddToTimelineCellAction } from './add_to_timeline';
jest.mock('../kibana');
const mockDispatch = jest.fn;
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
return {
...original,
useDispatch: () => mockDispatch,
};
});
describe('getAddToTimelineCellAction', () => {
const sampleData: TimelineNonEcsData = {
field: 'fizz',
value: ['buzz'],
};
const testComponent = () => <></>;
const componentProps = {
colIndex: 1,
rowIndex: 1,
columnId: 'fizz',
Component: testComponent,
isExpanded: false,
};
describe('when data property is', () => {
test('undefined', () => {
const CellComponent = getAddToTimelineCellAction({ pageSize: 1, data: undefined });
const result = render(<CellComponent {...componentProps} />);
expect(result.container).toBeEmptyDOMElement();
});
test('empty', () => {
const CellComponent = getAddToTimelineCellAction({ pageSize: 1, data: [] });
const result = render(<CellComponent {...componentProps} />);
expect(result.container).toBeEmptyDOMElement();
});
});
describe('AddToTimelineCellActions', () => {
const data: TimelineNonEcsData[][] = [[sampleData]];
test('should render with data', () => {
const AddToTimelineCellComponent = getAddToTimelineCellAction({ pageSize: 1, data });
const result = render(<AddToTimelineCellComponent {...componentProps} />);
expect(result.getByTestId('test-add-to-timeline')).toBeInTheDocument();
});
});
});

View file

@ -1,126 +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 type { EuiDataGridColumnCellActionProps } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { getPageRowIndex } from '@kbn/securitysolution-data-table';
import type { TimelineNonEcsData } from '../../../../common/search_strategy';
import type { DataProvider } from '../../../../common/types';
import { TimelineId } from '../../../../common/types/timeline';
import { useGetMappedNonEcsValue } from '../../../timelines/components/timeline/body/data_driven_columns';
import {
EXISTS_OPERATOR,
IS_OPERATOR,
} from '../../../timelines/components/timeline/data_providers/data_provider';
import { escapeDataProviderId } from '../../components/drag_and_drop/helpers';
import { EmptyComponent, useKibanaServices } from './helpers';
import { addProvider } from '../../../timelines/store/actions';
export const getAddToTimelineCellAction = ({
data,
pageSize,
closeCellPopover,
}: {
data?: TimelineNonEcsData[][];
pageSize: number;
closeCellPopover?: () => void;
}) =>
data && data.length > 0
? function AddToTimeline({ rowIndex, columnId, Component }: EuiDataGridColumnCellActionProps) {
const dispatch = useDispatch();
const { timelines } = useKibanaServices();
const pageRowIndex = getPageRowIndex(rowIndex, pageSize);
const rowData = useMemo(() => {
return {
data: data[pageRowIndex],
fieldName: columnId,
};
}, [pageRowIndex, columnId]);
const value = useGetMappedNonEcsValue(rowData);
const addToTimelineButton = useMemo(
() => timelines.getHoverActions().getAddToTimelineButton,
[timelines]
);
const dataProvider: DataProvider[] = useMemo(() => {
const queryIdPrefix = `${escapeDataProviderId(columnId)}-row-${rowIndex}-col-${columnId}`;
if (!value) {
return [
{
and: [],
enabled: true,
kqlQuery: '',
id: `${queryIdPrefix}`,
name: '',
excluded: true,
queryMatch: {
field: columnId,
value: '',
operator: EXISTS_OPERATOR,
},
},
];
}
return value.map((x) => ({
and: [],
enabled: true,
excluded: false,
kqlQuery: '',
id: `${queryIdPrefix}-val-${x}`,
name: x,
queryMatch: {
field: columnId,
value: x,
operator: IS_OPERATOR,
},
}));
}, [columnId, rowIndex, value]);
/*
* Add to Timeline button, adds data to dataprovider but does not persists the Timeline
* to the server because of following reasons.
*
* 1. Add to Timeline button performs actions in `timelines` plugin
* 2. `timelines` plugin does not have information on how to create/update the timelines in the server
* as it is owned by Security Solution
* */
const handleAddToTimelineAction = useCallback(() => {
dispatch(
addProvider({
id: TimelineId.active,
providers: dataProvider,
})
);
if (closeCellPopover) {
closeCellPopover();
}
}, [dataProvider, dispatch]);
const addToTimelineProps = useMemo(() => {
return {
Component,
dataProvider,
field: columnId,
ownFocus: false,
showTooltip: false,
onClick: handleAddToTimelineAction,
timelineType: 'default',
};
}, [Component, columnId, dataProvider, handleAddToTimelineAction]);
// data grid expects each cell action always return an element, it crashes if returns null
return pageRowIndex >= data.length ? (
<>{EmptyComponent}</>
) : (
<>{addToTimelineButton(addToTimelineProps)}</>
);
}
: EmptyComponent;

View file

@ -1,50 +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 React from 'react';
import { render } from '@testing-library/react';
import type { TimelineNonEcsData } from '@kbn/timelines-plugin/common';
import { getCopyCellAction } from './copy';
jest.mock('../kibana');
describe('getCopyCellAction', () => {
const sampleData: TimelineNonEcsData = {
field: 'fizz',
value: ['buzz'],
};
const testComponent = () => <></>;
const componentProps = {
colIndex: 1,
rowIndex: 1,
columnId: 'fizz',
Component: testComponent,
isExpanded: false,
};
describe('when data property is', () => {
test('undefined', () => {
const CellComponent = getCopyCellAction({ pageSize: 1, data: undefined });
const result = render(<CellComponent {...componentProps} />);
expect(result.container).toBeEmptyDOMElement();
});
test('empty', () => {
const CellComponent = getCopyCellAction({ pageSize: 1, data: [] });
const result = render(<CellComponent {...componentProps} />);
expect(result.container).toBeEmptyDOMElement();
});
});
describe('CopyCellAction', () => {
const data: TimelineNonEcsData[][] = [[sampleData]];
test('should render with data', () => {
const CopyCellAction = getCopyCellAction({ pageSize: 1, data });
const result = render(<CopyCellAction {...componentProps} />);
expect(result.getByTestId('test-copy-button')).toBeInTheDocument();
});
});
});

View file

@ -1,61 +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 type { EuiDataGridColumnCellActionProps } from '@elastic/eui';
import { getPageRowIndex } from '@kbn/securitysolution-data-table';
import React, { useMemo } from 'react';
import type { TimelineNonEcsData } from '../../../../common/search_strategy';
import { useGetMappedNonEcsValue } from '../../../timelines/components/timeline/body/data_driven_columns';
import { EmptyComponent, useKibanaServices } from './helpers';
export const getCopyCellAction = ({
data,
pageSize,
closeCellPopover,
}: {
data?: TimelineNonEcsData[][];
pageSize: number;
closeCellPopover?: () => void;
}) =>
data && data.length > 0
? function CopyButton({ rowIndex, columnId, Component }: EuiDataGridColumnCellActionProps) {
const { timelines } = useKibanaServices();
const pageRowIndex = getPageRowIndex(rowIndex, pageSize);
const copyButton = useMemo(() => timelines.getHoverActions().getCopyButton, [timelines]);
const rowData = useMemo(() => {
return {
data: data[pageRowIndex],
fieldName: columnId,
};
}, [pageRowIndex, columnId]);
const value = useGetMappedNonEcsValue(rowData);
const copyButtonProps = useMemo(() => {
return {
Component,
field: columnId,
isHoverAction: false,
ownFocus: false,
showTooltip: false,
value,
onClick: closeCellPopover,
};
}, [Component, columnId, value]);
// data grid expects each cell action always return an element, it crashes if returns null
return pageRowIndex >= data.length ? (
<>{EmptyComponent}</>
) : (
<>{copyButton(copyButtonProps)}</>
);
}
: EmptyComponent;

View file

@ -1,91 +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 type { EuiDataGridColumn, EuiDataGridColumnCellAction } from '@elastic/eui';
import type { ColumnHeaderType, DataTableCellAction } from '../../../../common/types';
import { TableId } from '@kbn/securitysolution-data-table';
import type {
BrowserFields,
TimelineNonEcsData,
} from '@kbn/timelines-plugin/common/search_strategy';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { defaultCellActions } from './default_cell_actions';
import { COLUMNS_WITH_LINKS, EmptyComponent } from './helpers';
describe('default cell actions', () => {
const browserFields: BrowserFields = {};
const data: TimelineNonEcsData[][] = [[]];
const ecsData: Ecs[] = [];
const tableId = TableId.test;
const pageSize = 10;
test('columns without any link action (e.g.: signal.status) should return an empty component (not null or data grid would crash)', () => {
const columnHeaders = [
{
category: 'signal',
columnHeaderType: 'no-filtered' as ColumnHeaderType,
id: 'signal.status',
type: 'string',
aggregatable: true,
initialWidth: 105,
},
];
const columnsWithCellActions: EuiDataGridColumn[] = columnHeaders.map((header) => {
const buildAction = (dataTableCellAction: DataTableCellAction) =>
dataTableCellAction({
browserFields,
data,
ecsData,
header: columnHeaders.find((h) => h.id === header.id),
pageSize,
scopeId: tableId,
}) as EuiDataGridColumnCellAction;
return {
...header,
cellActions: defaultCellActions?.map(buildAction),
};
});
expect(columnsWithCellActions[0]?.cellActions?.length).toEqual(5);
});
const columnHeadersToTest = COLUMNS_WITH_LINKS.map((c) => [
{
category: 'signal',
columnHeaderType: 'no-filtered' as ColumnHeaderType,
id: c.columnId,
type: c.fieldType,
aggregatable: true,
initialWidth: 105,
},
]);
describe.each(columnHeadersToTest)('columns with a link action', (columnHeaders) => {
test(`${columnHeaders.id ?? columnHeaders.type}`, () => {
const columnsWithCellActions: EuiDataGridColumn[] = [columnHeaders].map((header) => {
const buildAction = (dataTableCellAction: DataTableCellAction) =>
dataTableCellAction({
browserFields,
data,
ecsData,
header: [columnHeaders].find((h) => h.id === header.id),
pageSize,
scopeId: tableId,
}) as EuiDataGridColumnCellAction;
return {
...header,
cellActions: defaultCellActions?.map(buildAction),
};
});
expect(columnsWithCellActions[0]?.cellActions?.length).toEqual(5);
expect(columnsWithCellActions[0]?.cellActions![4]).not.toEqual(EmptyComponent);
});
});
});

View file

@ -1,24 +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 type { DataTableCellAction } from '../../../../common/types';
import { getFilterForCellAction } from './filter_for';
import { getFilterOutCellAction } from './filter_out';
import { getAddToTimelineCellAction } from './add_to_timeline';
import { getCopyCellAction } from './copy';
import { FieldValueCell } from './field_value';
export const cellActions: DataTableCellAction[] = [
getFilterForCellAction,
getFilterOutCellAction,
getAddToTimelineCellAction,
getCopyCellAction,
FieldValueCell,
];
/** the default actions shown in `EuiDataGrid` cells */
export const defaultCellActions = [...cellActions];

View file

@ -1,159 +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 type { EuiDataGridColumnCellActionProps } from '@elastic/eui';
import { head, getOr, get, isEmpty } from 'lodash/fp';
import React, { useMemo } from 'react';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { getPageRowIndex } from '@kbn/securitysolution-data-table';
import type { ColumnHeaderOptions } from '../../../../common/types';
import type { TimelineNonEcsData } from '../../../../common/search_strategy';
import { useGetMappedNonEcsValue } from '../../../timelines/components/timeline/body/data_driven_columns';
import { FormattedFieldValue } from '../../../timelines/components/timeline/body/renderers/formatted_field';
import { parseValue } from '../../../timelines/components/timeline/body/renderers/parse_value';
import { EmptyComponent, getLinkColumnDefinition } from './helpers';
import { getField, getFieldKey } from '../../../helpers';
const useFormattedFieldProps = ({
rowIndex,
pageSize,
ecsData,
columnId,
header,
data,
}: {
rowIndex: number;
data: TimelineNonEcsData[][];
ecsData: Ecs[];
header?: ColumnHeaderOptions;
columnId: string;
pageSize: number;
}) => {
const pageRowIndex = getPageRowIndex(rowIndex, pageSize);
const ecs = ecsData[pageRowIndex];
const link = getLinkColumnDefinition(columnId, header?.type, header?.linkField);
const linkField = header?.linkField ? header?.linkField : link?.linkField;
const linkValues = header && getOr([], linkField ?? '', ecs);
const eventId = (header && get('_id' ?? '', ecs)) || '';
const rowData = useMemo(() => {
return {
data: data[pageRowIndex],
fieldName: columnId,
};
}, [pageRowIndex, columnId, data]);
const values = useGetMappedNonEcsValue(rowData);
const value = parseValue(head(values));
const title = values && values.length > 1 ? `${link?.label}: ${value}` : link?.label;
// if linkField is defined but link values is empty, it's possible we are trying to look for a column definition for an old event set
if (linkField !== undefined && linkValues.length === 0 && values !== undefined) {
const normalizedLinkValue = getField(ecs, linkField);
const normalizedLinkField = getFieldKey(ecs, linkField);
const normalizedColumnId = getFieldKey(ecs, columnId);
const normalizedLink = getLinkColumnDefinition(
normalizedColumnId,
header?.type,
normalizedLinkField
);
return {
pageRowIndex,
link: normalizedLink,
eventId,
fieldFormat: header?.format || '',
fieldName: normalizedColumnId,
fieldType: header?.type || '',
value: parseValue(head(normalizedColumnId)),
values,
title,
linkValue: head<string>(normalizedLinkValue),
};
} else {
return {
pageRowIndex,
link,
eventId,
fieldFormat: header?.format || '',
fieldName: columnId,
fieldType: header?.type || '',
value,
values,
title,
linkValue: head<string>(linkValues),
};
}
};
export const FieldValueCell = ({
data,
ecsData,
header,
scopeId,
pageSize,
closeCellPopover,
}: {
data: TimelineNonEcsData[][];
ecsData: Ecs[];
header?: ColumnHeaderOptions;
scopeId: string;
pageSize: number;
closeCellPopover?: () => void;
}) => {
if (header !== undefined) {
return function FieldValue({
rowIndex,
columnId,
Component,
}: EuiDataGridColumnCellActionProps) {
const {
pageRowIndex,
link,
eventId,
value,
values,
title,
fieldName,
fieldFormat,
fieldType,
linkValue,
} = useFormattedFieldProps({ rowIndex, pageSize, ecsData, columnId, header, data });
const showEmpty = useMemo(() => {
const hasLink = link !== undefined && values && !isEmpty(value);
if (pageRowIndex >= data.length) {
return true;
} else {
return hasLink !== true;
}
}, [link, pageRowIndex, value, values]);
return showEmpty === false ? (
<FormattedFieldValue
Component={Component}
contextId={`expanded-value-${columnId}-row-${pageRowIndex}-${scopeId}`}
eventId={eventId}
fieldFormat={fieldFormat}
isAggregatable={header.aggregatable ?? false}
fieldName={fieldName}
fieldType={fieldType}
isButton={true}
isDraggable={false}
value={value}
truncate={false}
title={title}
linkValue={linkValue}
onClick={closeCellPopover}
/>
) : (
// data grid expects each cell action always return an element, it crashes if returns null
EmptyComponent
);
};
} else {
return EmptyComponent;
}
};

View file

@ -1,50 +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 React from 'react';
import { render } from '@testing-library/react';
import type { TimelineNonEcsData } from '@kbn/timelines-plugin/common';
import { getFilterForCellAction } from './filter_for';
jest.mock('../kibana');
describe('getFilterForCellAction', () => {
const sampleData: TimelineNonEcsData = {
field: 'fizz',
value: ['buzz'],
};
const testComponent = () => <></>;
const componentProps = {
colIndex: 1,
rowIndex: 1,
columnId: 'fizz',
Component: testComponent,
isExpanded: false,
};
describe('when data property is', () => {
test('undefined', () => {
const CellComponent = getFilterForCellAction({ pageSize: 1, data: undefined });
const result = render(<CellComponent {...componentProps} />);
expect(result.container).toBeEmptyDOMElement();
});
test('empty', () => {
const CellComponent = getFilterForCellAction({ pageSize: 1, data: [] });
const result = render(<CellComponent {...componentProps} />);
expect(result.container).toBeEmptyDOMElement();
});
});
describe('FilterForCellAction', () => {
const data: TimelineNonEcsData[][] = [[sampleData]];
test('should render with data', () => {
const FilterForCellAction = getFilterForCellAction({ pageSize: 1, data });
const result = render(<FilterForCellAction {...componentProps} />);
expect(result.getByTestId('test-filter-for')).toBeInTheDocument();
});
});
});

View file

@ -1,63 +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 type { EuiDataGridColumnCellActionProps } from '@elastic/eui';
import React, { useMemo } from 'react';
import type { TimelineNonEcsData } from '@kbn/timelines-plugin/common/search_strategy';
import { getPageRowIndex } from '@kbn/securitysolution-data-table';
import { useGetMappedNonEcsValue } from '../../../timelines/components/timeline/body/data_driven_columns';
import { EmptyComponent, onFilterAdded, useKibanaServices } from './helpers';
export const getFilterForCellAction = ({
data,
pageSize,
closeCellPopover,
}: {
data?: TimelineNonEcsData[][];
pageSize: number;
closeCellPopover?: () => void;
}) =>
data && data.length > 0
? function FilterFor({ rowIndex, columnId, Component }: EuiDataGridColumnCellActionProps) {
const { timelines, filterManager } = useKibanaServices();
const pageRowIndex = getPageRowIndex(rowIndex, pageSize);
const rowData = useMemo(() => {
return {
data: data[pageRowIndex],
fieldName: columnId,
};
}, [pageRowIndex, columnId]);
const value = useGetMappedNonEcsValue(rowData);
const filterForButton = useMemo(
() => timelines.getHoverActions().getFilterForValueButton,
[timelines]
);
const filterForProps = useMemo(() => {
return {
Component,
field: columnId,
filterManager,
onFilterAdded,
ownFocus: false,
showTooltip: false,
value,
onClick: closeCellPopover,
};
}, [Component, columnId, filterManager, value]);
// data grid expects each cell action always return an element, it crashes if returns null
return pageRowIndex >= data.length ? (
<>{EmptyComponent}</>
) : (
<>{filterForButton(filterForProps)}</>
);
}
: EmptyComponent;

View file

@ -1,50 +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 React from 'react';
import { render } from '@testing-library/react';
import type { TimelineNonEcsData } from '@kbn/timelines-plugin/common';
import { getFilterOutCellAction } from './filter_out';
jest.mock('../kibana');
describe('getFilterOutCellAction', () => {
const sampleData: TimelineNonEcsData = {
field: 'fizz',
value: ['buzz'],
};
const testComponent = () => <></>;
const componentProps = {
colIndex: 1,
rowIndex: 1,
columnId: 'fizz',
Component: testComponent,
isExpanded: false,
};
describe('when data property is', () => {
test('undefined', () => {
const CellComponent = getFilterOutCellAction({ pageSize: 1, data: undefined });
const result = render(<CellComponent {...componentProps} />);
expect(result.container).toBeEmptyDOMElement();
});
test('empty', () => {
const CellComponent = getFilterOutCellAction({ pageSize: 1, data: [] });
const result = render(<CellComponent {...componentProps} />);
expect(result.container).toBeEmptyDOMElement();
});
});
describe('FilterOutCellAction', () => {
const data: TimelineNonEcsData[][] = [[sampleData]];
test('should render with data', () => {
const FilterOutCellAction = getFilterOutCellAction({ pageSize: 1, data });
const result = render(<FilterOutCellAction {...componentProps} />);
expect(result.getByTestId('test-filter-out')).toBeInTheDocument();
});
});
});

View file

@ -1,63 +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 type { EuiDataGridColumnCellActionProps } from '@elastic/eui';
import React, { useMemo } from 'react';
import type { TimelineNonEcsData } from '@kbn/timelines-plugin/common/search_strategy';
import { getPageRowIndex } from '@kbn/securitysolution-data-table';
import { useGetMappedNonEcsValue } from '../../../timelines/components/timeline/body/data_driven_columns';
import { EmptyComponent, onFilterAdded, useKibanaServices } from './helpers';
export const getFilterOutCellAction = ({
data,
pageSize,
closeCellPopover,
}: {
data?: TimelineNonEcsData[][];
pageSize: number;
closeCellPopover?: () => void;
}) =>
data && data.length > 0
? function FilterOut({ rowIndex, columnId, Component }: EuiDataGridColumnCellActionProps) {
const { timelines, filterManager } = useKibanaServices();
const pageRowIndex = getPageRowIndex(rowIndex, pageSize);
const rowData = useMemo(() => {
return {
data: data[pageRowIndex],
fieldName: columnId,
};
}, [pageRowIndex, columnId]);
const value = useGetMappedNonEcsValue(rowData);
const filterOutButton = useMemo(
() => timelines.getHoverActions().getFilterOutValueButton,
[timelines]
);
const filterOutProps = useMemo(() => {
return {
Component,
field: columnId,
filterManager,
onFilterAdded,
ownFocus: false,
showTooltip: false,
value,
onClick: closeCellPopover,
};
}, [Component, columnId, filterManager, value]);
// data grid expects each cell action always return an element, it crashes if returns null
return pageRowIndex >= data.length ? (
<>{EmptyComponent}</>
) : (
<>{filterOutButton(filterOutProps)}</>
);
}
: EmptyComponent;

View file

@ -7,20 +7,6 @@
import { i18n } from '@kbn/i18n';
export const SHOW_TOP_VALUES = i18n.translate(
'xpack.securitySolution.expandedValue.showTopN.showTopValues',
{
defaultMessage: 'Show top values',
}
);
export const HIDE_TOP_VALUES = i18n.translate(
'xpack.securitySolution.expandedValue.hideTopValues.HideTopValues',
{
defaultMessage: 'Hide top values',
}
);
export const VIEW_HOST_SUMMARY = i18n.translate(
'xpack.securitySolution.expandedValue.links.viewHostSummary',
{

View file

@ -94,6 +94,8 @@ const platinumBaseColumns = [
{ columnHeaderType: 'not-filtered', id: 'destination.ip' },
];
const dataViewId = 'security-solution-default';
describe('alerts default_config', () => {
describe('buildAlertsRuleIdFilter', () => {
test('given a rule id this will return an array with a single filter', () => {
@ -102,6 +104,7 @@ describe('alerts default_config', () => {
meta: {
alias: null,
negate: false,
index: dataViewId,
disabled: false,
type: 'phrase',
key: 'kibana.alert.rule.rule_id',
@ -126,6 +129,7 @@ describe('alerts default_config', () => {
meta: {
alias: null,
disabled: false,
index: dataViewId,
negate: false,
key: 'kibana.alert.rule.type',
type: 'term',
@ -249,6 +253,7 @@ describe('alerts default_config', () => {
meta: {
alias: null,
disabled: false,
index: dataViewId,
negate: false,
},
query: {

View file

@ -105,6 +105,7 @@ export const buildAlertsFilter = (ruleStaticId: string | null): Filter[] =>
meta: {
alias: null,
negate: false,
index: 'security-solution-default',
disabled: false,
type: 'phrase',
key: ALERT_RULE_RULE_ID,
@ -133,6 +134,7 @@ export const buildShowBuildingBlockFilter = (showBuildingBlockAlerts: boolean):
type: 'exists',
key: ALERT_BUILDING_BLOCK_TYPE,
value: 'exists',
index: 'security-solution-default',
},
query: { exists: { field: ALERT_BUILDING_BLOCK_TYPE } },
},
@ -148,6 +150,7 @@ export const buildThreatMatchFilter = (showOnlyThreatIndicatorAlerts: boolean):
negate: false,
key: 'kibana.alert.rule.type',
type: 'term',
index: 'security-solution-default',
},
query: { term: { 'kibana.alert.rule.type': 'threat_match' } },
},
@ -178,6 +181,7 @@ export const buildAlertAssigneesFilter = (assigneesIds: AssigneesIdsSelection[])
alias: null,
negate: false,
disabled: false,
index: 'security-solution-default',
},
query: combinedQuery,
},

View file

@ -16,6 +16,7 @@ import { VIEW_SELECTION } from '../../../../common/constants';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
import { useShallowEqualSelector } from '../../../common/hooks/use_selector';
import { useGetFieldSpec } from '../../../common/hooks/use_get_field_spec';
import { useDataViewId } from '../../../common/hooks/use_data_view_id';
export const getUseCellActionsHook = (tableId: TableId) => {
const useCellActions: AlertsTableConfigurationRegistry['useCellActions'] = ({
@ -24,6 +25,7 @@ export const getUseCellActionsHook = (tableId: TableId) => {
dataGridRef,
}) => {
const getFieldSpec = useGetFieldSpec(SourcererScopeName.detections);
const dataViewId = useDataViewId(SourcererScopeName.detections);
/**
* There is difference between how `triggers actions` fetched data v/s
* how security solution fetches data via timelineSearchStrategy
@ -58,7 +60,7 @@ export const getUseCellActionsHook = (tableId: TableId) => {
useShallowEqualSelector((state) => (getTable(state, tableId) ?? tableDefaults).viewMode) ??
tableDefaults.viewMode;
const cellActionsMetadata = useMemo(() => ({ scopeId: tableId }), []);
const cellActionsMetadata = useMemo(() => ({ scopeId: tableId, dataViewId }), [dataViewId]);
const cellActionsFields = useMemo<UseDataGridColumnsSecurityCellActionsProps['fields']>(() => {
if (viewMode === VIEW_SELECTION.eventRenderedView) {

View file

@ -160,6 +160,7 @@ jest.mock('../../../timelines/components/side_panel/hooks/use_detail_panel', ()
}),
};
});
const dataViewId = 'security-solution-default';
const stateWithBuildingBlockAlertsEnabled: State = {
...mockGlobalState,
@ -278,6 +279,7 @@ describe('DetectionEnginePageComponent', () => {
type: 'exists',
key: 'kibana.alert.building_block_type',
value: 'exists',
index: dataViewId,
},
query: {
exists: {
@ -316,6 +318,7 @@ describe('DetectionEnginePageComponent', () => {
type: 'exists',
key: 'kibana.alert.building_block_type',
value: 'exists',
index: dataViewId,
},
query: {
exists: {

View file

@ -30,7 +30,7 @@ export const KubernetesContainer = React.memo(() => {
const { kubernetesSecurity, uiSettings } = useKibana().services;
const { globalFullScreen } = useGlobalFullScreen();
const { indexPattern, sourcererDataView } = useSourcererDataView();
const { indexPattern, sourcererDataView, dataViewId } = useSourcererDataView();
const { from, to } = useGlobalTime();
const getGlobalFiltersQuerySelector = useMemo(
@ -91,6 +91,7 @@ export const KubernetesContainer = React.memo(() => {
endDate: to,
},
renderSessionsView,
dataViewId: dataViewId ?? undefined,
})}
<SpyRoute pageName={SecurityPageName.kubernetes} />
</SecuritySolutionPageWrapper>

View file

@ -34,10 +34,13 @@ const FilterForValueButton: React.FC<FilterForValueProps> = React.memo(
size,
showTooltip = false,
value,
dataViewId,
}) => {
const filterForValueFn = useCallback(() => {
const makeFilter = (currentVal: string | null | undefined) =>
currentVal?.length === 0 ? createFilter(field, undefined) : createFilter(field, currentVal);
currentVal?.length === 0
? createFilter(field, undefined, false, dataViewId)
: createFilter(field, currentVal, false, dataViewId);
const filters = Array.isArray(value)
? value.map((currentVal: string | null | undefined) => makeFilter(currentVal))
: makeFilter(value);
@ -54,7 +57,7 @@ const FilterForValueButton: React.FC<FilterForValueProps> = React.memo(
if (onClick != null) {
onClick();
}
}, [field, filterManager, onClick, onFilterAdded, value]);
}, [dataViewId, field, filterManager, onClick, onFilterAdded, value]);
useEffect(() => {
if (!ownFocus) {

View file

@ -33,12 +33,13 @@ const FilterOutValueButton: React.FC<HoverActionComponentProps & FilterValueFnAr
size,
showTooltip = false,
value,
dataViewId,
}) => {
const filterOutValueFn = useCallback(() => {
const makeFilter = (currentVal: string | null | undefined) =>
currentVal == null || currentVal?.length === 0
? createFilter(field, null, false)
: createFilter(field, currentVal, true);
? createFilter(field, null, false, dataViewId)
: createFilter(field, currentVal, true, dataViewId);
const filters = Array.isArray(value)
? value.map((currentVal: string | null | undefined) => makeFilter(currentVal))
: makeFilter(value);
@ -54,7 +55,7 @@ const FilterOutValueButton: React.FC<HoverActionComponentProps & FilterValueFnAr
if (onClick != null) {
onClick();
}
}, [field, filterManager, onClick, onFilterAdded, value]);
}, [field, filterManager, onClick, onFilterAdded, value, dataViewId]);
useEffect(() => {
if (!ownFocus) {

View file

@ -15,6 +15,7 @@ export interface FilterValueFnArgs {
value: string[] | string | null | undefined;
filterManager: FilterManager | undefined;
onFilterAdded: (() => void) | undefined;
dataViewId?: string;
}
export interface HoverActionComponentProps {

View file

@ -24,7 +24,8 @@ export const getAdditionalScreenReaderOnlyContext = ({
export const createFilter = (
key: string,
value: string[] | string | null | undefined,
negate: boolean = false
negate: boolean = false,
index?: string
): Filter => {
const queryValue = value != null ? (Array.isArray(value) ? value[0] : value) : null;
return queryValue != null
@ -39,6 +40,7 @@ export const createFilter = (
params: {
query: queryValue,
},
index,
},
query: {
match: {
@ -60,6 +62,7 @@ export const createFilter = (
negate: value === undefined,
type: 'exists',
value: 'exists',
index,
},
} as Filter);
};

View file

@ -34433,7 +34433,6 @@
"xpack.securitySolution.exceptionsTable.manageRulesErrorDescription": "Une erreur s'est produite lors de l'association ou de la dissociation des règles",
"xpack.securitySolution.exceptionsTable.rulesCountLabel": "Règles",
"xpack.securitySolution.exitFullScreenButton": "Quitter le plein écran",
"xpack.securitySolution.expandedValue.hideTopValues.HideTopValues": "Masquer les valeurs les plus élevées",
"xpack.securitySolution.expandedValue.links.expandIpDetails": "Développer les détails d'IP",
"xpack.securitySolution.expandedValue.links.viewEventReference": "Afficher la référence de l'événement",
"xpack.securitySolution.expandedValue.links.viewHostSummary": "Afficher le résumé de l'hôte",
@ -34442,7 +34441,6 @@
"xpack.securitySolution.expandedValue.links.viewRuleDetails": "Afficher les détails de la règle",
"xpack.securitySolution.expandedValue.links.viewRuleReference": "Afficher la référence de la règle",
"xpack.securitySolution.expandedValue.links.viewUserSummary": "Afficher le résumé de l'utilisateur",
"xpack.securitySolution.expandedValue.showTopN.showTopValues": "Afficher les valeurs les plus élevées",
"xpack.securitySolution.explore.landing.pageTitle": "Explorer",
"xpack.securitySolution.featureCatalogueDescription": "Prévenez, collectez, détectez et traitez les menaces pour une protection unifiée dans toute votre infrastructure.",
"xpack.securitySolution.fieldBrowser.actionsLabel": "Actions",

View file

@ -34433,7 +34433,6 @@
"xpack.securitySolution.exceptionsTable.manageRulesErrorDescription": "ルールのリンクまたはリンク解除中にエラーが発生しました",
"xpack.securitySolution.exceptionsTable.rulesCountLabel": "ルール",
"xpack.securitySolution.exitFullScreenButton": "全画面を終了",
"xpack.securitySolution.expandedValue.hideTopValues.HideTopValues": "上位の値を非表示",
"xpack.securitySolution.expandedValue.links.expandIpDetails": "IP詳細を展開",
"xpack.securitySolution.expandedValue.links.viewEventReference": "イベント参照を表示",
"xpack.securitySolution.expandedValue.links.viewHostSummary": "ホスト概要を表示",
@ -34442,7 +34441,6 @@
"xpack.securitySolution.expandedValue.links.viewRuleDetails": "ルール詳細を表示",
"xpack.securitySolution.expandedValue.links.viewRuleReference": "ルール参照を表示",
"xpack.securitySolution.expandedValue.links.viewUserSummary": "ユーザー概要を表示",
"xpack.securitySolution.expandedValue.showTopN.showTopValues": "上位の値を表示",
"xpack.securitySolution.explore.landing.pageTitle": "探索",
"xpack.securitySolution.featureCatalogueDescription": "インフラストラクチャー全体の統合保護のため、脅威を防止、収集、検出し、それに対応します。",
"xpack.securitySolution.fieldBrowser.actionsLabel": "アクション",

View file

@ -34415,7 +34415,6 @@
"xpack.securitySolution.exceptionsTable.manageRulesErrorDescription": "链接或取消链接规则时出错",
"xpack.securitySolution.exceptionsTable.rulesCountLabel": "规则",
"xpack.securitySolution.exitFullScreenButton": "退出全屏",
"xpack.securitySolution.expandedValue.hideTopValues.HideTopValues": "隐藏排名最前值",
"xpack.securitySolution.expandedValue.links.expandIpDetails": "展开 IP 详情",
"xpack.securitySolution.expandedValue.links.viewEventReference": "查看事件参考",
"xpack.securitySolution.expandedValue.links.viewHostSummary": "查看主机摘要",
@ -34424,7 +34423,6 @@
"xpack.securitySolution.expandedValue.links.viewRuleDetails": "查看规则详情",
"xpack.securitySolution.expandedValue.links.viewRuleReference": "查看规则参考",
"xpack.securitySolution.expandedValue.links.viewUserSummary": "查看用户摘要",
"xpack.securitySolution.expandedValue.showTopN.showTopValues": "显示排名最前值",
"xpack.securitySolution.explore.landing.pageTitle": "浏览",
"xpack.securitySolution.featureCatalogueDescription": "预防、收集、检测和响应威胁,以对整个基础架构提供统一的保护。",
"xpack.securitySolution.fieldBrowser.actionsLabel": "操作",