mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[8.4] [Security Solution] [Kubernetes Security] Implement sessions and root login widgets (#133936)
* Implement sessions and root login percent widgets * Hook global filters with k8s percentage chart * Update loading state add sortByCount ft
This commit is contained in:
parent
89de75d953
commit
07e4ca46ef
22 changed files with 1120 additions and 49 deletions
|
@ -14,3 +14,13 @@ export const AGGREGATE_PAGE_SIZE = 10;
|
|||
// so, bucket sort can only page through what we request at the top level agg, which means there is a ceiling to how many aggs we can page through.
|
||||
// we should also test this approach at scale.
|
||||
export const AGGREGATE_MAX_BUCKETS = 2000;
|
||||
|
||||
// react-query caching keys
|
||||
export const QUERY_KEY_PERCENT_WIDGET = 'kubernetesSecurityPercentWidget';
|
||||
|
||||
export const DEFAULT_QUERY = '{"bool":{"must":[],"filter":[],"should":[],"must_not":[]}}';
|
||||
|
||||
// ECS fields
|
||||
export const ENTRY_LEADER_INTERACTIVE = 'process.entry_leader.interactive';
|
||||
export const ENTRY_LEADER_USER_ID = 'process.entry_leader.user.id';
|
||||
export const ENTRY_LEADER_ENTITY_ID = 'process.entry_leader.entity_id';
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export interface AggregateResult {
|
||||
key: string | number;
|
||||
key_as_string?: string;
|
||||
doc_count: number;
|
||||
count_by_aggs: {
|
||||
value: number;
|
||||
};
|
||||
}
|
|
@ -12,7 +12,9 @@
|
|||
"ruleRegistry",
|
||||
"sessionView"
|
||||
],
|
||||
"requiredBundles": [],
|
||||
"requiredBundles": [
|
||||
"kibanaReact"
|
||||
],
|
||||
"server": true,
|
||||
"ui": true
|
||||
}
|
||||
|
|
|
@ -8,14 +8,19 @@
|
|||
import React from 'react';
|
||||
// eslint-disable-next-line @kbn/eslint/module_migration
|
||||
import { MemoryRouterProps } from 'react-router';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { KubernetesSecurityRoutes } from '.';
|
||||
import { createAppRootMockRenderer } from '../../test';
|
||||
|
||||
jest.mock('../kubernetes_widget', () => ({
|
||||
KubernetesWidget: () => <div>{'Mock kubernetes widget'}</div>,
|
||||
}));
|
||||
|
||||
jest.mock('../percent_widget', () => ({
|
||||
PercentWidget: () => <div>{'Mock percent widget'}</div>,
|
||||
}));
|
||||
|
||||
const renderWithRouter = (
|
||||
initialEntries: MemoryRouterProps['initialEntries'] = ['/kubernetes']
|
||||
) => {
|
||||
|
@ -40,9 +45,17 @@ const renderWithRouter = (
|
|||
},
|
||||
};
|
||||
});
|
||||
return render(
|
||||
const mockedContext = createAppRootMockRenderer();
|
||||
return mockedContext.render(
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
<KubernetesSecurityRoutes filter={<div>{'Mock filters'}</div>} />
|
||||
<KubernetesSecurityRoutes
|
||||
filter={<div>{'Mock filters'}</div>}
|
||||
globalFilter={{
|
||||
filterQuery: '{"bool":{"must":[],"filter":[],"should":[],"must_not":[]}}',
|
||||
startDate: '2022-03-08T18:52:15.532Z',
|
||||
endDate: '2022-06-09T17:52:15.532Z',
|
||||
}}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
@ -51,5 +64,6 @@ describe('Kubernetes security routes', () => {
|
|||
it('navigates to the kubernetes page', () => {
|
||||
renderWithRouter();
|
||||
expect(screen.getAllByText('Mock kubernetes widget')).toHaveLength(3);
|
||||
expect(screen.getAllByText('Mock percent widget')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,41 +5,64 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import {
|
||||
EuiBadge,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiIconTip,
|
||||
EuiLoadingContent,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
EuiTextColor,
|
||||
} from '@elastic/eui';
|
||||
import { CSSObject } from '@emotion/react';
|
||||
import { KUBERNETES_PATH } from '../../../common/constants';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { euiThemeVars } from '@kbn/ui-theme';
|
||||
import {
|
||||
KUBERNETES_PATH,
|
||||
ENTRY_LEADER_INTERACTIVE,
|
||||
ENTRY_LEADER_USER_ID,
|
||||
ENTRY_LEADER_ENTITY_ID,
|
||||
} from '../../../common/constants';
|
||||
import { KubernetesWidget } from '../kubernetes_widget';
|
||||
import { PercentWidget } from '../percent_widget';
|
||||
import { KubernetesSecurityDeps } from '../../types';
|
||||
import { AggregateResult } from '../../../common/types/aggregate';
|
||||
import { useStyles } from './styles';
|
||||
|
||||
const widgetBadge: CSSObject = {
|
||||
position: 'absolute',
|
||||
bottom: '16px',
|
||||
left: '16px',
|
||||
width: 'calc(100% - 32px)',
|
||||
fontSize: '12px',
|
||||
lineHeight: '18px',
|
||||
padding: '4px 8px',
|
||||
display: 'flex',
|
||||
};
|
||||
const KubernetesSecurityRoutesComponent = ({
|
||||
filter,
|
||||
indexPattern,
|
||||
globalFilter,
|
||||
}: KubernetesSecurityDeps) => {
|
||||
const styles = useStyles();
|
||||
|
||||
const treeViewContainer: CSSObject = {
|
||||
position: 'relative',
|
||||
border: '1px solid #D3DAE6',
|
||||
borderRadius: '6px',
|
||||
padding: '16px',
|
||||
height: '500px',
|
||||
};
|
||||
const onReduceInteractiveAggs = useCallback(
|
||||
(result: AggregateResult[]): Record<string, number> =>
|
||||
result.reduce((groupedByKeyValue, aggregate) => {
|
||||
groupedByKeyValue[aggregate.key_as_string || (aggregate.key.toString() as string)] =
|
||||
aggregate.count_by_aggs.value;
|
||||
return groupedByKeyValue;
|
||||
}, {} as Record<string, number>),
|
||||
[]
|
||||
);
|
||||
|
||||
const onReduceRootAggs = useCallback(
|
||||
(result: AggregateResult[]): Record<string, number> =>
|
||||
result.reduce((groupedByKeyValue, aggregate) => {
|
||||
if (aggregate.key === '0') {
|
||||
groupedByKeyValue[aggregate.key] = aggregate.count_by_aggs.value;
|
||||
} else {
|
||||
groupedByKeyValue.nonRoot =
|
||||
(groupedByKeyValue.nonRoot || 0) + aggregate.count_by_aggs.value;
|
||||
}
|
||||
return groupedByKeyValue;
|
||||
}, {} as Record<string, number>),
|
||||
[]
|
||||
);
|
||||
|
||||
const KubernetesSecurityRoutesComponent = ({ filter }: KubernetesSecurityDeps) => {
|
||||
return (
|
||||
<Switch>
|
||||
<Route strict exact path={KUBERNETES_PATH}>
|
||||
|
@ -58,7 +81,7 @@ const KubernetesSecurityRoutesComponent = ({ filter }: KubernetesSecurityDeps) =
|
|||
href="#"
|
||||
target="blank"
|
||||
css={{
|
||||
...widgetBadge,
|
||||
...styles.widgetBadge,
|
||||
'.euiBadge__content': {
|
||||
width: '100%',
|
||||
'.euiBadge__text': {
|
||||
|
@ -77,7 +100,7 @@ const KubernetesSecurityRoutesComponent = ({ filter }: KubernetesSecurityDeps) =
|
|||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<KubernetesWidget title="Pods" icon="package" iconColor="warning" data={775}>
|
||||
<EuiBadge css={{ ...widgetBadge, justifyContent: 'center' }}>
|
||||
<EuiBadge css={{ ...styles.widgetBadge, justifyContent: 'center' }}>
|
||||
<EuiTextColor css={{ marginRight: '16px' }} color="success">
|
||||
<span css={{ fontWeight: 700 }}>1000</span>
|
||||
{' live'}
|
||||
|
@ -89,7 +112,98 @@ const KubernetesSecurityRoutesComponent = ({ filter }: KubernetesSecurityDeps) =
|
|||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="m" />
|
||||
<div css={treeViewContainer}>
|
||||
<EuiFlexGroup css={styles.percentageWidgets}>
|
||||
<EuiFlexItem>
|
||||
<PercentWidget
|
||||
title={
|
||||
<>
|
||||
<EuiText size="xs" css={styles.percentageChartTitle}>
|
||||
<FormattedMessage
|
||||
id="xpack.kubernetesSecurity.sessionsChart.title"
|
||||
defaultMessage="Sessions"
|
||||
/>
|
||||
</EuiText>
|
||||
<EuiIconTip
|
||||
content={
|
||||
<FormattedMessage
|
||||
id="xpack.kubernetesSecurity.sessionsChart.tooltip"
|
||||
defaultMessage="Sessions icon tip placeholder"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
widgetKey="sessionsPercentage"
|
||||
indexPattern={indexPattern}
|
||||
globalFilter={globalFilter}
|
||||
dataValueMap={{
|
||||
true: {
|
||||
name: i18n.translate('xpack.kubernetesSecurity.sessionsChart.interactive', {
|
||||
defaultMessage: 'Interactive',
|
||||
}),
|
||||
fieldName: ENTRY_LEADER_INTERACTIVE,
|
||||
color: euiThemeVars.euiColorVis0,
|
||||
},
|
||||
false: {
|
||||
name: i18n.translate('xpack.kubernetesSecurity.sessionsChart.nonInteractive', {
|
||||
defaultMessage: 'Non-interactive',
|
||||
}),
|
||||
fieldName: ENTRY_LEADER_INTERACTIVE,
|
||||
color: euiThemeVars.euiColorVis1,
|
||||
},
|
||||
}}
|
||||
groupedBy={ENTRY_LEADER_INTERACTIVE}
|
||||
countBy={ENTRY_LEADER_ENTITY_ID}
|
||||
onReduce={onReduceInteractiveAggs}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<PercentWidget
|
||||
title={
|
||||
<>
|
||||
<EuiText size="xs" css={styles.percentageChartTitle}>
|
||||
<FormattedMessage
|
||||
id="xpack.kubernetesSecurity.userLoginChart.title"
|
||||
defaultMessage="Sessions with entry root users"
|
||||
/>
|
||||
</EuiText>
|
||||
<EuiIconTip
|
||||
content={
|
||||
<FormattedMessage
|
||||
id="xpack.kubernetesSecurity.userLoginChart.tooltip"
|
||||
defaultMessage="Sessions with entry root users icon tip placeholder"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
widgetKey="rootLoginPercentage"
|
||||
indexPattern={indexPattern}
|
||||
globalFilter={globalFilter}
|
||||
dataValueMap={{
|
||||
'0': {
|
||||
name: i18n.translate('xpack.kubernetesSecurity.userLoginChart.root', {
|
||||
defaultMessage: 'Root',
|
||||
}),
|
||||
fieldName: ENTRY_LEADER_USER_ID,
|
||||
color: euiThemeVars.euiColorVis2,
|
||||
},
|
||||
nonRoot: {
|
||||
name: i18n.translate('xpack.kubernetesSecurity.userLoginChart.nonRoot', {
|
||||
defaultMessage: 'Non-root',
|
||||
}),
|
||||
fieldName: ENTRY_LEADER_USER_ID,
|
||||
color: euiThemeVars.euiColorVis3,
|
||||
shouldHideFilter: true,
|
||||
},
|
||||
}}
|
||||
groupedBy={ENTRY_LEADER_USER_ID}
|
||||
countBy={ENTRY_LEADER_ENTITY_ID}
|
||||
onReduce={onReduceRootAggs}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<div css={styles.treeViewContainer}>
|
||||
<EuiLoadingContent lines={3} />
|
||||
<EuiLoadingContent lines={3} />
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* 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 { useMemo } from 'react';
|
||||
import { CSSObject } from '@emotion/react';
|
||||
import { useEuiTheme } from '../../hooks';
|
||||
|
||||
export const useStyles = () => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
const cached = useMemo(() => {
|
||||
const { size, font } = euiTheme;
|
||||
|
||||
const widgetBadge: CSSObject = {
|
||||
position: 'absolute',
|
||||
bottom: size.base,
|
||||
left: size.base,
|
||||
width: `calc(100% - ${size.xl})`,
|
||||
fontSize: size.m,
|
||||
lineHeight: '18px',
|
||||
padding: `${size.xs} ${size.s}`,
|
||||
display: 'flex',
|
||||
};
|
||||
|
||||
const treeViewContainer: CSSObject = {
|
||||
position: 'relative',
|
||||
border: euiTheme.border.thin,
|
||||
borderRadius: euiTheme.border.radius.medium,
|
||||
padding: size.base,
|
||||
height: '500px',
|
||||
};
|
||||
|
||||
const percentageWidgets: CSSObject = {
|
||||
marginBottom: size.l,
|
||||
};
|
||||
|
||||
const percentageChartTitle: CSSObject = {
|
||||
marginRight: size.xs,
|
||||
display: 'inline',
|
||||
fontWeight: font.weight.bold,
|
||||
};
|
||||
|
||||
return {
|
||||
widgetBadge,
|
||||
treeViewContainer,
|
||||
percentageWidgets,
|
||||
percentageChartTitle,
|
||||
};
|
||||
}, [euiTheme]);
|
||||
|
||||
return cached;
|
||||
};
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* 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 { useQuery } from 'react-query';
|
||||
import { CoreStart } from '@kbn/core/public';
|
||||
import { useKibana } from '@kbn/kibana-react-plugin/public';
|
||||
import { QUERY_KEY_PERCENT_WIDGET, AGGREGATE_ROUTE } from '../../../common/constants';
|
||||
import { AggregateResult } from '../../../common/types/aggregate';
|
||||
|
||||
export const useFetchPercentWidgetData = (
|
||||
onReduce: (result: AggregateResult[]) => Record<string, number>,
|
||||
filterQuery: string,
|
||||
widgetKey: string,
|
||||
groupBy: string,
|
||||
countBy?: string,
|
||||
index?: string
|
||||
) => {
|
||||
const { http } = useKibana<CoreStart>().services;
|
||||
const cachingKeys = [QUERY_KEY_PERCENT_WIDGET, widgetKey, filterQuery, groupBy, countBy, index];
|
||||
const query = useQuery(
|
||||
cachingKeys,
|
||||
async (): Promise<Record<string, number>> => {
|
||||
const res = await http.get<AggregateResult[]>(AGGREGATE_ROUTE, {
|
||||
query: {
|
||||
query: filterQuery,
|
||||
groupBy,
|
||||
countBy,
|
||||
page: 0,
|
||||
index,
|
||||
},
|
||||
});
|
||||
|
||||
return onReduce(res);
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
}
|
||||
);
|
||||
|
||||
return query;
|
||||
};
|
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
* 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 { ENTRY_LEADER_INTERACTIVE } from '../../../common/constants';
|
||||
import { AppContextTestRender, createAppRootMockRenderer } from '../../test';
|
||||
import { GlobalFilter } from '../../types';
|
||||
import { PercentWidget, LOADING_TEST_ID, PERCENT_DATA_TEST_ID } from '.';
|
||||
import { useFetchPercentWidgetData } from './hooks';
|
||||
|
||||
const MOCK_DATA: Record<string, number> = {
|
||||
false: 47,
|
||||
true: 1,
|
||||
};
|
||||
const TITLE = 'Percent Widget Title';
|
||||
const GLOBAL_FILTER: GlobalFilter = {
|
||||
endDate: '2022-06-15T14:15:25.777Z',
|
||||
filterQuery: '{"bool":{"must":[],"filter":[],"should":[],"must_not":[]}}',
|
||||
startDate: '2022-05-15T14:15:25.777Z',
|
||||
};
|
||||
const DATA_VALUE_MAP = {
|
||||
true: {
|
||||
name: 'Interactive',
|
||||
fieldName: ENTRY_LEADER_INTERACTIVE,
|
||||
color: 'red',
|
||||
},
|
||||
false: {
|
||||
name: 'Non-interactive',
|
||||
fieldName: ENTRY_LEADER_INTERACTIVE,
|
||||
color: 'blue',
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock('../../hooks/use_filter', () => ({
|
||||
useSetFilter: () => ({
|
||||
getFilterForValueButton: jest.fn(),
|
||||
getFilterOutValueButton: jest.fn(),
|
||||
filterManager: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('./hooks');
|
||||
const mockUseFetchData = useFetchPercentWidgetData as jest.Mock;
|
||||
|
||||
describe('PercentWidget component', () => {
|
||||
let renderResult: ReturnType<typeof render>;
|
||||
const mockedContext = createAppRootMockRenderer();
|
||||
const render: () => ReturnType<AppContextTestRender['render']> = () =>
|
||||
(renderResult = mockedContext.render(
|
||||
<PercentWidget
|
||||
title={TITLE}
|
||||
dataValueMap={DATA_VALUE_MAP}
|
||||
widgetKey="percentWidget"
|
||||
globalFilter={GLOBAL_FILTER}
|
||||
groupedBy={ENTRY_LEADER_INTERACTIVE}
|
||||
onReduce={jest.fn()}
|
||||
/>
|
||||
));
|
||||
|
||||
describe('When PercentWidget is mounted', () => {
|
||||
describe('with data', () => {
|
||||
beforeEach(() => {
|
||||
mockUseFetchData.mockImplementation(() => ({
|
||||
data: MOCK_DATA,
|
||||
isLoading: false,
|
||||
}));
|
||||
});
|
||||
|
||||
it('should show title', async () => {
|
||||
render();
|
||||
|
||||
expect(renderResult.getByText(TITLE)).toBeVisible();
|
||||
});
|
||||
it('should show data value names and value', async () => {
|
||||
render();
|
||||
|
||||
expect(renderResult.getByText('Interactive')).toBeVisible();
|
||||
expect(renderResult.getByText('Non-interactive')).toBeVisible();
|
||||
expect(renderResult.queryByTestId(LOADING_TEST_ID)).toBeNull();
|
||||
expect(renderResult.getByText(47)).toBeVisible();
|
||||
expect(renderResult.getByText(1)).toBeVisible();
|
||||
});
|
||||
it('should show same number of data items as the number of records provided in dataValueMap', async () => {
|
||||
render();
|
||||
|
||||
expect(renderResult.getAllByTestId(PERCENT_DATA_TEST_ID)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('without data ', () => {
|
||||
it('should show data value names and zeros as values when loading', async () => {
|
||||
mockUseFetchData.mockImplementation(() => ({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
}));
|
||||
render();
|
||||
|
||||
expect(renderResult.getByText('Interactive')).toBeVisible();
|
||||
expect(renderResult.getByText('Non-interactive')).toBeVisible();
|
||||
expect(renderResult.getByTestId(LOADING_TEST_ID)).toBeVisible();
|
||||
expect(renderResult.getAllByText(0)).toHaveLength(2);
|
||||
});
|
||||
it('should show zeros as values if no data returned', async () => {
|
||||
mockUseFetchData.mockImplementation(() => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
}));
|
||||
render();
|
||||
|
||||
expect(renderResult.getByText('Interactive')).toBeVisible();
|
||||
expect(renderResult.getByText('Non-interactive')).toBeVisible();
|
||||
expect(renderResult.getAllByText(0)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,161 @@
|
|||
/*
|
||||
* 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, { ReactNode, useMemo, useState } from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiProgress, EuiText } from '@elastic/eui';
|
||||
import { useStyles } from './styles';
|
||||
import type { IndexPattern, GlobalFilter } from '../../types';
|
||||
import { useSetFilter } from '../../hooks';
|
||||
import { addTimerangeToQuery } from '../../utils/add_timerange_to_query';
|
||||
import { AggregateResult } from '../../../common/types/aggregate';
|
||||
import { useFetchPercentWidgetData } from './hooks';
|
||||
|
||||
export const LOADING_TEST_ID = 'kubernetesSecurity:percent-widget-loading';
|
||||
export const PERCENT_DATA_TEST_ID = 'kubernetesSecurity:percentage-widget-data';
|
||||
|
||||
export interface PercenWidgetDataValueMap {
|
||||
name: string;
|
||||
fieldName: string;
|
||||
color: string;
|
||||
shouldHideFilter?: boolean;
|
||||
}
|
||||
|
||||
export interface PercentWidgetDeps {
|
||||
title: ReactNode;
|
||||
dataValueMap: Record<string, PercenWidgetDataValueMap>;
|
||||
widgetKey: string;
|
||||
indexPattern?: IndexPattern;
|
||||
globalFilter: GlobalFilter;
|
||||
groupedBy: string;
|
||||
countBy?: string;
|
||||
onReduce: (result: AggregateResult[]) => Record<string, number>;
|
||||
}
|
||||
|
||||
interface FilterButtons {
|
||||
filterForButtons: ReactNode[];
|
||||
filterOutButtons: ReactNode[];
|
||||
}
|
||||
|
||||
export const PercentWidget = ({
|
||||
title,
|
||||
dataValueMap,
|
||||
widgetKey,
|
||||
indexPattern,
|
||||
globalFilter,
|
||||
groupedBy,
|
||||
countBy,
|
||||
onReduce,
|
||||
}: PercentWidgetDeps) => {
|
||||
const [hoveredFilter, setHoveredFilter] = useState<number | null>(null);
|
||||
const styles = useStyles();
|
||||
|
||||
const filterQueryWithTimeRange = useMemo(() => {
|
||||
return addTimerangeToQuery(
|
||||
globalFilter.filterQuery,
|
||||
globalFilter.startDate,
|
||||
globalFilter.endDate
|
||||
);
|
||||
}, [globalFilter.filterQuery, globalFilter.startDate, globalFilter.endDate]);
|
||||
|
||||
const { data, isLoading } = useFetchPercentWidgetData(
|
||||
onReduce,
|
||||
filterQueryWithTimeRange,
|
||||
widgetKey,
|
||||
groupedBy,
|
||||
countBy,
|
||||
indexPattern?.title
|
||||
);
|
||||
|
||||
const { getFilterForValueButton, getFilterOutValueButton, filterManager } = useSetFilter();
|
||||
const dataValueSum = useMemo(
|
||||
() => (data ? Object.keys(data).reduce((sumSoFar, current) => sumSoFar + data[current], 0) : 0),
|
||||
[data]
|
||||
);
|
||||
const filterButtons = useMemo(() => {
|
||||
const result: FilterButtons = {
|
||||
filterForButtons: [],
|
||||
filterOutButtons: [],
|
||||
};
|
||||
Object.keys(dataValueMap).forEach((groupedByValue) => {
|
||||
if (!dataValueMap[groupedByValue].shouldHideFilter) {
|
||||
result.filterForButtons.push(
|
||||
getFilterForValueButton({
|
||||
field: dataValueMap[groupedByValue].fieldName,
|
||||
filterManager,
|
||||
size: 'xs',
|
||||
onClick: () => {},
|
||||
onFilterAdded: () => {},
|
||||
ownFocus: false,
|
||||
showTooltip: true,
|
||||
value: [groupedByValue],
|
||||
})
|
||||
);
|
||||
result.filterOutButtons.push(
|
||||
getFilterOutValueButton({
|
||||
field: dataValueMap[groupedByValue].fieldName,
|
||||
filterManager,
|
||||
size: 'xs',
|
||||
onClick: () => {},
|
||||
onFilterAdded: () => {},
|
||||
ownFocus: false,
|
||||
showTooltip: true,
|
||||
value: [groupedByValue],
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [dataValueMap, filterManager, getFilterForValueButton, getFilterOutValueButton]);
|
||||
|
||||
return (
|
||||
<div css={styles.container}>
|
||||
{isLoading && (
|
||||
<EuiProgress
|
||||
size="xs"
|
||||
color="accent"
|
||||
position="absolute"
|
||||
data-test-subj={LOADING_TEST_ID}
|
||||
/>
|
||||
)}
|
||||
<div css={styles.title}>{title}</div>
|
||||
<EuiFlexGroup direction="column" gutterSize="m">
|
||||
{Object.keys(dataValueMap).map((groupedByValue, idx) => {
|
||||
const value = data?.[groupedByValue] || 0;
|
||||
return (
|
||||
<EuiFlexItem
|
||||
key={`percentage-widget--${dataValueMap[groupedByValue].name}`}
|
||||
onMouseEnter={() => setHoveredFilter(idx)}
|
||||
onMouseLeave={() => setHoveredFilter(null)}
|
||||
data-test-subj={PERCENT_DATA_TEST_ID}
|
||||
>
|
||||
<EuiText size="xs" css={styles.dataInfo}>
|
||||
{dataValueMap[groupedByValue].name}
|
||||
{hoveredFilter === idx && (
|
||||
<div css={styles.filters}>
|
||||
{filterButtons.filterForButtons[idx]}
|
||||
{filterButtons.filterOutButtons[idx]}
|
||||
</div>
|
||||
)}
|
||||
<span css={styles.dataValue}>{value}</span>
|
||||
</EuiText>
|
||||
<div css={styles.percentageBackground}>
|
||||
<div
|
||||
css={{
|
||||
...styles.percentageBar,
|
||||
width: `${(value / dataValueSum || 0) * 100}%`,
|
||||
backgroundColor: dataValueMap[groupedByValue].color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
})}
|
||||
</EuiFlexGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* 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 { useMemo } from 'react';
|
||||
import { CSSObject } from '@emotion/react';
|
||||
import { useEuiTheme } from '../../hooks';
|
||||
|
||||
export const useStyles = () => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
const cached = useMemo(() => {
|
||||
const { size, colors, font, border } = euiTheme;
|
||||
|
||||
const container: CSSObject = {
|
||||
padding: size.base,
|
||||
border: euiTheme.border.thin,
|
||||
borderRadius: euiTheme.border.radius.medium,
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
};
|
||||
|
||||
const title: CSSObject = {
|
||||
marginBottom: size.m,
|
||||
};
|
||||
|
||||
const dataInfo: CSSObject = {
|
||||
marginBottom: size.xs,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '18px',
|
||||
};
|
||||
|
||||
const dataValue: CSSObject = {
|
||||
fontWeight: font.weight.semiBold,
|
||||
marginLeft: 'auto',
|
||||
};
|
||||
|
||||
const filters: CSSObject = {
|
||||
marginLeft: size.s,
|
||||
};
|
||||
|
||||
const percentageBackground: CSSObject = {
|
||||
position: 'relative',
|
||||
backgroundColor: colors.lightShade,
|
||||
height: size.xs,
|
||||
borderRadius: border.radius.small,
|
||||
};
|
||||
|
||||
const percentageBar: CSSObject = {
|
||||
position: 'absolute',
|
||||
height: size.xs,
|
||||
borderRadius: border.radius.small,
|
||||
};
|
||||
|
||||
const loadingSpinner: CSSObject = {
|
||||
alignItems: 'center',
|
||||
margin: `${size.xs} auto ${size.xl} auto`,
|
||||
};
|
||||
|
||||
return {
|
||||
container,
|
||||
title,
|
||||
dataInfo,
|
||||
dataValue,
|
||||
filters,
|
||||
percentageBackground,
|
||||
percentageBar,
|
||||
loadingSpinner,
|
||||
};
|
||||
}, [euiTheme]);
|
||||
|
||||
return cached;
|
||||
};
|
9
x-pack/plugins/kubernetes_security/public/hooks/index.ts
Normal file
9
x-pack/plugins/kubernetes_security/public/hooks/index.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export { useEuiTheme } from './use_eui_theme';
|
||||
export { useSetFilter } from './use_filter';
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* 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 { shade, useEuiTheme as useEuiThemeHook } from '@elastic/eui';
|
||||
import { euiLightVars, euiDarkVars } from '@kbn/ui-theme';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
type EuiThemeProps = Parameters<typeof useEuiThemeHook>;
|
||||
type ExtraEuiVars = {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
euiColorVis6_asText: string;
|
||||
buttonsBackgroundNormalDefaultPrimary: string;
|
||||
};
|
||||
type EuiVars = typeof euiLightVars & ExtraEuiVars;
|
||||
type EuiThemeReturn = ReturnType<typeof useEuiThemeHook> & { euiVars: EuiVars };
|
||||
|
||||
// Not all Eui Tokens were fully migrated to @elastic/eui/useEuiTheme yet, so
|
||||
// this hook overrides the default useEuiTheme hook to provide a custom hook that
|
||||
// allows the use the euiVars tokens from the euiLightVars and euiDarkVars
|
||||
export const useEuiTheme = (...props: EuiThemeProps): EuiThemeReturn => {
|
||||
const euiThemeHook = useEuiThemeHook(...props);
|
||||
|
||||
const euiVars = useMemo(() => {
|
||||
const themeVars = euiThemeHook.colorMode === 'DARK' ? euiDarkVars : euiLightVars;
|
||||
|
||||
const extraEuiVars: ExtraEuiVars = {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
euiColorVis6_asText: shade(themeVars.euiColorVis6, 0.335),
|
||||
buttonsBackgroundNormalDefaultPrimary: '#006DE4',
|
||||
};
|
||||
|
||||
return {
|
||||
...themeVars,
|
||||
...extraEuiVars,
|
||||
};
|
||||
}, [euiThemeHook.colorMode]);
|
||||
|
||||
return {
|
||||
...euiThemeHook,
|
||||
euiVars,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* 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 { useMemo } from 'react';
|
||||
import { useKibana } from '@kbn/kibana-react-plugin/public';
|
||||
import type { CoreStart } from '@kbn/core/public';
|
||||
import type { StartPlugins } from '../types';
|
||||
|
||||
export const useSetFilter = () => {
|
||||
const { data, timelines } = useKibana<CoreStart & StartPlugins>().services;
|
||||
const { getFilterForValueButton, getFilterOutValueButton } = timelines.getHoverActions();
|
||||
|
||||
const filterManager = useMemo(() => data.query.filterManager, [data.query.filterManager]);
|
||||
|
||||
return {
|
||||
getFilterForValueButton,
|
||||
getFilterOutValueButton,
|
||||
filterManager,
|
||||
};
|
||||
};
|
137
x-pack/plugins/kubernetes_security/public/test/index.tsx
Normal file
137
x-pack/plugins/kubernetes_security/public/test/index.tsx
Normal file
|
@ -0,0 +1,137 @@
|
|||
/*
|
||||
* 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, { memo, ReactNode, useMemo } from 'react';
|
||||
import { createMemoryHistory, MemoryHistory } from 'history';
|
||||
import { render as reactRender, RenderOptions, RenderResult } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider, setLogger } from 'react-query';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { History } from 'history';
|
||||
import useObservable from 'react-use/lib/useObservable';
|
||||
import { I18nProvider } from '@kbn/i18n-react';
|
||||
import { CoreStart } from '@kbn/core/public';
|
||||
import { coreMock } from '@kbn/core/public/mocks';
|
||||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
|
||||
|
||||
type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult;
|
||||
|
||||
// hide react-query output in console
|
||||
setLogger({
|
||||
error: () => {},
|
||||
// eslint-disable-next-line no-console
|
||||
log: console.log,
|
||||
// eslint-disable-next-line no-console
|
||||
warn: console.warn,
|
||||
});
|
||||
|
||||
/**
|
||||
* Mocked app root context renderer
|
||||
*/
|
||||
export interface AppContextTestRender {
|
||||
history: ReturnType<typeof createMemoryHistory>;
|
||||
coreStart: ReturnType<typeof coreMock.createStart>;
|
||||
/**
|
||||
* A wrapper around `AppRootContext` component. Uses the mocked modules as input to the
|
||||
* `AppRootContext`
|
||||
*/
|
||||
AppWrapper: React.FC<any>;
|
||||
/**
|
||||
* Renders the given UI within the created `AppWrapper` providing the given UI a mocked
|
||||
* endpoint runtime context environment
|
||||
*/
|
||||
render: UiRender;
|
||||
}
|
||||
|
||||
const createCoreStartMock = (
|
||||
history: MemoryHistory<never>
|
||||
): ReturnType<typeof coreMock.createStart> => {
|
||||
const coreStart = coreMock.createStart({ basePath: '/mock' });
|
||||
|
||||
// Mock the certain APP Ids returned by `application.getUrlForApp()`
|
||||
coreStart.application.getUrlForApp.mockImplementation((appId) => {
|
||||
switch (appId) {
|
||||
case 'sessionView':
|
||||
return '/app/sessionView';
|
||||
default:
|
||||
return `${appId} not mocked!`;
|
||||
}
|
||||
});
|
||||
|
||||
coreStart.application.navigateToUrl.mockImplementation((url) => {
|
||||
history.push(url.replace('/app/sessionView', ''));
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
return coreStart;
|
||||
};
|
||||
|
||||
const AppRootProvider = memo<{
|
||||
history: History;
|
||||
coreStart: CoreStart;
|
||||
children: ReactNode | ReactNode[];
|
||||
}>(({ history, coreStart: { http, notifications, uiSettings, application }, children }) => {
|
||||
const isDarkMode = useObservable<boolean>(uiSettings.get$('theme:darkMode'));
|
||||
const services = useMemo(
|
||||
() => ({ http, notifications, application }),
|
||||
[application, http, notifications]
|
||||
);
|
||||
return (
|
||||
<I18nProvider>
|
||||
<KibanaContextProvider services={services}>
|
||||
<EuiThemeProvider darkMode={isDarkMode}>
|
||||
<Router history={history}>{children}</Router>
|
||||
</EuiThemeProvider>
|
||||
</KibanaContextProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
});
|
||||
|
||||
AppRootProvider.displayName = 'AppRootProvider';
|
||||
|
||||
/**
|
||||
* Creates a mocked app context custom renderer that can be used to render
|
||||
* component that depend upon the application's surrounding context providers.
|
||||
* Factory also returns the content that was used to create the custom renderer, allowing
|
||||
* for further customization.
|
||||
*/
|
||||
|
||||
export const createAppRootMockRenderer = (): AppContextTestRender => {
|
||||
const history = createMemoryHistory<never>();
|
||||
const coreStart = createCoreStartMock(history);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// turns retries off
|
||||
retry: false,
|
||||
// prevent jest did not exit errors
|
||||
cacheTime: Infinity,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const AppWrapper: React.FC<{ children: React.ReactElement }> = ({ children }) => (
|
||||
<AppRootProvider history={history} coreStart={coreStart}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</AppRootProvider>
|
||||
);
|
||||
|
||||
const render: UiRender = (ui, options = {}) => {
|
||||
return reactRender(ui, {
|
||||
wrapper: AppWrapper,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
history,
|
||||
coreStart,
|
||||
AppWrapper,
|
||||
render,
|
||||
};
|
||||
};
|
|
@ -6,11 +6,34 @@
|
|||
*/
|
||||
import React from 'react';
|
||||
import { CoreStart } from '@kbn/core/public';
|
||||
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
|
||||
import type { FieldSpec } from '@kbn/data-plugin/common';
|
||||
import type { TimelinesUIStart } from '@kbn/timelines-plugin/public';
|
||||
import type { SessionViewStart } from '@kbn/session-view-plugin/public';
|
||||
|
||||
export type KubernetesSecurityServices = CoreStart;
|
||||
export interface StartPlugins {
|
||||
data: DataPublicPluginStart;
|
||||
timelines: TimelinesUIStart;
|
||||
sessionView: SessionViewStart;
|
||||
}
|
||||
|
||||
export type KubernetesSecurityServices = CoreStart & StartPlugins;
|
||||
|
||||
export interface IndexPattern {
|
||||
fields: FieldSpec[];
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface GlobalFilter {
|
||||
filterQuery?: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
export interface KubernetesSecurityDeps {
|
||||
filter: React.ReactNode;
|
||||
indexPattern?: IndexPattern;
|
||||
globalFilter: GlobalFilter;
|
||||
}
|
||||
|
||||
export interface KubernetesSecurityStart {
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* 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 { DEFAULT_QUERY } from '../../common/constants';
|
||||
import { addTimerangeToQuery } from './add_timerange_to_query';
|
||||
|
||||
const TEST_QUERY =
|
||||
'{"bool":{"must":[],"filter":[{"bool":{"should":[{"match":{"process.entry_leader.same_as_process":true}}],"minimum_should_match":1}}],"should":[],"must_not":[]}}';
|
||||
const TEST_INVALID_QUERY = '{"bool":{"must":[';
|
||||
const TEST_EMPTY_STRING = '';
|
||||
const TEST_DATE = '2022-06-09T22:36:46.628Z';
|
||||
const VALID_RESULT =
|
||||
'{"bool":{"must":[],"filter":[{"bool":{"should":[{"match":{"process.entry_leader.same_as_process":true}}],"minimum_should_match":1}},{"range":{"@timestamp":{"gte":"2022-06-09T22:36:46.628Z","lte":"2022-06-09T22:36:46.628Z"}}}],"should":[],"must_not":[]}}';
|
||||
|
||||
describe('addTimerangeToQuery(query, startDate, endDate)', () => {
|
||||
it('works for valid query, startDate, and endDate', () => {
|
||||
expect(addTimerangeToQuery(TEST_QUERY, TEST_DATE, TEST_DATE)).toEqual(VALID_RESULT);
|
||||
});
|
||||
it('works with missing filter in bool', () => {
|
||||
expect(addTimerangeToQuery('{"bool":{}}', TEST_DATE, TEST_DATE)).toEqual(
|
||||
'{"bool":{"filter":[{"range":{"@timestamp":{"gte":"2022-06-09T22:36:46.628Z","lte":"2022-06-09T22:36:46.628Z"}}}]}}'
|
||||
);
|
||||
});
|
||||
it('returns default query with invalid JSON query', () => {
|
||||
expect(addTimerangeToQuery(TEST_INVALID_QUERY, TEST_DATE, TEST_DATE)).toEqual(DEFAULT_QUERY);
|
||||
expect(addTimerangeToQuery(TEST_EMPTY_STRING, TEST_DATE, TEST_DATE)).toEqual(DEFAULT_QUERY);
|
||||
expect(addTimerangeToQuery('{}', TEST_DATE, TEST_DATE)).toEqual(DEFAULT_QUERY);
|
||||
});
|
||||
it('returns default query with invalid startDate or endDate', () => {
|
||||
expect(addTimerangeToQuery(TEST_QUERY, TEST_EMPTY_STRING, TEST_DATE)).toEqual(DEFAULT_QUERY);
|
||||
expect(addTimerangeToQuery(TEST_QUERY, TEST_DATE, TEST_EMPTY_STRING)).toEqual(DEFAULT_QUERY);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* 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 { DEFAULT_QUERY } from '../../common/constants';
|
||||
|
||||
/**
|
||||
* Add startDate and endDate filter for '@timestamp' field into query.
|
||||
*
|
||||
* Used by frontend components
|
||||
*
|
||||
* @param {String | undefined} query Example: '{"bool":{"must":[],"filter":[],"should":[],"must_not":[]}}'
|
||||
* @param {String} startDate Example: '2022-06-08T18:52:15.532Z'
|
||||
* @param {String} endDate Example: '2022-06-09T17:52:15.532Z'
|
||||
* @return {String} Add startDate and endDate as a '@timestamp' range filter in query and return.
|
||||
* If startDate or endDate is invalid Date string, or that query is not
|
||||
* in the right format, return a default query.
|
||||
*/
|
||||
|
||||
export const addTimerangeToQuery = (
|
||||
query: string | undefined,
|
||||
startDate: string,
|
||||
endDate: string
|
||||
) => {
|
||||
if (!(query && !isNaN(Date.parse(startDate)) && !isNaN(Date.parse(endDate)))) {
|
||||
return DEFAULT_QUERY;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedQuery = JSON.parse(query);
|
||||
if (!parsedQuery.bool) {
|
||||
throw new Error("Field 'bool' does not exist in query.");
|
||||
}
|
||||
|
||||
const range = {
|
||||
range: {
|
||||
'@timestamp': {
|
||||
gte: startDate,
|
||||
lte: endDate,
|
||||
},
|
||||
},
|
||||
};
|
||||
if (parsedQuery.bool.filter) {
|
||||
parsedQuery.bool.filter = [...parsedQuery.bool.filter, range];
|
||||
} else {
|
||||
parsedQuery.bool.filter = [range];
|
||||
}
|
||||
|
||||
return JSON.stringify(parsedQuery);
|
||||
} catch {
|
||||
return DEFAULT_QUERY;
|
||||
}
|
||||
};
|
|
@ -4,6 +4,7 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import type { SortCombinations } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import type { ElasticsearchClient } from '@kbn/core/server';
|
||||
import { IRouter } from '@kbn/core/server';
|
||||
|
@ -14,6 +15,10 @@ import {
|
|||
AGGREGATE_MAX_BUCKETS,
|
||||
} from '../../common/constants';
|
||||
|
||||
// sort by values
|
||||
const ASC = 'asc';
|
||||
const DESC = 'desc';
|
||||
|
||||
export const registerAggregateRoute = (router: IRouter) => {
|
||||
router.get(
|
||||
{
|
||||
|
@ -21,18 +26,20 @@ export const registerAggregateRoute = (router: IRouter) => {
|
|||
validate: {
|
||||
query: schema.object({
|
||||
query: schema.string(),
|
||||
countBy: schema.maybe(schema.string()),
|
||||
groupBy: schema.string(),
|
||||
page: schema.number(),
|
||||
index: schema.maybe(schema.string()),
|
||||
sortByCount: schema.maybe(schema.string()),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (context, request, response) => {
|
||||
const client = (await context.core).elasticsearch.client.asCurrentUser;
|
||||
const { query, groupBy, page, index } = request.query;
|
||||
const { query, countBy, sortByCount, groupBy, page, index } = request.query;
|
||||
|
||||
try {
|
||||
const body = await doSearch(client, query, groupBy, page, index);
|
||||
const body = await doSearch(client, query, groupBy, page, index, countBy, sortByCount);
|
||||
|
||||
return response.ok({ body });
|
||||
} catch (err) {
|
||||
|
@ -47,10 +54,27 @@ export const doSearch = async (
|
|||
query: string,
|
||||
groupBy: string,
|
||||
page: number, // zero based
|
||||
index?: string
|
||||
index?: string,
|
||||
countBy?: string,
|
||||
sortByCount?: string
|
||||
) => {
|
||||
const queryDSL = JSON.parse(query);
|
||||
|
||||
const countByAggs = countBy
|
||||
? {
|
||||
count_by_aggs: {
|
||||
cardinality: {
|
||||
field: countBy,
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
let sort: SortCombinations = { _key: { order: ASC } };
|
||||
if (sortByCount === ASC || sortByCount === DESC) {
|
||||
sort = { 'count_by_aggs.value': { order: sortByCount } };
|
||||
}
|
||||
|
||||
const search = await client.search({
|
||||
index: [index || PROCESS_EVENTS_INDEX],
|
||||
body: {
|
||||
|
@ -63,9 +87,10 @@ export const doSearch = async (
|
|||
size: AGGREGATE_MAX_BUCKETS,
|
||||
},
|
||||
aggs: {
|
||||
...countByAggs,
|
||||
bucket_sort: {
|
||||
bucket_sort: {
|
||||
sort: [{ _key: { order: 'asc' } }], // defaulting to alphabetic sort
|
||||
sort: [sort], // defaulting to alphabetic sort
|
||||
size: AGGREGATE_PAGE_SIZE,
|
||||
from: AGGREGATE_PAGE_SIZE * page,
|
||||
},
|
||||
|
|
|
@ -5,7 +5,8 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { getEsQueryConfig } from '@kbn/data-plugin/common';
|
||||
import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper';
|
||||
import { useKibana } from '../../common/lib/kibana';
|
||||
import { SecurityPageName } from '../../../common/constants';
|
||||
|
@ -13,17 +14,52 @@ import { SpyRoute } from '../../common/utils/route/spy_routes';
|
|||
import { FiltersGlobal } from '../../common/components/filters_global';
|
||||
import { SiemSearchBar } from '../../common/components/search_bar';
|
||||
import { showGlobalFilters } from '../../timelines/components/timeline/helpers';
|
||||
import { inputsSelectors } from '../../common/store';
|
||||
import { useGlobalFullScreen } from '../../common/containers/use_full_screen';
|
||||
import { useSourcererDataView } from '../../common/containers/sourcerer';
|
||||
import { useGlobalTime } from '../../common/containers/use_global_time';
|
||||
import { useDeepEqualSelector } from '../../common/hooks/use_selector';
|
||||
import { convertToBuildEsQuery } from '../../common/lib/keury';
|
||||
import { useInvalidFilterQuery } from '../../common/hooks/use_invalid_filter_query';
|
||||
|
||||
export const KubernetesContainer = React.memo(() => {
|
||||
const { kubernetesSecurity } = useKibana().services;
|
||||
const { kubernetesSecurity, uiSettings } = useKibana().services;
|
||||
const { globalFullScreen } = useGlobalFullScreen();
|
||||
const {
|
||||
indexPattern,
|
||||
// runtimeMappings,
|
||||
// loading: isLoadingIndexPattern,
|
||||
} = useSourcererDataView();
|
||||
const { from, to } = useGlobalTime();
|
||||
|
||||
const getGlobalFiltersQuerySelector = useMemo(
|
||||
() => inputsSelectors.globalFiltersQuerySelector(),
|
||||
[]
|
||||
);
|
||||
const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []);
|
||||
const query = useDeepEqualSelector(getGlobalQuerySelector);
|
||||
const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector);
|
||||
|
||||
const [filterQuery, kqlError] = useMemo(
|
||||
() =>
|
||||
convertToBuildEsQuery({
|
||||
config: getEsQueryConfig(uiSettings),
|
||||
indexPattern,
|
||||
queries: [query],
|
||||
filters,
|
||||
}),
|
||||
[filters, indexPattern, uiSettings, query]
|
||||
);
|
||||
|
||||
useInvalidFilterQuery({
|
||||
id: 'kubernetesQuery',
|
||||
filterQuery,
|
||||
kqlError,
|
||||
query,
|
||||
startDate: from,
|
||||
endDate: to,
|
||||
});
|
||||
|
||||
return (
|
||||
<SecuritySolutionPageWrapper noPadding>
|
||||
{kubernetesSecurity.getKubernetesPage({
|
||||
|
@ -32,6 +68,12 @@ export const KubernetesContainer = React.memo(() => {
|
|||
<SiemSearchBar id="global" indexPattern={indexPattern} />
|
||||
</FiltersGlobal>
|
||||
),
|
||||
indexPattern,
|
||||
globalFilter: {
|
||||
filterQuery,
|
||||
startDate: from,
|
||||
endDate: to,
|
||||
},
|
||||
})}
|
||||
<SpyRoute pageName={SecurityPageName.kubernetes} />
|
||||
</SecuritySolutionPageWrapper>
|
||||
|
|
|
@ -8,7 +8,8 @@
|
|||
"@timestamp": "2020-12-16T15:16:18.570Z",
|
||||
"message": "hello world 1",
|
||||
"orchestrator.namespace": "namespace",
|
||||
"container.image.name": "debian11"
|
||||
"container.image.name": "debian11",
|
||||
"process.entry_leader.entity_id": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -23,7 +24,8 @@
|
|||
"@timestamp": "2020-12-16T15:16:18.570Z",
|
||||
"message": "hello world 1",
|
||||
"orchestrator.namespace": "namespace",
|
||||
"container.image.name": "debian11"
|
||||
"container.image.name": "debian11",
|
||||
"process.entry_leader.entity_id": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -38,7 +40,8 @@
|
|||
"@timestamp": "2020-12-16T15:16:19.570Z",
|
||||
"message": "hello world 1",
|
||||
"orchestrator.namespace": "namespace02",
|
||||
"container.image.name": "debian11"
|
||||
"container.image.name": "debian11",
|
||||
"process.entry_leader.entity_id": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -53,7 +56,8 @@
|
|||
"@timestamp": "2020-12-16T15:16:20.570Z",
|
||||
"message": "hello world security",
|
||||
"orchestrator.namespace": "namespace02",
|
||||
"container.image.name": "debian11"
|
||||
"container.image.name": "debian11",
|
||||
"process.entry_leader.entity_id": "2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -68,7 +72,8 @@
|
|||
"@timestamp": "2020-12-16T15:16:21.570Z",
|
||||
"message": "hello world security",
|
||||
"orchestrator.namespace": "namespace03",
|
||||
"container.image.name": "debian11"
|
||||
"container.image.name": "debian11",
|
||||
"process.entry_leader.entity_id": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -82,7 +87,8 @@
|
|||
"@timestamp": "2020-12-16T15:16:22.570Z",
|
||||
"message": "hello world security",
|
||||
"orchestrator.namespace": "namespace03",
|
||||
"container.image.name": "debian11"
|
||||
"container.image.name": "debian11",
|
||||
"process.entry_leader.entity_id": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -96,7 +102,8 @@
|
|||
"@timestamp": "2020-12-16T15:16:23.570Z",
|
||||
"message": "hello world security",
|
||||
"orchestrator.namespace": "namespace04",
|
||||
"container.image.name": "debian11"
|
||||
"container.image.name": "debian11",
|
||||
"process.entry_leader.entity_id": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -110,7 +117,8 @@
|
|||
"@timestamp": "2020-12-16T15:16:24.570Z",
|
||||
"message": "hello world security",
|
||||
"orchestrator.namespace": "namespace05",
|
||||
"container.image.name": "debian11"
|
||||
"container.image.name": "debian11",
|
||||
"process.entry_leader.entity_id": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -124,7 +132,8 @@
|
|||
"@timestamp": "2020-12-16T15:16:25.570Z",
|
||||
"message": "hello world security",
|
||||
"orchestrator.namespace": "namespace06",
|
||||
"container.image.name": "debian11"
|
||||
"container.image.name": "debian11",
|
||||
"process.entry_leader.entity_id": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -138,7 +147,8 @@
|
|||
"@timestamp": "2020-12-16T15:16:26.570Z",
|
||||
"message": "hello world security",
|
||||
"orchestrator.namespace": "namespace07",
|
||||
"container.image.name": "debian11"
|
||||
"container.image.name": "debian11",
|
||||
"process.entry_leader.entity_id": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -152,7 +162,8 @@
|
|||
"@timestamp": "2020-12-16T15:16:27.570Z",
|
||||
"message": "hello world security",
|
||||
"orchestrator.namespace": "namespace08",
|
||||
"container.image.name": "debian11"
|
||||
"container.image.name": "debian11",
|
||||
"process.entry_leader.entity_id": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -166,7 +177,8 @@
|
|||
"@timestamp": "2020-12-16T15:16:28.570Z",
|
||||
"message": "hello world security",
|
||||
"orchestrator.namespace": "namespace09",
|
||||
"container.image.name": "debian11"
|
||||
"container.image.name": "debian11",
|
||||
"process.entry_leader.entity_id": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -180,7 +192,8 @@
|
|||
"@timestamp": "2020-12-16T15:16:29.570Z",
|
||||
"message": "hello world security",
|
||||
"orchestrator.namespace": "namespace10",
|
||||
"container.image.name": "debian11"
|
||||
"container.image.name": "debian11",
|
||||
"process.entry_leader.entity_id": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -194,7 +207,8 @@
|
|||
"@timestamp": "2020-12-16T15:16:30.570Z",
|
||||
"message": "hello world security",
|
||||
"orchestrator.namespace": "namespace11",
|
||||
"container.image.name": "debian11"
|
||||
"container.image.name": "debian11",
|
||||
"process.entry_leader.entity_id": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,6 +20,10 @@
|
|||
"container.image.name": {
|
||||
"type": "keyword",
|
||||
"ignore_above": 256
|
||||
},
|
||||
"process.entry_leader.entity_id": {
|
||||
"type": "keyword",
|
||||
"ignore_above": 256
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import { FtrProviderContext } from '../../common/ftr_provider_context';
|
|||
const MOCK_INDEX = 'kubernetes-test-index';
|
||||
const ORCHESTRATOR_NAMESPACE_PROPERTY = 'orchestrator.namespace';
|
||||
const CONTAINER_IMAGE_NAME_PROPERTY = 'container.image.name';
|
||||
const ENTRY_LEADER_ENTITY_ID = 'process.entry_leader.entity_id';
|
||||
const TIMESTAMP_PROPERTY = '@timestamp';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
|
@ -65,6 +66,44 @@ export default function aggregateTests({ getService }: FtrProviderContext) {
|
|||
expect(response.body[0].key).to.be('namespace11');
|
||||
});
|
||||
|
||||
it(`${AGGREGATE_ROUTE} return countBy value for each aggregation`, async () => {
|
||||
const response = await supertest
|
||||
.get(AGGREGATE_ROUTE)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.query({
|
||||
query: JSON.stringify({ match: { [CONTAINER_IMAGE_NAME_PROPERTY]: 'debian11' } }),
|
||||
groupBy: ORCHESTRATOR_NAMESPACE_PROPERTY,
|
||||
countBy: ORCHESTRATOR_NAMESPACE_PROPERTY,
|
||||
page: 0,
|
||||
index: MOCK_INDEX,
|
||||
});
|
||||
expect(response.status).to.be(200);
|
||||
expect(response.body.length).to.be(10);
|
||||
|
||||
// when groupBy and countBy use the same field, count_by_aggs.value will always be 1
|
||||
response.body.forEach((agg: any) => {
|
||||
expect(agg.count_by_aggs.value).to.be(1);
|
||||
});
|
||||
});
|
||||
|
||||
it(`${AGGREGATE_ROUTE} return sorted aggregation by countBy field if sortByCount is true`, async () => {
|
||||
const response = await supertest
|
||||
.get(AGGREGATE_ROUTE)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.query({
|
||||
query: JSON.stringify({ match: { [CONTAINER_IMAGE_NAME_PROPERTY]: 'debian11' } }),
|
||||
groupBy: ORCHESTRATOR_NAMESPACE_PROPERTY,
|
||||
countBy: ENTRY_LEADER_ENTITY_ID,
|
||||
page: 0,
|
||||
index: MOCK_INDEX,
|
||||
sortByCount: 'desc',
|
||||
});
|
||||
expect(response.status).to.be(200);
|
||||
expect(response.body.length).to.be(10);
|
||||
expect(response.body[0].count_by_aggs.value).to.be(2);
|
||||
expect(response.body[1].count_by_aggs.value).to.be(1);
|
||||
});
|
||||
|
||||
it(`${AGGREGATE_ROUTE} allows a range query`, async () => {
|
||||
const response = await supertest
|
||||
.get(AGGREGATE_ROUTE)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue