mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[8.x] [Case Observables][Similar Cases] Add value label to similarities in response & the view (#206934) (#207377)
# Backport This will backport the following commits from `main` to `8.x`: - [[Case Observables][Similar Cases] Add value label to similarities in response & the view (#206934)](https://github.com/elastic/kibana/pull/206934) <!--- Backport version: 9.4.3 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) <!--BACKPORT [{"author":{"name":"Luke Gmys","email":"11671118+lgestc@users.noreply.github.com"},"sourceCommit":{"committedDate":"2025-01-21T14:53:27Z","message":"[Case Observables][Similar Cases] Add value label to similarities in response & the view (#206934)\n\n## Summary\r\n\r\nThis PR improves similar value rendering by wrapping them in badges and\r\nadding in the observable type label to the api response & the view.\r\n\r\n\r\n\r\n### Testing:\r\n\r\nAdd two observables in distinct cases, with same value and type. They\r\nshould show up in the Similar Cases tab just like on the screenshot\r\nbelow.\r\n\r\n---------\r\n\r\nCo-authored-by: Antonio <antoniodcoelho@gmail.com>","sha":"b44ccfcede6300412b0ec6ddcc95939a40625260","branchLabelMapping":{"^v9.0.0$":"main","^v8.18.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:ResponseOps","v9.0.0","Team:Threat Hunting:Investigations","backport:prev-minor"],"title":"[Case Observables][Similar Cases] Add value label to similarities in response & the view","number":206934,"url":"https://github.com/elastic/kibana/pull/206934","mergeCommit":{"message":"[Case Observables][Similar Cases] Add value label to similarities in response & the view (#206934)\n\n## Summary\r\n\r\nThis PR improves similar value rendering by wrapping them in badges and\r\nadding in the observable type label to the api response & the view.\r\n\r\n\r\n\r\n### Testing:\r\n\r\nAdd two observables in distinct cases, with same value and type. They\r\nshould show up in the Similar Cases tab just like on the screenshot\r\nbelow.\r\n\r\n---------\r\n\r\nCo-authored-by: Antonio <antoniodcoelho@gmail.com>","sha":"b44ccfcede6300412b0ec6ddcc95939a40625260"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/206934","number":206934,"mergeCommit":{"message":"[Case Observables][Similar Cases] Add value label to similarities in response & the view (#206934)\n\n## Summary\r\n\r\nThis PR improves similar value rendering by wrapping them in badges and\r\nadding in the observable type label to the api response & the view.\r\n\r\n\r\n\r\n### Testing:\r\n\r\nAdd two observables in distinct cases, with same value and type. They\r\nshould show up in the Similar Cases tab just like on the screenshot\r\nbelow.\r\n\r\n---------\r\n\r\nCo-authored-by: Antonio <antoniodcoelho@gmail.com>","sha":"b44ccfcede6300412b0ec6ddcc95939a40625260"}}]}] BACKPORT--> Co-authored-by: Luke Gmys <11671118+lgestc@users.noreply.github.com>
This commit is contained in:
parent
3abc1783a6
commit
864adea19f
8 changed files with 133 additions and 34 deletions
|
@ -162,6 +162,7 @@ export const RelatedCaseRt = rt.strict({
|
|||
|
||||
export const SimilarityRt = rt.strict({
|
||||
typeKey: rt.string,
|
||||
typeLabel: rt.string,
|
||||
value: rt.string,
|
||||
});
|
||||
|
||||
|
|
|
@ -8,12 +8,12 @@
|
|||
import React from 'react';
|
||||
import { type AppMockRenderer, createAppMockRenderer } from '../../common/mock';
|
||||
import { SimilarCasesTable, type SimilarCasesTableProps } from './table';
|
||||
import { mockCase, mockObservables } from '../../containers/mock';
|
||||
import { mockCase, mockSimilarObservables } from '../../containers/mock';
|
||||
|
||||
describe('SimilarCasesTable', () => {
|
||||
let appMock: AppMockRenderer;
|
||||
const props: SimilarCasesTableProps = {
|
||||
cases: [{ ...mockCase, similarities: { observables: mockObservables } }],
|
||||
cases: [{ ...mockCase, similarities: { observables: mockSimilarObservables } }],
|
||||
isLoading: false,
|
||||
onChange: jest.fn(),
|
||||
pagination: { pageIndex: 0, totalItemCount: 1 },
|
||||
|
@ -30,6 +30,12 @@ describe('SimilarCasesTable', () => {
|
|||
expect(result.getByTestId('similar-cases-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders similarities correctly', async () => {
|
||||
const result = appMock.render(<SimilarCasesTable {...props} />);
|
||||
|
||||
expect(await result.findByTestId('similar-cases-table-column-similarities')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders loading indicator when loading', async () => {
|
||||
const result = appMock.render(<SimilarCasesTable {...props} isLoading={true} />);
|
||||
expect(result.queryByTestId('similar-cases-table')).not.toBeInTheDocument();
|
||||
|
|
|
@ -48,13 +48,14 @@ export interface UseSimilarCasesColumnsReturnValue {
|
|||
|
||||
export const useSimilarCasesColumns = (): UseSimilarCasesColumnsReturnValue => {
|
||||
const casesColumnsConfig = useCasesColumnsConfiguration(false);
|
||||
|
||||
const columns: SimilarCasesColumns[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
field: casesColumnsConfig.title.field,
|
||||
name: casesColumnsConfig.title.name,
|
||||
sortable: false,
|
||||
render: (title: string, theCase: SimilarCaseUI) => {
|
||||
render: (_title: string, theCase: SimilarCaseUI) => {
|
||||
if (theCase.id != null && theCase.title != null) {
|
||||
const caseDetailsLinkComponent = (
|
||||
<CaseDetailsLink detailName={theCase.id} title={theCase.title}>
|
||||
|
@ -175,16 +176,74 @@ export const useSimilarCasesColumns = (): UseSimilarCasesColumnsReturnValue => {
|
|||
field: SIMILARITIES_FIELD,
|
||||
name: i18n.SIMILARITY_REASON,
|
||||
sortable: false,
|
||||
render: (similarities: SimilarCaseUI['similarities'], theCase: SimilarCaseUI) => {
|
||||
if (theCase.id != null && theCase.title != null) {
|
||||
return similarities.observables.map((similarity) => similarity.value).join(', ');
|
||||
render: (similarities: SimilarCaseUI['similarities']) => {
|
||||
const similarObservableValues = similarities.observables.map(
|
||||
(similarity) => `${similarity.typeLabel}:${similarity.value}`
|
||||
);
|
||||
|
||||
if (similarObservableValues.length > 0) {
|
||||
const clampedBadges = (
|
||||
<EuiBadgeGroup
|
||||
data-test-subj="similar-cases-table-column-similarities"
|
||||
css={getLineClampedCss}
|
||||
gutterSize="xs"
|
||||
>
|
||||
{similarObservableValues.map((similarValue: string) => (
|
||||
<EuiBadge
|
||||
css={css`
|
||||
max-width: 100px;
|
||||
`}
|
||||
color="hollow"
|
||||
key={`${similarValue}`}
|
||||
data-test-subj={`similar-cases-table-column-similarities-${similarValue}`}
|
||||
>
|
||||
{similarValue}
|
||||
</EuiBadge>
|
||||
))}
|
||||
</EuiBadgeGroup>
|
||||
);
|
||||
|
||||
const unclampedBadges = (
|
||||
<EuiBadgeGroup data-test-subj="similar-cases-table-column-similarities">
|
||||
{similarObservableValues.map((similarValue: string) => (
|
||||
<EuiBadge
|
||||
color="hollow"
|
||||
key={`${similarValue}`}
|
||||
data-test-subj={`similar-cases-table-column-similarities-${similarValue}`}
|
||||
>
|
||||
{similarValue}
|
||||
</EuiBadge>
|
||||
))}
|
||||
</EuiBadgeGroup>
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiToolTip
|
||||
data-test-subj="similar-cases-table-column-similarities-tooltip"
|
||||
position="left"
|
||||
content={unclampedBadges}
|
||||
>
|
||||
{clampedBadges}
|
||||
</EuiToolTip>
|
||||
);
|
||||
}
|
||||
return getEmptyCellValue();
|
||||
},
|
||||
width: '20%',
|
||||
},
|
||||
],
|
||||
[casesColumnsConfig]
|
||||
[
|
||||
casesColumnsConfig.category.field,
|
||||
casesColumnsConfig.category.name,
|
||||
casesColumnsConfig.severity.field,
|
||||
casesColumnsConfig.severity.name,
|
||||
casesColumnsConfig.status.field,
|
||||
casesColumnsConfig.status.name,
|
||||
casesColumnsConfig.tags.field,
|
||||
casesColumnsConfig.tags.name,
|
||||
casesColumnsConfig.title.field,
|
||||
casesColumnsConfig.title.name,
|
||||
]
|
||||
);
|
||||
|
||||
return { columns, rowHeader: casesColumnsConfig.title.field };
|
||||
|
|
|
@ -1324,3 +1324,16 @@ export const mockObservables: ObservableUI[] = [
|
|||
updatedAt: '2024-12-11',
|
||||
},
|
||||
];
|
||||
|
||||
export const mockSimilarObservables = [
|
||||
{
|
||||
value: '127.0.0.1',
|
||||
typeKey: OBSERVABLE_TYPE_IPV4.key,
|
||||
typeLabel: OBSERVABLE_TYPE_IPV4.label,
|
||||
},
|
||||
{
|
||||
value: '10.0.0.1',
|
||||
typeKey: OBSERVABLE_TYPE_IPV4.key,
|
||||
typeLabel: OBSERVABLE_TYPE_IPV4.label,
|
||||
},
|
||||
];
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import { intersection } from 'lodash';
|
||||
import Boom from '@hapi/boom';
|
||||
import type { ObservableType } from '../../../common/types/domain/observable/v1';
|
||||
import { OWNER_FIELD } from '../../../common/constants';
|
||||
import type { CasesSimilarResponse, SimilarCasesSearchRequest } from '../../../common/types/api';
|
||||
import { SimilarCasesSearchRequestRt, CasesSimilarResponseRt } from '../../../common/types/api';
|
||||
|
@ -19,7 +20,7 @@ import { Operations } from '../../authorization';
|
|||
import { buildFilter, buildObservablesFieldsFilter, combineFilters } from '../utils';
|
||||
import { combineFilterWithAuthorizationFilter } from '../../authorization/utils';
|
||||
import type { CaseSavedObjectTransformed } from '../../common/types/case';
|
||||
import { getAvailableObservableTypesSet } from '../observable_types';
|
||||
import { getAvailableObservableTypesMap } from '../observable_types';
|
||||
|
||||
interface Similarity {
|
||||
typeKey: string;
|
||||
|
@ -29,7 +30,7 @@ interface Similarity {
|
|||
const getSimilarities = (
|
||||
a: CaseSavedObjectTransformed,
|
||||
b: CaseSavedObjectTransformed,
|
||||
availableObservableTypes: Set<string>
|
||||
availableObservableTypes: Map<string, ObservableType>
|
||||
): Similarity[] => {
|
||||
const stringify = (observable: { typeKey: string; value: string }) =>
|
||||
[observable.typeKey, observable.value].join(',');
|
||||
|
@ -46,6 +47,7 @@ const getSimilarities = (
|
|||
return {
|
||||
typeKey,
|
||||
value,
|
||||
typeLabel: availableObservableTypes.get(typeKey)?.label,
|
||||
};
|
||||
})
|
||||
.filter((observable) => availableObservableTypes.has(observable.typeKey));
|
||||
|
@ -78,7 +80,7 @@ export const similar = async (
|
|||
const paramArgs = decodeWithExcessOrThrow(SimilarCasesSearchRequestRt)(params);
|
||||
const retrievedCase = await caseService.getCase({ id: caseId });
|
||||
|
||||
const availableObservableTypesSet = await getAvailableObservableTypesSet(
|
||||
const availableObservableTypesMap = await getAvailableObservableTypesMap(
|
||||
casesClient,
|
||||
retrievedCase.attributes.owner
|
||||
);
|
||||
|
@ -95,7 +97,7 @@ export const similar = async (
|
|||
const similarCasesFilter = buildObservablesFieldsFilter(
|
||||
retrievedCase.attributes.observables.reduce((observableMap, observable) => {
|
||||
// NOTE: skip non-existent observable types
|
||||
if (!availableObservableTypesSet.has(observable.typeKey)) {
|
||||
if (!availableObservableTypesMap.has(observable.typeKey)) {
|
||||
return observableMap;
|
||||
}
|
||||
|
||||
|
@ -144,7 +146,7 @@ export const similar = async (
|
|||
cases: cases.saved_objects.map((so) => ({
|
||||
...flattenCaseSavedObject({ savedObject: so }),
|
||||
similarities: {
|
||||
observables: getSimilarities(retrievedCase, so, availableObservableTypesSet),
|
||||
observables: getSimilarities(retrievedCase, so, availableObservableTypesMap),
|
||||
},
|
||||
})),
|
||||
page: cases.page,
|
||||
|
|
|
@ -6,18 +6,23 @@
|
|||
*/
|
||||
|
||||
import type { Configurations } from '../../common/types/domain/configure/v1';
|
||||
import { OBSERVABLE_TYPES_BUILTIN_KEYS } from '../../common/constants';
|
||||
import { OBSERVABLE_TYPES_BUILTIN } from '../../common/constants';
|
||||
import { createCasesClientMock } from './mocks';
|
||||
import { getAvailableObservableTypesSet } from './observable_types';
|
||||
import { getAvailableObservableTypesMap } from './observable_types';
|
||||
import type { ObservableType } from '../../common/types/domain';
|
||||
|
||||
const mockCasesClient = createCasesClientMock();
|
||||
|
||||
describe('getAvailableObservableTypesSet', () => {
|
||||
const arrayToMap = (arr: ObservableType[]): Map<string, ObservableType> => {
|
||||
return new Map(arr.map((item) => [item.key, item]));
|
||||
};
|
||||
|
||||
describe('getAvailableObservableTypesMap', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return a set of available observable types', async () => {
|
||||
it('should return a map of available observable types', async () => {
|
||||
const mockObservableTypes = [
|
||||
{ key: 'type1', label: 'test 1' },
|
||||
{ key: 'type2', label: 'test 2' },
|
||||
|
@ -29,9 +34,9 @@ describe('getAvailableObservableTypesSet', () => {
|
|||
},
|
||||
] as unknown as Configurations);
|
||||
|
||||
const result = await getAvailableObservableTypesSet(mockCasesClient, 'mock-owner');
|
||||
const result = await getAvailableObservableTypesMap(mockCasesClient, 'mock-owner');
|
||||
|
||||
expect(result).toEqual(new Set(['type1', 'type2', ...OBSERVABLE_TYPES_BUILTIN_KEYS]));
|
||||
expect(result).toEqual(arrayToMap([...OBSERVABLE_TYPES_BUILTIN, ...mockObservableTypes]));
|
||||
});
|
||||
|
||||
it('should return only built-in observable types if no types are configured', async () => {
|
||||
|
@ -41,18 +46,18 @@ describe('getAvailableObservableTypesSet', () => {
|
|||
},
|
||||
] as unknown as Configurations);
|
||||
|
||||
const result = await getAvailableObservableTypesSet(mockCasesClient, 'mock-owner');
|
||||
const result = await getAvailableObservableTypesMap(mockCasesClient, 'mock-owner');
|
||||
|
||||
expect(result).toEqual(new Set(OBSERVABLE_TYPES_BUILTIN_KEYS));
|
||||
expect(result).toEqual(arrayToMap(OBSERVABLE_TYPES_BUILTIN));
|
||||
});
|
||||
|
||||
it('should handle errors and return an empty set', async () => {
|
||||
it('should handle errors and return an empty map', async () => {
|
||||
jest
|
||||
.mocked(mockCasesClient.configure.get)
|
||||
.mockRejectedValue(new Error('Failed to fetch configuration'));
|
||||
|
||||
const result = await getAvailableObservableTypesSet(mockCasesClient, 'mock-owner');
|
||||
const result = await getAvailableObservableTypesMap(mockCasesClient, 'mock-owner');
|
||||
|
||||
expect(result).toEqual(new Set());
|
||||
expect(result).toEqual(new Map());
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,22 +5,35 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { ObservableType } from '../../common/types/domain';
|
||||
import { OBSERVABLE_TYPES_BUILTIN } from '../../common/constants';
|
||||
import type { CasesClient } from './client';
|
||||
|
||||
export const getAvailableObservableTypesSet = async (casesClient: CasesClient, owner: string) => {
|
||||
try {
|
||||
const configurations = await casesClient.configure.get({
|
||||
owner,
|
||||
});
|
||||
const observableTypes = configurations?.[0]?.observableTypes ?? [];
|
||||
export const getAvailableObservableTypes = async (casesClient: CasesClient, owner: string) => {
|
||||
const configurations = await casesClient.configure.get({
|
||||
owner,
|
||||
});
|
||||
const observableTypes = configurations?.[0]?.observableTypes ?? [];
|
||||
|
||||
const availableObservableTypesSet = new Set(
|
||||
[...observableTypes, ...OBSERVABLE_TYPES_BUILTIN].map(({ key }) => key)
|
||||
return [...observableTypes, ...OBSERVABLE_TYPES_BUILTIN];
|
||||
};
|
||||
|
||||
export const getAvailableObservableTypesMap = async (
|
||||
casesClient: CasesClient,
|
||||
owner: string
|
||||
): Promise<Map<string, ObservableType>> => {
|
||||
try {
|
||||
const observableTypes = await getAvailableObservableTypes(casesClient, owner);
|
||||
|
||||
const availableObservableTypesSet = new Map(
|
||||
[...observableTypes, ...OBSERVABLE_TYPES_BUILTIN].map((observableType) => [
|
||||
observableType.key,
|
||||
observableType,
|
||||
])
|
||||
);
|
||||
|
||||
return availableObservableTypesSet;
|
||||
} catch (error) {
|
||||
return new Set<string>();
|
||||
return new Map();
|
||||
}
|
||||
};
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
import Boom from '@hapi/boom';
|
||||
import { OBSERVABLE_TYPES_BUILTIN } from '../../common/constants';
|
||||
import { type CasesClient } from './client';
|
||||
import { getAvailableObservableTypesSet } from './observable_types';
|
||||
import { getAvailableObservableTypesMap } from './observable_types';
|
||||
|
||||
/**
|
||||
* Throws an error if the request has custom fields with duplicated keys.
|
||||
|
@ -122,7 +122,7 @@ export const validateObservableTypeKeyExists = async (
|
|||
observableTypeKey: string;
|
||||
}
|
||||
) => {
|
||||
const observableTypesSet = await getAvailableObservableTypesSet(casesClient, caseOwner);
|
||||
const observableTypesSet = await getAvailableObservableTypesMap(casesClient, caseOwner);
|
||||
if (!observableTypesSet.has(observableTypeKey)) {
|
||||
throw Boom.badRequest(`Invalid observable type, key does not exist: ${observableTypeKey}`);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue