Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RAM] Add cases functionality for ML #172217

Merged
merged 22 commits into from
Dec 22, 2023
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
376372c
add cases for ml
XavierM Nov 29, 2023
96ed03a
Merge branch 'main' of github.com:elastic/kibana into ml-alerts-cases
XavierM Nov 30, 2023
45215c2
review first part
XavierM Dec 4, 2023
79ff3ac
no more harcoding logic for ml and o11y
XavierM Dec 5, 2023
4c69c8b
Merge branch 'main' of github.com:elastic/kibana into ml-alerts-cases
XavierM Dec 5, 2023
8f70bae
[CI] Auto-commit changed files from 'node scripts/lint_ts_projects --…
kibanamachine Dec 5, 2023
2472fb6
fix showing a single alert without a flyout
XavierM Dec 6, 2023
96ccdcb
Merge branch 'main' of github.com:elastic/kibana into ml-alerts-cases
XavierM Dec 6, 2023
d49afff
Merge branch 'ml-alerts-cases' of github.com:XavierM/kibana into ml-a…
XavierM Dec 6, 2023
1e92ffc
Merge branch 'main' of github.com:elastic/kibana into ml-alerts-cases
XavierM Dec 7, 2023
63f4d08
review + fix test
XavierM Dec 7, 2023
a0c504c
Merge branch 'main' of github.com:elastic/kibana into ml-alerts-cases
XavierM Dec 8, 2023
90f3c09
merge with umbo
XavierM Dec 8, 2023
a544bfd
Merge branch 'main' into ml-alerts-cases
XavierM Dec 13, 2023
1f25f97
Merge branch 'main' into ml-alerts-cases
XavierM Dec 18, 2023
05fb4fa
Merge branch 'main' of github.com:elastic/kibana into ml-alerts-cases
XavierM Dec 21, 2023
eadda83
set reTry to true
XavierM Dec 21, 2023
5d83f0f
omit retry to avoid to run this query forever
XavierM Dec 21, 2023
9e0c4eb
Merge branch 'main' of github.com:elastic/kibana into ml-alerts-cases
XavierM Dec 21, 2023
df6ffd0
christos remarks
XavierM Dec 22, 2023
50f72f8
Merge branch 'main' of github.com:elastic/kibana into ml-alerts-cases
XavierM Dec 22, 2023
850138a
Merge branch 'main' into ml-alerts-cases
XavierM Dec 22, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion x-pack/plugins/cases/public/components/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const CasesAppComponent: React.FC<CasesAppProps> = ({
useFetchAlertData: () => [false, {}],
permissions: userCapabilities.generalCases,
basePath: '/',
features: { alerts: { enabled: false } },
features: { alerts: { enabled: true } },
XavierM marked this conversation as resolved.
Show resolved Hide resolved
XavierM marked this conversation as resolved.
Show resolved Hide resolved
})}
</Wrapper>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ describe('CaseUI View Page activity tab', () => {
appMockRender = createAppMockRenderer();
appMockRender.coreStart.triggersActionsUi.getAlertsStateTable =
getAlertsStateTableMock.mockReturnValue(<div data-test-subj="alerts-table" />);
appMockRender.coreStart.triggersActionsUi.alertsTableConfigurationRegistry.register({
id: 'case-details-alerts-observability',
columns: [],
ruleTypeIds: ['log-threshold'],
});
});
afterEach(() => {
jest.clearAllMocks();
});

Expand All @@ -46,7 +53,7 @@ describe('CaseUI View Page activity tab', () => {
expect(getAlertsStateTableMock).toHaveBeenCalledWith({
alertsTableConfigurationRegistry: expect.anything(),
configurationId: 'securitySolution-case',
featureIds: ['siem', 'observability'],
featureIds: ['siem'],
id: 'case-details-alerts-securitySolution',
query: {
ids: {
Expand All @@ -60,7 +67,13 @@ describe('CaseUI View Page activity tab', () => {

it('should call the alerts table with correct props for observability', async () => {
const getFeatureIdsMock = jest.spyOn(api, 'getFeatureIds');
getFeatureIdsMock.mockResolvedValueOnce(['observability']);
getFeatureIdsMock.mockResolvedValueOnce({
aggregations: {
consumer: { buckets: [{ doc_count: 1, key: 'observability' }] },
producer: { buckets: [] },
ruleTypeIds: { buckets: [{ doc_count: 1, key: 'log-threshold' }] },
},
} as unknown as api.FeatureIdsResponse);
appMockRender.render(
<CaseViewAlerts
caseData={{
Expand All @@ -73,7 +86,7 @@ describe('CaseUI View Page activity tab', () => {
await waitFor(async () => {
expect(getAlertsStateTableMock).toHaveBeenCalledWith({
alertsTableConfigurationRegistry: expect.anything(),
configurationId: 'observability',
configurationId: 'case-details-alerts-observability',
featureIds: ['observability'],
id: 'case-details-alerts-observability',
query: {
Expand All @@ -86,12 +99,23 @@ describe('CaseUI View Page activity tab', () => {
});
});

it('should call the getFeatureIds with the correct registration context', async () => {
it('should call the getFeatureIds with the correct alert ID', async () => {
const getFeatureIdsMock = jest.spyOn(api, 'getFeatureIds');
appMockRender.render(<CaseViewAlerts caseData={caseData} />);
appMockRender.render(
<CaseViewAlerts
caseData={{
...caseData,
owner: OBSERVABILITY_OWNER,
}}
/>
);
await waitFor(async () => {
expect(getFeatureIdsMock).toHaveBeenCalledWith({
query: { registrationContext: ['matchme'] },
query: {
ids: {
values: ['alert-id-1'],
},
},
signal: expect.anything(),
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
import React, { useMemo } from 'react';

import { EuiFlexItem, EuiFlexGroup, EuiProgress } from '@elastic/eui';
import type { ValidFeatureId } from '@kbn/rule-registry-plugin/common/technical_rule_data_field_names';
import { AlertConsumers } from '@kbn/rule-registry-plugin/common/technical_rule_data_field_names';
import { SECURITY_SOLUTION_OWNER } from '../../../../common/constants';
import type { CaseUI } from '../../../../common';
import { useKibana } from '../../../common/lib/kibana';
import { getManualAlertIds, getRegistrationContextFromAlerts } from './helpers';
import { getManualAlertIds } from './helpers';
import { useGetFeatureIds } from '../../../containers/use_get_feature_ids';
import { CaseViewAlertsEmpty } from './case_view_alerts_empty';
import { CaseViewTabs } from '../case_view_tabs';
Expand All @@ -22,34 +24,49 @@ interface CaseViewAlertsProps {
export const CaseViewAlerts = ({ caseData }: CaseViewAlertsProps) => {
const { triggersActionsUi } = useKibana().services;

const alertIds = getManualAlertIds(caseData.comments);
const alertIdsQuery = useMemo(
() => ({
ids: {
values: getManualAlertIds(caseData.comments),
values: alertIds,
},
}),
[caseData.comments]
[alertIds]
);

const alertRegistrationContexts = useMemo(
() => getRegistrationContextFromAlerts(caseData.comments),
XavierM marked this conversation as resolved.
Show resolved Hide resolved
[caseData.comments]
const { isLoading: isLoadingAlertFeatureIds, data: alertData } = useGetFeatureIds(
alertIds,
caseData.owner !== SECURITY_SOLUTION_OWNER
);

const { isLoading: isLoadingAlertFeatureIds, data: alertFeatureIds } =
useGetFeatureIds(alertRegistrationContexts);

const configId =
caseData.owner === SECURITY_SOLUTION_OWNER ? `${caseData.owner}-case` : caseData.owner;
caseData.owner === SECURITY_SOLUTION_OWNER
? `${caseData.owner}-case`
: !isLoadingAlertFeatureIds
XavierM marked this conversation as resolved.
Show resolved Hide resolved
? triggersActionsUi.alertsTableConfigurationRegistry.getAlertConfigIdPerRuleType(
alertData?.ruleTypeIds ?? []
)
: '';

const alertStateProps = {
alertsTableConfigurationRegistry: triggersActionsUi.alertsTableConfigurationRegistry,
configurationId: configId,
id: `case-details-alerts-${caseData.owner}`,
featureIds: alertFeatureIds ?? [],
query: alertIdsQuery,
showAlertStatusWithFlapping: caseData.owner !== SECURITY_SOLUTION_OWNER,
};
const alertStateProps = useMemo(
() => ({
alertsTableConfigurationRegistry: triggersActionsUi.alertsTableConfigurationRegistry,
configurationId: configId,
id: `case-details-alerts-${caseData.owner}`,
featureIds: (caseData.owner === SECURITY_SOLUTION_OWNER
? [AlertConsumers.SIEM]
: alertData?.featureIds ?? []) as ValidFeatureId[],
query: alertIdsQuery,
showAlertStatusWithFlapping: caseData.owner !== SECURITY_SOLUTION_OWNER,
}),
[
triggersActionsUi.alertsTableConfigurationRegistry,
configId,
caseData.owner,
alertData?.featureIds,
alertIdsQuery,
]
);

if (alertIdsQuery.ids.values.length === 0) {
return (
Expand Down
43 changes: 34 additions & 9 deletions x-pack/plugins/cases/public/containers/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* 2.0.
*/

import type { ValidFeatureId } from '@kbn/rule-data-utils';
import { ALERT_RULE_CONSUMER, ALERT_RULE_PRODUCER, ALERT_RULE_TYPE_ID } from '@kbn/rule-data-utils';
import { BASE_RAC_ALERTS_API_PATH } from '@kbn/rule-registry-plugin/common/constants';
import type { User } from '../../common/types/domain';
import { AttachmentType } from '../../common/types/domain';
Expand Down Expand Up @@ -73,6 +73,7 @@ import {
import type {
ActionLicense,
CaseUI,
FeatureIdsResponse,
SingleCaseMetrics,
SingleCaseMetricsFeature,
UserActionUI,
Expand Down Expand Up @@ -511,16 +512,40 @@ export const getFeatureIds = async ({
query,
signal,
}: {
query: { registrationContext: string[] };
query: {
ids: {
values: string[];
};
};
signal?: AbortSignal;
}): Promise<ValidFeatureId[]> => {
return KibanaServices.get().http.fetch<ValidFeatureId[]>(
`${BASE_RAC_ALERTS_API_PATH}/_feature_ids`,
{
signal,
}): Promise<FeatureIdsResponse> => {
XavierM marked this conversation as resolved.
Show resolved Hide resolved
return KibanaServices.get().http.post<FeatureIdsResponse>(`${BASE_RAC_ALERTS_API_PATH}/find`, {
XavierM marked this conversation as resolved.
Show resolved Hide resolved
method: 'POST',
body: JSON.stringify({
aggs: {
consumer: {
terms: {
field: ALERT_RULE_CONSUMER,
size: 100,
},
},
producer: {
terms: {
field: ALERT_RULE_PRODUCER,
size: 100,
},
},
ruleTypeIds: {
terms: {
field: ALERT_RULE_TYPE_ID,
size: 100,
},
},
},
query,
}
);
}),
signal,
});
};

export const getCaseConnectors = async (
Expand Down
17 changes: 17 additions & 0 deletions x-pack/plugins/cases/public/containers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,21 @@
* 2.0.
*/

import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';

export * from '../../common/ui';

export type FeatureIdsResponse = estypes.SearchResponse<
unknown,
{
consumer: {
buckets: Array<{ key: string; doc_count: number }>;
};
producer: {
buckets: Array<{ key: string; doc_count: number }>;
};
ruleTypeIds: {
buckets: Array<{ key: string; doc_count: number }>;
};
}
>;
Original file line number Diff line number Diff line change
Expand Up @@ -32,36 +32,54 @@ describe('useGetFeaturesIds', () => {
it('returns the features ids correctly', async () => {
const spy = jest.spyOn(api, 'getFeatureIds').mockRejectedValue([]);

const { waitForNextUpdate } = renderHook(() => useGetFeatureIds(['context1']), {
const { waitForNextUpdate } = renderHook(() => useGetFeatureIds(['alert-id-1'], true), {
wrapper: appMockRender.AppWrapper,
});

await waitForNextUpdate();

await waitFor(() => {
expect(spy).toHaveBeenCalledWith({
query: { registrationContext: ['context1'] },
query: {
ids: {
values: ['alert-id-1'],
},
},
signal: expect.any(AbortSignal),
});
});
});

it('never call API if disable', async () => {
const spyMock = jest.spyOn(api, 'getFeatureIds');

renderHook(() => useGetFeatureIds(['alert-id-1'], false), {
wrapper: appMockRender.AppWrapper,
});

expect(spyMock).toHaveBeenCalledTimes(0);
});

it('shows a toast error when the api return an error', async () => {
(useToasts as jest.Mock).mockReturnValue({ addError });

const spy = jest
.spyOn(api, 'getFeatureIds')
.mockRejectedValue(new Error('Something went wrong'));

const { waitForNextUpdate } = renderHook(() => useGetFeatureIds(['context1']), {
const { waitForNextUpdate } = renderHook(() => useGetFeatureIds(['alert-id-1'], true), {
wrapper: appMockRender.AppWrapper,
});

await waitForNextUpdate();

await waitFor(() => {
expect(spy).toHaveBeenCalledWith({
query: { registrationContext: ['context1'] },
query: {
ids: {
values: ['alert-id-1'],
},
},
signal: expect.any(AbortSignal),
});
expect(addError).toHaveBeenCalled();
Expand Down
62 changes: 55 additions & 7 deletions x-pack/plugins/cases/public/containers/use_get_feature_ids.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,76 @@
*/

import { useQuery } from '@tanstack/react-query';
import type { ValidFeatureId } from '@kbn/rule-data-utils';
import { isValidFeatureId } from '@kbn/rule-data-utils';
import { useMemo } from 'react';
import type { ServerError } from '../types';
import { useCasesToast } from '../common/use_cases_toast';
import * as i18n from './translations';
import { getFeatureIds } from './api';
import { casesQueriesKeys } from './constants';
import type { FeatureIdsResponse } from './types';

export const useGetFeatureIds = (alertRegistrationContexts: string[]) => {
const { showErrorToast } = useCasesToast();
interface UseGetFeatureIdsResponse {
featureIds: string[];
ruleTypeIds: string[];
}

const featureIdsToMap = (data: FeatureIdsResponse): UseGetFeatureIdsResponse => {
XavierM marked this conversation as resolved.
Show resolved Hide resolved
const localFeatureIds = new Set<string>();
data?.aggregations?.consumer?.buckets?.forEach(
({ key, doc_count: docCount }: { key: string; doc_count: number }) => {
if (docCount > 0 && isValidFeatureId(key)) {
localFeatureIds.add(key);
}
}
);
data?.aggregations?.producer?.buckets?.forEach(
({ key, doc_count: docCount }: { key: string; doc_count: number }) => {
if (docCount > 0 && isValidFeatureId(key)) {
localFeatureIds.add(key);
}
}
);
Comment on lines +25 to +38
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: What about combining the two loops into one like [...data?.aggregations?.consumer?.buckets?, ...data?.aggregations?.producer?.buckets]?

const ruleTypeIds =
data?.aggregations?.ruleTypeIds?.buckets
?.filter(({ doc_count: docCount }: { doc_count: number }) => docCount > 0)
.map(({ key }: { key: string }) => key) ?? [];

return useQuery<ValidFeatureId[], ServerError>(
casesQueriesKeys.alertFeatureIds(alertRegistrationContexts),
return { featureIds: [...localFeatureIds], ruleTypeIds };
};

export const useGetFeatureIds = (alertIds: string[], enabled: boolean) => {
const { showErrorToast } = useCasesToast();
const { data, isInitialLoading, isLoading } = useQuery<
FeatureIdsResponse,
ServerError,
UseGetFeatureIdsResponse
>(
casesQueriesKeys.alertFeatureIds(alertIds),
XavierM marked this conversation as resolved.
Show resolved Hide resolved
({ signal }) => {
const query = { registrationContext: alertRegistrationContexts };
return getFeatureIds({ query, signal });
return getFeatureIds({
query: {
ids: {
values: alertIds,
},
},
signal,
});
},
{
select: featureIdsToMap,
retry: false,
XavierM marked this conversation as resolved.
Show resolved Hide resolved
enabled,
onError: (error: ServerError) => {
showErrorToast(error, { title: i18n.ERROR_TITLE });
},
}
);

return useMemo(
() => ({ data, isLoading: (isInitialLoading || isLoading) && enabled }),
[data, enabled, isInitialLoading, isLoading]
);
};

export type UseGetFeatureIds = typeof useGetFeatureIds;
Loading