[Security Solution][Notes] - store setup (#186433)

This commit is contained in:
Philippe Oberti 2024-06-20 09:34:17 -05:00 committed by GitHub
parent 6ce61db2ff
commit 153ec668e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 228 additions and 8 deletions

1
.github/CODEOWNERS vendored
View file

@ -1445,6 +1445,7 @@ x-pack/test/security_solution_cypress/cypress/tasks/expandable_flyout @elastic/
/x-pack/plugins/security_solution/public/detections/components/alerts_info @elastic/security-threat-hunting-investigations
/x-pack/plugins/security_solution/public/flyout/document_details @elastic/security-threat-hunting-investigations
/x-pack/plugins/security_solution/public/flyout/shared @elastic/security-threat-hunting-investigations
/x-pack/plugins/security_solution/public/notes @elastic/security-threat-hunting-investigations
/x-pack/plugins/security_solution/public/resolver @elastic/security-threat-hunting-investigations
/x-pack/plugins/security_solution/public/threat_intelligence @elastic/security-threat-hunting-investigations
/x-pack/plugins/security_solution/public/timelines @elastic/security-threat-hunting-investigations

View file

@ -7,6 +7,7 @@
import { TableId } from '@kbn/securitysolution-data-table';
import type { DataViewSpec, FieldSpec } from '@kbn/data-views-plugin/public';
import { ReqStatus } from '../../notes/store/notes.slice';
import { HostsFields } from '../../../common/api/search_strategy/hosts/model/sort';
import { InputsModelId } from '../store/inputs/constants';
import {
@ -500,4 +501,26 @@ export const mockGlobalState: State = {
*/
management: mockManagementState as ManagementState,
discover: getMockDiscoverInTimelineState(),
notes: {
ids: ['1'],
entities: {
'1': {
eventId: 'event-id',
noteId: '1',
note: 'note-1',
timelineId: 'timeline-1',
created: 1663882629000,
createdBy: 'elastic',
updated: 1663882629000,
updatedBy: 'elastic',
version: 'version',
},
},
status: {
fetchNotesByDocumentId: ReqStatus.Idle,
},
error: {
fetchNotesByDocumentId: null,
},
},
};

View file

@ -13,6 +13,7 @@ import { useSourcererDataView } from '../../sourcerer/containers';
import { renderHook } from '@testing-library/react-hooks';
import { initialGroupingState } from './grouping/reducer';
import { initialAnalyzerState } from '../../resolver/store/helpers';
import { initialNotesState } from '../../notes/store/notes.slice';
jest.mock('../hooks/use_selector');
jest.mock('../lib/kibana', () => {
@ -69,7 +70,8 @@ describe('createInitialState', () => {
},
{
analyzer: initialAnalyzerState,
}
},
initialNotesState
);
test('indicesExist should be TRUE if patternList is NOT empty', async () => {
@ -107,7 +109,9 @@ describe('createInitialState', () => {
},
{
analyzer: initialAnalyzerState,
}
},
initialNotesState
);
const { result } = renderHook(() => useSourcererDataView(), {
wrapper: ({ children }) => (

View file

@ -35,6 +35,8 @@ import type { GroupState } from './grouping/types';
import { analyzerReducer } from '../../resolver/store/reducer';
import { securitySolutionDiscoverReducer } from './discover/reducer';
import type { AnalyzerState } from '../../resolver/types';
import type { NotesState } from '../../notes/store/notes.slice';
import { notesReducer } from '../../notes/store/notes.slice';
enableMapSet();
@ -66,7 +68,8 @@ export const createInitialState = (
},
dataTableState: DataTableState,
groupsState: GroupState,
analyzerState: AnalyzerState
analyzerState: AnalyzerState,
notesState: NotesState
): State => {
const initialPatterns = {
[SourcererScopeName.default]: getScopePatternListSelection(
@ -128,6 +131,7 @@ export const createInitialState = (
internal: undefined,
savedSearch: undefined,
},
notes: notesState,
};
return preloadedState;
@ -150,4 +154,5 @@ export const createReducer: (
analyzer: analyzerReducer,
discover: securitySolutionDiscoverReducer,
...pluginsReducer,
notes: notesReducer,
});

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import thunk from 'redux-thunk';
import type {
Action,
Store,
@ -54,6 +55,7 @@ import { dataAccessLayerFactory } from '../../resolver/data_access_layer/factory
import { sourcererActions } from '../../sourcerer/store';
import { createMiddlewares } from './middlewares';
import { addNewTimeline } from '../../timelines/store/helpers';
import { initialNotesState } from '../../notes/store/notes.slice';
let store: Store<State, Action> | null = null;
@ -168,7 +170,8 @@ export const createStoreFactory = async (
},
dataTableInitialState,
groupsInitialState,
analyzerInitialState
analyzerInitialState,
initialNotesState
);
const rootReducer = {
@ -284,7 +287,8 @@ export const createStore = (
const middlewareEnhancer = applyMiddleware(
...createMiddlewares(kibana, storage),
telemetryMiddleware,
...(additionalMiddleware ?? [])
...(additionalMiddleware ?? []),
thunk
);
store = createReduxStore(

View file

@ -25,11 +25,11 @@ import type { GlobalUrlParam } from './global_url_param';
import type { GroupState } from './grouping/types';
import type { SecuritySolutionDiscoverState } from './discover/model';
import type { AnalyzerState } from '../../resolver/types';
import type { NotesState } from '../../notes/store/notes.slice';
export type State = HostsPluginState &
UsersPluginState &
NetworkPluginState &
UsersPluginState &
TimelinePluginState &
ManagementPluginState & {
app: AppState;
@ -40,7 +40,7 @@ export type State = HostsPluginState &
discover: SecuritySolutionDiscoverState;
} & DataTableState &
GroupState &
AnalyzerState;
AnalyzerState & { notes: NotesState };
/**
* The Redux store type for the Security app.
*/

View file

@ -5,13 +5,23 @@
* 2.0.
*/
import React, { memo } from 'react';
import React, { memo, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { fetchNotesByDocumentId } from '../../../../notes/store/notes.slice';
import { useLeftPanelContext } from '../context';
/**
* List all the notes for a document id and allows to create new notes associated with that document.
* Displayed in the document details expandable flyout left section.
*/
export const NotesDetails = memo(() => {
const dispatch = useDispatch();
const { eventId } = useLeftPanelContext();
useEffect(() => {
dispatch(fetchNotesByDocumentId({ documentId: eventId }));
}, [dispatch, eventId]);
return <></>;
});

View file

@ -0,0 +1,33 @@
/*
* 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 * as uuid from 'uuid';
// TODO point to the correct API when it is available
/**
* Fetches all the notes for a document id
*/
export const fetchNotesByDocumentId = async (documentId: string) => {
const response = {
totalCount: 1,
notes: [generateNoteMock(documentId)],
};
return response.notes;
};
// TODO remove when the API is available
const generateNoteMock = (documentId: string) => ({
noteId: uuid.v4(),
version: 'WzU1MDEsMV0=',
timelineId: '',
eventId: documentId,
note: 'This is a mocked note',
created: new Date().getTime(),
createdBy: 'elastic',
updated: new Date().getTime(),
updatedBy: 'elastic',
});

View file

@ -0,0 +1,54 @@
/*
* 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 { Note } from '../../../common/api/timeline';
/**
* Interface to represent a normalized entity
*/
export interface NormalizedEntity<T> {
entities: {
[entity: string]: {
[id: string]: T;
};
};
result: string;
}
/**
* Interface to represent normalized entities
*/
export interface NormalizedEntities<T> {
entities: {
[entity: string]: {
[id: string]: T;
};
};
result: string[];
}
/**
* Normalizes a single note
*/
export const normalizeEntity = (res: Note): NormalizedEntity<Note> => ({
entities: {
notes: {
[res.noteId]: res,
},
},
result: res.noteId,
});
/**
* Normalizes an array of notes
*/
export const normalizeEntities = (res: Note[]): NormalizedEntities<Note> => ({
entities: {
notes: res.reduce((obj, item) => Object.assign(obj, { [item.noteId]: item }), {}),
},
result: res.map((note) => note.noteId),
});

View file

@ -0,0 +1,86 @@
/*
* 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 { EntityState, SerializedError } from '@reduxjs/toolkit';
import { createAsyncThunk, createEntityAdapter, createSlice } from '@reduxjs/toolkit';
import type { State } from '../../common/store';
import { fetchNotesByDocumentId as fetchNotesByDocumentIdApi } from '../api/api';
import type { NormalizedEntities } from './normalize';
import { normalizeEntities } from './normalize';
import type { Note } from '../../../common/api/timeline';
export enum ReqStatus {
Idle = 'idle',
Loading = 'loading',
Succeeded = 'succeeded',
Failed = 'failed',
}
interface HttpError {
type: 'http';
status: number;
}
export interface NotesState extends EntityState<Note> {
status: {
fetchNotesByDocumentId: ReqStatus;
};
error: {
fetchNotesByDocumentId: SerializedError | HttpError | null;
};
}
const notesAdapter = createEntityAdapter<Note>({
selectId: (note: Note) => note.noteId,
});
export const initialNotesState: NotesState = notesAdapter.getInitialState({
status: {
fetchNotesByDocumentId: ReqStatus.Idle,
},
error: {
fetchNotesByDocumentId: null,
},
});
export const fetchNotesByDocumentId = createAsyncThunk<
NormalizedEntities<Note>,
{ documentId: string },
{}
>('notes/fetchNotesByDocumentId', async (args) => {
const { documentId } = args;
const res = await fetchNotesByDocumentIdApi(documentId);
return normalizeEntities(res);
});
const notesSlice = createSlice({
name: 'notes',
initialState: initialNotesState,
reducers: {},
extraReducers(builder) {
builder
.addCase(fetchNotesByDocumentId.pending, (state, action) => {
state.status.fetchNotesByDocumentId = ReqStatus.Loading;
})
.addCase(fetchNotesByDocumentId.fulfilled, (state, action) => {
notesAdapter.upsertMany(state, action.payload.entities.notes);
state.status.fetchNotesByDocumentId = ReqStatus.Succeeded;
})
.addCase(fetchNotesByDocumentId.rejected, (state, action) => {
state.status.fetchNotesByDocumentId = ReqStatus.Failed;
state.error.fetchNotesByDocumentId = action.payload ?? action.error;
});
},
});
export const notesReducer = notesSlice.reducer;
export const {
selectAll: selectAllNotes,
selectById: selectNoteById,
selectIds: selectNoteIds,
} = notesAdapter.getSelectors((state: State) => state.notes);