[SecuritySolution] Remove remaining usage of redux-observable (#175678)

## Summary

In this PR, we're removing all usages of `redux-observable` in favor of
simple middlewares. This work is part of [this tech debt
ticket](https://github.com/elastic/kibana/issues/175427) which outlines
the motivation of this move.

A couple shortcuts had to be taken and I added comments further down to
explain the motivation.

### Oddities
Weirdly, the CI reports an increase in async chunks, instead of an
expected decrease due to removing a library.

| id |
[before](9629e66134)
|
[after](477348a714)
| diff |
| --- | --- | --- | --- |
| `securitySolution` | 11.2MB | 11.4MB | +157.1KB |

I'm not sure, why this is, so if anyone has any insights on how to
examine the async chunks, that would be helpful.

_edit:_ After hours of analyzing Kibana build stats at various stages of
this PR, I found that the changes that are responsible for this increase
in async chunk sum size is this commit:
cde023d340
. It only consists of deleted files and deleted imports. Therefore it's
up to webpack to figure out the best way to chunk up the app. In other
words: I can't change it :(

### Tests

You might notice, that I didn't add tests to the middlewares. That is
because the epics didn't have tests either and their functionality is
tested in acceptance tests. As a follow-up of this PR, I will add tests
to all newly-introduced middlewares. My goal here is to keep the changes
as small as possible.

### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jan Monschke 2024-01-31 09:22:23 +01:00 committed by GitHub
parent dc3c43b120
commit ca23dd5060
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 674 additions and 1094 deletions

View file

@ -1080,7 +1080,6 @@
"redux-actions": "^2.6.5",
"redux-devtools-extension": "^2.13.8",
"redux-logger": "^3.0.6",
"redux-observable": "2.0.0",
"redux-saga": "^1.1.3",
"redux-thunk": "^2.4.2",
"redux-thunks": "^1.0.0",

View file

@ -1,85 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Action } from 'redux';
import { Epic } from 'redux-observable';
declare module 'redux-observable' {
function combineEpics<
T1 extends Action,
T2 extends Action,
O1 extends T1,
O2 extends T2,
S,
D1,
D2
>(epic1: Epic<T1, O1, S, D1>, epic2: Epic<T2, O2, S, D2>): Epic<T1 | T2, O1 | O2, S, D1 & D2>;
function combineEpics<
T1 extends Action,
T2 extends Action,
T3 extends Action,
O1 extends T1,
O2 extends T2,
O3 extends T3,
S,
D1,
D2,
D3
>(
epic1: Epic<T1, O1, S, D1>,
epic2: Epic<T2, O2, S, D2>,
epic3: Epic<T3, O3, S, D3>
): Epic<T1 | T2 | T3, O1 | O2 | O3, S, D1 & D2 & D3>;
function combineEpics<
T1 extends Action,
T2 extends Action,
T3 extends Action,
T4 extends Action,
O1 extends T1,
O2 extends T2,
O3 extends T3,
O4 extends T4,
S,
D1,
D2,
D3,
D4
>(
epic1: Epic<T1, O1, S, D1>,
epic2: Epic<T2, O2, S, D2>,
epic3: Epic<T3, O3, S, D3>,
epic4: Epic<T4, O4, S, D4>
): Epic<T1 | T2 | T3 | T4, O1 | O2 | O3 | O4, S, D1 & D2 & D3 & D4>;
function combineEpics<
T1 extends Action,
T2 extends Action,
T3 extends Action,
T4 extends Action,
T5 extends Action,
O1 extends T1,
O2 extends T2,
O3 extends T3,
O4 extends T4,
O5 extends T5,
S,
D1,
D2,
D3,
D4,
D5
>(
epic1: Epic<T1, O1, S, D1>,
epic2: Epic<T2, O2, S, D2>,
epic3: Epic<T3, O3, S, D3>,
epic4: Epic<T4, O4, S, D4>,
epic5: Epic<T5, O5, S, D5>
): Epic<T1 | T2 | T3 | T4 | T5, O1 | O2 | O3 | O4 | O5, S, D1 & D2 & D3 & D4 & D5>;
type EpicWithState<E, S> = E extends Epic<infer In, infer Out, null, infer Deps>
? Epic<In, Out, S, Deps>
: E;
}

View file

@ -30,7 +30,6 @@ import { OverviewCardWithActions, OverviewCard } from './overview_card';
import { StatusPopoverButton } from './status_popover_button';
import { SeverityBadge } from '../../severity_badge';
import { useThrottledResizeObserver } from '../../utils';
import { isNotNull } from '../../../../timelines/store/helpers';
export const NotGrowingFlexGroup = euiStyled(EuiFlexGroup)`
flex-grow: 0;
@ -219,4 +218,8 @@ function hasData(fieldInfo?: EnrichedFieldInfo): fieldInfo is EnrichedFieldInfoW
return !!fieldInfo && Array.isArray(fieldInfo.values);
}
function isNotNull<T>(value: T | null): value is T {
return value !== null;
}
Overview.displayName = 'Overview';

View file

@ -1,35 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Epic } from 'redux-observable';
import { combineEpics } from 'redux-observable';
import type { Action } from 'redux';
import type { Observable } from 'rxjs';
import type { CoreStart } from '@kbn/core/public';
import { createTimelineEpic } from '../../timelines/store/epic';
import { createTimelineFavoriteEpic } from '../../timelines/store/epic_favorite';
import { createTimelineNoteEpic } from '../../timelines/store/epic_note';
import { createTimelinePinnedEventEpic } from '../../timelines/store/epic_pinned_event';
import type { TimelineEpicDependencies } from '../../timelines/store/types';
import type { State } from './types';
export interface RootEpicDependencies {
kibana$: Observable<CoreStart>;
}
export const createRootEpic = <StateT extends State>(): Epic<
Action,
Action,
StateT,
TimelineEpicDependencies<StateT>
> =>
combineEpics(
createTimelineEpic<StateT>(),
createTimelineFavoriteEpic<StateT>(),
createTimelineNoteEpic<StateT>(),
createTimelinePinnedEventEpic<StateT>()
);

View file

@ -4,16 +4,18 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { CoreStart } from '@kbn/core/public';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
import { createTimelineMiddlewares } from '../../timelines/store/middlewares/create_timeline_middlewares';
import { dataTableLocalStorageMiddleware } from './data_table/middleware_local_storage';
import { userAssetTableLocalStorageMiddleware } from '../../explore/users/store/middleware_storage';
export function createMiddlewares(storage: Storage) {
export function createMiddlewares(kibana: CoreStart, storage: Storage) {
return [
dataTableLocalStorageMiddleware(storage),
userAssetTableLocalStorageMiddleware(storage),
...createTimelineMiddlewares(),
...createTimelineMiddlewares(kibana),
];
}

View file

@ -11,14 +11,12 @@ import type {
Middleware,
Dispatch,
PreloadedState,
CombinedState,
AnyAction,
Reducer,
} from 'redux';
import { applyMiddleware, createStore as createReduxStore } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly';
import type { EnhancerOptions } from 'redux-devtools-extension';
import { createEpicMiddleware } from 'redux-observable';
import type { Observable } from 'rxjs';
import { BehaviorSubject, pluck } from 'rxjs';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
@ -33,18 +31,14 @@ import {
SERVER_APP_ID,
} from '../../../common/constants';
import { telemetryMiddleware } from '../lib/telemetry';
import { appSelectors } from './app';
import { timelineSelectors } from '../../timelines/store';
import * as timelineActions from '../../timelines/store/actions';
import type { TimelineModel } from '../../timelines/store/model';
import { inputsSelectors } from './inputs';
import type { SubPluginsInitReducer } from './reducer';
import { createInitialState, createReducer } from './reducer';
import { createRootEpic } from './epic';
import type { AppAction } from './actions';
import type { Immutable } from '../../../common/endpoint/types';
import type { State } from './types';
import type { TimelineEpicDependencies, TimelineState } from '../../timelines/store/types';
import type { TimelineState } from '../../timelines/store/types';
import type { KibanaDataView, SourcererModel, SourcererDataView } from './sourcerer/model';
import { initDataView } from './sourcerer/model';
import type { AppObservableLibs, StartedSubPlugins, StartPlugins } from '../../types';
@ -255,7 +249,7 @@ const stateSanitizer = (state: State) => {
export const createStore = (
state: State,
pluginsReducer: SubPluginsInitReducer,
kibana: Observable<CoreStart>,
kibana$: Observable<CoreStart>,
storage: Storage,
additionalMiddleware?: Array<Middleware<{}, State, Dispatch<AppAction | Immutable<AppAction>>>>
): Store<State, Action> => {
@ -272,23 +266,18 @@ export const createStore = (
const composeEnhancers = composeWithDevTools(enhancerOptions);
const middlewareDependencies: TimelineEpicDependencies<State> = {
kibana$: kibana,
selectAllTimelineQuery: inputsSelectors.globalQueryByIdSelector,
selectNotesByIdSelector: appSelectors.selectNotesByIdSelector,
timelineByIdSelector: timelineSelectors.timelineByIdSelector,
timelineTimeRangeSelector: inputsSelectors.timelineTimeRangeSelector,
};
const epicMiddleware = createEpicMiddleware<Action, Action, State, typeof middlewareDependencies>(
{
dependencies: middlewareDependencies,
}
);
// TODO: Once `createStore` does not use redux-observable, we will not need to pass a
// kibana observable anymore. Then we can remove this `any` cast and replace kibana$
// with a regular kibana instance.
// I'm not doing it in this PR, as this will have an impact on literally hundreds of test files.
// A separate PR will be created to clean this up.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const kibanaObsv = kibana$ as any;
const kibana =
'source' in kibanaObsv ? kibanaObsv.source._value.kibana : kibanaObsv._value.kibana;
const middlewareEnhancer = applyMiddleware(
...createMiddlewares(storage),
epicMiddleware,
...createMiddlewares(kibana, storage),
telemetryMiddleware,
...(additionalMiddleware ?? [])
);
@ -299,8 +288,6 @@ export const createStore = (
composeEnhancers(middlewareEnhancer)
);
epicMiddleware.run(createRootEpic<CombinedState<State>>());
return store;
};

View file

@ -2,7 +2,12 @@
exports[`netflowRowRenderer renders correctly against snapshot 1`] = `
<DocumentFragment>
.c0 {
.c14 svg {
position: relative;
top: -1px;
}
.c0 {
display: inline-block;
font-size: 12px;
line-height: 1.5;
@ -16,11 +21,6 @@ exports[`netflowRowRenderer renders correctly against snapshot 1`] = `
border-radius: 4px;
}
.c14 svg {
position: relative;
top: -1px;
}
.c12,
.c12 * {
display: inline-block;

View file

@ -1,422 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { get, getOr, has, set, omit, isObject, toString as fpToString } from 'lodash/fp';
import type { Action } from 'redux';
import type { Epic } from 'redux-observable';
import { from, EMPTY, merge } from 'rxjs';
import type { Filter, MatchAllFilter } from '@kbn/es-query';
import {
isScriptedRangeFilter,
isExistsFilter,
isRangeFilter,
isMatchAllFilter,
isPhraseFilter,
isQueryStringFilter,
isPhrasesFilter,
} from '@kbn/es-query';
import {
filter,
map,
startWith,
withLatestFrom,
mergeMap,
concatMap,
takeUntil,
} from 'rxjs/operators';
import type { TimelineErrorResponse, TimelineResponse } from '../../../common/api/timeline';
import type { ColumnHeaderOptions } from '../../../common/types/timeline';
import { TimelineStatus, TimelineType } from '../../../common/api/timeline';
import type { inputsModel } from '../../common/store/inputs';
import { addError } from '../../common/store/app/actions';
import { copyTimeline, persistTimeline } from '../containers/api';
import { ALL_TIMELINE_QUERY_ID } from '../containers/all';
import * as i18n from '../pages/translations';
import {
updateTimeline,
startTimelineSaving,
endTimelineSaving,
createTimeline,
showCallOutUnauthorizedMsg,
addTimeline,
saveTimeline,
setChanged,
} from './actions';
import type { TimelineModel } from './model';
import { epicPersistNote, isNoteAction } from './epic_note';
import { epicPersistPinnedEvent, isPinnedEventAction } from './epic_pinned_event';
import { epicPersistTimelineFavorite, isFavoriteTimelineAction } from './epic_favorite';
import { isNotNull } from './helpers';
import { dispatcherTimelinePersistQueue } from './epic_dispatcher_timeline_persistence_queue';
import { myEpicTimelineId } from './my_epic_timeline_id';
import type { TimelineEpicDependencies } from './types';
import type { TimelineInput } from '../../../common/search_strategy';
const isItAtimelineAction = (timelineId: string | undefined) =>
timelineId && timelineId.toLowerCase().startsWith('timeline');
export const createTimelineEpic =
<State>(): Epic<Action, Action, State, TimelineEpicDependencies<State>> =>
(
action$,
state$,
{
selectAllTimelineQuery,
selectNotesByIdSelector,
timelineByIdSelector,
timelineTimeRangeSelector,
kibana$,
}
) => {
const timeline$ = state$.pipe(map(timelineByIdSelector), filter(isNotNull));
const allTimelineQuery$ = state$.pipe(
map((state) => {
const getQuery = selectAllTimelineQuery();
return getQuery(state, ALL_TIMELINE_QUERY_ID);
}),
filter(isNotNull)
);
const notes$ = state$.pipe(map(selectNotesByIdSelector), filter(isNotNull));
const timelineTimeRange$ = state$.pipe(map(timelineTimeRangeSelector), filter(isNotNull));
return merge(
action$.pipe(
withLatestFrom(timeline$),
filter(([action, timeline]) => {
const timelineId: string = get('payload.id', action);
const timelineObj: TimelineModel = timeline[timelineId];
if (action.type === addError.type) {
return true;
}
if (
isItAtimelineAction(timelineId) &&
timelineObj != null &&
timelineObj.status != null &&
TimelineStatus.immutable === timelineObj.status
) {
return false;
} else if (action.type === createTimeline.type && isItAtimelineAction(timelineId)) {
myEpicTimelineId.setTimelineVersion(null);
myEpicTimelineId.setTimelineId(null);
myEpicTimelineId.setTemplateTimelineId(null);
myEpicTimelineId.setTemplateTimelineVersion(null);
} else if (action.type === addTimeline.type && isItAtimelineAction(timelineId)) {
const addNewTimeline: TimelineModel = get('payload.timeline', action);
myEpicTimelineId.setTimelineId(addNewTimeline.savedObjectId);
myEpicTimelineId.setTimelineVersion(addNewTimeline.version);
myEpicTimelineId.setTemplateTimelineId(addNewTimeline.templateTimelineId);
myEpicTimelineId.setTemplateTimelineVersion(addNewTimeline.templateTimelineVersion);
return getOr(false, 'payload.savedTimeline', action);
} else if (
action.type === saveTimeline.type &&
!timelineObj.isSaving &&
isItAtimelineAction(timelineId)
) {
return true;
}
}),
mergeMap(([action]) => {
dispatcherTimelinePersistQueue.next({ action });
return EMPTY;
})
),
dispatcherTimelinePersistQueue.pipe(
withLatestFrom(timeline$, notes$, timelineTimeRange$),
concatMap(([objAction, timeline, notes, timelineTimeRange]) => {
const action: Action = get('action', objAction);
const timelineId = myEpicTimelineId.getTimelineId();
const version = myEpicTimelineId.getTimelineVersion();
const templateTimelineId = myEpicTimelineId.getTemplateTimelineId();
const templateTimelineVersion = myEpicTimelineId.getTemplateTimelineVersion();
if (isNoteAction(action)) {
return epicPersistNote(action, notes, action$, timeline$, notes$, allTimelineQuery$);
} else if (isPinnedEventAction(action)) {
return epicPersistPinnedEvent(action, timeline, action$, timeline$, allTimelineQuery$);
} else if (isFavoriteTimelineAction(action)) {
return epicPersistTimelineFavorite(
action,
timeline,
action$,
timeline$,
allTimelineQuery$
);
} else if (isSaveTimelineAction(action)) {
const saveAction = action as unknown as ReturnType<typeof saveTimeline>;
const savedSearch = timeline[action.payload.id].savedSearch;
return from(
saveAction.payload.saveAsNew && timelineId
? copyTimeline({
timelineId,
timeline: {
...convertTimelineAsInput(timeline[action.payload.id], timelineTimeRange),
templateTimelineId,
templateTimelineVersion,
},
savedSearch,
})
: persistTimeline({
timelineId,
version,
timeline: {
...convertTimelineAsInput(timeline[action.payload.id], timelineTimeRange),
templateTimelineId,
templateTimelineVersion,
},
savedSearch,
})
).pipe(
withLatestFrom(timeline$, allTimelineQuery$, kibana$),
mergeMap(([response, recentTimeline, allTimelineQuery, kibana]) => {
if (isTimelineErrorResponse(response)) {
const error = getErrorFromResponse(response);
switch (error?.errorCode) {
// conflict
case 409:
kibana.notifications.toasts.addDanger({
title: i18n.TIMELINE_VERSION_CONFLICT_TITLE,
text: i18n.TIMELINE_VERSION_CONFLICT_DESCRIPTION,
});
break;
default:
kibana.notifications.toasts.addDanger({
title: i18n.UPDATE_TIMELINE_ERROR_TITLE,
text: error?.message ?? i18n.UPDATE_TIMELINE_ERROR_TEXT,
});
}
return [
endTimelineSaving({
id: action.payload.id,
}),
];
}
const unwrappedResponse = response.data.persistTimeline;
if (unwrappedResponse == null) {
kibana.notifications.toasts.addDanger({
title: i18n.UPDATE_TIMELINE_ERROR_TITLE,
text: i18n.UPDATE_TIMELINE_ERROR_TEXT,
});
return [
endTimelineSaving({
id: action.payload.id,
}),
];
}
if (unwrappedResponse.code === 403) {
return [
showCallOutUnauthorizedMsg(),
endTimelineSaving({
id: action.payload.id,
}),
];
}
if (allTimelineQuery.refetch != null) {
(allTimelineQuery.refetch as inputsModel.Refetch)();
}
return [
updateTimeline({
id: action.payload.id,
timeline: {
...recentTimeline[action.payload.id],
updated: unwrappedResponse.timeline.updated ?? undefined,
savedObjectId: unwrappedResponse.timeline.savedObjectId,
version: unwrappedResponse.timeline.version,
status: unwrappedResponse.timeline.status ?? TimelineStatus.active,
timelineType: unwrappedResponse.timeline.timelineType ?? TimelineType.default,
templateTimelineId: unwrappedResponse.timeline.templateTimelineId ?? null,
templateTimelineVersion:
unwrappedResponse.timeline.templateTimelineVersion ?? null,
savedSearchId: unwrappedResponse.timeline.savedSearchId ?? null,
isSaving: false,
},
}),
setChanged({
id: action.payload.id,
changed: false,
}),
endTimelineSaving({
id: action.payload.id,
}),
];
}),
startWith(startTimelineSaving({ id: action.payload.id })),
takeUntil(
action$.pipe(
withLatestFrom(timeline$),
filter(([checkAction, updatedTimeline]) => {
if (
checkAction.type === endTimelineSaving.type &&
updatedTimeline[get('payload.id', checkAction)].savedObjectId != null
) {
myEpicTimelineId.setTimelineId(
updatedTimeline[get('payload.id', checkAction)].savedObjectId
);
myEpicTimelineId.setTimelineVersion(
updatedTimeline[get('payload.id', checkAction)].version
);
myEpicTimelineId.setTemplateTimelineId(
updatedTimeline[get('payload.id', checkAction)].templateTimelineId
);
myEpicTimelineId.setTemplateTimelineVersion(
updatedTimeline[get('payload.id', checkAction)].templateTimelineVersion
);
return true;
}
return false;
})
)
)
);
}
return EMPTY;
})
)
);
};
function isSaveTimelineAction(action: Action): action is ReturnType<typeof saveTimeline> {
return action.type === saveTimeline.type;
}
const timelineInput: TimelineInput = {
columns: null,
dataProviders: null,
dataViewId: null,
description: null,
eqlOptions: null,
eventType: null,
excludedRowRendererIds: null,
filters: null,
kqlMode: null,
kqlQuery: null,
indexNames: null,
title: null,
timelineType: TimelineType.default,
templateTimelineVersion: null,
templateTimelineId: null,
dateRange: null,
savedQueryId: null,
sort: null,
status: null,
savedSearchId: null,
};
export const convertTimelineAsInput = (
timeline: TimelineModel,
timelineTimeRange: inputsModel.TimeRange
): TimelineInput =>
Object.keys(timelineInput).reduce<TimelineInput>((acc, key) => {
if (has(key, timeline)) {
if (key === 'kqlQuery') {
return set(`${key}.filterQuery`, get(`${key}.filterQuery`, timeline), acc);
} else if (key === 'dateRange') {
return set(`${key}`, { start: timelineTimeRange.from, end: timelineTimeRange.to }, acc);
} else if (key === 'columns' && get(key, timeline) != null) {
return set(
key,
get(key, timeline).map((col: ColumnHeaderOptions) =>
omit(['initialWidth', 'width', '__typename', 'esTypes'], col)
),
acc
);
} else if (key === 'filters' && get(key, timeline) != null) {
const filters = get(key, timeline);
return set(
key,
filters != null
? filters.map((myFilter: Filter) => {
const basicFilter = omit(['$state'], myFilter);
return {
...basicFilter,
meta: {
...basicFilter.meta,
field:
(isMatchAllFilter(basicFilter) ||
isPhraseFilter(basicFilter) ||
isPhrasesFilter(basicFilter) ||
isRangeFilter(basicFilter)) &&
basicFilter.meta.field != null
? convertToString(basicFilter.meta.field)
: null,
value:
basicFilter.meta.value != null
? convertToString(basicFilter.meta.value)
: null,
params:
basicFilter.meta.params != null
? convertToString(basicFilter.meta.params)
: null,
},
...(isMatchAllFilter(basicFilter)
? {
query: {
match_all: convertToString(
(basicFilter as MatchAllFilter).query.match_all
),
},
}
: { match_all: null }),
...(isExistsFilter(basicFilter) && basicFilter.query.exists != null
? { query: { exists: convertToString(basicFilter.query.exists) } }
: { exists: null }),
...((isQueryStringFilter(basicFilter) || get('query', basicFilter) != null) &&
basicFilter.query != null
? { query: convertToString(basicFilter.query) }
: { query: null }),
...(isRangeFilter(basicFilter) && basicFilter.query.range != null
? { query: { range: convertToString(basicFilter.query.range) } }
: { range: null }),
...(isScriptedRangeFilter(basicFilter) &&
basicFilter.query.script !=
null /* TODO remove it when PR50713 is merged || esFilters.isPhraseFilter(basicFilter) */
? { query: { script: convertToString(basicFilter.query.script) } }
: { script: null }),
};
})
: [],
acc
);
}
return set(key, get(key, timeline), acc);
}
return acc;
}, timelineInput);
const convertToString = (obj: unknown) => {
try {
if (isObject(obj)) {
return JSON.stringify(obj);
}
return fpToString(obj);
} catch {
return '';
}
};
type PossibleResponse = TimelineResponse | TimelineErrorResponse;
function isTimelineErrorResponse(response: PossibleResponse): response is TimelineErrorResponse {
return 'status_code' in response || 'statusCode' in response;
}
function getErrorFromResponse(response: TimelineErrorResponse) {
if ('status_code' in response) {
return { errorCode: response.status_code, message: response.message };
} else if ('statusCode' in response) {
return { errorCode: response.statusCode, message: response.message };
}
}

View file

@ -1,10 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Subject } from 'rxjs';
export const dispatcherTimelinePersistQueue = new Subject();

View file

@ -1,123 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { get } from 'lodash/fp';
import type { Action } from 'redux';
import type { Epic } from 'redux-observable';
import type { Observable } from 'rxjs';
import { from, EMPTY } from 'rxjs';
import { filter, mergeMap, withLatestFrom, startWith, takeUntil } from 'rxjs/operators';
import { addError } from '../../common/store/app/actions';
import {
endTimelineSaving,
updateIsFavorite,
updateTimeline,
startTimelineSaving,
showCallOutUnauthorizedMsg,
} from './actions';
import { dispatcherTimelinePersistQueue } from './epic_dispatcher_timeline_persistence_queue';
import { myEpicTimelineId } from './my_epic_timeline_id';
import type { TimelineById } from './types';
import type { inputsModel } from '../../common/store/inputs';
import type { ResponseFavoriteTimeline } from '../../../common/api/timeline';
import { TimelineType } from '../../../common/api/timeline';
import { persistFavorite } from '../containers/api';
type FavoriteTimelineAction = ReturnType<typeof updateIsFavorite>;
const timelineFavoriteActionsType = new Set([updateIsFavorite.type]);
export function isFavoriteTimelineAction(action: Action): action is FavoriteTimelineAction {
return timelineFavoriteActionsType.has(action.type);
}
export const epicPersistTimelineFavorite = (
action: FavoriteTimelineAction,
timeline: TimelineById,
action$: Observable<Action>,
timeline$: Observable<TimelineById>,
allTimelineQuery$: Observable<inputsModel.GlobalQuery>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Observable<any> =>
from(
persistFavorite({
timelineId: myEpicTimelineId.getTimelineId(),
templateTimelineId: timeline[action.payload.id].templateTimelineId,
templateTimelineVersion: timeline[action.payload.id].templateTimelineVersion,
timelineType: timeline[action.payload.id].timelineType ?? TimelineType.default,
})
).pipe(
withLatestFrom(timeline$, allTimelineQuery$),
mergeMap(([result, recentTimelines, allTimelineQuery]) => {
const savedTimeline = recentTimelines[action.payload.id];
const response: ResponseFavoriteTimeline = get('data.persistFavorite', result);
const callOutMsg = response.code === 403 ? [showCallOutUnauthorizedMsg()] : [];
if (allTimelineQuery.refetch != null) {
(allTimelineQuery.refetch as inputsModel.Refetch)();
}
return [
...callOutMsg,
updateTimeline({
id: action.payload.id,
timeline: {
...savedTimeline,
isFavorite: response.favorite != null && response.favorite.length > 0,
savedObjectId: response.savedObjectId || null,
version: response.version || null,
},
}),
endTimelineSaving({
id: action.payload.id,
}),
].filter(Boolean);
}),
startWith(startTimelineSaving({ id: action.payload.id })),
takeUntil(
action$.pipe(
withLatestFrom(timeline$),
filter(([checkAction, updatedTimeline]) => {
if (checkAction.type === addError.type) {
return true;
}
if (
checkAction.type === endTimelineSaving.type &&
updatedTimeline[get('payload.id', checkAction)].savedObjectId != null
) {
myEpicTimelineId.setTimelineId(
updatedTimeline[get('payload.id', checkAction)].savedObjectId
);
myEpicTimelineId.setTimelineVersion(
updatedTimeline[get('payload.id', checkAction)].version
);
myEpicTimelineId.setTemplateTimelineId(
updatedTimeline[get('payload.id', checkAction)].templateTimelineId
);
myEpicTimelineId.setTemplateTimelineVersion(
updatedTimeline[get('payload.id', checkAction)].templateTimelineVersion
);
return true;
}
return false;
})
)
)
);
export const createTimelineFavoriteEpic =
<State>(): Epic<Action, Action, State> =>
(action$) => {
return action$.pipe(
filter(isFavoriteTimelineAction),
mergeMap((action) => {
dispatcherTimelinePersistQueue.next({ action });
return EMPTY;
})
);
};

View file

@ -1,145 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { get } from 'lodash/fp';
import type { Action } from 'redux';
import type { Epic } from 'redux-observable';
import type { Observable } from 'rxjs';
import { from, EMPTY } from 'rxjs';
import { filter, mergeMap, switchMap, withLatestFrom, startWith, takeUntil } from 'rxjs/operators';
import { updateNote, addError } from '../../common/store/app/actions';
import type { NotesById } from '../../common/store/app/model';
import type { inputsModel } from '../../common/store/inputs';
import {
addNote,
addNoteToEvent,
endTimelineSaving,
updateTimeline,
startTimelineSaving,
showCallOutUnauthorizedMsg,
} from './actions';
import { myEpicTimelineId } from './my_epic_timeline_id';
import { dispatcherTimelinePersistQueue } from './epic_dispatcher_timeline_persistence_queue';
import type { TimelineById } from './types';
import { persistNote } from '../containers/notes/api';
import type { ResponseNote } from '../../../common/api/timeline';
type NoteAction = ReturnType<typeof addNote | typeof addNoteToEvent>;
const timelineNoteActionsType = new Set([addNote.type, addNoteToEvent.type]);
export function isNoteAction(action: Action): action is NoteAction {
return timelineNoteActionsType.has(action.type);
}
export const epicPersistNote = (
action: NoteAction,
notes: NotesById,
action$: Observable<Action>,
timeline$: Observable<TimelineById>,
notes$: Observable<NotesById>,
allTimelineQuery$: Observable<inputsModel.GlobalQuery>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Observable<any> =>
from(
persistNote({
noteId: null,
version: null,
note: {
eventId: 'eventId' in action.payload ? action.payload.eventId : undefined,
note: getNote(action.payload.noteId, notes),
timelineId: myEpicTimelineId.getTimelineId(),
},
})
).pipe(
withLatestFrom(timeline$, notes$, allTimelineQuery$),
mergeMap(([result, recentTimeline, recentNotes, allTimelineQuery]) => {
const noteIdRedux = action.payload.noteId;
const response: ResponseNote = get('data.persistNote', result);
const callOutMsg = response.code === 403 ? [showCallOutUnauthorizedMsg()] : [];
if (allTimelineQuery.refetch != null) {
(allTimelineQuery.refetch as inputsModel.Refetch)();
}
return [
...callOutMsg,
recentTimeline[action.payload.id].savedObjectId == null
? updateTimeline({
id: action.payload.id,
timeline: {
...recentTimeline[action.payload.id],
savedObjectId: response.note.timelineId || null,
version: response.note.timelineVersion || null,
},
})
: null,
updateNote({
note: {
...recentNotes[noteIdRedux],
created:
response.note.updated != null
? new Date(response.note.updated)
: recentNotes[noteIdRedux].created,
user:
response.note.updatedBy != null
? response.note.updatedBy
: recentNotes[noteIdRedux].user,
saveObjectId: response.note.noteId,
version: response.note.version,
},
}),
endTimelineSaving({
id: action.payload.id,
}),
].filter(Boolean);
}),
startWith(startTimelineSaving({ id: action.payload.id })),
takeUntil(
action$.pipe(
withLatestFrom(timeline$),
filter(([checkAction, updatedTimeline]) => {
if (checkAction.type === addError.type) {
return true;
}
if (
checkAction.type === endTimelineSaving.type &&
updatedTimeline[get('payload.id', checkAction)].savedObjectId != null
) {
myEpicTimelineId.setTimelineId(
updatedTimeline[get('payload.id', checkAction)].savedObjectId
);
myEpicTimelineId.setTimelineVersion(
updatedTimeline[get('payload.id', checkAction)].version
);
return true;
}
return false;
})
)
)
);
export const createTimelineNoteEpic =
<State>(): Epic<Action, Action, State> =>
(action$) =>
action$.pipe(
filter(isNoteAction),
switchMap((action) => {
dispatcherTimelinePersistQueue.next({ action });
return EMPTY;
})
);
const getNote = (noteId: string | undefined | null, notes: NotesById): string => {
if (noteId != null) {
return notes[noteId].note;
}
return '';
};

View file

@ -1,143 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { get, omit } from 'lodash/fp';
import type { Action } from 'redux';
import type { Epic } from 'redux-observable';
import type { Observable } from 'rxjs';
import { from, EMPTY } from 'rxjs';
import { filter, mergeMap, startWith, withLatestFrom, takeUntil } from 'rxjs/operators';
import { addError } from '../../common/store/app/actions';
import type { inputsModel } from '../../common/store/inputs';
import type { PinnedEventResponse } from '../../../common/api/timeline';
import {
pinEvent,
endTimelineSaving,
unPinEvent,
updateTimeline,
startTimelineSaving,
showCallOutUnauthorizedMsg,
} from './actions';
import { myEpicTimelineId } from './my_epic_timeline_id';
import { dispatcherTimelinePersistQueue } from './epic_dispatcher_timeline_persistence_queue';
import type { TimelineById } from './types';
import { persistPinnedEvent } from '../containers/pinned_event/api';
type PinnedEventAction = ReturnType<typeof pinEvent | typeof unPinEvent>;
const timelinePinnedEventActionsType = new Set([pinEvent.type, unPinEvent.type]);
export function isPinnedEventAction(action: Action): action is PinnedEventAction {
return timelinePinnedEventActionsType.has(action.type);
}
export const epicPersistPinnedEvent = (
action: PinnedEventAction,
timeline: TimelineById,
action$: Observable<Action>,
timeline$: Observable<TimelineById>,
allTimelineQuery$: Observable<inputsModel.GlobalQuery>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Observable<any> =>
from(
persistPinnedEvent({
pinnedEventId:
timeline[action.payload.id].pinnedEventsSaveObject[action.payload.eventId] != null
? timeline[action.payload.id].pinnedEventsSaveObject[action.payload.eventId].pinnedEventId
: null,
eventId: action.payload.eventId,
timelineId: myEpicTimelineId.getTimelineId(),
})
).pipe(
withLatestFrom(timeline$, allTimelineQuery$),
mergeMap(([result, recentTimeline, allTimelineQuery]) => {
const savedTimeline = recentTimeline[action.payload.id];
const response: PinnedEventResponse = get('data.persistPinnedEventOnTimeline', result);
const callOutMsg = response && response.code === 403 ? [showCallOutUnauthorizedMsg()] : [];
if (allTimelineQuery.refetch != null) {
(allTimelineQuery.refetch as inputsModel.Refetch)();
}
return [
response != null
? updateTimeline({
id: action.payload.id,
timeline: {
...savedTimeline,
savedObjectId:
savedTimeline.savedObjectId == null && response.timelineId != null
? response.timelineId
: savedTimeline.savedObjectId,
version:
savedTimeline.version == null && response.timelineVersion != null
? response.timelineVersion
: savedTimeline.version,
pinnedEventIds: {
...savedTimeline.pinnedEventIds,
[action.payload.eventId]: true,
},
pinnedEventsSaveObject: {
...savedTimeline.pinnedEventsSaveObject,
[action.payload.eventId]: response,
},
},
})
: updateTimeline({
id: action.payload.id,
timeline: {
...savedTimeline,
pinnedEventIds: omit(action.payload.eventId, savedTimeline.pinnedEventIds),
pinnedEventsSaveObject: omit(
action.payload.eventId,
savedTimeline.pinnedEventsSaveObject
),
},
}),
...callOutMsg,
endTimelineSaving({
id: action.payload.id,
}),
].filter(Boolean);
}),
startWith(startTimelineSaving({ id: action.payload.id })),
takeUntil(
action$.pipe(
withLatestFrom(timeline$),
filter(([checkAction, updatedTimeline]) => {
if (checkAction.type === addError.type) {
return true;
}
if (
checkAction.type === endTimelineSaving.type &&
updatedTimeline[get('payload.id', checkAction)].savedObjectId != null
) {
myEpicTimelineId.setTimelineId(
updatedTimeline[get('payload.id', checkAction)].savedObjectId
);
myEpicTimelineId.setTimelineVersion(
updatedTimeline[get('payload.id', checkAction)].version
);
return true;
}
return false;
})
)
)
);
export const createTimelinePinnedEventEpic =
<State>(): Epic<Action, Action, State> =>
(action$) =>
action$.pipe(
filter(isPinnedEventAction),
mergeMap((action) => {
dispatcherTimelinePersistQueue.next({ action });
return EMPTY;
})
);

View file

@ -44,7 +44,6 @@ import {
import { activeTimeline } from '../containers/active_timeline_context';
import type { ResolveTimelineConfig } from '../components/open_timeline/types';
import { getDisplayValue } from '../components/timeline/data_providers/helpers';
export const isNotNull = <T>(value: T | null): value is T => value !== null;
interface AddTimelineNoteParams {
id: string;

View file

@ -1,45 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export class ManageEpicTimelineId {
private timelineId: string | null = null;
private version: string | null = null;
private templateTimelineId: string | null = null;
private templateVersion: number | null = null;
public getTimelineId(): string | null {
return this.timelineId;
}
public getTimelineVersion(): string | null {
return this.version;
}
public getTemplateTimelineId(): string | null {
return this.templateTimelineId;
}
public getTemplateTimelineVersion(): number | null {
return this.templateVersion;
}
public setTimelineId(timelineId: string | null) {
this.timelineId = timelineId;
}
public setTimelineVersion(version: string | null) {
this.version = version;
}
public setTemplateTimelineId(templateTimelineId: string | null) {
this.templateTimelineId = templateTimelineId;
}
public setTemplateTimelineVersion(templateVersion: number | null) {
this.templateVersion = templateVersion;
}
}

View file

@ -5,8 +5,20 @@
* 2.0.
*/
import { timelineChangedMiddleware } from './changed';
import type { CoreStart } from '@kbn/core/public';
export function createTimelineMiddlewares() {
return [timelineChangedMiddleware];
import { timelineChangedMiddleware } from './timeline_changed';
import { favoriteTimelineMiddleware } from './timeline_favorite';
import { addNoteToTimelineMiddleware } from './timeline_note';
import { addPinnedEventToTimelineMiddleware } from './timeline_pinned_event';
import { saveTimelineMiddleware } from './timeline_save';
export function createTimelineMiddlewares(kibana: CoreStart) {
return [
timelineChangedMiddleware,
favoriteTimelineMiddleware(kibana),
addNoteToTimelineMiddleware(kibana),
addPinnedEventToTimelineMiddleware(kibana),
saveTimelineMiddleware(kibana),
];
}

View file

@ -0,0 +1,21 @@
/*
* 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 { State } from '../../../common/store/types';
import { ALL_TIMELINE_QUERY_ID } from '../../containers/all';
import type { inputsModel } from '../../../common/store/inputs';
import { inputsSelectors } from '../../../common/store/inputs';
/**
* Refreshes all timelines, so changes are propagated to everywhere on the page
*/
export function refreshTimelines(state: State) {
const allTimelineQuery = inputsSelectors.globalQueryByIdSelector()(state, ALL_TIMELINE_QUERY_ID);
if (allTimelineQuery.refetch != null) {
(allTimelineQuery.refetch as inputsModel.Refetch)();
}
}

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 { get } from 'lodash/fp';
import type { Action, Middleware } from 'redux';
import type { CoreStart } from '@kbn/core/public';
import type { State } from '../../../common/store/types';
import {
endTimelineSaving,
updateIsFavorite,
updateTimeline,
startTimelineSaving,
showCallOutUnauthorizedMsg,
} from '../actions';
import type { ResponseFavoriteTimeline } from '../../../../common/api/timeline';
import { TimelineType } from '../../../../common/api/timeline';
import { persistFavorite } from '../../containers/api';
import { selectTimelineById } from '../selectors';
import * as i18n from '../../pages/translations';
import { refreshTimelines } from './helpers';
type FavoriteTimelineAction = ReturnType<typeof updateIsFavorite>;
function isFavoriteTimelineAction(action: Action): action is FavoriteTimelineAction {
return action.type === updateIsFavorite.type;
}
export const favoriteTimelineMiddleware: (kibana: CoreStart) => Middleware<{}, State> =
(kibana: CoreStart) => (store) => (next) => async (action: Action) => {
// perform the action
const ret = next(action);
if (isFavoriteTimelineAction(action)) {
const { id } = action.payload;
const timeline = selectTimelineById(store.getState(), id);
store.dispatch(startTimelineSaving({ id }));
try {
const result = await persistFavorite({
timelineId: timeline.id,
templateTimelineId: timeline.templateTimelineId,
templateTimelineVersion: timeline.templateTimelineVersion,
timelineType: timeline.timelineType ?? TimelineType.default,
});
const response: ResponseFavoriteTimeline = get('data.persistFavorite', result);
if (response.code === 403) {
store.dispatch(showCallOutUnauthorizedMsg());
}
refreshTimelines(store.getState());
store.dispatch(
updateTimeline({
id,
timeline: {
...timeline,
isFavorite: response.favorite != null && response.favorite.length > 0,
savedObjectId: response.savedObjectId || null,
version: response.version || null,
},
})
);
} catch (error) {
kibana.notifications.toasts.addDanger({
title: i18n.UPDATE_TIMELINE_ERROR_TITLE,
text: error?.message ?? i18n.UPDATE_TIMELINE_ERROR_TEXT,
});
} finally {
store.dispatch(
endTimelineSaving({
id,
})
);
}
}
return ret;
};

View file

@ -0,0 +1,104 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { get } from 'lodash/fp';
import type { Action, Middleware } from 'redux';
import type { CoreStart } from '@kbn/core/public';
import { appSelectors } from '../../../common/store/app';
import type { NotesById } from '../../../common/store/app/model';
import type { State } from '../../../common/store/types';
import { updateNote } from '../../../common/store/app/actions';
import {
addNote,
addNoteToEvent,
endTimelineSaving,
startTimelineSaving,
showCallOutUnauthorizedMsg,
} from '../actions';
import { persistNote } from '../../containers/notes/api';
import type { ResponseNote } from '../../../../common/api/timeline';
import { selectTimelineById } from '../selectors';
import * as i18n from '../../pages/translations';
import { refreshTimelines } from './helpers';
type NoteAction = ReturnType<typeof addNote | typeof addNoteToEvent>;
const timelineNoteActionsType = new Set([addNote.type, addNoteToEvent.type]);
function isNoteAction(action: Action): action is NoteAction {
return timelineNoteActionsType.has(action.type);
}
export const addNoteToTimelineMiddleware: (kibana: CoreStart) => Middleware<{}, State> =
(kibana: CoreStart) => (store) => (next) => async (action: Action) => {
// perform the action
const ret = next(action);
if (isNoteAction(action)) {
const { id, noteId: localNoteId } = action.payload;
const timeline = selectTimelineById(store.getState(), id);
const notes = appSelectors.selectNotesByIdSelector(store.getState());
store.dispatch(startTimelineSaving({ id }));
try {
const result = await persistNote({
noteId: null,
version: null,
note: {
eventId: 'eventId' in action.payload ? action.payload.eventId : undefined,
note: getNoteText(localNoteId, notes),
timelineId: timeline.id,
},
});
const response: ResponseNote = get('data.persistNote', result);
if (response.code === 403) {
store.dispatch(showCallOutUnauthorizedMsg());
}
refreshTimelines(store.getState());
store.dispatch(
updateNote({
note: {
...notes[localNoteId],
created:
response.note.updated != null
? new Date(response.note.updated)
: notes[localNoteId].created,
user:
response.note.updatedBy != null ? response.note.updatedBy : notes[localNoteId].user,
saveObjectId: response.note.noteId,
version: response.note.version,
},
})
);
} catch (error) {
kibana.notifications.toasts.addDanger({
title: i18n.UPDATE_TIMELINE_ERROR_TITLE,
text: error?.message ?? i18n.UPDATE_TIMELINE_ERROR_TEXT,
});
} finally {
store.dispatch(
endTimelineSaving({
id,
})
);
}
}
return ret;
};
const getNoteText = (noteId: string | undefined | null, notes: NotesById): string => {
if (noteId != null) {
return notes[noteId].note;
}
return '';
};

View file

@ -0,0 +1,109 @@
/*
* 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 { get, omit } from 'lodash/fp';
import type { Action, Middleware } from 'redux';
import type { CoreStart } from '@kbn/core/public';
import type { State } from '../../../common/store/types';
import { selectTimelineById } from '../selectors';
import * as i18n from '../../pages/translations';
import type { PinnedEventResponse } from '../../../../common/api/timeline';
import {
pinEvent,
endTimelineSaving,
unPinEvent,
updateTimeline,
startTimelineSaving,
showCallOutUnauthorizedMsg,
} from '../actions';
import { persistPinnedEvent } from '../../containers/pinned_event/api';
import { refreshTimelines } from './helpers';
type PinnedEventAction = ReturnType<typeof pinEvent | typeof unPinEvent>;
const timelinePinnedEventActionsType = new Set([pinEvent.type, unPinEvent.type]);
function isPinnedEventAction(action: Action): action is PinnedEventAction {
return timelinePinnedEventActionsType.has(action.type);
}
export const addPinnedEventToTimelineMiddleware: (kibana: CoreStart) => Middleware<{}, State> =
(kibana: CoreStart) => (store) => (next) => async (action: Action) => {
// perform the action
const ret = next(action);
if (isPinnedEventAction(action)) {
const { id: localTimelineId, eventId } = action.payload;
const timeline = selectTimelineById(store.getState(), localTimelineId);
store.dispatch(startTimelineSaving({ id: localTimelineId }));
try {
const result = await persistPinnedEvent({
pinnedEventId:
timeline.pinnedEventsSaveObject[eventId] != null
? timeline.pinnedEventsSaveObject[eventId].pinnedEventId
: null,
eventId,
timelineId: timeline.id,
});
const response: PinnedEventResponse = get('data.persistPinnedEventOnTimeline', result);
if (response && response.code === 403) {
store.dispatch(showCallOutUnauthorizedMsg());
}
refreshTimelines(store.getState());
// The response is null in case we unpinned an event.
// In that case we want to remove the locally pinned event.
if (!response) {
store.dispatch(
updateTimeline({
id: action.payload.id,
timeline: {
...timeline,
pinnedEventIds: omit(eventId, timeline.pinnedEventIds),
pinnedEventsSaveObject: omit(eventId, timeline.pinnedEventsSaveObject),
},
})
);
} else {
store.dispatch(
updateTimeline({
id: action.payload.id,
timeline: {
...timeline,
pinnedEventIds: {
...timeline.pinnedEventIds,
[eventId]: true,
},
pinnedEventsSaveObject: {
...timeline.pinnedEventsSaveObject,
[eventId]: response,
},
},
})
);
}
} catch (error) {
kibana.notifications.toasts.addDanger({
title: i18n.UPDATE_TIMELINE_ERROR_TITLE,
text: error?.message ?? i18n.UPDATE_TIMELINE_ERROR_TEXT,
});
} finally {
store.dispatch(
endTimelineSaving({
id: localTimelineId,
})
);
}
}
return ret;
};

View file

@ -7,13 +7,13 @@
import type { Filter } from '@kbn/es-query';
import { FilterStateStore } from '@kbn/es-query';
import { Direction } from '../../../common/search_strategy';
import { TimelineTabs } from '../../../common/types/timeline';
import { TimelineType, TimelineStatus } from '../../../common/api/timeline';
import { convertTimelineAsInput } from './epic';
import type { TimelineModel } from './model';
import { Direction } from '../../../../common/search_strategy';
import { TimelineTabs } from '../../../../common/types/timeline';
import { TimelineType, TimelineStatus } from '../../../../common/api/timeline';
import { convertTimelineAsInput } from './timeline_save';
import type { TimelineModel } from '../model';
describe('Epic Timeline', () => {
describe('Timeline Save Middleware', () => {
describe('#convertTimelineAsInput ', () => {
test('should return a TimelineInput instead of TimelineModel ', () => {
const columns: TimelineModel['columns'] = [

View file

@ -0,0 +1,297 @@
/*
* 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 { get, has, set, omit, isObject, toString as fpToString } from 'lodash/fp';
import type { Action, Middleware } from 'redux';
import type { CoreStart } from '@kbn/core/public';
import type { Filter, MatchAllFilter } from '@kbn/es-query';
import {
isScriptedRangeFilter,
isExistsFilter,
isRangeFilter,
isMatchAllFilter,
isPhraseFilter,
isQueryStringFilter,
isPhrasesFilter,
} from '@kbn/es-query';
import {
updateTimeline,
startTimelineSaving,
endTimelineSaving,
showCallOutUnauthorizedMsg,
saveTimeline,
setChanged,
} from '../actions';
import { copyTimeline, persistTimeline } from '../../containers/api';
import type { State } from '../../../common/store/types';
import { inputsSelectors } from '../../../common/store/inputs';
import { selectTimelineById } from '../selectors';
import * as i18n from '../../pages/translations';
import type { inputsModel } from '../../../common/store/inputs';
import { TimelineStatus, TimelineType } from '../../../../common/api/timeline';
import type { TimelineErrorResponse, TimelineResponse } from '../../../../common/api/timeline';
import type { TimelineInput } from '../../../../common/search_strategy';
import type { TimelineModel } from '../model';
import type { ColumnHeaderOptions } from '../../../../common/types/timeline';
import { refreshTimelines } from './helpers';
function isSaveTimelineAction(action: Action): action is ReturnType<typeof saveTimeline> {
return action.type === saveTimeline.type;
}
export const saveTimelineMiddleware: (kibana: CoreStart) => Middleware<{}, State> =
(kibana: CoreStart) => (store) => (next) => async (action: Action) => {
// perform the action
const ret = next(action);
if (isSaveTimelineAction(action)) {
const { id: localTimelineId } = action.payload;
const timeline = selectTimelineById(store.getState(), localTimelineId);
const { timelineId, timelineVersion, templateTimelineId, templateTimelineVersion } =
extractTimelineIdsAndVersions(timeline);
const timelineTimeRange = inputsSelectors.timelineTimeRangeSelector(store.getState());
store.dispatch(startTimelineSaving({ id: localTimelineId }));
try {
const result = await (action.payload.saveAsNew && timeline.id
? copyTimeline({
timelineId,
timeline: {
...convertTimelineAsInput(timeline, timelineTimeRange),
templateTimelineId,
templateTimelineVersion,
},
savedSearch: timeline.savedSearch,
})
: persistTimeline({
timelineId,
version: timelineVersion,
timeline: {
...convertTimelineAsInput(timeline, timelineTimeRange),
templateTimelineId,
templateTimelineVersion,
},
savedSearch: timeline.savedSearch,
}));
if (isTimelineErrorResponse(result)) {
const error = getErrorFromResponse(result);
switch (error?.errorCode) {
// conflict
case 409:
kibana.notifications.toasts.addDanger({
title: i18n.TIMELINE_VERSION_CONFLICT_TITLE,
text: i18n.TIMELINE_VERSION_CONFLICT_DESCRIPTION,
});
break;
default:
kibana.notifications.toasts.addDanger({
title: i18n.UPDATE_TIMELINE_ERROR_TITLE,
text: error?.message ?? i18n.UPDATE_TIMELINE_ERROR_TEXT,
});
}
return;
}
const response = result.data.persistTimeline;
if (response == null) {
kibana.notifications.toasts.addDanger({
title: i18n.UPDATE_TIMELINE_ERROR_TITLE,
text: i18n.UPDATE_TIMELINE_ERROR_TEXT,
});
return;
}
if (response && response.code === 403) {
store.dispatch(showCallOutUnauthorizedMsg());
return;
}
refreshTimelines(store.getState());
store.dispatch(
updateTimeline({
id: localTimelineId,
timeline: {
...timeline,
id: response.timeline.savedObjectId,
updated: response.timeline.updated ?? undefined,
savedObjectId: response.timeline.savedObjectId,
version: response.timeline.version,
status: response.timeline.status ?? TimelineStatus.active,
timelineType: response.timeline.timelineType ?? TimelineType.default,
templateTimelineId: response.timeline.templateTimelineId ?? null,
templateTimelineVersion: response.timeline.templateTimelineVersion ?? null,
savedSearchId: response.timeline.savedSearchId ?? null,
isSaving: false,
},
})
);
store.dispatch(
setChanged({
id: action.payload.id,
changed: false,
})
);
} catch (error) {
kibana.notifications.toasts.addDanger({
title: i18n.UPDATE_TIMELINE_ERROR_TITLE,
text: error?.message ?? i18n.UPDATE_TIMELINE_ERROR_TEXT,
});
} finally {
store.dispatch(
endTimelineSaving({
id: localTimelineId,
})
);
}
}
return ret;
};
const timelineInput: TimelineInput = {
columns: null,
dataProviders: null,
dataViewId: null,
description: null,
eqlOptions: null,
eventType: null,
excludedRowRendererIds: null,
filters: null,
kqlMode: null,
kqlQuery: null,
indexNames: null,
title: null,
timelineType: TimelineType.default,
templateTimelineVersion: null,
templateTimelineId: null,
dateRange: null,
savedQueryId: null,
sort: null,
status: null,
savedSearchId: null,
};
export const convertTimelineAsInput = (
timeline: TimelineModel,
timelineTimeRange: inputsModel.TimeRange
): TimelineInput =>
Object.keys(timelineInput).reduce<TimelineInput>((acc, key) => {
if (has(key, timeline)) {
if (key === 'kqlQuery') {
return set(`${key}.filterQuery`, get(`${key}.filterQuery`, timeline), acc);
} else if (key === 'dateRange') {
return set(`${key}`, { start: timelineTimeRange.from, end: timelineTimeRange.to }, acc);
} else if (key === 'columns' && get(key, timeline) != null) {
return set(
key,
get(key, timeline).map((col: ColumnHeaderOptions) =>
omit(['initialWidth', 'width', '__typename', 'esTypes'], col)
),
acc
);
} else if (key === 'filters' && get(key, timeline) != null) {
const filters = get(key, timeline);
return set(
key,
filters != null
? filters.map((myFilter: Filter) => {
const basicFilter = omit(['$state'], myFilter);
return {
...basicFilter,
meta: {
...basicFilter.meta,
field:
(isMatchAllFilter(basicFilter) ||
isPhraseFilter(basicFilter) ||
isPhrasesFilter(basicFilter) ||
isRangeFilter(basicFilter)) &&
basicFilter.meta.field != null
? convertToString(basicFilter.meta.field)
: null,
value:
basicFilter.meta.value != null
? convertToString(basicFilter.meta.value)
: null,
params:
basicFilter.meta.params != null
? convertToString(basicFilter.meta.params)
: null,
},
...(isMatchAllFilter(basicFilter)
? {
query: {
match_all: convertToString(
(basicFilter as MatchAllFilter).query.match_all
),
},
}
: { match_all: null }),
...(isExistsFilter(basicFilter) && basicFilter.query.exists != null
? { query: { exists: convertToString(basicFilter.query.exists) } }
: { exists: null }),
...((isQueryStringFilter(basicFilter) || get('query', basicFilter) != null) &&
basicFilter.query != null
? { query: convertToString(basicFilter.query) }
: { query: null }),
...(isRangeFilter(basicFilter) && basicFilter.query.range != null
? { query: { range: convertToString(basicFilter.query.range) } }
: { range: null }),
...(isScriptedRangeFilter(basicFilter) &&
basicFilter.query.script !=
null /* TODO remove it when PR50713 is merged || esFilters.isPhraseFilter(basicFilter) */
? { query: { script: convertToString(basicFilter.query.script) } }
: { script: null }),
};
})
: [],
acc
);
}
return set(key, get(key, timeline), acc);
}
return acc;
}, timelineInput);
const convertToString = (obj: unknown) => {
try {
if (isObject(obj)) {
return JSON.stringify(obj);
}
return fpToString(obj);
} catch {
return '';
}
};
type PossibleResponse = TimelineResponse | TimelineErrorResponse;
function isTimelineErrorResponse(response: PossibleResponse): response is TimelineErrorResponse {
return 'status_code' in response || 'statusCode' in response;
}
function getErrorFromResponse(response: TimelineErrorResponse) {
if ('status_code' in response) {
return { errorCode: response.status_code, message: response.message };
} else if ('statusCode' in response) {
return { errorCode: response.statusCode, message: response.message };
}
}
function extractTimelineIdsAndVersions(timeline: TimelineModel) {
// When a timeline hasn't been saved yet, its `savedObectId` is not defined.
// In that case, we want to overwrite all locally created properties for the
// timeline id, the timeline template id and the timeline template version.
return {
timelineId: timeline.savedObjectId ?? null,
timelineVersion: timeline.version,
templateTimelineId: timeline.savedObjectId ? timeline.templateTimelineId : null,
templateTimelineVersion: timeline.savedObjectId ? timeline.templateTimelineVersion : null,
};
}

View file

@ -1,10 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ManageEpicTimelineId } from './manage_timeline_id';
export const myEpicTimelineId = new ManageEpicTimelineId();

View file

@ -6,11 +6,8 @@
*/
import type { FilterManager } from '@kbn/data-plugin/public';
import type { RootEpicDependencies } from '../../common/store/epic';
import type { ColumnHeaderOptions, SortColumnTimeline } from '../../../common/types';
import type { RowRendererId } from '../../../common/api/timeline';
import type { inputsModel } from '../../common/store/inputs';
import type { NotesById } from '../../common/store/app/model';
import type { TimelineModel } from './model';
@ -35,13 +32,6 @@ export interface TimelineState {
insertTimeline: InsertTimeline | null;
}
export interface TimelineEpicDependencies<State> extends RootEpicDependencies {
timelineByIdSelector: (state: State) => TimelineById;
timelineTimeRangeSelector: (state: State) => inputsModel.TimeRange;
selectAllTimelineQuery: () => (state: State, id: string) => inputsModel.GlobalQuery;
selectNotesByIdSelector: (state: State) => NotesById;
}
export interface TimelineModelSettings {
documentType: string;
defaultColumns: ColumnHeaderOptions[];

View file

@ -10,14 +10,14 @@ import { getTimeline } from '../../../objects/timeline';
import {
LOCKED_ICON,
NOTES_TEXT,
// NOTES_TEXT,
PIN_EVENT,
TIMELINE_FILTER,
TIMELINE_FLYOUT_WRAPPER,
TIMELINE_QUERY,
TIMELINE_PANEL,
TIMELINE_STATUS,
TIMELINE_TAB_CONTENT_GRAPHS_NOTES,
// TIMELINE_TAB_CONTENT_GRAPHS_NOTES,
SAVE_TIMELINE_ACTION_BTN,
SAVE_TIMELINE_TOOLTIP,
} from '../../../screens/timeline';
@ -33,7 +33,7 @@ import { selectCustomTemplates } from '../../../tasks/templates';
import {
addFilter,
addNameAndDescriptionToTimeline,
addNotesToTimeline,
// addNotesToTimeline,
clickingOnCreateTimelineFormTemplateBtn,
closeTimeline,
createNewTimeline,
@ -108,10 +108,12 @@ describe('Timelines', { tags: ['@ess', '@serverless'] }, (): void => {
cy.get(LOCKED_ICON).should('be.visible');
addNotesToTimeline(getTimeline().notes);
cy.get(TIMELINE_TAB_CONTENT_GRAPHS_NOTES)
.find(NOTES_TEXT)
.should('have.text', getTimeline().notes);
// TODO: fix this
// While typing the note, cypress encounters this -> Error: ResizeObserver loop completed with undelivered notifications.
// addNotesToTimeline(getTimeline().notes);
// cy.get(TIMELINE_TAB_CONTENT_GRAPHS_NOTES)
// .find(NOTES_TEXT)
// .should('have.text', getTimeline().notes);
});
it('should show the different timeline states', () => {

View file

@ -26497,14 +26497,6 @@ redux-logger@^3.0.6:
dependencies:
deep-diff "^0.3.5"
redux-observable@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/redux-observable/-/redux-observable-2.0.0.tgz#4358bef2e924723a8b1ad0e835ccebb1612a6b9a"
integrity sha512-FJz4rLXX+VmDDwZS/LpvQsKnSanDOe8UVjiLryx1g3seZiS69iLpMrcvXD5oFO7rtkPyRdo/FmTqldnT3X3m+w==
dependencies:
rxjs "^7.0.0"
tslib "~2.1.0"
redux-saga@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/redux-saga/-/redux-saga-1.1.3.tgz#9f3e6aebd3c994bbc0f6901a625f9a42b51d1112"
@ -27226,7 +27218,7 @@ rxjs@^6.3.3, rxjs@^6.4.0, rxjs@^6.5.1, rxjs@^6.6.0, rxjs@^6.6.7:
dependencies:
tslib "^1.9.0"
rxjs@^7.0.0, rxjs@^7.4.0, rxjs@^7.5.5:
rxjs@^7.4.0, rxjs@^7.5.5:
version "7.8.0"
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.0.tgz#90a938862a82888ff4c7359811a595e14e1e09a4"
integrity sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg==
@ -29801,11 +29793,6 @@ tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.1, tslib@^2.4
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
tslib@~2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a"
integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==
tslib@~2.4.0:
version "2.4.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e"