;
}
| undefined
@@ -499,7 +499,7 @@ export const VisualizationWrapper = ({
.map((validationError) => (
<>
diff --git a/x-pack/plugins/lens/public/editor_frame_service/types.ts b/x-pack/plugins/lens/public/editor_frame_service/types.ts
index ebfd098b5fb19..9435faf374420 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/types.ts
+++ b/x-pack/plugins/lens/public/editor_frame_service/types.ts
@@ -11,6 +11,6 @@ export type TableInspectorAdapter = Record;
export interface ErrorMessage {
shortMessage: string;
- longMessage: string;
+ longMessage: React.ReactNode;
type?: 'fixable' | 'critical';
}
diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.test.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.test.tsx
index 74aac932a6861..a0831e8a73b57 100644
--- a/x-pack/plugins/lens/public/embeddable/embeddable.test.tsx
+++ b/x-pack/plugins/lens/public/embeddable/embeddable.test.tsx
@@ -11,6 +11,7 @@ import {
LensByReferenceInput,
LensSavedObjectAttributes,
LensEmbeddableInput,
+ ResolvedLensSavedObjectAttributes,
} from './embeddable';
import { ReactExpressionRendererProps } from 'src/plugins/expressions/public';
import { Query, TimeRange, Filter, IndexPatternsContract } from 'src/plugins/data/public';
@@ -68,12 +69,17 @@ const options = {
const attributeServiceMockFromSavedVis = (document: Document): LensAttributeService => {
const core = coreMock.createStart();
const service = new AttributeService<
- LensSavedObjectAttributes,
+ ResolvedLensSavedObjectAttributes,
LensByValueInput,
LensByReferenceInput
>('lens', jest.fn(), core.i18n.Context, core.notifications.toasts, options);
service.unwrapAttributes = jest.fn((input: LensByValueInput | LensByReferenceInput) => {
- return Promise.resolve({ ...document } as LensSavedObjectAttributes);
+ return Promise.resolve({
+ ...document,
+ sharingSavedObjectProps: {
+ outcome: 'exactMatch',
+ },
+ } as ResolvedLensSavedObjectAttributes);
});
service.wrapAttributes = jest.fn();
return service;
@@ -86,7 +92,7 @@ describe('embeddable', () => {
let trigger: { exec: jest.Mock };
let basePath: IBasePath;
let attributeService: AttributeService<
- LensSavedObjectAttributes,
+ ResolvedLensSavedObjectAttributes,
LensByValueInput,
LensByReferenceInput
>;
@@ -223,6 +229,50 @@ describe('embeddable', () => {
expect(expressionRenderer).toHaveBeenCalledTimes(0);
});
+ it('should not render the vis if loaded saved object conflicts', async () => {
+ attributeService.unwrapAttributes = jest.fn(
+ (input: LensByValueInput | LensByReferenceInput) => {
+ return Promise.resolve({
+ ...savedVis,
+ sharingSavedObjectProps: {
+ outcome: 'conflict',
+ errorJSON: '{targetType: "lens", sourceId: "1", targetSpace: "space"}',
+ aliasTargetId: '2',
+ },
+ } as ResolvedLensSavedObjectAttributes);
+ }
+ );
+ const embeddable = new Embeddable(
+ {
+ timefilter: dataPluginMock.createSetupContract().query.timefilter.timefilter,
+ attributeService,
+ inspector: inspectorPluginMock.createStartContract(),
+ expressionRenderer,
+ basePath,
+ indexPatternService: {} as IndexPatternsContract,
+ capabilities: {
+ canSaveDashboards: true,
+ canSaveVisualizations: true,
+ },
+ getTrigger,
+ documentToExpression: () =>
+ Promise.resolve({
+ ast: {
+ type: 'expression',
+ chain: [
+ { type: 'function', function: 'my', arguments: {} },
+ { type: 'function', function: 'expression', arguments: {} },
+ ],
+ },
+ errors: undefined,
+ }),
+ },
+ {} as LensEmbeddableInput
+ );
+ await embeddable.initializeSavedVis({} as LensEmbeddableInput);
+ expect(expressionRenderer).toHaveBeenCalledTimes(0);
+ });
+
it('should initialize output with deduped list of index patterns', async () => {
attributeService = attributeServiceMockFromSavedVis({
...savedVis,
diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx
index 172274b1f90bc..7e87dd3076faa 100644
--- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx
+++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx
@@ -41,7 +41,11 @@ import {
ReferenceOrValueEmbeddable,
} from '../../../../../src/plugins/embeddable/public';
import { Document, injectFilterReferences } from '../persistence';
-import { ExpressionWrapper, ExpressionWrapperProps } from './expression_wrapper';
+import {
+ ExpressionWrapper,
+ ExpressionWrapperProps,
+ savedObjectConflictError,
+} from './expression_wrapper';
import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public';
import {
isLensBrushEvent,
@@ -58,8 +62,12 @@ import { IBasePath } from '../../../../../src/core/public';
import { LensAttributeService } from '../lens_attribute_service';
import type { ErrorMessage } from '../editor_frame_service/types';
import { getLensInspectorService, LensInspector } from '../lens_inspector_service';
+import { SharingSavedObjectProps } from '../types';
export type LensSavedObjectAttributes = Omit;
+export interface ResolvedLensSavedObjectAttributes extends LensSavedObjectAttributes {
+ sharingSavedObjectProps?: SharingSavedObjectProps;
+}
interface LensBaseEmbeddableInput extends EmbeddableInput {
filters?: Filter[];
@@ -76,7 +84,7 @@ interface LensBaseEmbeddableInput extends EmbeddableInput {
}
export type LensByValueInput = {
- attributes: LensSavedObjectAttributes;
+ attributes: ResolvedLensSavedObjectAttributes;
} & LensBaseEmbeddableInput;
export type LensByReferenceInput = SavedObjectEmbeddableInput & LensBaseEmbeddableInput;
@@ -253,15 +261,18 @@ export class Embeddable
}
async initializeSavedVis(input: LensEmbeddableInput) {
- const attributes:
- | LensSavedObjectAttributes
+ const attrs:
+ | ResolvedLensSavedObjectAttributes
| false = await this.deps.attributeService.unwrapAttributes(input).catch((e: Error) => {
this.onFatalError(e);
return false;
});
- if (!attributes || this.isDestroyed) {
+ if (!attrs || this.isDestroyed) {
return;
}
+
+ const { sharingSavedObjectProps, ...attributes } = attrs;
+
this.savedVis = {
...attributes,
type: this.type,
@@ -269,8 +280,12 @@ export class Embeddable
};
const { ast, errors } = await this.deps.documentToExpression(this.savedVis);
this.errors = errors;
+ if (sharingSavedObjectProps?.outcome === 'conflict') {
+ const conflictError = savedObjectConflictError(sharingSavedObjectProps.errorJSON!);
+ this.errors = this.errors ? [...this.errors, conflictError] : [conflictError];
+ }
this.expression = ast ? toExpression(ast) : null;
- if (errors) {
+ if (this.errors) {
this.logError('validation');
}
await this.initializeOutput();
diff --git a/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx b/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx
index d57e1c450fea2..1116b4a0d3963 100644
--- a/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx
+++ b/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx
@@ -5,10 +5,20 @@
* 2.0.
*/
-import React from 'react';
+import React, { useState } from 'react';
import { I18nProvider } from '@kbn/i18n/react';
import { FormattedMessage } from '@kbn/i18n/react';
-import { EuiFlexGroup, EuiFlexItem, EuiText, EuiIcon, EuiEmptyPrompt } from '@elastic/eui';
+import {
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiText,
+ EuiIcon,
+ EuiEmptyPrompt,
+ EuiButtonEmpty,
+ EuiCallOut,
+ EuiSpacer,
+ EuiLink,
+} from '@elastic/eui';
import {
ExpressionRendererEvent,
ReactExpressionRendererType,
@@ -18,6 +28,7 @@ import type { KibanaExecutionContext } from 'src/core/public';
import { ExecutionContextSearch } from 'src/plugins/data/public';
import { DefaultInspectorAdapters, RenderMode } from 'src/plugins/expressions';
import classNames from 'classnames';
+import { i18n } from '@kbn/i18n';
import { getOriginalRequestErrorMessages } from '../editor_frame_service/error_helper';
import { ErrorMessage } from '../editor_frame_service/types';
import { LensInspector } from '../lens_inspector_service';
@@ -158,3 +169,52 @@ export function ExpressionWrapper({
);
}
+
+const SavedObjectConflictMessage = ({ json }: { json: string }) => {
+ const [expandError, setExpandError] = useState(false);
+ return (
+ <>
+
+ {i18n.translate('xpack.lens.embeddable.legacyURLConflict.documentationLinkText', {
+ defaultMessage: 'legacy URL alias',
+ })}
+
+ ),
+ }}
+ />
+
+ {expandError ? (
+
+ ) : (
+ setExpandError(true)}>
+ {i18n.translate('xpack.lens.embeddable.legacyURLConflict.expandError', {
+ defaultMessage: `Show more`,
+ })}
+
+ )}
+ >
+ );
+};
+
+export const savedObjectConflictError = (json: string): ErrorMessage => ({
+ shortMessage: i18n.translate('xpack.lens.embeddable.legacyURLConflict.shortMessage', {
+ defaultMessage: `You've encountered a URL conflict`,
+ }),
+ longMessage: ,
+});
diff --git a/x-pack/plugins/lens/public/lens_attribute_service.ts b/x-pack/plugins/lens/public/lens_attribute_service.ts
index 39a1903c6d0c4..09c98b3dcba72 100644
--- a/x-pack/plugins/lens/public/lens_attribute_service.ts
+++ b/x-pack/plugins/lens/public/lens_attribute_service.ts
@@ -9,47 +9,68 @@ import { CoreStart } from '../../../../src/core/public';
import { LensPluginStartDependencies } from './plugin';
import { AttributeService } from '../../../../src/plugins/embeddable/public';
import {
- LensSavedObjectAttributes,
+ ResolvedLensSavedObjectAttributes,
LensByValueInput,
LensByReferenceInput,
} from './embeddable/embeddable';
-import { SavedObjectIndexStore, Document } from './persistence';
+import { SavedObjectIndexStore } from './persistence';
import { checkForDuplicateTitle, OnSaveProps } from '../../../../src/plugins/saved_objects/public';
import { DOC_TYPE } from '../common';
export type LensAttributeService = AttributeService<
- LensSavedObjectAttributes,
+ ResolvedLensSavedObjectAttributes,
LensByValueInput,
LensByReferenceInput
>;
-function documentToAttributes(doc: Document): LensSavedObjectAttributes {
- delete doc.savedObjectId;
- delete doc.type;
- return { ...doc };
-}
-
export function getLensAttributeService(
core: CoreStart,
startDependencies: LensPluginStartDependencies
): LensAttributeService {
const savedObjectStore = new SavedObjectIndexStore(core.savedObjects.client);
return startDependencies.embeddable.getAttributeService<
- LensSavedObjectAttributes,
+ ResolvedLensSavedObjectAttributes,
LensByValueInput,
LensByReferenceInput
>(DOC_TYPE, {
- saveMethod: async (attributes: LensSavedObjectAttributes, savedObjectId?: string) => {
+ saveMethod: async (attributes: ResolvedLensSavedObjectAttributes, savedObjectId?: string) => {
+ const { sharingSavedObjectProps, ...attributesToSave } = attributes;
const savedDoc = await savedObjectStore.save({
- ...attributes,
+ ...attributesToSave,
savedObjectId,
type: DOC_TYPE,
});
return { id: savedDoc.savedObjectId };
},
- unwrapMethod: async (savedObjectId: string): Promise => {
- const attributes = documentToAttributes(await savedObjectStore.load(savedObjectId));
- return attributes;
+ unwrapMethod: async (savedObjectId: string): Promise => {
+ const {
+ saved_object: savedObject,
+ outcome,
+ alias_target_id: aliasTargetId,
+ } = await savedObjectStore.load(savedObjectId);
+ const { attributes, references, type, id } = savedObject;
+ const document = {
+ ...attributes,
+ references,
+ };
+
+ const sharingSavedObjectProps = {
+ aliasTargetId,
+ outcome,
+ errorJSON:
+ outcome === 'conflict'
+ ? JSON.stringify({
+ targetType: type,
+ sourceId: id,
+ targetSpace: (await startDependencies.spaces.getActiveSpace()).id,
+ })
+ : undefined,
+ };
+
+ return {
+ sharingSavedObjectProps,
+ ...document,
+ };
},
checkForDuplicateTitle: (props: OnSaveProps) => {
const savedObjectsClient = core.savedObjects.client;
diff --git a/x-pack/plugins/lens/public/mocks.tsx b/x-pack/plugins/lens/public/mocks.tsx
index b2c8d3948b285..8fbd263fe909e 100644
--- a/x-pack/plugins/lens/public/mocks.tsx
+++ b/x-pack/plugins/lens/public/mocks.tsx
@@ -24,11 +24,12 @@ import { LensAppServices } from './app_plugin/types';
import { DOC_TYPE, layerTypes } from '../common';
import { DataPublicPluginStart, esFilters, UI_SETTINGS } from '../../../../src/plugins/data/public';
import { inspectorPluginMock } from '../../../../src/plugins/inspector/public/mocks';
+import { spacesPluginMock } from '../../spaces/public/mocks';
import { dashboardPluginMock } from '../../../../src/plugins/dashboard/public/mocks';
import type {
LensByValueInput,
- LensSavedObjectAttributes,
LensByReferenceInput,
+ ResolvedLensSavedObjectAttributes,
} from './embeddable/embeddable';
import {
mockAttributeService,
@@ -352,7 +353,7 @@ export function makeDefaultServices(
function makeAttributeService(): LensAttributeService {
const attributeServiceMock = mockAttributeService<
- LensSavedObjectAttributes,
+ ResolvedLensSavedObjectAttributes,
LensByValueInput,
LensByReferenceInput
>(
@@ -365,7 +366,12 @@ export function makeDefaultServices(
core
);
- attributeServiceMock.unwrapAttributes = jest.fn().mockResolvedValue(doc);
+ attributeServiceMock.unwrapAttributes = jest.fn().mockResolvedValue({
+ ...doc,
+ sharingSavedObjectProps: {
+ outcome: 'exactMatch',
+ },
+ });
attributeServiceMock.wrapAttributes = jest.fn().mockResolvedValue({
savedObjectId: ((doc as unknown) as LensByReferenceInput).savedObjectId,
});
@@ -404,6 +410,7 @@ export function makeDefaultServices(
remove: jest.fn(),
clear: jest.fn(),
},
+ spaces: spacesPluginMock.createStartContract(),
};
}
diff --git a/x-pack/plugins/lens/public/persistence/saved_object_store.test.ts b/x-pack/plugins/lens/public/persistence/saved_object_store.test.ts
index ab0708d99f082..5a42ea054b4d9 100644
--- a/x-pack/plugins/lens/public/persistence/saved_object_store.test.ts
+++ b/x-pack/plugins/lens/public/persistence/saved_object_store.test.ts
@@ -15,7 +15,7 @@ describe('LensStore', () => {
bulkUpdate: jest.fn(([{ id }]: SavedObjectsBulkUpdateObject[]) =>
Promise.resolve({ savedObjects: [{ id }, { id }] })
),
- get: jest.fn(),
+ resolve: jest.fn(),
};
return {
@@ -142,15 +142,18 @@ describe('LensStore', () => {
describe('load', () => {
test('throws if an error is returned', async () => {
const { client, store } = testStore();
- client.get = jest.fn(async () => ({
- id: 'Paul',
- type: 'lens',
- attributes: {
- title: 'Hope clouds observation.',
- visualizationType: 'dune',
- state: '{ "datasource": { "giantWorms": true } }',
+ client.resolve = jest.fn(async () => ({
+ outcome: 'exactMatch',
+ saved_object: {
+ id: 'Paul',
+ type: 'lens',
+ attributes: {
+ title: 'Hope clouds observation.',
+ visualizationType: 'dune',
+ state: '{ "datasource": { "giantWorms": true } }',
+ },
+ error: new Error('shoot dang!'),
},
- error: new Error('shoot dang!'),
}));
await expect(store.load('Paul')).rejects.toThrow('shoot dang!');
diff --git a/x-pack/plugins/lens/public/persistence/saved_object_store.ts b/x-pack/plugins/lens/public/persistence/saved_object_store.ts
index c87548daf53dc..79d7b78f768ae 100644
--- a/x-pack/plugins/lens/public/persistence/saved_object_store.ts
+++ b/x-pack/plugins/lens/public/persistence/saved_object_store.ts
@@ -9,9 +9,11 @@ import {
SavedObjectAttributes,
SavedObjectsClientContract,
SavedObjectReference,
+ ResolvedSimpleSavedObject,
} from 'kibana/public';
import { Query } from '../../../../../src/plugins/data/public';
import { DOC_TYPE, PersistableFilter } from '../../common';
+import { LensSavedObjectAttributes } from '../async_services';
export interface Document {
savedObjectId?: string;
@@ -37,7 +39,7 @@ export interface DocumentSaver {
}
export interface DocumentLoader {
- load: (savedObjectId: string) => Promise;
+ load: (savedObjectId: string) => Promise;
}
export type SavedObjectStore = DocumentLoader & DocumentSaver;
@@ -87,18 +89,16 @@ export class SavedObjectIndexStore implements SavedObjectStore {
).savedObjects[1];
}
- async load(savedObjectId: string): Promise {
- const { type, attributes, references, error } = await this.client.get(DOC_TYPE, savedObjectId);
+ async load(savedObjectId: string): Promise> {
+ const resolveResult = await this.client.resolve(
+ DOC_TYPE,
+ savedObjectId
+ );
- if (error) {
- throw error;
+ if (resolveResult.saved_object.error) {
+ throw resolveResult.saved_object.error;
}
- return {
- ...(attributes as SavedObjectAttributes),
- references,
- savedObjectId,
- type,
- } as Document;
+ return resolveResult;
}
}
diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts
index 95f2e13cbc464..26278f446c558 100644
--- a/x-pack/plugins/lens/public/plugin.ts
+++ b/x-pack/plugins/lens/public/plugin.ts
@@ -9,6 +9,7 @@ import { AppMountParameters, CoreSetup, CoreStart } from 'kibana/public';
import type { Start as InspectorStartContract } from 'src/plugins/inspector/public';
import type { FieldFormatsSetup, FieldFormatsStart } from 'src/plugins/field_formats/public';
import { UsageCollectionSetup, UsageCollectionStart } from 'src/plugins/usage_collection/public';
+import { SpacesPluginStart } from '../../spaces/public';
import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public';
import { EmbeddableSetup, EmbeddableStart } from '../../../../src/plugins/embeddable/public';
import { DashboardStart } from '../../../../src/plugins/dashboard/public';
@@ -100,6 +101,7 @@ export interface LensPluginStartDependencies {
presentationUtil: PresentationUtilPluginStart;
indexPatternFieldEditor: IndexPatternFieldEditorStart;
inspector: InspectorStartContract;
+ spaces: SpacesPluginStart;
usageCollection?: UsageCollectionStart;
}
diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx
index ad1755bdbe85c..cda891871168e 100644
--- a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx
+++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx
@@ -6,7 +6,7 @@
*/
import React from 'react';
-import { EuiColorPalettePickerPaletteProps } from '@elastic/eui';
+import { EuiButtonGroup, EuiColorPalettePickerPaletteProps } from '@elastic/eui';
import { mountWithIntl } from '@kbn/test/jest';
import { chartPluginMock } from 'src/plugins/charts/public/mocks';
import type { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public';
@@ -14,6 +14,7 @@ import { ReactWrapper } from 'enzyme';
import type { CustomPaletteParams } from '../../../common';
import { applyPaletteParams } from './utils';
import { CustomizablePalette } from './palette_configuration';
+import { act } from 'react-dom/test-utils';
// mocking random id generator function
jest.mock('@elastic/eui', () => {
@@ -128,71 +129,136 @@ describe('palette panel', () => {
});
});
- describe('reverse option', () => {
- beforeEach(() => {
- props = {
- activePalette: { type: 'palette', name: 'positive' },
- palettes: paletteRegistry,
- setPalette: jest.fn(),
- dataBounds: { min: 0, max: 100 },
- };
- });
+ it('should rewrite the min/max range values on palette change', () => {
+ const instance = mountWithIntl();
+
+ changePaletteIn(instance, 'custom');
- function toggleReverse(instance: ReactWrapper, checked: boolean) {
- return instance
- .find('[data-test-subj="lnsPalettePanel_dynamicColoring_reverse"]')
- .first()
- .prop('onClick')!({} as React.MouseEvent);
- }
-
- it('should reverse the colorStops on click', () => {
- const instance = mountWithIntl();
-
- toggleReverse(instance, true);
-
- expect(props.setPalette).toHaveBeenCalledWith(
- expect.objectContaining({
- params: expect.objectContaining({
- reverse: true,
- }),
- })
- );
+ expect(props.setPalette).toHaveBeenCalledWith({
+ type: 'palette',
+ name: 'custom',
+ params: expect.objectContaining({
+ rangeMin: 0,
+ rangeMax: 50,
+ }),
});
});
+ });
+
+ describe('reverse option', () => {
+ beforeEach(() => {
+ props = {
+ activePalette: { type: 'palette', name: 'positive' },
+ palettes: paletteRegistry,
+ setPalette: jest.fn(),
+ dataBounds: { min: 0, max: 100 },
+ };
+ });
+
+ function toggleReverse(instance: ReactWrapper, checked: boolean) {
+ return instance
+ .find('[data-test-subj="lnsPalettePanel_dynamicColoring_reverse"]')
+ .first()
+ .prop('onClick')!({} as React.MouseEvent);
+ }
+
+ it('should reverse the colorStops on click', () => {
+ const instance = mountWithIntl();
+
+ toggleReverse(instance, true);
+
+ expect(props.setPalette).toHaveBeenCalledWith(
+ expect.objectContaining({
+ params: expect.objectContaining({
+ reverse: true,
+ }),
+ })
+ );
+ });
+ });
+
+ describe('percentage / number modes', () => {
+ beforeEach(() => {
+ props = {
+ activePalette: { type: 'palette', name: 'positive' },
+ palettes: paletteRegistry,
+ setPalette: jest.fn(),
+ dataBounds: { min: 5, max: 200 },
+ };
+ });
- describe('custom stops', () => {
- beforeEach(() => {
- props = {
- activePalette: { type: 'palette', name: 'positive' },
- palettes: paletteRegistry,
- setPalette: jest.fn(),
- dataBounds: { min: 0, max: 100 },
- };
+ it('should switch mode and range boundaries on click', () => {
+ const instance = mountWithIntl();
+ act(() => {
+ instance
+ .find('[data-test-subj="lnsPalettePanel_dynamicColoring_custom_range_groups"]')
+ .find(EuiButtonGroup)
+ .prop('onChange')!('number');
});
- it('should be visible for predefined palettes', () => {
- const instance = mountWithIntl();
- expect(
- instance.find('[data-test-subj="lnsPalettePanel_dynamicColoring_custom_stops"]').exists()
- ).toEqual(true);
+
+ act(() => {
+ instance
+ .find('[data-test-subj="lnsPalettePanel_dynamicColoring_custom_range_groups"]')
+ .find(EuiButtonGroup)
+ .prop('onChange')!('percent');
});
- it('should be visible for custom palettes', () => {
- const instance = mountWithIntl(
- {
+ beforeEach(() => {
+ props = {
+ activePalette: { type: 'palette', name: 'positive' },
+ palettes: paletteRegistry,
+ setPalette: jest.fn(),
+ dataBounds: { min: 0, max: 100 },
+ };
+ });
+ it('should be visible for predefined palettes', () => {
+ const instance = mountWithIntl();
+ expect(
+ instance.find('[data-test-subj="lnsPalettePanel_dynamicColoring_custom_stops"]').exists()
+ ).toEqual(true);
+ });
+
+ it('should be visible for custom palettes', () => {
+ const instance = mountWithIntl(
+
- );
- expect(
- instance.find('[data-test-subj="lnsPalettePanel_dynamicColoring_custom_stops"]').exists()
- ).toEqual(true);
- });
+ },
+ }}
+ />
+ );
+ expect(
+ instance.find('[data-test-subj="lnsPalettePanel_dynamicColoring_custom_stops"]').exists()
+ ).toEqual(true);
});
});
});
diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx
index bc6a590db0cb7..1d1e212b87c0c 100644
--- a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx
+++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx
@@ -108,16 +108,21 @@ export function CustomizablePalette({
colorStops: undefined,
};
+ const newColorStops = getColorStops(palettes, [], activePalette, dataBounds);
if (isNewPaletteCustom) {
- newParams.colorStops = getColorStops(palettes, [], activePalette, dataBounds);
+ newParams.colorStops = newColorStops;
}
newParams.stops = getPaletteStops(palettes, newParams, {
prevPalette:
isNewPaletteCustom || isCurrentPaletteCustom ? undefined : newPalette.name,
dataBounds,
+ mapFromMinValue: true,
});
+ newParams.rangeMin = newColorStops[0].stop;
+ newParams.rangeMax = newColorStops[newColorStops.length - 1].stop;
+
setPalette({
...newPalette,
params: newParams,
@@ -266,18 +271,18 @@ export function CustomizablePalette({
) as RequiredPaletteParamTypes['rangeType'];
const params: CustomPaletteParams = { rangeType: newRangeType };
+ const { min: newMin, max: newMax } = getDataMinMax(newRangeType, dataBounds);
+ const { min: oldMin, max: oldMax } = getDataMinMax(
+ activePalette.params?.rangeType,
+ dataBounds
+ );
+ const newColorStops = remapStopsByNewInterval(colorStopsToShow, {
+ oldInterval: oldMax - oldMin,
+ newInterval: newMax - newMin,
+ newMin,
+ oldMin,
+ });
if (isCurrentPaletteCustom) {
- const { min: newMin, max: newMax } = getDataMinMax(newRangeType, dataBounds);
- const { min: oldMin, max: oldMax } = getDataMinMax(
- activePalette.params?.rangeType,
- dataBounds
- );
- const newColorStops = remapStopsByNewInterval(colorStopsToShow, {
- oldInterval: oldMax - oldMin,
- newInterval: newMax - newMin,
- newMin,
- oldMin,
- });
const stops = getPaletteStops(
palettes,
{ ...activePalette.params, colorStops: newColorStops, ...params },
@@ -285,8 +290,6 @@ export function CustomizablePalette({
);
params.colorStops = newColorStops;
params.stops = stops;
- params.rangeMin = newColorStops[0].stop;
- params.rangeMax = newColorStops[newColorStops.length - 1].stop;
} else {
params.stops = getPaletteStops(
palettes,
@@ -294,6 +297,11 @@ export function CustomizablePalette({
{ prevPalette: activePalette.name, dataBounds }
);
}
+ // why not use newMin/newMax here?
+ // That's because there's the concept of continuity to accomodate, where in some scenarios it has to
+ // take into account the stop value rather than the data value
+ params.rangeMin = newColorStops[0].stop;
+ params.rangeMax = newColorStops[newColorStops.length - 1].stop;
setPalette(mergePaletteParams(activePalette, params));
}}
/>
diff --git a/x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts b/x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts
index 97dc2e45c96dc..07d93ca5c40c6 100644
--- a/x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts
+++ b/x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts
@@ -8,6 +8,7 @@
import { chartPluginMock } from 'src/plugins/charts/public/mocks';
import {
applyPaletteParams,
+ getColorStops,
getContrastColor,
getDataMinMax,
getPaletteStops,
@@ -59,6 +60,78 @@ describe('applyPaletteParams', () => {
});
});
+describe('getColorStops', () => {
+ const paletteRegistry = chartPluginMock.createPaletteRegistry();
+ it('should return the same colorStops if a custom palette is passed, avoiding recomputation', () => {
+ const colorStops = [
+ { stop: 0, color: 'red' },
+ { stop: 100, color: 'blue' },
+ ];
+ expect(
+ getColorStops(
+ paletteRegistry,
+ colorStops,
+ { name: 'custom', type: 'palette' },
+ { min: 0, max: 100 }
+ )
+ ).toBe(colorStops);
+ });
+
+ it('should get a fresh list of colors', () => {
+ expect(
+ getColorStops(
+ paletteRegistry,
+ [
+ { stop: 0, color: 'red' },
+ { stop: 100, color: 'blue' },
+ ],
+ { name: 'mocked', type: 'palette' },
+ { min: 0, max: 100 }
+ )
+ ).toEqual([
+ { color: 'blue', stop: 0 },
+ { color: 'yellow', stop: 50 },
+ ]);
+ });
+
+ it('should get a fresh list of colors even if custom palette but empty colorStops', () => {
+ expect(
+ getColorStops(paletteRegistry, [], { name: 'mocked', type: 'palette' }, { min: 0, max: 100 })
+ ).toEqual([
+ { color: 'blue', stop: 0 },
+ { color: 'yellow', stop: 50 },
+ ]);
+ });
+
+ it('should correctly map the new colorStop to the current data bound and minValue', () => {
+ expect(
+ getColorStops(
+ paletteRegistry,
+ [],
+ { name: 'mocked', type: 'palette', params: { rangeType: 'number' } },
+ { min: 100, max: 1000 }
+ )
+ ).toEqual([
+ { color: 'blue', stop: 100 },
+ { color: 'yellow', stop: 550 },
+ ]);
+ });
+
+ it('should reverse the colors', () => {
+ expect(
+ getColorStops(
+ paletteRegistry,
+ [],
+ { name: 'mocked', type: 'palette', params: { reverse: true } },
+ { min: 100, max: 1000 }
+ )
+ ).toEqual([
+ { color: 'yellow', stop: 0 },
+ { color: 'blue', stop: 50 },
+ ]);
+ });
+});
+
describe('remapStopsByNewInterval', () => {
it('should correctly remap the current palette from 0..1 to 0...100', () => {
expect(
diff --git a/x-pack/plugins/lens/public/shared_components/coloring/utils.ts b/x-pack/plugins/lens/public/shared_components/coloring/utils.ts
index b2969565f5390..413e3708e9c9b 100644
--- a/x-pack/plugins/lens/public/shared_components/coloring/utils.ts
+++ b/x-pack/plugins/lens/public/shared_components/coloring/utils.ts
@@ -269,11 +269,10 @@ export function getColorStops(
palettes: PaletteRegistry,
colorStops: Required['stops'],
activePalette: PaletteOutput,
- dataBounds: { min: number; max: number },
- defaultPalette?: string
+ dataBounds: { min: number; max: number }
) {
// just forward the current stops if custom
- if (activePalette?.name === CUSTOM_PALETTE) {
+ if (activePalette?.name === CUSTOM_PALETTE && colorStops?.length) {
return colorStops;
}
// for predefined palettes create some stops, then drop the last one.
diff --git a/x-pack/plugins/lens/public/state_management/init_middleware/index.ts b/x-pack/plugins/lens/public/state_management/init_middleware/index.ts
index bf13ca69e82c0..256684c5dbc25 100644
--- a/x-pack/plugins/lens/public/state_management/init_middleware/index.ts
+++ b/x-pack/plugins/lens/public/state_management/init_middleware/index.ts
@@ -19,13 +19,7 @@ export const initMiddleware = (storeDeps: LensStoreDeps) => (store: MiddlewareAP
);
return (next: Dispatch) => (action: PayloadAction) => {
if (lensSlice.actions.loadInitial.match(action)) {
- return loadInitial(
- store,
- storeDeps,
- action.payload.redirectCallback,
- action.payload.initialInput,
- action.payload.emptyState
- );
+ return loadInitial(store, storeDeps, action.payload);
} else if (lensSlice.actions.navigateAway.match(action)) {
return unsubscribeFromExternalContext();
}
diff --git a/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.test.tsx b/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.test.tsx
index 79402b698af98..6d3b77c6476e5 100644
--- a/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.test.tsx
+++ b/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.test.tsx
@@ -12,6 +12,7 @@ import {
createMockDatasource,
DatasourceMock,
} from '../../mocks';
+import { Location, History } from 'history';
import { act } from 'react-dom/test-utils';
import { loadInitial } from './load_initial';
import { LensEmbeddableInput } from '../../embeddable';
@@ -65,7 +66,12 @@ describe('Mounter', () => {
it('should initialize initial datasource', async () => {
const services = makeDefaultServices();
- services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue(defaultDoc);
+ services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue({
+ ...defaultDoc,
+ sharingSavedObjectProps: {
+ outcome: 'exactMatch',
+ },
+ });
const lensStore = await makeLensStore({
data: services.data,
@@ -79,8 +85,10 @@ describe('Mounter', () => {
datasourceMap,
visualizationMap,
},
- jest.fn(),
- ({ savedObjectId: defaultSavedObjectId } as unknown) as LensEmbeddableInput
+ {
+ redirectCallback: jest.fn(),
+ initialInput: ({ savedObjectId: defaultSavedObjectId } as unknown) as LensEmbeddableInput,
+ }
);
});
expect(mockDatasource.initialize).toHaveBeenCalled();
@@ -88,7 +96,12 @@ describe('Mounter', () => {
it('should have initialized only the initial datasource and visualization', async () => {
const services = makeDefaultServices();
- services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue(defaultDoc);
+ services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue({
+ ...defaultDoc,
+ sharingSavedObjectProps: {
+ outcome: 'exactMatch',
+ },
+ });
const lensStore = await makeLensStore({ data: services.data, preloadedState });
await act(async () => {
@@ -99,7 +112,7 @@ describe('Mounter', () => {
datasourceMap,
visualizationMap,
},
- jest.fn()
+ { redirectCallback: jest.fn() }
);
});
expect(mockDatasource.initialize).toHaveBeenCalled();
@@ -129,7 +142,7 @@ describe('Mounter', () => {
datasourceMap,
visualizationMap,
},
- jest.fn()
+ { redirectCallback: jest.fn() }
);
expect(services.attributeService.unwrapAttributes).not.toHaveBeenCalled();
});
@@ -170,7 +183,11 @@ describe('Mounter', () => {
const emptyState = getPreloadedState(storeDeps) as LensAppState;
services.attributeService.unwrapAttributes = jest.fn();
await act(async () => {
- await loadInitial(lensStore, storeDeps, jest.fn(), undefined, emptyState);
+ await loadInitial(lensStore, storeDeps, {
+ redirectCallback: jest.fn(),
+ initialInput: undefined,
+ emptyState,
+ });
});
expect(lensStore.getState()).toEqual({
@@ -189,20 +206,28 @@ describe('Mounter', () => {
it('loads a document and uses query and filters if initial input is provided', async () => {
const services = makeDefaultServices();
- services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue(defaultDoc);
+ services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue({
+ ...defaultDoc,
+ sharingSavedObjectProps: {
+ outcome: 'exactMatch',
+ },
+ });
+ const storeDeps = {
+ lensServices: services,
+ datasourceMap,
+ visualizationMap,
+ };
+ const emptyState = getPreloadedState(storeDeps) as LensAppState;
const lensStore = await makeLensStore({ data: services.data, preloadedState });
await act(async () => {
- await loadInitial(
- lensStore,
- {
- lensServices: services,
- datasourceMap,
- visualizationMap,
- },
- jest.fn(),
- ({ savedObjectId: defaultSavedObjectId } as unknown) as LensEmbeddableInput
- );
+ await loadInitial(lensStore, storeDeps, {
+ redirectCallback: jest.fn(),
+ initialInput: ({
+ savedObjectId: defaultSavedObjectId,
+ } as unknown) as LensEmbeddableInput,
+ emptyState,
+ });
});
expect(services.attributeService.unwrapAttributes).toHaveBeenCalledWith({
@@ -235,8 +260,12 @@ describe('Mounter', () => {
datasourceMap,
visualizationMap,
},
- jest.fn(),
- ({ savedObjectId: defaultSavedObjectId } as unknown) as LensEmbeddableInput
+ {
+ redirectCallback: jest.fn(),
+ initialInput: ({
+ savedObjectId: defaultSavedObjectId,
+ } as unknown) as LensEmbeddableInput,
+ }
);
});
@@ -248,8 +277,12 @@ describe('Mounter', () => {
datasourceMap,
visualizationMap,
},
- jest.fn(),
- ({ savedObjectId: defaultSavedObjectId } as unknown) as LensEmbeddableInput
+ {
+ redirectCallback: jest.fn(),
+ initialInput: ({
+ savedObjectId: defaultSavedObjectId,
+ } as unknown) as LensEmbeddableInput,
+ }
);
});
@@ -263,8 +296,10 @@ describe('Mounter', () => {
datasourceMap,
visualizationMap,
},
- jest.fn(),
- ({ savedObjectId: '5678' } as unknown) as LensEmbeddableInput
+ {
+ redirectCallback: jest.fn(),
+ initialInput: ({ savedObjectId: '5678' } as unknown) as LensEmbeddableInput,
+ }
);
});
@@ -287,8 +322,12 @@ describe('Mounter', () => {
datasourceMap,
visualizationMap,
},
- redirectCallback,
- ({ savedObjectId: defaultSavedObjectId } as unknown) as LensEmbeddableInput
+ {
+ redirectCallback,
+ initialInput: ({
+ savedObjectId: defaultSavedObjectId,
+ } as unknown) as LensEmbeddableInput,
+ }
);
});
expect(services.attributeService.unwrapAttributes).toHaveBeenCalledWith({
@@ -298,6 +337,50 @@ describe('Mounter', () => {
expect(redirectCallback).toHaveBeenCalled();
});
+ it('redirects if saved object is an aliasMatch', async () => {
+ const services = makeDefaultServices();
+
+ const lensStore = makeLensStore({ data: services.data, preloadedState });
+
+ services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue({
+ ...defaultDoc,
+ sharingSavedObjectProps: {
+ outcome: 'aliasMatch',
+ aliasTargetId: 'id2',
+ },
+ });
+
+ await act(async () => {
+ await loadInitial(
+ lensStore,
+ {
+ lensServices: services,
+ datasourceMap,
+ visualizationMap,
+ },
+ {
+ redirectCallback: jest.fn(),
+ initialInput: ({
+ savedObjectId: defaultSavedObjectId,
+ } as unknown) as LensEmbeddableInput,
+ history: {
+ location: {
+ search: '?search',
+ } as Location,
+ } as History,
+ }
+ );
+ });
+ expect(services.attributeService.unwrapAttributes).toHaveBeenCalledWith({
+ savedObjectId: defaultSavedObjectId,
+ });
+
+ expect(services.spaces.ui.redirectLegacyUrl).toHaveBeenCalledWith(
+ '#/edit/id2?search',
+ 'Lens visualization'
+ );
+ });
+
it('adds to the recently accessed list on load', async () => {
const services = makeDefaultServices();
const lensStore = makeLensStore({ data: services.data, preloadedState });
@@ -309,8 +392,12 @@ describe('Mounter', () => {
datasourceMap,
visualizationMap,
},
- jest.fn(),
- ({ savedObjectId: defaultSavedObjectId } as unknown) as LensEmbeddableInput
+ {
+ redirectCallback: jest.fn(),
+ initialInput: ({
+ savedObjectId: defaultSavedObjectId,
+ } as unknown) as LensEmbeddableInput,
+ }
);
});
diff --git a/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts b/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts
index 0be2bc9cfc00e..8ae6e58019c91 100644
--- a/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts
+++ b/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts
@@ -8,8 +8,10 @@
import { MiddlewareAPI } from '@reduxjs/toolkit';
import { isEqual } from 'lodash';
import { i18n } from '@kbn/i18n';
+import { History } from 'history';
import { LensAppState, setState } from '..';
import { updateLayer, updateVisualizationState, LensStoreDeps } from '..';
+import { SharingSavedObjectProps } from '../../types';
import { LensEmbeddableInput, LensByReferenceInput } from '../../embeddable/embeddable';
import { getInitialDatasourceId } from '../../utils';
import { initializeDatasources } from '../../editor_frame_service/editor_frame';
@@ -19,22 +21,50 @@ import {
switchToSuggestion,
} from '../../editor_frame_service/editor_frame/suggestion_helpers';
import { LensAppServices } from '../../app_plugin/types';
-import { getFullPath, LENS_EMBEDDABLE_TYPE } from '../../../common/constants';
+import { getEditPath, getFullPath, LENS_EMBEDDABLE_TYPE } from '../../../common/constants';
import { Document, injectFilterReferences } from '../../persistence';
export const getPersisted = async ({
initialInput,
lensServices,
+ history,
}: {
initialInput: LensEmbeddableInput;
lensServices: LensAppServices;
-}): Promise<{ doc: Document } | undefined> => {
- const { notifications, attributeService } = lensServices;
+ history?: History;
+}): Promise<
+ { doc: Document; sharingSavedObjectProps: Omit } | undefined
+> => {
+ const { notifications, spaces, attributeService } = lensServices;
let doc: Document;
try {
- const attributes = await attributeService.unwrapAttributes(initialInput);
-
+ const result = await attributeService.unwrapAttributes(initialInput);
+ if (!result) {
+ return {
+ doc: ({
+ ...initialInput,
+ type: LENS_EMBEDDABLE_TYPE,
+ } as unknown) as Document,
+ sharingSavedObjectProps: {
+ outcome: 'exactMatch',
+ },
+ };
+ }
+ const { sharingSavedObjectProps, ...attributes } = result;
+ if (spaces && sharingSavedObjectProps?.outcome === 'aliasMatch' && history) {
+ // We found this object by a legacy URL alias from its old ID; redirect the user to the page with its new ID, preserving any URL hash
+ const newObjectId = sharingSavedObjectProps?.aliasTargetId; // This is always defined if outcome === 'aliasMatch'
+ const newPath = lensServices.http.basePath.prepend(
+ `${getEditPath(newObjectId)}${history.location.search}`
+ );
+ await spaces.ui.redirectLegacyUrl(
+ newPath,
+ i18n.translate('xpack.lens.legacyUrlConflict.objectNoun', {
+ defaultMessage: 'Lens visualization',
+ })
+ );
+ }
doc = {
...initialInput,
...attributes,
@@ -43,6 +73,10 @@ export const getPersisted = async ({
return {
doc,
+ sharingSavedObjectProps: {
+ aliasTargetId: sharingSavedObjectProps?.aliasTargetId,
+ outcome: sharingSavedObjectProps?.outcome,
+ },
};
} catch (e) {
notifications.toasts.addDanger(
@@ -62,9 +96,17 @@ export function loadInitial(
embeddableEditorIncomingState,
initialContext,
}: LensStoreDeps,
- redirectCallback: (savedObjectId?: string) => void,
- initialInput?: LensEmbeddableInput,
- emptyState?: LensAppState
+ {
+ redirectCallback,
+ initialInput,
+ emptyState,
+ history,
+ }: {
+ redirectCallback: (savedObjectId?: string) => void;
+ initialInput?: LensEmbeddableInput;
+ emptyState?: LensAppState;
+ history?: History;
+ }
) {
const { getState, dispatch } = store;
const { attributeService, notifications, data, dashboardFeatureFlag } = lensServices;
@@ -146,11 +188,11 @@ export function loadInitial(
redirectCallback();
});
}
- getPersisted({ initialInput, lensServices })
+ getPersisted({ initialInput, lensServices, history })
.then(
(persisted) => {
if (persisted) {
- const { doc } = persisted;
+ const { doc, sharingSavedObjectProps } = persisted;
if (attributeService.inputIsRefType(initialInput)) {
lensServices.chrome.recentlyAccessed.add(
getFullPath(initialInput.savedObjectId),
@@ -190,6 +232,7 @@ export function loadInitial(
dispatch(
setState({
+ sharingSavedObjectProps,
query: doc.state.query,
searchSessionId:
dashboardFeatureFlag.allowByValueEmbeddables &&
diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.ts b/x-pack/plugins/lens/public/state_management/lens_slice.ts
index 85cb79f6ea5da..6cf0529b34575 100644
--- a/x-pack/plugins/lens/public/state_management/lens_slice.ts
+++ b/x-pack/plugins/lens/public/state_management/lens_slice.ts
@@ -6,6 +6,7 @@
*/
import { createSlice, current, PayloadAction } from '@reduxjs/toolkit';
+import { History } from 'history';
import { LensEmbeddableInput } from '..';
import { TableInspectorAdapter } from '../editor_frame_service/types';
import { getInitialDatasourceId, getResolvedDateRange } from '../utils';
@@ -301,6 +302,7 @@ export const lensSlice = createSlice({
initialInput?: LensEmbeddableInput;
redirectCallback: (savedObjectId?: string) => void;
emptyState: LensAppState;
+ history: History;
}>
) => state,
},
diff --git a/x-pack/plugins/lens/public/state_management/types.ts b/x-pack/plugins/lens/public/state_management/types.ts
index 7321f72386b42..33f311a982f05 100644
--- a/x-pack/plugins/lens/public/state_management/types.ts
+++ b/x-pack/plugins/lens/public/state_management/types.ts
@@ -13,8 +13,7 @@ import { Document } from '../persistence';
import { TableInspectorAdapter } from '../editor_frame_service/types';
import { DateRange } from '../../common';
import { LensAppServices } from '../app_plugin/types';
-import { DatasourceMap, VisualizationMap } from '../types';
-
+import { DatasourceMap, VisualizationMap, SharingSavedObjectProps } from '../types';
export interface VisualizationState {
activeId: string | null;
state: unknown;
@@ -44,6 +43,7 @@ export interface LensAppState extends EditorFrameState {
savedQuery?: SavedQuery;
searchSessionId: string;
resolvedDateRange: DateRange;
+ sharingSavedObjectProps?: Omit;
}
export type DispatchSetState = (
diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts
index 399e226a711db..844541cd2ad3e 100644
--- a/x-pack/plugins/lens/public/types.ts
+++ b/x-pack/plugins/lens/public/types.ts
@@ -256,7 +256,7 @@ export interface Datasource {
) =>
| Array<{
shortMessage: string;
- longMessage: string;
+ longMessage: React.ReactNode;
fixAction?: { label: string; newState: () => Promise };
}>
| undefined;
@@ -729,7 +729,7 @@ export interface Visualization {
) =>
| Array<{
shortMessage: string;
- longMessage: string;
+ longMessage: React.ReactNode;
}>
| undefined;
@@ -813,3 +813,9 @@ export interface ILensInterpreterRenderHandlers extends IInterpreterRenderHandle
| LensTableRowContextMenuEvent
) => void;
}
+
+export interface SharingSavedObjectProps {
+ outcome?: 'aliasMatch' | 'exactMatch' | 'conflict';
+ aliasTargetId?: string;
+ errorJSON?: string;
+}
diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx
index 0a4b18f554f31..026c2827cedbd 100644
--- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx
+++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx
@@ -383,7 +383,7 @@ export const getXyVisualization = ({
const errors: Array<{
shortMessage: string;
- longMessage: string;
+ longMessage: React.ReactNode;
}> = [];
// check if the layers in the state are compatible with this type of chart
@@ -488,7 +488,7 @@ function validateLayersForDimension(
| { valid: true }
| {
valid: false;
- payload: { shortMessage: string; longMessage: string };
+ payload: { shortMessage: string; longMessage: React.ReactNode };
} {
// Multiple layers must be consistent:
// * either a dimension is missing in ALL of them
diff --git a/x-pack/plugins/lens/tsconfig.json b/x-pack/plugins/lens/tsconfig.json
index 04d3838df2063..16287ae596df3 100644
--- a/x-pack/plugins/lens/tsconfig.json
+++ b/x-pack/plugins/lens/tsconfig.json
@@ -15,6 +15,7 @@
"../../../typings/**/*"
],
"references": [
+ { "path": "../spaces/tsconfig.json" },
{ "path": "../../../src/core/tsconfig.json" },
{ "path": "../task_manager/tsconfig.json" },
{ "path": "../global_search/tsconfig.json"},
diff --git a/x-pack/plugins/ml/common/constants/messages.test.mock.ts b/x-pack/plugins/ml/common/constants/messages.test.mock.ts
index 6e539617604c1..fbfff20adc5c2 100644
--- a/x-pack/plugins/ml/common/constants/messages.test.mock.ts
+++ b/x-pack/plugins/ml/common/constants/messages.test.mock.ts
@@ -78,4 +78,7 @@ export const nonBasicIssuesMessages = [
{
id: 'missing_summary_count_field_name',
},
+ {
+ id: 'datafeed_preview_failed',
+ },
];
diff --git a/x-pack/plugins/ml/common/constants/messages.test.ts b/x-pack/plugins/ml/common/constants/messages.test.ts
index 59fc50757b674..c46eba458d1d2 100644
--- a/x-pack/plugins/ml/common/constants/messages.test.ts
+++ b/x-pack/plugins/ml/common/constants/messages.test.ts
@@ -173,6 +173,12 @@ describe('Constants: Messages parseMessages()', () => {
text:
'A job configured with a datafeed with aggregations must set summary_count_field_name; use doc_count or suitable alternative.',
},
+ {
+ id: 'datafeed_preview_failed',
+ status: 'error',
+ text:
+ 'The datafeed preview failed. This may be due to an error in the job or datafeed configurations.',
+ },
]);
});
});
diff --git a/x-pack/plugins/ml/common/constants/messages.ts b/x-pack/plugins/ml/common/constants/messages.ts
index 0327e8746c7d8..fd3b9aa9d19b9 100644
--- a/x-pack/plugins/ml/common/constants/messages.ts
+++ b/x-pack/plugins/ml/common/constants/messages.ts
@@ -626,6 +626,30 @@ export const getMessages = once((docLinks?: DocLinksStart) => {
'the UNIX epoch beginning. Timestamps before 01/01/1970 00:00:00 (UTC) are not supported for machine learning jobs.',
}),
},
+ datafeed_preview_no_documents: {
+ status: VALIDATION_STATUS.WARNING,
+ heading: i18n.translate(
+ 'xpack.ml.models.jobValidation.messages.datafeedPreviewNoDocumentsHeading',
+ {
+ defaultMessage: 'Datafeed preview',
+ }
+ ),
+ text: i18n.translate(
+ 'xpack.ml.models.jobValidation.messages.datafeedPreviewNoDocumentsMessage',
+ {
+ defaultMessage:
+ 'Running the datafeed preview over the current job configuration produces no results. ' +
+ 'If the index contains no documents this warning can be ignored, otherwise the job may be misconfigured.',
+ }
+ ),
+ },
+ datafeed_preview_failed: {
+ status: VALIDATION_STATUS.ERROR,
+ text: i18n.translate('xpack.ml.models.jobValidation.messages.datafeedPreviewFailedMessage', {
+ defaultMessage:
+ 'The datafeed preview failed. This may be due to an error in the job or datafeed configurations.',
+ }),
+ },
};
});
diff --git a/x-pack/plugins/ml/public/application/components/import_export_jobs/export_jobs_flyout/export_jobs_flyout.tsx b/x-pack/plugins/ml/public/application/components/import_export_jobs/export_jobs_flyout/export_jobs_flyout.tsx
index bd4b805baa186..509c74c359657 100644
--- a/x-pack/plugins/ml/public/application/components/import_export_jobs/export_jobs_flyout/export_jobs_flyout.tsx
+++ b/x-pack/plugins/ml/public/application/components/import_export_jobs/export_jobs_flyout/export_jobs_flyout.tsx
@@ -63,6 +63,7 @@ export const ExportJobsFlyout: FC = ({ isDisabled, currentTab }) => {
const [exporting, setExporting] = useState(false);
const [selectedJobType, setSelectedJobType] = useState(currentTab);
const [switchTabConfirmVisible, setSwitchTabConfirmVisible] = useState(false);
+ const [switchTabNextTab, setSwitchTabNextTab] = useState(currentTab);
const { displayErrorToast, displaySuccessToast } = useMemo(
() => toastNotificationServiceProvider(toasts),
[toasts]
@@ -170,16 +171,23 @@ export const ExportJobsFlyout: FC = ({ isDisabled, currentTab }) => {
}
}
- const attemptTabSwitch = useCallback(() => {
- // if the user has already selected some jobs, open a confirm modal
- // rather than changing tabs
- if (selectedJobIds.length > 0) {
- setSwitchTabConfirmVisible(true);
- return;
- }
+ const attemptTabSwitch = useCallback(
+ (jobType: JobType) => {
+ if (jobType === selectedJobType) {
+ return;
+ }
+ // if the user has already selected some jobs, open a confirm modal
+ // rather than changing tabs
+ if (selectedJobIds.length > 0) {
+ setSwitchTabNextTab(jobType);
+ setSwitchTabConfirmVisible(true);
+ return;
+ }
- switchTab();
- }, [selectedJobIds]);
+ switchTab(jobType);
+ },
+ [selectedJobIds]
+ );
useEffect(() => {
setSelectedJobDependencies(
@@ -187,10 +195,7 @@ export const ExportJobsFlyout: FC = ({ isDisabled, currentTab }) => {
);
}, [selectedJobIds]);
- function switchTab() {
- const jobType =
- selectedJobType === 'anomaly-detector' ? 'data-frame-analytics' : 'anomaly-detector';
-
+ function switchTab(jobType: JobType) {
setSwitchTabConfirmVisible(false);
setSelectedJobIds([]);
setSelectedJobType(jobType);
@@ -211,7 +216,12 @@ export const ExportJobsFlyout: FC = ({ isDisabled, currentTab }) => {
{showFlyout === true && isDisabled === false && (
<>
- setShowFlyout(false)} hideCloseButton size="s">
+ setShowFlyout(false)}
+ hideCloseButton
+ size="s"
+ data-test-subj="mlJobMgmtExportJobsFlyout"
+ >
@@ -227,8 +237,9 @@ export const ExportJobsFlyout: FC = ({ isDisabled, currentTab }) => {
attemptTabSwitch('anomaly-detector')}
disabled={exporting}
+ data-test-subj="mlJobMgmtExportJobsADTab"
>
= ({ isDisabled, currentTab }) => {
attemptTabSwitch('data-frame-analytics')}
disabled={exporting}
+ data-test-subj="mlJobMgmtExportJobsDFATab"
>
= ({ isDisabled, currentTab }) => {
) : (
<>
-
-
+
+ {selectedJobIds.length === adJobIds.length ? (
+
+ ) : (
+
+ )}
- {adJobIds.map((id) => (
-
- toggleSelectedJob(e.target.checked, id)}
- />
-
-
- ))}
+
+ {adJobIds.map((id) => (
+
+ toggleSelectedJob(e.target.checked, id)}
+ />
+
+
+ ))}
+
>
)}
>
@@ -284,26 +310,39 @@ export const ExportJobsFlyout: FC = ({ isDisabled, currentTab }) => {
) : (
<>
-
-
+
+ {selectedJobIds.length === dfaJobIds.length ? (
+
+ ) : (
+
+ )}
-
- {dfaJobIds.map((id) => (
-
- toggleSelectedJob(e.target.checked, id)}
- />
-
-
- ))}
+
+ {dfaJobIds.map((id) => (
+
+ toggleSelectedJob(e.target.checked, id)}
+ />
+
+
+ ))}
+
>
)}
>
@@ -329,6 +368,7 @@ export const ExportJobsFlyout: FC = ({ isDisabled, currentTab }) => {
disabled={selectedJobIds.length === 0 || exporting === true}
onClick={onExport}
fill
+ data-test-subj="mlJobMgmtExportExportButton"
>
= ({ isDisabled, currentTab }) => {
{switchTabConfirmVisible === true ? (
switchTab(switchTabNextTab)}
/>
) : null}
>
diff --git a/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/cannot_import_jobs_callout.tsx b/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/cannot_import_jobs_callout.tsx
index 732be345a1ee4..565ded9c6f6c3 100644
--- a/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/cannot_import_jobs_callout.tsx
+++ b/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/cannot_import_jobs_callout.tsx
@@ -30,6 +30,7 @@ export const CannotImportJobsCallout: FC = ({ jobs, autoExpand = false })
values: { num: jobs.length },
})}
color="warning"
+ data-test-subj="mlJobMgmtImportJobsCannotBeImportedCallout"
>
{autoExpand ? (
diff --git a/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/cannot_read_file_callout.tsx b/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/cannot_read_file_callout.tsx
index 4c7a2471db9d6..70f94d1e03155 100644
--- a/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/cannot_read_file_callout.tsx
+++ b/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/cannot_read_file_callout.tsx
@@ -21,10 +21,12 @@ export const CannotReadFileCallout: FC = () => {
})}
color="warning"
>
-
+
+
+
>
);
diff --git a/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/import_jobs_flyout.tsx b/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/import_jobs_flyout.tsx
index 68db42cdbf0eb..dfe07b1984e11 100644
--- a/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/import_jobs_flyout.tsx
+++ b/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/import_jobs_flyout.tsx
@@ -341,7 +341,12 @@ export const ImportJobsFlyout: FC = ({ isDisabled }) => {
{showFlyout === true && isDisabled === false && (
-
+
@@ -373,22 +378,26 @@ export const ImportJobsFlyout: FC = ({ isDisabled }) => {
{showFileReadError ? : null}
{totalJobsRead > 0 && jobType !== null && (
- <>
+
{jobType === 'anomaly-detector' && (
-
+
+
+
)}
{jobType === 'data-frame-analytics' && (
-
+
+
+
)}
@@ -426,6 +435,7 @@ export const ImportJobsFlyout: FC
= ({ isDisabled }) => {
value={jobId.jobId}
onChange={(e) => renameJob(e.target.value, i)}
isInvalid={jobId.jobIdValid === false}
+ data-test-subj="mlJobMgmtImportJobIdInput"
/>
@@ -465,7 +475,7 @@ export const ImportJobsFlyout: FC = ({ isDisabled }) => {
))}
- >
+
)}
>
@@ -484,7 +494,12 @@ export const ImportJobsFlyout: FC = ({ isDisabled }) => {
-
+
= ({ setCurrentStep, isCurrentStep }) => {
const { jobCreator, jobCreatorUpdate, jobValidator } = useContext(JobCreatorContext);
+ const [nextActive, setNextActive] = useState(false);
if (jobCreator.type === JOB_TYPE.ADVANCED) {
// for advanced jobs, ignore time range warning as the
@@ -52,6 +53,7 @@ export const ValidationStep: FC = ({ setCurrentStep, isCurrentStep })
// keep a record of the advanced validation in the jobValidator
function setIsValid(valid: boolean) {
jobValidator.advancedValid = valid;
+ setNextActive(valid);
}
return (
@@ -69,7 +71,7 @@ export const ValidationStep: FC = ({ setCurrentStep, isCurrentStep })
setCurrentStep(WIZARD_STEPS.JOB_DETAILS)}
next={() => setCurrentStep(WIZARD_STEPS.SUMMARY)}
- nextActive={true}
+ nextActive={nextActive}
/>
)}
diff --git a/x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts b/x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts
index a5483491f1357..e890020eb726d 100644
--- a/x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts
+++ b/x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts
@@ -10,6 +10,7 @@ import { IScopedClusterClient } from 'kibana/server';
import { validateJob, ValidateJobPayload } from './job_validation';
import { ES_CLIENT_TOTAL_HITS_RELATION } from '../../../common/types/es_client';
import type { MlClient } from '../../lib/ml_client';
+import type { AuthorizationHeader } from '../../lib/request_authorization';
const callAs = {
fieldCaps: () => Promise.resolve({ body: { fields: [] } }),
@@ -19,6 +20,8 @@ const callAs = {
}),
};
+const authHeader: AuthorizationHeader = {};
+
const mlClusterClient = ({
asCurrentUser: callAs,
asInternalUser: callAs,
@@ -34,18 +37,19 @@ const mlClient = ({
},
},
}),
+ previewDatafeed: () => Promise.resolve({ body: [{}] }),
} as unknown) as MlClient;
// Note: The tests cast `payload` as any
// so we can simulate possible runtime payloads
// that don't satisfy the TypeScript specs.
describe('ML - validateJob', () => {
- it('basic validation messages', () => {
+ it('basic validation messages', async () => {
const payload = ({
job: { analysis_config: { detectors: [] } },
} as unknown) as ValidateJobPayload;
- return validateJob(mlClusterClient, mlClient, payload).then((messages) => {
+ return validateJob(mlClusterClient, mlClient, payload, authHeader).then((messages) => {
const ids = messages.map((m) => m.id);
expect(ids).toStrictEqual([
@@ -58,14 +62,14 @@ describe('ML - validateJob', () => {
});
const jobIdTests = (testIds: string[], messageId: string) => {
- const promises = testIds.map((id) => {
+ const promises = testIds.map(async (id) => {
const payload = ({
job: {
analysis_config: { detectors: [] },
job_id: id,
},
} as unknown) as ValidateJobPayload;
- return validateJob(mlClusterClient, mlClient, payload).catch(() => {
+ return validateJob(mlClusterClient, mlClient, payload, authHeader).catch(() => {
new Error('Promise should not fail for jobIdTests.');
});
});
@@ -86,7 +90,7 @@ describe('ML - validateJob', () => {
job: { analysis_config: { detectors: [] }, groups: testIds },
} as unknown) as ValidateJobPayload;
- return validateJob(mlClusterClient, mlClient, payload).then((messages) => {
+ return validateJob(mlClusterClient, mlClient, payload, authHeader).then((messages) => {
const ids = messages.map((m) => m.id);
expect(ids.includes(messageId)).toBe(true);
});
@@ -126,7 +130,7 @@ describe('ML - validateJob', () => {
const payload = ({
job: { analysis_config: { bucket_span: format, detectors: [] } },
} as unknown) as ValidateJobPayload;
- return validateJob(mlClusterClient, mlClient, payload).catch(() => {
+ return validateJob(mlClusterClient, mlClient, payload, authHeader).catch(() => {
new Error('Promise should not fail for bucketSpanFormatTests.');
});
});
@@ -150,7 +154,7 @@ describe('ML - validateJob', () => {
return bucketSpanFormatTests(validBucketSpanFormats, 'bucket_span_valid');
});
- it('at least one detector function is empty', () => {
+ it('at least one detector function is empty', async () => {
const payload = ({
job: { analysis_config: { detectors: [] as Array<{ function?: string }> } },
} as unknown) as ValidateJobPayload;
@@ -165,13 +169,13 @@ describe('ML - validateJob', () => {
function: undefined,
});
- return validateJob(mlClusterClient, mlClient, payload).then((messages) => {
+ return validateJob(mlClusterClient, mlClient, payload, authHeader).then((messages) => {
const ids = messages.map((m) => m.id);
expect(ids.includes('detectors_function_empty')).toBe(true);
});
});
- it('detector function is not empty', () => {
+ it('detector function is not empty', async () => {
const payload = ({
job: { analysis_config: { detectors: [] as Array<{ function?: string }> } },
} as unknown) as ValidateJobPayload;
@@ -179,37 +183,37 @@ describe('ML - validateJob', () => {
function: 'count',
});
- return validateJob(mlClusterClient, mlClient, payload).then((messages) => {
+ return validateJob(mlClusterClient, mlClient, payload, authHeader).then((messages) => {
const ids = messages.map((m) => m.id);
expect(ids.includes('detectors_function_not_empty')).toBe(true);
});
});
- it('invalid index fields', () => {
+ it('invalid index fields', async () => {
const payload = ({
job: { analysis_config: { detectors: [] } },
fields: {},
} as unknown) as ValidateJobPayload;
- return validateJob(mlClusterClient, mlClient, payload).then((messages) => {
+ return validateJob(mlClusterClient, mlClient, payload, authHeader).then((messages) => {
const ids = messages.map((m) => m.id);
expect(ids.includes('index_fields_invalid')).toBe(true);
});
});
- it('valid index fields', () => {
+ it('valid index fields', async () => {
const payload = ({
job: { analysis_config: { detectors: [] } },
fields: { testField: {} },
} as unknown) as ValidateJobPayload;
- return validateJob(mlClusterClient, mlClient, payload).then((messages) => {
+ return validateJob(mlClusterClient, mlClient, payload, authHeader).then((messages) => {
const ids = messages.map((m) => m.id);
expect(ids.includes('index_fields_valid')).toBe(true);
});
});
- const getBasicPayload = (): any => ({
+ const getBasicPayload = (): ValidateJobPayload => ({
job: {
job_id: 'test',
analysis_config: {
@@ -231,7 +235,7 @@ describe('ML - validateJob', () => {
const payload = getBasicPayload() as any;
delete payload.job.analysis_config.influencers;
- validateJob(mlClusterClient, mlClient, payload).then(
+ validateJob(mlClusterClient, mlClient, payload, authHeader).then(
() =>
done(
new Error('Promise should not resolve for this test when influencers is not an Array.')
@@ -240,10 +244,10 @@ describe('ML - validateJob', () => {
);
});
- it('detect duplicate detectors', () => {
+ it('detect duplicate detectors', async () => {
const payload = getBasicPayload() as any;
payload.job.analysis_config.detectors.push({ function: 'count' });
- return validateJob(mlClusterClient, mlClient, payload).then((messages) => {
+ return validateJob(mlClusterClient, mlClient, payload, authHeader).then((messages) => {
const ids = messages.map((m) => m.id);
expect(ids).toStrictEqual([
'job_id_valid',
@@ -256,7 +260,7 @@ describe('ML - validateJob', () => {
});
});
- it('dedupe duplicate messages', () => {
+ it('dedupe duplicate messages', async () => {
const payload = getBasicPayload() as any;
// in this test setup, the following configuration passes
// the duplicate detectors check, but would return the same
@@ -266,7 +270,7 @@ describe('ML - validateJob', () => {
{ function: 'count', by_field_name: 'airline' },
{ function: 'count', partition_field_name: 'airline' },
];
- return validateJob(mlClusterClient, mlClient, payload).then((messages) => {
+ return validateJob(mlClusterClient, mlClient, payload, authHeader).then((messages) => {
const ids = messages.map((m) => m.id);
expect(ids).toStrictEqual([
'job_id_valid',
@@ -278,9 +282,9 @@ describe('ML - validateJob', () => {
});
});
- it('basic validation passes, extended checks return some messages', () => {
+ it('basic validation passes, extended checks return some messages', async () => {
const payload = getBasicPayload();
- return validateJob(mlClusterClient, mlClient, payload).then((messages) => {
+ return validateJob(mlClusterClient, mlClient, payload, authHeader).then((messages) => {
const ids = messages.map((m) => m.id);
expect(ids).toStrictEqual([
'job_id_valid',
@@ -291,8 +295,8 @@ describe('ML - validateJob', () => {
});
});
- it('categorization job using mlcategory passes aggregatable field check', () => {
- const payload: any = {
+ it('categorization job using mlcategory passes aggregatable field check', async () => {
+ const payload: ValidateJobPayload = {
job: {
job_id: 'categorization_test',
analysis_config: {
@@ -312,7 +316,7 @@ describe('ML - validateJob', () => {
fields: { testField: {} },
};
- return validateJob(mlClusterClient, mlClient, payload).then((messages) => {
+ return validateJob(mlClusterClient, mlClient, payload, authHeader).then((messages) => {
const ids = messages.map((m) => m.id);
expect(ids).toStrictEqual([
'job_id_valid',
@@ -325,8 +329,8 @@ describe('ML - validateJob', () => {
});
});
- it('non-existent field reported as non aggregatable', () => {
- const payload: any = {
+ it('non-existent field reported as non aggregatable', async () => {
+ const payload: ValidateJobPayload = {
job: {
job_id: 'categorization_test',
analysis_config: {
@@ -345,7 +349,7 @@ describe('ML - validateJob', () => {
fields: { testField: {} },
};
- return validateJob(mlClusterClient, mlClient, payload).then((messages) => {
+ return validateJob(mlClusterClient, mlClient, payload, authHeader).then((messages) => {
const ids = messages.map((m) => m.id);
expect(ids).toStrictEqual([
'job_id_valid',
@@ -357,8 +361,8 @@ describe('ML - validateJob', () => {
});
});
- it('script field not reported as non aggregatable', () => {
- const payload: any = {
+ it('script field not reported as non aggregatable', async () => {
+ const payload: ValidateJobPayload = {
job: {
job_id: 'categorization_test',
analysis_config: {
@@ -387,7 +391,7 @@ describe('ML - validateJob', () => {
fields: { testField: {} },
};
- return validateJob(mlClusterClient, mlClient, payload).then((messages) => {
+ return validateJob(mlClusterClient, mlClient, payload, authHeader).then((messages) => {
const ids = messages.map((m) => m.id);
expect(ids).toStrictEqual([
'job_id_valid',
@@ -399,4 +403,88 @@ describe('ML - validateJob', () => {
]);
});
});
+
+ it('datafeed preview contains no docs', async () => {
+ const payload: ValidateJobPayload = {
+ job: {
+ job_id: 'categorization_test',
+ analysis_config: {
+ bucket_span: '15m',
+ detectors: [
+ {
+ function: 'count',
+ partition_field_name: 'custom_script_field',
+ },
+ ],
+ influencers: [''],
+ },
+ data_description: { time_field: '@timestamp' },
+ datafeed_config: {
+ indices: [],
+ },
+ },
+ fields: { testField: {} },
+ };
+
+ const mlClientEmptyDatafeedPreview = ({
+ ...mlClient,
+ previewDatafeed: () => Promise.resolve({ body: [] }),
+ } as unknown) as MlClient;
+
+ return validateJob(mlClusterClient, mlClientEmptyDatafeedPreview, payload, authHeader).then(
+ (messages) => {
+ const ids = messages.map((m) => m.id);
+ expect(ids).toStrictEqual([
+ 'job_id_valid',
+ 'detectors_function_not_empty',
+ 'index_fields_valid',
+ 'field_not_aggregatable',
+ 'time_field_invalid',
+ 'datafeed_preview_no_documents',
+ ]);
+ }
+ );
+ });
+
+ it('datafeed preview failed', async () => {
+ const payload: ValidateJobPayload = {
+ job: {
+ job_id: 'categorization_test',
+ analysis_config: {
+ bucket_span: '15m',
+ detectors: [
+ {
+ function: 'count',
+ partition_field_name: 'custom_script_field',
+ },
+ ],
+ influencers: [''],
+ },
+ data_description: { time_field: '@timestamp' },
+ datafeed_config: {
+ indices: [],
+ },
+ },
+ fields: { testField: {} },
+ };
+
+ const mlClientEmptyDatafeedPreview = ({
+ ...mlClient,
+ previewDatafeed: () => Promise.reject({}),
+ } as unknown) as MlClient;
+
+ return validateJob(mlClusterClient, mlClientEmptyDatafeedPreview, payload, authHeader).then(
+ (messages) => {
+ const ids = messages.map((m) => m.id);
+ expect(ids).toStrictEqual([
+ 'job_id_valid',
+ 'detectors_function_not_empty',
+ 'index_fields_valid',
+ 'field_not_aggregatable',
+ 'time_field_invalid',
+ 'datafeed_preview_failed',
+ ]);
+ }
+ );
+ });
});
diff --git a/x-pack/plugins/ml/server/models/job_validation/job_validation.ts b/x-pack/plugins/ml/server/models/job_validation/job_validation.ts
index 80eba7b864051..838f188455d44 100644
--- a/x-pack/plugins/ml/server/models/job_validation/job_validation.ts
+++ b/x-pack/plugins/ml/server/models/job_validation/job_validation.ts
@@ -6,7 +6,7 @@
*/
import Boom from '@hapi/boom';
-import { IScopedClusterClient } from 'kibana/server';
+import type { IScopedClusterClient } from 'kibana/server';
import { TypeOf } from '@kbn/config-schema';
import { fieldsServiceProvider } from '../fields_service';
import { getMessages, MessageId, JobValidationMessage } from '../../../common/constants/messages';
@@ -17,12 +17,14 @@ import { basicJobValidation, uniqWithIsEqual } from '../../../common/util/job_ut
import { validateBucketSpan } from './validate_bucket_span';
import { validateCardinality } from './validate_cardinality';
import { validateInfluencers } from './validate_influencers';
+import { validateDatafeedPreview } from './validate_datafeed_preview';
import { validateModelMemoryLimit } from './validate_model_memory_limit';
import { validateTimeRange, isValidTimeField } from './validate_time_range';
import { validateJobSchema } from '../../routes/schemas/job_validation_schema';
-import { CombinedJob } from '../../../common/types/anomaly_detection_jobs';
+import type { CombinedJob } from '../../../common/types/anomaly_detection_jobs';
import type { MlClient } from '../../lib/ml_client';
import { getDatafeedAggregations } from '../../../common/util/datafeed_utils';
+import type { AuthorizationHeader } from '../../lib/request_authorization';
export type ValidateJobPayload = TypeOf;
@@ -34,6 +36,7 @@ export async function validateJob(
client: IScopedClusterClient,
mlClient: MlClient,
payload: ValidateJobPayload,
+ authHeader: AuthorizationHeader,
isSecurityDisabled?: boolean
) {
const messages = getMessages();
@@ -107,6 +110,8 @@ export async function validateJob(
if (datafeedAggregations !== undefined && !job.analysis_config?.summary_count_field_name) {
validationMessages.push({ id: 'missing_summary_count_field_name' });
}
+
+ validationMessages.push(...(await validateDatafeedPreview(mlClient, authHeader, job)));
} else {
validationMessages = basicValidation.messages;
validationMessages.push({ id: 'skipped_extended_tests' });
diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_datafeed_preview.ts b/x-pack/plugins/ml/server/models/job_validation/validate_datafeed_preview.ts
new file mode 100644
index 0000000000000..e009dcf49fdab
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/job_validation/validate_datafeed_preview.ts
@@ -0,0 +1,38 @@
+/*
+ * 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 { MlClient } from '../../lib/ml_client';
+import type { AuthorizationHeader } from '../../lib/request_authorization';
+import type { CombinedJob } from '../../../common/types/anomaly_detection_jobs';
+import type { JobValidationMessage } from '../../../common/constants/messages';
+
+export async function validateDatafeedPreview(
+ mlClient: MlClient,
+ authHeader: AuthorizationHeader,
+ job: CombinedJob
+): Promise {
+ const { datafeed_config: datafeed, ...tempJob } = job;
+ try {
+ const { body } = ((await mlClient.previewDatafeed(
+ {
+ body: {
+ job_config: tempJob,
+ datafeed_config: datafeed,
+ },
+ },
+ authHeader
+ // previewDatafeed response type is incorrect
+ )) as unknown) as { body: unknown[] };
+
+ if (Array.isArray(body) === false || body.length === 0) {
+ return [{ id: 'datafeed_preview_no_documents' }];
+ }
+ return [];
+ } catch (error) {
+ return [{ id: 'datafeed_preview_failed' }];
+ }
+}
diff --git a/x-pack/plugins/ml/server/routes/job_validation.ts b/x-pack/plugins/ml/server/routes/job_validation.ts
index 9309592dfc474..b75eab20e7bc0 100644
--- a/x-pack/plugins/ml/server/routes/job_validation.ts
+++ b/x-pack/plugins/ml/server/routes/job_validation.ts
@@ -8,9 +8,9 @@
import Boom from '@hapi/boom';
import { IScopedClusterClient } from 'kibana/server';
import { TypeOf } from '@kbn/config-schema';
-import { AnalysisConfig, Datafeed } from '../../common/types/anomaly_detection_jobs';
+import type { AnalysisConfig, Datafeed } from '../../common/types/anomaly_detection_jobs';
import { wrapError } from '../client/error_wrapper';
-import { RouteInitialization } from '../types';
+import type { RouteInitialization } from '../types';
import {
estimateBucketSpanSchema,
modelMemoryLimitSchema,
@@ -20,6 +20,7 @@ import {
import { estimateBucketSpanFactory } from '../models/bucket_span_estimator';
import { calculateModelMemoryLimitProvider } from '../models/calculate_model_memory_limit';
import { validateJob, validateCardinality } from '../models/job_validation';
+import { getAuthorizationHeader } from '../lib/request_authorization';
import type { MlClient } from '../lib/ml_client';
type CalculateModelMemoryLimitPayload = TypeOf;
@@ -192,6 +193,7 @@ export function jobValidationRoutes({ router, mlLicense, routeGuard }: RouteInit
client,
mlClient,
request.body,
+ getAuthorizationHeader(request),
mlLicense.isSecurityEnabled() === false
);
diff --git a/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts b/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts
index 118d2e4140ced..27e1b6afe3364 100644
--- a/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts
+++ b/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts
@@ -52,7 +52,7 @@ export const datafeedConfigSchema = schema.object({
runtime_mappings: schema.maybe(schema.any()),
scroll_size: schema.maybe(schema.number()),
delayed_data_check_config: schema.maybe(schema.any()),
- indices_options: indicesOptionsSchema,
+ indices_options: schema.maybe(indicesOptionsSchema),
});
export const datafeedIdSchema = schema.object({ datafeedId: schema.string() });
diff --git a/x-pack/plugins/monitoring/public/application/global_state_context.tsx b/x-pack/plugins/monitoring/public/application/global_state_context.tsx
index dc33316dbd9d9..57bb638651d05 100644
--- a/x-pack/plugins/monitoring/public/application/global_state_context.tsx
+++ b/x-pack/plugins/monitoring/public/application/global_state_context.tsx
@@ -13,9 +13,11 @@ interface GlobalStateProviderProps {
toasts: MonitoringStartPluginDependencies['core']['notifications']['toasts'];
}
-interface State {
+export interface State {
cluster_uuid?: string;
ccs?: any;
+ inSetupMode?: boolean;
+ save?: () => void;
}
export const GlobalStateContext = createContext({} as State);
diff --git a/x-pack/plugins/monitoring/public/application/pages/cluster/overview_page.tsx b/x-pack/plugins/monitoring/public/application/pages/cluster/overview_page.tsx
index ddc097caea575..f329323bafda8 100644
--- a/x-pack/plugins/monitoring/public/application/pages/cluster/overview_page.tsx
+++ b/x-pack/plugins/monitoring/public/application/pages/cluster/overview_page.tsx
@@ -15,8 +15,15 @@ import { TabMenuItem } from '../page_template';
import { PageLoading } from '../../../components';
import { Overview } from '../../../components/cluster/overview';
import { ExternalConfigContext } from '../../external_config_context';
+import { SetupModeRenderer } from '../../setup_mode/setup_mode_renderer';
+import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context';
const CODE_PATHS = [CODE_PATH_ALL];
+interface SetupModeProps {
+ setupMode: any;
+ flyoutComponent: any;
+ bottomBarComponent: any;
+}
export const ClusterOverview: React.FC<{}> = () => {
// TODO: check how many requests with useClusters
@@ -49,11 +56,20 @@ export const ClusterOverview: React.FC<{}> = () => {
return (
{loaded ? (
- (
+
+ {flyoutComponent}
+
+ {/* */}
+ {bottomBarComponent}
+
+ )}
/>
) : (
diff --git a/x-pack/plugins/monitoring/public/application/pages/page_template.tsx b/x-pack/plugins/monitoring/public/application/pages/page_template.tsx
index f40c2d3ec5e50..29aafa09814fb 100644
--- a/x-pack/plugins/monitoring/public/application/pages/page_template.tsx
+++ b/x-pack/plugins/monitoring/public/application/pages/page_template.tsx
@@ -5,7 +5,7 @@
* 2.0.
*/
-import { EuiFlexGroup, EuiFlexItem, EuiTab, EuiTabs, EuiTitle } from '@elastic/eui';
+import { EuiTab, EuiTabs } from '@elastic/eui';
import React from 'react';
import { useTitle } from '../hooks/use_title';
import { MonitoringToolbar } from '../../components/shared/toolbar';
@@ -29,34 +29,7 @@ export const PageTemplate: React.FC = ({ title, pageTitle, ta
return (
-
-
-
-
- {/* HERE GOES THE SETUP BUTTON */}
-
-
- {pageTitle && (
-
-
- {pageTitle}
-
-
- )}
-
-
-
-
-
-
-
-
-
+
{tabs && (
{tabs.map((item, idx) => {
diff --git a/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode.tsx b/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode.tsx
new file mode 100644
index 0000000000000..70932e5177337
--- /dev/null
+++ b/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode.tsx
@@ -0,0 +1,200 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+import { render } from 'react-dom';
+import { get, includes } from 'lodash';
+import { i18n } from '@kbn/i18n';
+import { HttpStart } from 'kibana/public';
+import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public';
+import { Legacy } from '../../legacy_shims';
+import { SetupModeEnterButton } from '../../components/setup_mode/enter_button';
+import { SetupModeFeature } from '../../../common/enums';
+import { ISetupModeContext } from '../../components/setup_mode/setup_mode_context';
+import { State as GlobalState } from '../../application/global_state_context';
+
+function isOnPage(hash: string) {
+ return includes(window.location.hash, hash);
+}
+
+let globalState: GlobalState;
+let httpService: HttpStart;
+
+interface ISetupModeState {
+ enabled: boolean;
+ data: any;
+ callback?: (() => void) | null;
+ hideBottomBar: boolean;
+}
+const setupModeState: ISetupModeState = {
+ enabled: false,
+ data: null,
+ callback: null,
+ hideBottomBar: false,
+};
+
+export const getSetupModeState = () => setupModeState;
+
+export const setNewlyDiscoveredClusterUuid = (clusterUuid: string) => {
+ globalState.cluster_uuid = clusterUuid;
+ globalState.save?.();
+};
+
+export const fetchCollectionData = async (uuid?: string, fetchWithoutClusterUuid = false) => {
+ const clusterUuid = globalState.cluster_uuid;
+ const ccs = globalState.ccs;
+
+ let url = '../api/monitoring/v1/setup/collection';
+ if (uuid) {
+ url += `/node/${uuid}`;
+ } else if (!fetchWithoutClusterUuid && clusterUuid) {
+ url += `/cluster/${clusterUuid}`;
+ } else {
+ url += '/cluster';
+ }
+
+ try {
+ const response = await httpService.post(url, {
+ body: JSON.stringify({
+ ccs,
+ }),
+ });
+ return response;
+ } catch (err) {
+ // TODO: handle errors
+ throw new Error(err);
+ }
+};
+
+const notifySetupModeDataChange = () => setupModeState.callback && setupModeState.callback();
+
+export const updateSetupModeData = async (uuid?: string, fetchWithoutClusterUuid = false) => {
+ const data = await fetchCollectionData(uuid, fetchWithoutClusterUuid);
+ setupModeState.data = data;
+ const hasPermissions = get(data, '_meta.hasPermissions', false);
+ if (!hasPermissions) {
+ let text: string = '';
+ if (!hasPermissions) {
+ text = i18n.translate('xpack.monitoring.setupMode.notAvailablePermissions', {
+ defaultMessage: 'You do not have the necessary permissions to do this.',
+ });
+ }
+
+ Legacy.shims.toastNotifications.addDanger({
+ title: i18n.translate('xpack.monitoring.setupMode.notAvailableTitle', {
+ defaultMessage: 'Setup mode is not available',
+ }),
+ text,
+ });
+ return toggleSetupMode(false);
+ }
+ notifySetupModeDataChange();
+
+ const clusterUuid = globalState.cluster_uuid;
+ if (!clusterUuid) {
+ const liveClusterUuid: string = get(data, '_meta.liveClusterUuid');
+ const migratedEsNodes = Object.values(get(data, 'elasticsearch.byUuid', {})).filter(
+ (node: any) => node.isPartiallyMigrated || node.isFullyMigrated
+ );
+ if (liveClusterUuid && migratedEsNodes.length > 0) {
+ setNewlyDiscoveredClusterUuid(liveClusterUuid);
+ }
+ }
+};
+
+export const hideBottomBar = () => {
+ setupModeState.hideBottomBar = true;
+ notifySetupModeDataChange();
+};
+export const showBottomBar = () => {
+ setupModeState.hideBottomBar = false;
+ notifySetupModeDataChange();
+};
+
+export const disableElasticsearchInternalCollection = async () => {
+ const clusterUuid = globalState.cluster_uuid;
+ const url = `../api/monitoring/v1/setup/collection/${clusterUuid}/disable_internal_collection`;
+ try {
+ const response = await httpService.post(url);
+ return response;
+ } catch (err) {
+ // TODO: handle errors
+ throw new Error(err);
+ }
+};
+
+export const toggleSetupMode = (inSetupMode: boolean) => {
+ setupModeState.enabled = inSetupMode;
+ globalState.inSetupMode = inSetupMode;
+ globalState.save?.();
+ setSetupModeMenuItem();
+ notifySetupModeDataChange();
+
+ if (inSetupMode) {
+ // Intentionally do not await this so we don't block UI operations
+ updateSetupModeData();
+ }
+};
+
+export const setSetupModeMenuItem = () => {
+ if (isOnPage('no-data')) {
+ return;
+ }
+
+ const enabled = !globalState.inSetupMode;
+ const I18nContext = Legacy.shims.I18nContext;
+
+ render(
+
+
+
+
+ ,
+ document.getElementById('setupModeNav')
+ );
+};
+
+export const initSetupModeState = async (
+ state: GlobalState,
+ http: HttpStart,
+ callback?: () => void
+) => {
+ globalState = state;
+ httpService = http;
+ if (callback) {
+ setupModeState.callback = callback;
+ }
+
+ if (globalState.inSetupMode) {
+ toggleSetupMode(true);
+ }
+};
+
+export const isInSetupMode = (context?: ISetupModeContext) => {
+ if (context?.setupModeSupported === false) {
+ return false;
+ }
+ if (setupModeState.enabled) {
+ return true;
+ }
+
+ return globalState.inSetupMode;
+};
+
+export const isSetupModeFeatureEnabled = (feature: SetupModeFeature) => {
+ if (!setupModeState.enabled) {
+ return false;
+ }
+
+ if (feature === SetupModeFeature.MetricbeatMigration) {
+ if (Legacy.shims.isCloud) {
+ return false;
+ }
+ }
+
+ return true;
+};
diff --git a/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.d.ts b/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.d.ts
new file mode 100644
index 0000000000000..27462f07c07be
--- /dev/null
+++ b/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.d.ts
@@ -0,0 +1,8 @@
+/*
+ * 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 const SetupModeRenderer: FunctionComponent;
diff --git a/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.js b/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.js
new file mode 100644
index 0000000000000..337dacd4ecae9
--- /dev/null
+++ b/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.js
@@ -0,0 +1,217 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { Fragment } from 'react';
+import {
+ getSetupModeState,
+ initSetupModeState,
+ updateSetupModeData,
+ disableElasticsearchInternalCollection,
+ toggleSetupMode,
+ setSetupModeMenuItem,
+} from './setup_mode';
+import { Flyout } from '../../components/metricbeat_migration/flyout';
+import {
+ EuiBottomBar,
+ EuiButton,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiTextColor,
+ EuiIcon,
+ EuiSpacer,
+} from '@elastic/eui';
+import { findNewUuid } from '../../components/renderers/lib/find_new_uuid';
+import { i18n } from '@kbn/i18n';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { GlobalStateContext } from '../../application/global_state_context';
+import { withKibana } from '../../../../../../src/plugins/kibana_react/public';
+
+class WrappedSetupModeRenderer extends React.Component {
+ globalState;
+ state = {
+ renderState: false,
+ isFlyoutOpen: false,
+ instance: null,
+ newProduct: null,
+ isSettingUpNew: false,
+ };
+
+ UNSAFE_componentWillMount() {
+ this.globalState = this.context;
+ const { kibana } = this.props;
+ initSetupModeState(this.globalState, kibana.services.http, (_oldData) => {
+ const newState = { renderState: true };
+
+ const { productName } = this.props;
+ if (!productName) {
+ this.setState(newState);
+ return;
+ }
+
+ const setupModeState = getSetupModeState();
+ if (!setupModeState.enabled || !setupModeState.data) {
+ this.setState(newState);
+ return;
+ }
+
+ const data = setupModeState.data[productName];
+ const oldData = _oldData ? _oldData[productName] : null;
+ if (data && oldData) {
+ const newUuid = findNewUuid(Object.keys(oldData.byUuid), Object.keys(data.byUuid));
+ if (newUuid) {
+ newState.newProduct = data.byUuid[newUuid];
+ }
+ }
+
+ this.setState(newState);
+ });
+ setSetupModeMenuItem();
+ }
+
+ reset() {
+ this.setState({
+ renderState: false,
+ isFlyoutOpen: false,
+ instance: null,
+ newProduct: null,
+ isSettingUpNew: false,
+ });
+ }
+
+ getFlyout(data, meta) {
+ const { productName } = this.props;
+ const { isFlyoutOpen, instance, isSettingUpNew, newProduct } = this.state;
+ if (!data || !isFlyoutOpen) {
+ return null;
+ }
+
+ let product = null;
+ if (newProduct) {
+ product = newProduct;
+ }
+ // For new instance discovery flow, we pass in empty instance object
+ else if (instance && Object.keys(instance).length) {
+ product = data.byUuid[instance.uuid];
+ }
+
+ if (!product) {
+ const uuids = Object.values(data.byUuid);
+ if (uuids.length && !isSettingUpNew) {
+ product = uuids[0];
+ } else {
+ product = {
+ isNetNewUser: true,
+ };
+ }
+ }
+
+ return (
+ this.reset()}
+ productName={productName}
+ product={product}
+ meta={meta}
+ instance={instance}
+ updateProduct={updateSetupModeData}
+ isSettingUpNew={isSettingUpNew}
+ />
+ );
+ }
+
+ getBottomBar(setupModeState) {
+ if (!setupModeState.enabled || setupModeState.hideBottomBar) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ ,
+ }}
+ />
+
+
+
+
+
+
+
+ toggleSetupMode(false)}
+ >
+ {i18n.translate('xpack.monitoring.setupMode.exit', {
+ defaultMessage: `Exit setup mode`,
+ })}
+
+
+
+
+
+
+
+ );
+ }
+
+ async shortcutToFinishMigration() {
+ await disableElasticsearchInternalCollection();
+ await updateSetupModeData();
+ }
+
+ render() {
+ const { render, productName } = this.props;
+ const setupModeState = getSetupModeState();
+
+ let data = { byUuid: {} };
+ if (setupModeState.data) {
+ if (productName && setupModeState.data[productName]) {
+ data = setupModeState.data[productName];
+ } else if (setupModeState.data) {
+ data = setupModeState.data;
+ }
+ }
+
+ const meta = setupModeState.data ? setupModeState.data._meta : null;
+
+ return render({
+ setupMode: {
+ data,
+ meta,
+ enabled: setupModeState.enabled,
+ productName,
+ updateSetupModeData,
+ shortcutToFinishMigration: () => this.shortcutToFinishMigration(),
+ openFlyout: (instance, isSettingUpNew) =>
+ this.setState({ isFlyoutOpen: true, instance, isSettingUpNew }),
+ closeFlyout: () => this.setState({ isFlyoutOpen: false }),
+ },
+ flyoutComponent: this.getFlyout(data, meta),
+ bottomBarComponent: this.getBottomBar(setupModeState),
+ });
+ }
+}
+
+WrappedSetupModeRenderer.contextType = GlobalStateContext;
+export const SetupModeRenderer = withKibana(WrappedSetupModeRenderer);
diff --git a/x-pack/plugins/monitoring/public/components/shared/toolbar.tsx b/x-pack/plugins/monitoring/public/components/shared/toolbar.tsx
index 6e45d4d831ec9..e5962b7f80876 100644
--- a/x-pack/plugins/monitoring/public/components/shared/toolbar.tsx
+++ b/x-pack/plugins/monitoring/public/components/shared/toolbar.tsx
@@ -5,11 +5,21 @@
* 2.0.
*/
-import { EuiFlexGroup, EuiFlexItem, EuiSuperDatePicker, OnRefreshChangeProps } from '@elastic/eui';
+import {
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiSuperDatePicker,
+ EuiTitle,
+ OnRefreshChangeProps,
+} from '@elastic/eui';
import React, { useContext, useCallback } from 'react';
import { MonitoringTimeContainer } from '../../application/pages/use_monitoring_time';
-export const MonitoringToolbar = () => {
+interface MonitoringToolbarProps {
+ pageTitle?: string;
+}
+
+export const MonitoringToolbar: React.FC = ({ pageTitle }) => {
const {
currentTimerange,
handleTimeChange,
@@ -38,18 +48,36 @@ export const MonitoringToolbar = () => {
);
return (
-
- Setup Button
+
+
+
+
+ {/* HERE GOES THE SETUP BUTTON */}
+
+
+ {pageTitle && (
+
+
+ {pageTitle}
+
+
+ )}
+
+
+
+
- {}}
- isPaused={isPaused}
- refreshInterval={refreshInterval}
- onRefreshChange={onRefreshChange}
- />
+
+ {}}
+ isPaused={isPaused}
+ refreshInterval={refreshInterval}
+ onRefreshChange={onRefreshChange}
+ />
+
);
diff --git a/x-pack/plugins/monitoring/public/external_config.ts b/x-pack/plugins/monitoring/public/external_config.ts
new file mode 100644
index 0000000000000..29ce410a5a9dc
--- /dev/null
+++ b/x-pack/plugins/monitoring/public/external_config.ts
@@ -0,0 +1,16 @@
+/*
+ * 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.
+ */
+
+let config: { [key: string]: unknown } = {};
+
+export const setConfig = (externalConfig: { [key: string]: unknown }) => {
+ config = externalConfig;
+};
+
+export const isReactMigrationEnabled = () => {
+ return config.renderReactApp;
+};
diff --git a/x-pack/plugins/monitoring/public/lib/setup_mode.tsx b/x-pack/plugins/monitoring/public/lib/setup_mode.tsx
index 28fd7494b1d10..f622f2944a31a 100644
--- a/x-pack/plugins/monitoring/public/lib/setup_mode.tsx
+++ b/x-pack/plugins/monitoring/public/lib/setup_mode.tsx
@@ -15,6 +15,8 @@ import { ajaxErrorHandlersProvider } from './ajax_error_handler';
import { SetupModeEnterButton } from '../components/setup_mode/enter_button';
import { SetupModeFeature } from '../../common/enums';
import { ISetupModeContext } from '../components/setup_mode/setup_mode_context';
+import * as setupModeReact from '../application/setup_mode/setup_mode';
+import { isReactMigrationEnabled } from '../external_config';
function isOnPage(hash: string) {
return includes(window.location.hash, hash);
@@ -209,6 +211,7 @@ export const initSetupModeState = async ($scope: any, $injector: any, callback?:
};
export const isInSetupMode = (context?: ISetupModeContext) => {
+ if (isReactMigrationEnabled()) return setupModeReact.isInSetupMode(context);
if (context?.setupModeSupported === false) {
return false;
}
@@ -222,6 +225,7 @@ export const isInSetupMode = (context?: ISetupModeContext) => {
};
export const isSetupModeFeatureEnabled = (feature: SetupModeFeature) => {
+ if (isReactMigrationEnabled()) return setupModeReact.isSetupModeFeatureEnabled(feature);
if (!setupModeState.enabled) {
return false;
}
diff --git a/x-pack/plugins/monitoring/public/plugin.ts b/x-pack/plugins/monitoring/public/plugin.ts
index 6884dba760fcd..6f625194287ba 100644
--- a/x-pack/plugins/monitoring/public/plugin.ts
+++ b/x-pack/plugins/monitoring/public/plugin.ts
@@ -36,6 +36,7 @@ import { createThreadPoolRejectionsAlertType } from './alerts/thread_pool_reject
import { createMemoryUsageAlertType } from './alerts/memory_usage_alert';
import { createCCRReadExceptionsAlertType } from './alerts/ccr_read_exceptions_alert';
import { createLargeShardSizeAlertType } from './alerts/large_shard_size_alert';
+import { setConfig } from './external_config';
interface MonitoringSetupPluginDependencies {
home?: HomePublicPluginSetup;
@@ -125,6 +126,7 @@ export class MonitoringPlugin
});
const config = Object.fromEntries(externalConfig);
+ setConfig(config);
if (config.renderReactApp) {
const { renderApp } = await import('./application');
return renderApp(coreStart, pluginsStart, params, config);
diff --git a/x-pack/plugins/reporting/server/routes/deprecations.ts b/x-pack/plugins/reporting/server/routes/deprecations.ts
index 874885e2258ae..d1d8302e394c3 100644
--- a/x-pack/plugins/reporting/server/routes/deprecations.ts
+++ b/x-pack/plugins/reporting/server/routes/deprecations.ts
@@ -5,6 +5,7 @@
* 2.0.
*/
import { errors } from '@elastic/elasticsearch';
+import { SecurityHasPrivilegesIndexPrivilegesCheck } from '@elastic/elasticsearch/api/types';
import { RequestHandler } from 'src/core/server';
import {
API_MIGRATE_ILM_POLICY_URL,
@@ -36,10 +37,11 @@ export const registerDeprecationsRoutes = (reporting: ReportingCore, logger: Log
const { body } = await elasticsearch.client.asCurrentUser.security.hasPrivileges({
body: {
index: [
- {
+ ({
privileges: ['manage'], // required to do anything with the reporting indices
names: [store.getReportingIndexPattern()],
- },
+ allow_restricted_indices: true,
+ } as unknown) as SecurityHasPrivilegesIndexPrivilegesCheck, // TODO: Needed until `allow_restricted_indices` is added to the types.
],
},
});
diff --git a/x-pack/plugins/reporting/server/usage/__snapshots__/reporting_usage_collector.test.ts.snap b/x-pack/plugins/reporting/server/usage/__snapshots__/reporting_usage_collector.test.ts.snap
index 12e89f19e6248..4fab4ca72abab 100644
--- a/x-pack/plugins/reporting/server/usage/__snapshots__/reporting_usage_collector.test.ts.snap
+++ b/x-pack/plugins/reporting/server/usage/__snapshots__/reporting_usage_collector.test.ts.snap
@@ -16,6 +16,17 @@ Object {
"type": "long",
},
},
+ "PNGV2": Object {
+ "available": Object {
+ "type": "boolean",
+ },
+ "deprecated": Object {
+ "type": "long",
+ },
+ "total": Object {
+ "type": "long",
+ },
+ },
"_all": Object {
"type": "long",
},
@@ -62,6 +73,17 @@ Object {
"type": "long",
},
},
+ "PNGV2": Object {
+ "available": Object {
+ "type": "boolean",
+ },
+ "deprecated": Object {
+ "type": "long",
+ },
+ "total": Object {
+ "type": "long",
+ },
+ },
"_all": Object {
"type": "long",
},
@@ -117,6 +139,36 @@ Object {
"type": "long",
},
},
+ "printable_pdf_v2": Object {
+ "app": Object {
+ "canvas workpad": Object {
+ "type": "long",
+ },
+ "dashboard": Object {
+ "type": "long",
+ },
+ "visualization": Object {
+ "type": "long",
+ },
+ },
+ "available": Object {
+ "type": "boolean",
+ },
+ "deprecated": Object {
+ "type": "long",
+ },
+ "layout": Object {
+ "preserve_layout": Object {
+ "type": "long",
+ },
+ "print": Object {
+ "type": "long",
+ },
+ },
+ "total": Object {
+ "type": "long",
+ },
+ },
"status": Object {
"completed": Object {
"type": "long",
@@ -147,6 +199,17 @@ Object {
"type": "long",
},
},
+ "PNGV2": Object {
+ "canvas workpad": Object {
+ "type": "long",
+ },
+ "dashboard": Object {
+ "type": "long",
+ },
+ "visualization": Object {
+ "type": "long",
+ },
+ },
"csv": Object {
"canvas workpad": Object {
"type": "long",
@@ -180,6 +243,17 @@ Object {
"type": "long",
},
},
+ "printable_pdf_v2": Object {
+ "canvas workpad": Object {
+ "type": "long",
+ },
+ "dashboard": Object {
+ "type": "long",
+ },
+ "visualization": Object {
+ "type": "long",
+ },
+ },
},
"completed_with_warnings": Object {
"PNG": Object {
@@ -193,6 +267,17 @@ Object {
"type": "long",
},
},
+ "PNGV2": Object {
+ "canvas workpad": Object {
+ "type": "long",
+ },
+ "dashboard": Object {
+ "type": "long",
+ },
+ "visualization": Object {
+ "type": "long",
+ },
+ },
"csv": Object {
"canvas workpad": Object {
"type": "long",
@@ -226,6 +311,17 @@ Object {
"type": "long",
},
},
+ "printable_pdf_v2": Object {
+ "canvas workpad": Object {
+ "type": "long",
+ },
+ "dashboard": Object {
+ "type": "long",
+ },
+ "visualization": Object {
+ "type": "long",
+ },
+ },
},
"failed": Object {
"PNG": Object {
@@ -239,6 +335,17 @@ Object {
"type": "long",
},
},
+ "PNGV2": Object {
+ "canvas workpad": Object {
+ "type": "long",
+ },
+ "dashboard": Object {
+ "type": "long",
+ },
+ "visualization": Object {
+ "type": "long",
+ },
+ },
"csv": Object {
"canvas workpad": Object {
"type": "long",
@@ -272,6 +379,17 @@ Object {
"type": "long",
},
},
+ "printable_pdf_v2": Object {
+ "canvas workpad": Object {
+ "type": "long",
+ },
+ "dashboard": Object {
+ "type": "long",
+ },
+ "visualization": Object {
+ "type": "long",
+ },
+ },
},
"pending": Object {
"PNG": Object {
@@ -285,6 +403,17 @@ Object {
"type": "long",
},
},
+ "PNGV2": Object {
+ "canvas workpad": Object {
+ "type": "long",
+ },
+ "dashboard": Object {
+ "type": "long",
+ },
+ "visualization": Object {
+ "type": "long",
+ },
+ },
"csv": Object {
"canvas workpad": Object {
"type": "long",
@@ -318,6 +447,17 @@ Object {
"type": "long",
},
},
+ "printable_pdf_v2": Object {
+ "canvas workpad": Object {
+ "type": "long",
+ },
+ "dashboard": Object {
+ "type": "long",
+ },
+ "visualization": Object {
+ "type": "long",
+ },
+ },
},
"processing": Object {
"PNG": Object {
@@ -331,6 +471,17 @@ Object {
"type": "long",
},
},
+ "PNGV2": Object {
+ "canvas workpad": Object {
+ "type": "long",
+ },
+ "dashboard": Object {
+ "type": "long",
+ },
+ "visualization": Object {
+ "type": "long",
+ },
+ },
"csv": Object {
"canvas workpad": Object {
"type": "long",
@@ -364,6 +515,17 @@ Object {
"type": "long",
},
},
+ "printable_pdf_v2": Object {
+ "canvas workpad": Object {
+ "type": "long",
+ },
+ "dashboard": Object {
+ "type": "long",
+ },
+ "visualization": Object {
+ "type": "long",
+ },
+ },
},
},
},
@@ -397,6 +559,36 @@ Object {
"type": "long",
},
},
+ "printable_pdf_v2": Object {
+ "app": Object {
+ "canvas workpad": Object {
+ "type": "long",
+ },
+ "dashboard": Object {
+ "type": "long",
+ },
+ "visualization": Object {
+ "type": "long",
+ },
+ },
+ "available": Object {
+ "type": "boolean",
+ },
+ "deprecated": Object {
+ "type": "long",
+ },
+ "layout": Object {
+ "preserve_layout": Object {
+ "type": "long",
+ },
+ "print": Object {
+ "type": "long",
+ },
+ },
+ "total": Object {
+ "type": "long",
+ },
+ },
"status": Object {
"completed": Object {
"type": "long",
@@ -427,6 +619,17 @@ Object {
"type": "long",
},
},
+ "PNGV2": Object {
+ "canvas workpad": Object {
+ "type": "long",
+ },
+ "dashboard": Object {
+ "type": "long",
+ },
+ "visualization": Object {
+ "type": "long",
+ },
+ },
"csv": Object {
"canvas workpad": Object {
"type": "long",
@@ -460,6 +663,17 @@ Object {
"type": "long",
},
},
+ "printable_pdf_v2": Object {
+ "canvas workpad": Object {
+ "type": "long",
+ },
+ "dashboard": Object {
+ "type": "long",
+ },
+ "visualization": Object {
+ "type": "long",
+ },
+ },
},
"completed_with_warnings": Object {
"PNG": Object {
@@ -473,6 +687,17 @@ Object {
"type": "long",
},
},
+ "PNGV2": Object {
+ "canvas workpad": Object {
+ "type": "long",
+ },
+ "dashboard": Object {
+ "type": "long",
+ },
+ "visualization": Object {
+ "type": "long",
+ },
+ },
"csv": Object {
"canvas workpad": Object {
"type": "long",
@@ -506,6 +731,17 @@ Object {
"type": "long",
},
},
+ "printable_pdf_v2": Object {
+ "canvas workpad": Object {
+ "type": "long",
+ },
+ "dashboard": Object {
+ "type": "long",
+ },
+ "visualization": Object {
+ "type": "long",
+ },
+ },
},
"failed": Object {
"PNG": Object {
@@ -519,6 +755,17 @@ Object {
"type": "long",
},
},
+ "PNGV2": Object {
+ "canvas workpad": Object {
+ "type": "long",
+ },
+ "dashboard": Object {
+ "type": "long",
+ },
+ "visualization": Object {
+ "type": "long",
+ },
+ },
"csv": Object {
"canvas workpad": Object {
"type": "long",
@@ -552,6 +799,17 @@ Object {
"type": "long",
},
},
+ "printable_pdf_v2": Object {
+ "canvas workpad": Object {
+ "type": "long",
+ },
+ "dashboard": Object {
+ "type": "long",
+ },
+ "visualization": Object {
+ "type": "long",
+ },
+ },
},
"pending": Object {
"PNG": Object {
@@ -565,6 +823,17 @@ Object {
"type": "long",
},
},
+ "PNGV2": Object {
+ "canvas workpad": Object {
+ "type": "long",
+ },
+ "dashboard": Object {
+ "type": "long",
+ },
+ "visualization": Object {
+ "type": "long",
+ },
+ },
"csv": Object {
"canvas workpad": Object {
"type": "long",
@@ -598,6 +867,17 @@ Object {
"type": "long",
},
},
+ "printable_pdf_v2": Object {
+ "canvas workpad": Object {
+ "type": "long",
+ },
+ "dashboard": Object {
+ "type": "long",
+ },
+ "visualization": Object {
+ "type": "long",
+ },
+ },
},
"processing": Object {
"PNG": Object {
@@ -611,6 +891,17 @@ Object {
"type": "long",
},
},
+ "PNGV2": Object {
+ "canvas workpad": Object {
+ "type": "long",
+ },
+ "dashboard": Object {
+ "type": "long",
+ },
+ "visualization": Object {
+ "type": "long",
+ },
+ },
"csv": Object {
"canvas workpad": Object {
"type": "long",
@@ -644,6 +935,17 @@ Object {
"type": "long",
},
},
+ "printable_pdf_v2": Object {
+ "canvas workpad": Object {
+ "type": "long",
+ },
+ "dashboard": Object {
+ "type": "long",
+ },
+ "visualization": Object {
+ "type": "long",
+ },
+ },
},
},
},
diff --git a/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts b/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts
index 72824f6aeeb38..31ce6581d7de6 100644
--- a/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts
+++ b/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts
@@ -487,6 +487,7 @@ describe('data modeling', () => {
// just check that the example objects can be cast to ReportingUsageType
check({
PNG: { available: true, total: 7 },
+ PNGV2: { available: true, total: 7 },
_all: 21,
available: true,
browser_type: 'chromium',
@@ -495,6 +496,7 @@ describe('data modeling', () => {
enabled: true,
last7Days: {
PNG: { available: true, total: 0 },
+ PNGV2: { available: true, total: 0 },
_all: 0,
csv: { available: true, total: 0 },
csv_searchsource: { available: true, total: 0 },
@@ -504,6 +506,12 @@ describe('data modeling', () => {
layout: { preserve_layout: 0, print: 0 },
total: 0,
},
+ printable_pdf_v2: {
+ app: { dashboard: 0, visualization: 0 },
+ available: true,
+ layout: { preserve_layout: 0, print: 0 },
+ total: 0,
+ },
status: { completed: 0, failed: 0 },
statuses: {},
},
@@ -513,17 +521,26 @@ describe('data modeling', () => {
layout: { preserve_layout: 7, print: 3 },
total: 10,
},
+ printable_pdf_v2: {
+ app: { 'canvas workpad': 3, dashboard: 3, visualization: 4 },
+ available: true,
+ layout: { preserve_layout: 7, print: 3 },
+ total: 10,
+ },
status: { completed: 21, failed: 0 },
statuses: {
completed: {
PNG: { dashboard: 3, visualization: 4 },
+ PNGV2: { dashboard: 3, visualization: 4 },
csv: {},
printable_pdf: { 'canvas workpad': 3, dashboard: 3, visualization: 4 },
+ printable_pdf_v2: { 'canvas workpad': 3, dashboard: 3, visualization: 4 },
},
},
});
check({
PNG: { available: true, total: 3 },
+ PNGV2: { available: true, total: 3 },
_all: 4,
available: true,
browser_type: 'chromium',
@@ -532,6 +549,7 @@ describe('data modeling', () => {
enabled: true,
last7Days: {
PNG: { available: true, total: 3 },
+ PNGV2: { available: true, total: 3 },
_all: 4,
csv: { available: true, total: 0 },
csv_searchsource: { available: true, total: 0 },
@@ -541,6 +559,12 @@ describe('data modeling', () => {
layout: { preserve_layout: 1, print: 0 },
total: 1,
},
+ printable_pdf_v2: {
+ app: { 'canvas workpad': 1, dashboard: 0, visualization: 0 },
+ available: true,
+ layout: { preserve_layout: 1, print: 0 },
+ total: 1,
+ },
status: { completed: 4, failed: 0 },
statuses: {
completed: { PNG: { visualization: 3 }, printable_pdf: { 'canvas workpad': 1 } },
@@ -552,6 +576,12 @@ describe('data modeling', () => {
layout: { preserve_layout: 1, print: 0 },
total: 1,
},
+ printable_pdf_v2: {
+ app: { 'canvas workpad': 1, dashboard: 0, visualization: 0 },
+ available: true,
+ layout: { preserve_layout: 1, print: 0 },
+ total: 1,
+ },
status: { completed: 4, failed: 0 },
statuses: {
completed: { PNG: { visualization: 3 }, printable_pdf: { 'canvas workpad': 1 } },
@@ -571,9 +601,16 @@ describe('data modeling', () => {
app: { dashboard: 0, visualization: 0 },
layout: { preserve_layout: 0, print: 0 },
},
+ printable_pdf_v2: {
+ available: true,
+ total: 0,
+ app: { dashboard: 0, visualization: 0 },
+ layout: { preserve_layout: 0, print: 0 },
+ },
csv: { available: true, total: 0 },
csv_searchsource: { available: true, total: 0 },
PNG: { available: true, total: 0 },
+ PNGV2: { available: true, total: 0 },
},
_all: 0,
status: { completed: 0, failed: 0 },
@@ -584,9 +621,16 @@ describe('data modeling', () => {
app: { dashboard: 0, visualization: 0 },
layout: { preserve_layout: 0, print: 0 },
},
+ printable_pdf_v2: {
+ available: true,
+ total: 0,
+ app: { dashboard: 0, visualization: 0 },
+ layout: { preserve_layout: 0, print: 0 },
+ },
csv: { available: true, total: 0 },
csv_searchsource: { available: true, total: 0 },
PNG: { available: true, total: 0 },
+ PNGV2: { available: true, total: 0 },
});
});
});
diff --git a/x-pack/plugins/reporting/server/usage/schema.ts b/x-pack/plugins/reporting/server/usage/schema.ts
index 2060fdcb1f01e..54545dd23509b 100644
--- a/x-pack/plugins/reporting/server/usage/schema.ts
+++ b/x-pack/plugins/reporting/server/usage/schema.ts
@@ -25,7 +25,9 @@ const byAppCountsSchema: MakeSchemaFrom = {
csv: appCountsSchema,
csv_searchsource: appCountsSchema,
PNG: appCountsSchema,
+ PNGV2: appCountsSchema,
printable_pdf: appCountsSchema,
+ printable_pdf_v2: appCountsSchema,
};
const availableTotalSchema: MakeSchemaFrom = {
@@ -38,6 +40,7 @@ const jobTypesSchema: MakeSchemaFrom = {
csv: availableTotalSchema,
csv_searchsource: availableTotalSchema,
PNG: availableTotalSchema,
+ PNGV2: availableTotalSchema,
printable_pdf: {
...availableTotalSchema,
app: appCountsSchema,
@@ -46,6 +49,14 @@ const jobTypesSchema: MakeSchemaFrom = {
preserve_layout: { type: 'long' },
},
},
+ printable_pdf_v2: {
+ ...availableTotalSchema,
+ app: appCountsSchema,
+ layout: {
+ print: { type: 'long' },
+ preserve_layout: { type: 'long' },
+ },
+ },
};
const rangeStatsSchema: MakeSchemaFrom = {
diff --git a/x-pack/plugins/reporting/server/usage/types.ts b/x-pack/plugins/reporting/server/usage/types.ts
index aae8c0ff42710..389dc27c46c66 100644
--- a/x-pack/plugins/reporting/server/usage/types.ts
+++ b/x-pack/plugins/reporting/server/usage/types.ts
@@ -63,7 +63,13 @@ export interface AvailableTotal {
deprecated?: number;
}
-type BaseJobTypes = 'csv' | 'csv_searchsource' | 'PNG' | 'printable_pdf';
+type BaseJobTypes =
+ | 'csv'
+ | 'csv_searchsource'
+ | 'PNG'
+ | 'PNGV2'
+ | 'printable_pdf'
+ | 'printable_pdf_v2';
export interface LayoutCounts {
print: number;
@@ -80,6 +86,11 @@ export type JobTypes = { [K in BaseJobTypes]: AvailableTotal } & {
app: AppCounts;
layout: LayoutCounts;
};
+} & {
+ printable_pdf_v2: AvailableTotal & {
+ app: AppCounts;
+ layout: LayoutCounts;
+ };
};
export type ByAppCounts = { [J in BaseJobTypes]?: AppCounts };
diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts
index 259a9e9e8de38..48f3a81a00af2 100644
--- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts
+++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts
@@ -162,7 +162,6 @@ export const createLifecycleExecutor = (
> = {
alertWithLifecycle: ({ id, fields }) => {
currentAlerts[id] = fields;
-
return alertInstanceFactory(id);
},
};
@@ -179,7 +178,6 @@ export const createLifecycleExecutor = (
const currentAlertIds = Object.keys(currentAlerts);
const trackedAlertIds = Object.keys(state.trackedAlerts);
const newAlertIds = currentAlertIds.filter((alertId) => !trackedAlertIds.includes(alertId));
-
const allAlertIds = [...new Set(currentAlertIds.concat(trackedAlertIds))];
const trackedAlertStates = Object.values(state.trackedAlerts);
@@ -188,9 +186,10 @@ export const createLifecycleExecutor = (
`Tracking ${allAlertIds.length} alerts (${newAlertIds.length} new, ${trackedAlertStates.length} previous)`
);
- const alertsDataMap: Record> = {
- ...currentAlerts,
- };
+ const trackedAlertsDataMap: Record<
+ string,
+ { indexName: string; fields: Partial }
+ > = {};
if (trackedAlertStates.length) {
const { hits } = await ruleDataClient.getReader().search({
@@ -228,59 +227,77 @@ export const createLifecycleExecutor = (
hits.hits.forEach((hit) => {
const fields = parseTechnicalFields(hit.fields);
+ const indexName = hit._index;
const alertId = fields[ALERT_INSTANCE_ID];
- alertsDataMap[alertId] = {
- ...commonRuleFields,
- ...fields,
+ trackedAlertsDataMap[alertId] = {
+ indexName,
+ fields,
};
});
}
- const eventsToIndex = allAlertIds.map((alertId) => {
- const alertData = alertsDataMap[alertId];
-
- if (!alertData) {
- logger.warn(`Could not find alert data for ${alertId}`);
- }
-
- const isNew = !state.trackedAlerts[alertId];
- const isRecovered = !currentAlerts[alertId];
- const isActive = !isRecovered;
-
- const { alertUuid, started } = state.trackedAlerts[alertId] ?? {
- alertUuid: v4(),
- started: commonRuleFields[TIMESTAMP],
- };
- const event: ParsedTechnicalFields = {
- ...alertData,
- ...commonRuleFields,
- [ALERT_DURATION]: (options.startedAt.getTime() - new Date(started).getTime()) * 1000,
- [ALERT_INSTANCE_ID]: alertId,
- [ALERT_START]: started,
- [ALERT_STATUS]: isActive ? ALERT_STATUS_ACTIVE : ALERT_STATUS_RECOVERED,
- [ALERT_WORKFLOW_STATUS]: alertData[ALERT_WORKFLOW_STATUS] ?? 'open',
- [ALERT_UUID]: alertUuid,
- [EVENT_KIND]: 'signal',
- [EVENT_ACTION]: isNew ? 'open' : isActive ? 'active' : 'close',
- [VERSION]: ruleDataClient.kibanaVersion,
- ...(isRecovered ? { [ALERT_END]: commonRuleFields[TIMESTAMP] } : {}),
- };
-
- return event;
- });
+ const makeEventsDataMapFor = (alertIds: string[]) =>
+ alertIds.map((alertId) => {
+ const alertData = trackedAlertsDataMap[alertId];
+ const currentAlertData = currentAlerts[alertId];
+
+ if (!alertData) {
+ logger.warn(`Could not find alert data for ${alertId}`);
+ }
+
+ const isNew = !state.trackedAlerts[alertId];
+ const isRecovered = !currentAlerts[alertId];
+ const isActive = !isRecovered;
+
+ const { alertUuid, started } = state.trackedAlerts[alertId] ?? {
+ alertUuid: v4(),
+ started: commonRuleFields[TIMESTAMP],
+ };
+
+ const event: ParsedTechnicalFields = {
+ ...alertData?.fields,
+ ...commonRuleFields,
+ ...currentAlertData,
+ [ALERT_DURATION]: (options.startedAt.getTime() - new Date(started).getTime()) * 1000,
+
+ [ALERT_INSTANCE_ID]: alertId,
+ [ALERT_START]: started,
+ [ALERT_UUID]: alertUuid,
+ [ALERT_STATUS]: isRecovered ? ALERT_STATUS_RECOVERED : ALERT_STATUS_ACTIVE,
+ [ALERT_WORKFLOW_STATUS]: alertData?.fields[ALERT_WORKFLOW_STATUS] ?? 'open',
+ [EVENT_KIND]: 'signal',
+ [EVENT_ACTION]: isNew ? 'open' : isActive ? 'active' : 'close',
+ [VERSION]: ruleDataClient.kibanaVersion,
+ ...(isRecovered ? { [ALERT_END]: commonRuleFields[TIMESTAMP] } : {}),
+ };
+
+ return {
+ indexName: alertData?.indexName,
+ event,
+ };
+ });
+
+ const trackedEventsToIndex = makeEventsDataMapFor(trackedAlertIds);
+ const newEventsToIndex = makeEventsDataMapFor(newAlertIds);
+ const allEventsToIndex = [...trackedEventsToIndex, ...newEventsToIndex];
- if (eventsToIndex.length > 0 && ruleDataClient.isWriteEnabled()) {
- logger.debug(`Preparing to index ${eventsToIndex.length} alerts.`);
+ if (allEventsToIndex.length > 0 && ruleDataClient.isWriteEnabled()) {
+ logger.debug(`Preparing to index ${allEventsToIndex.length} alerts.`);
await ruleDataClient.getWriter().bulk({
- body: eventsToIndex.flatMap((event) => [{ index: { _id: event[ALERT_UUID]! } }, event]),
+ body: allEventsToIndex.flatMap(({ event, indexName }) => [
+ indexName
+ ? { index: { _id: event[ALERT_UUID]!, _index: indexName, require_alias: false } }
+ : { index: { _id: event[ALERT_UUID]! } },
+ event,
+ ]),
});
}
const nextTrackedAlerts = Object.fromEntries(
- eventsToIndex
- .filter((event) => event[ALERT_STATUS] !== 'closed')
- .map((event) => {
+ allEventsToIndex
+ .filter(({ event }) => event[ALERT_STATUS] !== 'closed')
+ .map(({ event }) => {
const alertId = event[ALERT_INSTANCE_ID]!;
const alertUuid = event[ALERT_UUID]!;
const started = new Date(event[ALERT_START]!).toISOString();
diff --git a/x-pack/plugins/security/server/authentication/authentication_service.test.ts b/x-pack/plugins/security/server/authentication/authentication_service.test.ts
index d38f963a60c33..a7ef6b34616c3 100644
--- a/x-pack/plugins/security/server/authentication/authentication_service.test.ts
+++ b/x-pack/plugins/security/server/authentication/authentication_service.test.ts
@@ -318,6 +318,52 @@ describe('AuthenticationService', () => {
});
});
+ describe('getServerBaseURL()', () => {
+ let getServerBaseURL: () => string;
+ beforeEach(() => {
+ mockStartAuthenticationParams.http.getServerInfo.mockReturnValue({
+ name: 'some-name',
+ protocol: 'socket',
+ hostname: 'test-hostname',
+ port: 1234,
+ });
+
+ service.setup(mockSetupAuthenticationParams);
+ service.start(mockStartAuthenticationParams);
+
+ getServerBaseURL = jest.requireMock('./authenticator').Authenticator.mock.calls[0][0]
+ .getServerBaseURL;
+ });
+
+ it('falls back to legacy server config if `public` config is not specified', async () => {
+ expect(getServerBaseURL()).toBe('socket://test-hostname:1234');
+ });
+
+ it('respects `public` config if it is specified', async () => {
+ mockStartAuthenticationParams.config.public = {
+ protocol: 'https',
+ } as ConfigType['public'];
+ expect(getServerBaseURL()).toBe('https://test-hostname:1234');
+
+ mockStartAuthenticationParams.config.public = {
+ hostname: 'elastic.co',
+ } as ConfigType['public'];
+ expect(getServerBaseURL()).toBe('socket://elastic.co:1234');
+
+ mockStartAuthenticationParams.config.public = {
+ port: 4321,
+ } as ConfigType['public'];
+ expect(getServerBaseURL()).toBe('socket://test-hostname:4321');
+
+ mockStartAuthenticationParams.config.public = {
+ protocol: 'https',
+ hostname: 'elastic.co',
+ port: 4321,
+ } as ConfigType['public'];
+ expect(getServerBaseURL()).toBe('https://elastic.co:4321');
+ });
+ });
+
describe('getCurrentUser()', () => {
let getCurrentUser: (r: KibanaRequest) => AuthenticatedUser | null;
beforeEach(async () => {
diff --git a/x-pack/plugins/security/server/authentication/authentication_service.ts b/x-pack/plugins/security/server/authentication/authentication_service.ts
index 79dcfb8d804b2..538bc26e6ffe3 100644
--- a/x-pack/plugins/security/server/authentication/authentication_service.ts
+++ b/x-pack/plugins/security/server/authentication/authentication_service.ts
@@ -41,7 +41,7 @@ interface AuthenticationServiceSetupParams {
}
interface AuthenticationServiceStartParams {
- http: Pick;
+ http: Pick;
config: ConfigType;
clusterClient: IClusterClient;
legacyAuditLogger: SecurityAuditLogger;
@@ -234,6 +234,17 @@ export class AuthenticationService {
license: this.license,
});
+ /**
+ * Retrieves server protocol name/host name/port and merges it with `xpack.security.public` config
+ * to construct a server base URL (deprecated, used by the SAML provider only).
+ */
+ const getServerBaseURL = () => {
+ const { protocol, hostname, port } = http.getServerInfo();
+ const serverConfig = { protocol, hostname, port, ...config.public };
+
+ return `${serverConfig.protocol}://${serverConfig.hostname}:${serverConfig.port}`;
+ };
+
const getCurrentUser = (request: KibanaRequest) =>
http.auth.get(request).state ?? null;
@@ -247,6 +258,7 @@ export class AuthenticationService {
config: { authc: config.authc },
getCurrentUser,
featureUsageService,
+ getServerBaseURL,
license: this.license,
session,
});
diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts
index ca33be92e9e99..27dfd89a31756 100644
--- a/x-pack/plugins/security/server/authentication/authenticator.test.ts
+++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts
@@ -55,6 +55,7 @@ function getMockOptions({
basePath: httpServiceMock.createSetupContract().basePath,
license: licenseMock.create(),
loggers: loggingSystemMock.create(),
+ getServerBaseURL: jest.fn(),
config: createConfig(
ConfigSchema.validate({ authc: { selector, providers, http } }),
loggingSystemMock.create().get(),
diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts
index 4eeadf23c50b2..5252f5c618f97 100644
--- a/x-pack/plugins/security/server/authentication/authenticator.ts
+++ b/x-pack/plugins/security/server/authentication/authenticator.ts
@@ -87,6 +87,7 @@ export interface AuthenticatorOptions {
loggers: LoggerFactory;
clusterClient: IClusterClient;
session: PublicMethodsOf;
+ getServerBaseURL: () => string;
}
// Mapping between provider key defined in the config and authentication
@@ -216,6 +217,7 @@ export class Authenticator {
client: this.options.clusterClient.asInternalUser,
logger: this.options.loggers.get('tokens'),
}),
+ getServerBaseURL: this.options.getServerBaseURL,
};
this.providers = new Map(
diff --git a/x-pack/plugins/security/server/authentication/providers/base.mock.ts b/x-pack/plugins/security/server/authentication/providers/base.mock.ts
index 5d3417ae9db11..6554b525fc9e0 100644
--- a/x-pack/plugins/security/server/authentication/providers/base.mock.ts
+++ b/x-pack/plugins/security/server/authentication/providers/base.mock.ts
@@ -17,6 +17,7 @@ export type MockAuthenticationProviderOptions = ReturnType<
export function mockAuthenticationProviderOptions(options?: { name: string }) {
return {
+ getServerBaseURL: () => 'test-protocol://test-hostname:1234',
client: elasticsearchServiceMock.createClusterClient(),
logger: loggingSystemMock.create().get(),
basePath: httpServiceMock.createBasePath(),
diff --git a/x-pack/plugins/security/server/authentication/providers/base.ts b/x-pack/plugins/security/server/authentication/providers/base.ts
index f6d9af24ee1ad..d5b173fcfad8c 100644
--- a/x-pack/plugins/security/server/authentication/providers/base.ts
+++ b/x-pack/plugins/security/server/authentication/providers/base.ts
@@ -26,6 +26,7 @@ import type { Tokens } from '../tokens';
*/
export interface AuthenticationProviderOptions {
name: string;
+ getServerBaseURL: () => string;
basePath: HttpServiceSetup['basePath'];
getRequestOriginalURL: (
request: KibanaRequest,
diff --git a/x-pack/plugins/security/server/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/authentication/providers/saml.test.ts
index 4a32383d18dec..251a59228fb03 100644
--- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts
+++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts
@@ -39,23 +39,7 @@ describe('SAMLAuthenticationProvider', () => {
);
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
- provider = new SAMLAuthenticationProvider(mockOptions, {
- realm: 'test-realm',
- });
- });
-
- it('throws if `realm` option is not specified', () => {
- const providerOptions = mockAuthenticationProviderOptions();
-
- expect(() => new SAMLAuthenticationProvider(providerOptions)).toThrowError(
- 'Realm name must be specified'
- );
- expect(() => new SAMLAuthenticationProvider(providerOptions, {})).toThrowError(
- 'Realm name must be specified'
- );
- expect(() => new SAMLAuthenticationProvider(providerOptions, { realm: '' })).toThrowError(
- 'Realm name must be specified'
- );
+ provider = new SAMLAuthenticationProvider(mockOptions);
});
describe('`login` method', () => {
@@ -67,6 +51,7 @@ describe('SAMLAuthenticationProvider', () => {
body: {
access_token: 'some-token',
refresh_token: 'some-refresh-token',
+ realm: 'test-realm',
authentication: mockUser,
},
})
@@ -108,13 +93,13 @@ describe('SAMLAuthenticationProvider', () => {
body: {
access_token: 'some-token',
refresh_token: 'some-refresh-token',
+ realm: 'test-realm',
authentication: mockUser,
},
})
);
provider = new SAMLAuthenticationProvider(mockOptions, {
- realm: 'test-realm',
useRelayStateDeepLink: true,
});
await expect(
@@ -169,6 +154,10 @@ describe('SAMLAuthenticationProvider', () => {
it('fails if realm from state is different from the realm provider is configured with.', async () => {
const request = httpServerMock.createKibanaRequest();
+ const customMockOptions = mockAuthenticationProviderOptions({ name: 'saml' });
+ provider = new SAMLAuthenticationProvider(customMockOptions, {
+ realm: 'test-realm',
+ });
await expect(
provider.login(
@@ -184,7 +173,7 @@ describe('SAMLAuthenticationProvider', () => {
)
);
- expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled();
+ expect(customMockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled();
});
it('redirects to the default location if state contains empty redirect URL.', async () => {
@@ -195,6 +184,7 @@ describe('SAMLAuthenticationProvider', () => {
body: {
access_token: 'user-initiated-login-token',
refresh_token: 'user-initiated-login-refresh-token',
+ realm: 'test-realm',
authentication: mockUser,
},
})
@@ -232,13 +222,13 @@ describe('SAMLAuthenticationProvider', () => {
body: {
access_token: 'user-initiated-login-token',
refresh_token: 'user-initiated-login-refresh-token',
+ realm: 'test-realm',
authentication: mockUser,
},
})
);
provider = new SAMLAuthenticationProvider(mockOptions, {
- realm: 'test-realm',
useRelayStateDeepLink: true,
});
await expect(
@@ -275,6 +265,7 @@ describe('SAMLAuthenticationProvider', () => {
mockOptions.client.asInternalUser.transport.request.mockResolvedValue(
securityMock.createApiResponse({
body: {
+ realm: 'test-realm',
access_token: 'idp-initiated-login-token',
refresh_token: 'idp-initiated-login-refresh-token',
authentication: mockUser,
@@ -301,7 +292,7 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/authenticate',
- body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' },
+ body: { ids: [], content: 'saml-response-xml' },
});
});
@@ -342,20 +333,19 @@ describe('SAMLAuthenticationProvider', () => {
username: 'user',
access_token: 'valid-token',
refresh_token: 'valid-refresh-token',
+ realm: 'test-realm',
authentication: mockUser,
},
})
);
provider = new SAMLAuthenticationProvider(mockOptions, {
- realm: 'test-realm',
useRelayStateDeepLink: true,
});
});
it('redirects to the home page if `useRelayStateDeepLink` is set to `false`.', async () => {
provider = new SAMLAuthenticationProvider(mockOptions, {
- realm: 'test-realm',
useRelayStateDeepLink: false,
});
@@ -454,10 +444,39 @@ describe('SAMLAuthenticationProvider', () => {
)
);
});
+
+ it('uses `realm` name instead of `acs` if it is specified for SAML authenticate request.', async () => {
+ // Create new provider instance with additional `realm` option.
+ provider = new SAMLAuthenticationProvider(mockOptions, {
+ realm: 'test-realm',
+ });
+
+ await expect(
+ provider.login(httpServerMock.createKibanaRequest({ headers: {} }), {
+ type: SAMLLogin.LoginWithSAMLResponse,
+ samlResponse: 'saml-response-xml',
+ })
+ ).resolves.toEqual(
+ AuthenticationResult.redirectTo(`${mockOptions.basePath.serverBasePath}/`, {
+ state: {
+ accessToken: 'valid-token',
+ refreshToken: 'valid-refresh-token',
+ realm: 'test-realm',
+ },
+ user: mockUser,
+ })
+ );
+
+ expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
+ method: 'POST',
+ path: '/_security/saml/authenticate',
+ body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' },
+ });
+ });
});
describe('IdP initiated login with existing session', () => {
- it('returns `notHandled` if new SAML Response is rejected.', async () => {
+ it('fails if new SAML Response is rejected and provider is not configured with specific realm.', async () => {
const request = httpServerMock.createKibanaRequest({ headers: {} });
const authorization = 'Bearer some-valid-token';
@@ -466,6 +485,39 @@ describe('SAMLAuthenticationProvider', () => {
);
mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason);
+ await expect(
+ provider.login(
+ request,
+ { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' },
+ {
+ accessToken: 'some-valid-token',
+ refreshToken: 'some-valid-refresh-token',
+ realm: 'test-realm',
+ }
+ )
+ ).resolves.toEqual(AuthenticationResult.failed(failureReason));
+
+ expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } });
+ expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
+ method: 'POST',
+ path: '/_security/saml/authenticate',
+ body: { ids: [], content: 'saml-response-xml' },
+ });
+ });
+
+ it('returns `notHandled` if new SAML Response is rejected and provider is configured with specific realm.', async () => {
+ const request = httpServerMock.createKibanaRequest({ headers: {} });
+ const authorization = 'Bearer some-valid-token';
+
+ provider = new SAMLAuthenticationProvider(mockOptions, {
+ realm: 'test-realm',
+ });
+
+ const failureReason = new errors.ResponseError(
+ securityMock.createApiResponse({ statusCode: 503, body: {} })
+ );
+ mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason);
+
await expect(
provider.login(
request,
@@ -521,7 +573,7 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/authenticate',
- body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' },
+ body: { ids: [], content: 'saml-response-xml' },
});
expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1);
@@ -543,7 +595,7 @@ describe('SAMLAuthenticationProvider', () => {
),
],
[
- 'current session is is expired',
+ 'current session is expired',
Promise.reject(
new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} }))
),
@@ -568,6 +620,7 @@ describe('SAMLAuthenticationProvider', () => {
username: 'user',
access_token: 'new-valid-token',
refresh_token: 'new-valid-refresh-token',
+ realm: 'test-realm',
authentication: mockUser,
},
})
@@ -595,7 +648,7 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/authenticate',
- body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' },
+ body: { ids: [], content: 'saml-response-xml' },
});
expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1);
@@ -624,6 +677,7 @@ describe('SAMLAuthenticationProvider', () => {
username: 'user',
access_token: 'new-valid-token',
refresh_token: 'new-valid-refresh-token',
+ realm: 'test-realm',
authentication: mockUser,
},
})
@@ -632,7 +686,6 @@ describe('SAMLAuthenticationProvider', () => {
mockOptions.tokens.invalidate.mockResolvedValue(undefined);
provider = new SAMLAuthenticationProvider(mockOptions, {
- realm: 'test-realm',
useRelayStateDeepLink: true,
});
@@ -661,7 +714,7 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/authenticate',
- body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' },
+ body: { ids: [], content: 'saml-response-xml' },
});
expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1);
@@ -699,19 +752,16 @@ describe('SAMLAuthenticationProvider', () => {
body: {
id: 'some-request-id',
redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20',
+ realm: 'test-realm',
},
})
);
await expect(
- provider.login(
- request,
- {
- type: SAMLLogin.LoginInitiatedByUser,
- redirectURL: '/test-base-path/some-path#some-fragment',
- },
- { realm: 'test-realm' }
- )
+ provider.login(request, {
+ type: SAMLLogin.LoginInitiatedByUser,
+ redirectURL: '/test-base-path/some-path#some-fragment',
+ })
).resolves.toEqual(
AuthenticationResult.redirectTo(
'https://idp-host/path/login?SAMLRequest=some%20request%20',
@@ -728,7 +778,9 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/prepare',
- body: { realm: 'test-realm' },
+ body: {
+ acs: 'test-protocol://test-hostname:1234/mock-server-basepath/api/security/v1/saml',
+ },
});
expect(mockOptions.logger.warn).not.toHaveBeenCalled();
@@ -742,6 +794,7 @@ describe('SAMLAuthenticationProvider', () => {
body: {
id: 'some-request-id',
redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20',
+ realm: 'test-realm',
},
})
);
@@ -771,19 +824,32 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/prepare',
- body: { realm: 'test-realm' },
+ body: {
+ acs: 'test-protocol://test-hostname:1234/mock-server-basepath/api/security/v1/saml',
+ },
});
expect(mockOptions.logger.warn).not.toHaveBeenCalled();
});
- it('fails if SAML request preparation fails.', async () => {
- const request = httpServerMock.createKibanaRequest();
+ it('uses `realm` name instead of `acs` if it is specified for SAML prepare request.', async () => {
+ const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' });
- const failureReason = new errors.ResponseError(
- securityMock.createApiResponse({ statusCode: 401, body: {} })
+ // Create new provider instance with additional `realm` option.
+ const customMockOptions = mockAuthenticationProviderOptions();
+ provider = new SAMLAuthenticationProvider(customMockOptions, {
+ realm: 'test-realm',
+ });
+
+ customMockOptions.client.asInternalUser.transport.request.mockResolvedValue(
+ securityMock.createApiResponse({
+ body: {
+ id: 'some-request-id',
+ redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20',
+ realm: 'test-realm',
+ },
+ })
);
- mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason);
await expect(
provider.login(
@@ -794,12 +860,47 @@ describe('SAMLAuthenticationProvider', () => {
},
{ realm: 'test-realm' }
)
+ ).resolves.toEqual(
+ AuthenticationResult.redirectTo(
+ 'https://idp-host/path/login?SAMLRequest=some%20request%20',
+ {
+ state: {
+ requestId: 'some-request-id',
+ redirectURL: '/test-base-path/some-path#some-fragment',
+ realm: 'test-realm',
+ },
+ }
+ )
+ );
+
+ expect(customMockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
+ method: 'POST',
+ path: '/_security/saml/prepare',
+ body: { realm: 'test-realm' },
+ });
+ });
+
+ it('fails if SAML request preparation fails.', async () => {
+ const request = httpServerMock.createKibanaRequest();
+
+ const failureReason = new errors.ResponseError(
+ securityMock.createApiResponse({ statusCode: 401, body: {} })
+ );
+ mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason);
+
+ await expect(
+ provider.login(request, {
+ type: SAMLLogin.LoginInitiatedByUser,
+ redirectURL: '/test-base-path/some-path#some-fragment',
+ })
).resolves.toEqual(AuthenticationResult.failed(failureReason));
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/prepare',
- body: { realm: 'test-realm' },
+ body: {
+ acs: 'test-protocol://test-hostname:1234/mock-server-basepath/api/security/v1/saml',
+ },
});
});
});
@@ -893,7 +994,6 @@ describe('SAMLAuthenticationProvider', () => {
state: {
requestId: 'some-request-id',
redirectURL: '/mock-server-basepath/s/foo/some-path#some-fragment',
- realm: 'test-realm',
},
}
)
@@ -905,7 +1005,9 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/prepare',
- body: { realm: 'test-realm' },
+ body: {
+ acs: 'test-protocol://test-hostname:1234/mock-server-basepath/api/security/v1/saml',
+ },
});
});
@@ -1112,6 +1214,13 @@ describe('SAMLAuthenticationProvider', () => {
it('fails if realm from state is different from the realm provider is configured with.', async () => {
const request = httpServerMock.createKibanaRequest();
+
+ // Create new provider instance with additional `realm` option.
+ const customMockOptions = mockAuthenticationProviderOptions({ name: 'saml' });
+ provider = new SAMLAuthenticationProvider(customMockOptions, {
+ realm: 'test-realm',
+ });
+
await expect(provider.authenticate(request, { realm: 'other-realm' })).resolves.toEqual(
AuthenticationResult.failed(
Boom.unauthorized(
@@ -1186,7 +1295,10 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/invalidate',
- body: { query_string: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' },
+ body: {
+ query_string: 'SAMLRequest=xxx%20yyy',
+ acs: 'test-protocol://test-hostname:1234/mock-server-basepath/api/security/v1/saml',
+ },
});
});
@@ -1305,7 +1417,10 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/invalidate',
- body: { query_string: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' },
+ body: {
+ query_string: 'SAMLRequest=xxx%20yyy',
+ acs: 'test-protocol://test-hostname:1234/mock-server-basepath/api/security/v1/saml',
+ },
});
});
@@ -1324,7 +1439,10 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/invalidate',
- body: { query_string: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' },
+ body: {
+ query_string: 'SAMLRequest=xxx%20yyy',
+ acs: 'test-protocol://test-hostname:1234/mock-server-basepath/api/security/v1/saml',
+ },
});
});
diff --git a/x-pack/plugins/security/server/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts
index 37e7e868e4d3d..6eab0c5dc4875 100644
--- a/x-pack/plugins/security/server/authentication/providers/saml.ts
+++ b/x-pack/plugins/security/server/authentication/providers/saml.ts
@@ -42,9 +42,10 @@ interface ProviderState extends Partial {
redirectURL?: string;
/**
- * The name of the SAML realm that was used to establish session.
+ * The name of the SAML realm that was used to establish session (may not be known during URL
+ * fragment capturing stage).
*/
- realm: string;
+ realm?: string;
}
/**
@@ -105,9 +106,10 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
static readonly type = 'saml';
/**
- * Specifies Elasticsearch SAML realm name that Kibana should use.
+ * Optionally specifies Elasticsearch SAML realm name that Kibana should use. If not specified
+ * Kibana ACS URL is used for realm matching instead.
*/
- private readonly realm: string;
+ private readonly realm?: string;
/**
* Indicates if we should treat non-empty `RelayState` as a deep link in Kibana we should redirect
@@ -121,12 +123,8 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
) {
super(options);
- if (!samlOptions || !samlOptions.realm) {
- throw new Error('Realm name must be specified');
- }
-
- this.realm = samlOptions.realm;
- this.useRelayStateDeepLink = samlOptions.useRelayStateDeepLink ?? false;
+ this.realm = samlOptions?.realm;
+ this.useRelayStateDeepLink = samlOptions?.useRelayStateDeepLink ?? false;
}
/**
@@ -144,7 +142,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
// It may happen that Kibana is re-configured to use different realm for the same provider name,
// we should clear such session an log user out.
- if (state?.realm && state.realm !== this.realm) {
+ if (state && this.realm && state.realm !== this.realm) {
const message = `State based on realm "${state.realm}", but provider with the name "${this.options.name}" is configured to use realm "${this.realm}".`;
this.logger.debug(message);
return AuthenticationResult.failed(Boom.unauthorized(message));
@@ -215,7 +213,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
// It may happen that Kibana is re-configured to use different realm for the same provider name,
// we should clear such session an log user out.
- if (state?.realm && state.realm !== this.realm) {
+ if (state && this.realm && state.realm !== this.realm) {
const message = `State based on realm "${state.realm}", but provider with the name "${this.options.name}" is configured to use realm "${this.realm}".`;
this.logger.debug(message);
return AuthenticationResult.failed(Boom.unauthorized(message));
@@ -274,7 +272,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
// and state !== undefined). In this case case it'd be safer to trigger SP initiated logout
// for the new session as well.
const redirect = isIdPInitiatedSLORequest
- ? await this.performIdPInitiatedSingleLogout(request)
+ ? await this.performIdPInitiatedSingleLogout(request, this.realm || state?.realm)
: state
? await this.performUserInitiatedSingleLogout(state.accessToken!, state.refreshToken!)
: // Once Elasticsearch can consume logout response we'll be sending it here. See https://github.com/elastic/elasticsearch/issues/40901
@@ -331,9 +329,14 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
// If we have a `SAMLResponse` and state, but state doesn't contain all the necessary information,
// then something unexpected happened and we should fail.
- const { requestId: stateRequestId, redirectURL: stateRedirectURL } = state || {
+ const {
+ requestId: stateRequestId,
+ redirectURL: stateRedirectURL,
+ realm: stateRealm,
+ } = state || {
requestId: '',
redirectURL: '',
+ realm: '',
};
if (state && !stateRequestId) {
const message = 'SAML response state does not have corresponding request id.';
@@ -349,7 +352,14 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
: 'Login has been initiated by Identity Provider.'
);
- let result: { access_token: string; refresh_token: string; authentication: AuthenticationInfo };
+ const providerRealm = this.realm || stateRealm;
+
+ let result: {
+ access_token: string;
+ refresh_token: string;
+ realm: string;
+ authentication: AuthenticationInfo;
+ };
try {
// This operation should be performed on behalf of the user with a privilege that normal
// user usually doesn't have `cluster:admin/xpack/security/saml/authenticate`.
@@ -362,7 +372,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
body: {
ids: !isIdPInitiatedLogin ? [stateRequestId] : [],
content: samlResponse,
- realm: this.realm,
+ ...(providerRealm ? { realm: providerRealm } : {}),
},
})
).body as any;
@@ -372,7 +382,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
// Since we don't know upfront what realm is targeted by the Identity Provider initiated login
// there is a chance that it failed because of realm mismatch and hence we should return
// `notHandled` and give other SAML providers a chance to properly handle it instead.
- return isIdPInitiatedLogin
+ return isIdPInitiatedLogin && providerRealm
? AuthenticationResult.notHandled()
: AuthenticationResult.failed(err);
}
@@ -404,7 +414,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
state: {
accessToken: result.access_token,
refreshToken: result.refresh_token,
- realm: this.realm,
+ realm: result.realm,
},
user: this.authenticationInfoToAuthenticatedUser(result.authentication),
}
@@ -545,7 +555,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
authHeaders: {
authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(),
},
- state: { accessToken, refreshToken, realm: this.realm },
+ state: { accessToken, refreshToken, realm: this.realm || state.realm },
}
);
}
@@ -559,15 +569,18 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
this.logger.debug('Trying to initiate SAML handshake.');
try {
+ // Prefer realm name if it's specified, otherwise fallback to ACS.
+ const preparePayload = this.realm ? { realm: this.realm } : { acs: this.getACS() };
+
// This operation should be performed on behalf of the user with a privilege that normal
// user usually doesn't have `cluster:admin/xpack/security/saml/prepare`.
// We can replace generic `transport.request` with a dedicated API method call once
// https://github.com/elastic/elasticsearch/issues/67189 is resolved.
- const { id: requestId, redirect } = (
+ const { id: requestId, redirect, realm } = (
await this.options.client.asInternalUser.transport.request({
method: 'POST',
path: '/_security/saml/prepare',
- body: { realm: this.realm },
+ body: preparePayload,
})
).body as any;
@@ -575,7 +588,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
// Store request id in the state so that we can reuse it once we receive `SAMLResponse`.
return AuthenticationResult.redirectTo(redirect, {
- state: { requestId, redirectURL, realm: this.realm },
+ state: { requestId, redirectURL, realm },
});
} catch (err) {
this.logger.debug(`Failed to initiate SAML handshake: ${getDetailedErrorMessage(err)}`);
@@ -612,10 +625,14 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
* Calls `saml/invalidate` with the `SAMLRequest` query string parameter received from the Identity
* Provider and redirects user back to the Identity Provider if needed.
* @param request Request instance.
+ * @param realm Configured SAML realm name.
*/
- private async performIdPInitiatedSingleLogout(request: KibanaRequest) {
+ private async performIdPInitiatedSingleLogout(request: KibanaRequest, realm?: string) {
this.logger.debug('Single logout has been initiated by the Identity Provider.');
+ // Prefer realm name if it's specified, otherwise fallback to ACS.
+ const invalidatePayload = realm ? { realm } : { acs: this.getACS() };
+
// This operation should be performed on behalf of the user with a privilege that normal
// user usually doesn't have `cluster:admin/xpack/security/saml/invalidate`.
// We can replace generic `transport.request` with a dedicated API method call once
@@ -627,7 +644,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
// Elasticsearch expects `query_string` without leading `?`, so we should strip it with `slice`.
body: {
query_string: request.url.search ? request.url.search.slice(1) : '',
- realm: this.realm,
+ ...invalidatePayload,
},
})
).body as any;
@@ -637,6 +654,15 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
return redirect;
}
+ /**
+ * Constructs and returns Kibana's Assertion consumer service URL.
+ */
+ private getACS() {
+ return `${this.options.getServerBaseURL()}${
+ this.options.basePath.serverBasePath
+ }/api/security/v1/saml`;
+ }
+
/**
* Tries to initiate SAML authentication handshake. If the request already includes user URL hash fragment, we will
* initiate handshake right away, otherwise we'll redirect user to a dedicated page where we capture URL hash fragment
diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts
index 75dfcb6151ea7..3be565d59a11f 100644
--- a/x-pack/plugins/security/server/config.test.ts
+++ b/x-pack/plugins/security/server/config.test.ts
@@ -58,6 +58,7 @@ describe('config schema', () => {
"enabled": true,
"encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"loginAssistanceMessage": "",
+ "public": Object {},
"secureCookies": false,
"session": Object {
"cleanupInterval": "PT1H",
@@ -109,6 +110,7 @@ describe('config schema', () => {
"enabled": true,
"encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"loginAssistanceMessage": "",
+ "public": Object {},
"secureCookies": false,
"session": Object {
"cleanupInterval": "PT1H",
@@ -159,6 +161,7 @@ describe('config schema', () => {
"cookieName": "sid",
"enabled": true,
"loginAssistanceMessage": "",
+ "public": Object {},
"secureCookies": false,
"session": Object {
"cleanupInterval": "PT1H",
@@ -179,6 +182,109 @@ describe('config schema', () => {
);
});
+ describe('public', () => {
+ it('properly validates `protocol`', async () => {
+ expect(ConfigSchema.validate({ public: { protocol: 'http' } }).public).toMatchInlineSnapshot(`
+ Object {
+ "protocol": "http",
+ }
+ `);
+
+ expect(ConfigSchema.validate({ public: { protocol: 'https' } }).public)
+ .toMatchInlineSnapshot(`
+ Object {
+ "protocol": "https",
+ }
+ `);
+
+ expect(() => ConfigSchema.validate({ public: { protocol: 'ftp' } }))
+ .toThrowErrorMatchingInlineSnapshot(`
+ "[public.protocol]: types that failed validation:
+ - [public.protocol.0]: expected value to equal [http]
+ - [public.protocol.1]: expected value to equal [https]"
+ `);
+
+ expect(() => ConfigSchema.validate({ public: { protocol: 'some-protocol' } }))
+ .toThrowErrorMatchingInlineSnapshot(`
+ "[public.protocol]: types that failed validation:
+ - [public.protocol.0]: expected value to equal [http]
+ - [public.protocol.1]: expected value to equal [https]"
+ `);
+ });
+
+ it('properly validates `hostname`', async () => {
+ expect(ConfigSchema.validate({ public: { hostname: 'elastic.co' } }).public)
+ .toMatchInlineSnapshot(`
+ Object {
+ "hostname": "elastic.co",
+ }
+ `);
+
+ expect(ConfigSchema.validate({ public: { hostname: '192.168.1.1' } }).public)
+ .toMatchInlineSnapshot(`
+ Object {
+ "hostname": "192.168.1.1",
+ }
+ `);
+
+ expect(ConfigSchema.validate({ public: { hostname: '::1' } }).public).toMatchInlineSnapshot(`
+ Object {
+ "hostname": "::1",
+ }
+ `);
+
+ expect(() =>
+ ConfigSchema.validate({ public: { hostname: 'http://elastic.co' } })
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"[public.hostname]: value must be a valid hostname (see RFC 1123)."`
+ );
+
+ expect(() =>
+ ConfigSchema.validate({ public: { hostname: 'localhost:5601' } })
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"[public.hostname]: value must be a valid hostname (see RFC 1123)."`
+ );
+ });
+
+ it('properly validates `port`', async () => {
+ expect(ConfigSchema.validate({ public: { port: 1234 } }).public).toMatchInlineSnapshot(`
+ Object {
+ "port": 1234,
+ }
+ `);
+
+ expect(ConfigSchema.validate({ public: { port: 0 } }).public).toMatchInlineSnapshot(`
+ Object {
+ "port": 0,
+ }
+ `);
+
+ expect(ConfigSchema.validate({ public: { port: 65535 } }).public).toMatchInlineSnapshot(`
+ Object {
+ "port": 65535,
+ }
+ `);
+
+ expect(() =>
+ ConfigSchema.validate({ public: { port: -1 } })
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"[public.port]: Value must be equal to or greater than [0]."`
+ );
+
+ expect(() =>
+ ConfigSchema.validate({ public: { port: 65536 } })
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"[public.port]: Value must be equal to or lower than [65535]."`
+ );
+
+ expect(() =>
+ ConfigSchema.validate({ public: { port: '56x1' } })
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"[public.port]: expected value of type [number] but got [string]"`
+ );
+ });
+ });
+
describe('authc.oidc', () => {
it(`returns a validation error when authc.providers is "['oidc']" and realm is unspecified`, async () => {
expect(() => ConfigSchema.validate({ authc: { providers: ['oidc'] } })).toThrow(
@@ -255,14 +361,42 @@ describe('config schema', () => {
});
describe('authc.saml', () => {
- it('fails if authc.providers includes `saml`, but `saml.realm` is not specified', async () => {
- expect(() => ConfigSchema.validate({ authc: { providers: ['saml'] } })).toThrow(
- '[authc.saml.realm]: expected value of type [string] but got [undefined]'
- );
+ it('does not fail if authc.providers includes `saml`, but `saml.realm` is not specified', async () => {
+ expect(ConfigSchema.validate({ authc: { providers: ['saml'] } }).authc)
+ .toMatchInlineSnapshot(`
+ Object {
+ "http": Object {
+ "autoSchemesEnabled": true,
+ "enabled": true,
+ "schemes": Array [
+ "apikey",
+ ],
+ },
+ "providers": Array [
+ "saml",
+ ],
+ "saml": Object {},
+ "selector": Object {},
+ }
+ `);
- expect(() => ConfigSchema.validate({ authc: { providers: ['saml'], saml: {} } })).toThrow(
- '[authc.saml.realm]: expected value of type [string] but got [undefined]'
- );
+ expect(ConfigSchema.validate({ authc: { providers: ['saml'], saml: {} } }).authc)
+ .toMatchInlineSnapshot(`
+ Object {
+ "http": Object {
+ "autoSchemesEnabled": true,
+ "enabled": true,
+ "schemes": Array [
+ "apikey",
+ ],
+ },
+ "providers": Array [
+ "saml",
+ ],
+ "saml": Object {},
+ "selector": Object {},
+ }
+ `);
expect(
ConfigSchema.validate({
diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts
index 9daf0aff4c6cb..90fccf4bc6c26 100644
--- a/x-pack/plugins/security/server/config.ts
+++ b/x-pack/plugins/security/server/config.ts
@@ -228,6 +228,11 @@ export const ConfigSchema = schema.object({
sameSiteCookies: schema.maybe(
schema.oneOf([schema.literal('Strict'), schema.literal('Lax'), schema.literal('None')])
),
+ public: schema.object({
+ protocol: schema.maybe(schema.oneOf([schema.literal('http'), schema.literal('https')])),
+ hostname: schema.maybe(schema.string({ hostname: true })),
+ port: schema.maybe(schema.number({ min: 0, max: 65535 })),
+ }),
authc: schema.object({
selector: schema.object({ enabled: schema.maybe(schema.boolean()) }),
providers: schema.oneOf([schema.arrayOf(schema.string()), providersConfigSchema], {
@@ -256,7 +261,7 @@ export const ConfigSchema = schema.object({
saml: providerOptionsSchema(
'saml',
schema.object({
- realm: schema.string(),
+ realm: schema.maybe(schema.string()),
maxRedirectURLSize: schema.maybe(schema.byteSize()),
})
),
diff --git a/x-pack/plugins/security/server/config_deprecations.test.ts b/x-pack/plugins/security/server/config_deprecations.test.ts
index ad6f81eaeefff..4518679755156 100644
--- a/x-pack/plugins/security/server/config_deprecations.test.ts
+++ b/x-pack/plugins/security/server/config_deprecations.test.ts
@@ -286,7 +286,7 @@ describe('Config Deprecations', () => {
const { messages } = applyConfigDeprecations(cloneDeep(config));
expect(messages).toMatchInlineSnapshot(`
Array [
- "\\"xpack.security.authc.providers.saml..maxRedirectURLSize\\" is is no longer used.",
+ "\\"xpack.security.authc.providers.saml..maxRedirectURLSize\\" is no longer used.",
]
`);
});
diff --git a/x-pack/plugins/security/server/config_deprecations.ts b/x-pack/plugins/security/server/config_deprecations.ts
index f68112760632e..169211184a325 100644
--- a/x-pack/plugins/security/server/config_deprecations.ts
+++ b/x-pack/plugins/security/server/config_deprecations.ts
@@ -13,6 +13,7 @@ export const securityConfigDeprecationProvider: ConfigDeprecationProvider = ({
unused,
}) => [
rename('sessionTimeout', 'session.idleTimeout'),
+ rename('authProviders', 'authc.providers'),
rename('audit.appender.kind', 'audit.appender.type'),
rename('audit.appender.layout.kind', 'audit.appender.layout.type'),
@@ -121,7 +122,7 @@ export const securityConfigDeprecationProvider: ConfigDeprecationProvider = ({
}),
message: i18n.translate('xpack.security.deprecations.maxRedirectURLSizeMessage', {
defaultMessage:
- '"xpack.security.authc.providers.saml..maxRedirectURLSize" is is no longer used.',
+ '"xpack.security.authc.providers.saml..maxRedirectURLSize" is no longer used.',
}),
correctiveActions: {
manualSteps: [
diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts
index 98cb7729c9440..69fce914cb1d5 100644
--- a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts
+++ b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts
@@ -23,8 +23,8 @@ export const EndpointActionLogRequestSchema = {
query: schema.object({
page: schema.number({ defaultValue: 1, min: 1 }),
page_size: schema.number({ defaultValue: 10, min: 1, max: 100 }),
- start_date: schema.maybe(schema.string()),
- end_date: schema.maybe(schema.string()),
+ start_date: schema.string(),
+ end_date: schema.string(),
}),
params: schema.object({
agent_id: schema.string(),
diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts
index d49868aae9227..c6d30825c21c9 100644
--- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts
+++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts
@@ -65,8 +65,8 @@ export type ActivityLogEntry = ActivityLogAction | ActivityLogActionResponse;
export interface ActivityLog {
page: number;
pageSize: number;
- startDate?: string;
- endDate?: string;
+ startDate: string;
+ endDate: string;
data: ActivityLogEntry[];
}
diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx
index e179c02987462..3c277d1d4019b 100644
--- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx
@@ -5,7 +5,6 @@
* 2.0.
*/
-import { EuiLoadingContent, EuiPanel } from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { connect, ConnectedProps, useDispatch } from 'react-redux';
@@ -369,11 +368,7 @@ export const AlertsTableComponent: React.FC = ({
}, [dispatch, defaultTimelineModel, filterManager, tGridEnabled, timelineId]);
if (loading || indexPatternsLoading || isEmpty(selectedPatterns)) {
- return (
-
-
-
- );
+ return null;
}
return (
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx
index 4c3db2ae62be3..4d4ac102ea645 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx
@@ -695,7 +695,7 @@ const RuleDetailsPageComponent: React.FC = ({
enabled={isExistingRule && (rule?.enabled ?? false)}
onChange={handleOnChangeEnabledRule}
/>
- {i18n.ACTIVATED_RULE}
+ {i18n.ACTIVATE_RULE}
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/translations.ts
index ca3e5a4587a09..a83647f8a9781 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/translations.ts
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/translations.ts
@@ -28,10 +28,10 @@ export const EXPERIMENTAL = i18n.translate(
}
);
-export const ACTIVATED_RULE = i18n.translate(
- 'xpack.securitySolution.detectionEngine.ruleDetails.activatedRuleLabel',
+export const ACTIVATE_RULE = i18n.translate(
+ 'xpack.securitySolution.detectionEngine.ruleDetails.activateRuleLabel',
{
- defaultMessage: 'Activated',
+ defaultMessage: 'Activate',
}
);
diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts
index 0ac73df6704c8..9c557f83012bf 100644
--- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts
+++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts
@@ -126,6 +126,8 @@ export const endpointActivityLogHttpMock = httpHandlerMockFactory => {
disabled: false,
page: 1,
pageSize: 50,
- startDate: undefined,
- endDate: undefined,
+ startDate: 'now-1d',
+ endDate: 'now',
isInvalidDateRange: false,
+ autoRefreshOptions: {
+ enabled: false,
+ duration: DEFAULT_POLL_INTERVAL,
+ },
+ recentlyUsedDateRanges: [],
},
logData: createUninitialisedResourceState(),
},
diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts
index 7fbe2dfc0a099..49ba88fd47717 100644
--- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts
+++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts
@@ -48,7 +48,14 @@ describe('EndpointList store concerns', () => {
disabled: false,
page: 1,
pageSize: 50,
+ startDate: 'now-1d',
+ endDate: 'now',
isInvalidDateRange: false,
+ autoRefreshOptions: {
+ enabled: false,
+ duration: DEFAULT_POLL_INTERVAL,
+ },
+ recentlyUsedDateRanges: [],
},
logData: { type: 'UninitialisedResourceState' },
},
diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts
index e51fe15e7130f..83d3e62cf98f2 100644
--- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts
+++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts
@@ -267,6 +267,8 @@ describe('endpoint list middleware', () => {
payload: {
page,
pageSize: 50,
+ startDate: 'now-1d',
+ endDate: 'now',
},
});
};
@@ -311,6 +313,8 @@ describe('endpoint list middleware', () => {
expect(mockedApis.responseProvider.activityLogResponse).toHaveBeenCalledWith({
path: expect.any(String),
query: {
+ end_date: 'now',
+ start_date: 'now-1d',
page: 1,
page_size: 50,
},
@@ -396,6 +400,8 @@ describe('endpoint list middleware', () => {
query: {
page: 3,
page_size: 50,
+ start_date: 'now-1d',
+ end_date: 'now',
},
});
});
diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts
index df4361a6048a8..6b88183db6841 100644
--- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts
+++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts
@@ -640,12 +640,12 @@ async function endpointDetailsActivityLogChangedMiddleware({
});
try {
- const { page, pageSize } = getActivityLogDataPaging(getState());
+ const { page, pageSize, startDate, endDate } = getActivityLogDataPaging(getState());
const route = resolvePathVariables(ENDPOINT_ACTION_LOG_ROUTE, {
agent_id: selectedAgent(getState()),
});
const activityLog = await coreStart.http.get(route, {
- query: { page, page_size: pageSize },
+ query: { page, page_size: pageSize, start_date: startDate, end_date: endDate },
});
dispatch({
type: 'endpointDetailsActivityLogChanged',
diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts
index 02d2adce833cf..b16caf00b4e28 100644
--- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts
+++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts
@@ -24,6 +24,7 @@ import { AppAction } from '../../../../common/store/actions';
import { ImmutableReducer } from '../../../../common/store';
import { Immutable } from '../../../../../common/endpoint/types';
import { createUninitialisedResourceState, isUninitialisedResourceState } from '../../../state';
+import { DEFAULT_POLL_INTERVAL } from '../../../common/constants';
type StateReducer = ImmutableReducer;
type CaseReducer = (
@@ -172,7 +173,11 @@ export const endpointListReducer: StateReducer = (state = initialEndpointPageSta
},
},
};
- } else if (action.type === 'endpointDetailsActivityLogUpdatePaging') {
+ } else if (
+ action.type === 'endpointDetailsActivityLogUpdatePaging' ||
+ action.type === 'endpointDetailsActivityLogUpdateIsInvalidDateRange' ||
+ action.type === 'userUpdatedActivityLogRefreshOptions'
+ ) {
return {
...state,
endpointDetails: {
@@ -186,7 +191,7 @@ export const endpointListReducer: StateReducer = (state = initialEndpointPageSta
},
},
};
- } else if (action.type === 'endpointDetailsActivityLogUpdateIsInvalidDateRange') {
+ } else if (action.type === 'userUpdatedActivityLogRecentlyUsedDateRanges') {
return {
...state,
endpointDetails: {
@@ -195,7 +200,7 @@ export const endpointListReducer: StateReducer = (state = initialEndpointPageSta
...state.endpointDetails.activityLog,
paging: {
...state.endpointDetails.activityLog.paging,
- ...action.payload,
+ recentlyUsedDateRanges: action.payload,
},
},
},
@@ -315,9 +320,16 @@ export const endpointListReducer: StateReducer = (state = initialEndpointPageSta
logData: createUninitialisedResourceState(),
paging: {
disabled: false,
+ isInvalidDateRange: false,
page: 1,
pageSize: 50,
- isInvalidDateRange: false,
+ startDate: 'now-1d',
+ endDate: 'now',
+ autoRefreshOptions: {
+ enabled: false,
+ duration: DEFAULT_POLL_INTERVAL,
+ },
+ recentlyUsedDateRanges: [],
},
};
@@ -337,7 +349,16 @@ export const endpointListReducer: StateReducer = (state = initialEndpointPageSta
...stateUpdates,
endpointDetails: {
...state.endpointDetails,
- activityLog,
+ activityLog: {
+ ...activityLog,
+ paging: {
+ ...activityLog.paging,
+ startDate: state.endpointDetails.activityLog.paging.startDate,
+ endDate: state.endpointDetails.activityLog.paging.endDate,
+ recentlyUsedDateRanges:
+ state.endpointDetails.activityLog.paging.recentlyUsedDateRanges,
+ },
+ },
hostDetails: {
...state.endpointDetails.hostDetails,
detailsError: undefined,
@@ -355,7 +376,16 @@ export const endpointListReducer: StateReducer = (state = initialEndpointPageSta
...stateUpdates,
endpointDetails: {
...state.endpointDetails,
- activityLog,
+ activityLog: {
+ ...activityLog,
+ paging: {
+ ...activityLog.paging,
+ startDate: state.endpointDetails.activityLog.paging.startDate,
+ endDate: state.endpointDetails.activityLog.paging.endDate,
+ recentlyUsedDateRanges:
+ state.endpointDetails.activityLog.paging.recentlyUsedDateRanges,
+ },
+ },
hostDetails: {
...state.endpointDetails.hostDetails,
detailsLoading: !isNotLoadingDetails,
@@ -372,7 +402,16 @@ export const endpointListReducer: StateReducer = (state = initialEndpointPageSta
...stateUpdates,
endpointDetails: {
...state.endpointDetails,
- activityLog,
+ activityLog: {
+ ...activityLog,
+ paging: {
+ ...activityLog.paging,
+ startDate: state.endpointDetails.activityLog.paging.startDate,
+ endDate: state.endpointDetails.activityLog.paging.endDate,
+ recentlyUsedDateRanges:
+ state.endpointDetails.activityLog.paging.recentlyUsedDateRanges,
+ },
+ },
hostDetails: {
...state.endpointDetails.hostDetails,
detailsLoading: true,
@@ -391,7 +430,15 @@ export const endpointListReducer: StateReducer = (state = initialEndpointPageSta
...stateUpdates,
endpointDetails: {
...state.endpointDetails,
- activityLog,
+ activityLog: {
+ ...activityLog,
+ paging: {
+ ...activityLog.paging,
+ startDate: state.endpointDetails.activityLog.paging.startDate,
+ endDate: state.endpointDetails.activityLog.paging.endDate,
+ recentlyUsedDateRanges: state.endpointDetails.activityLog.paging.recentlyUsedDateRanges,
+ },
+ },
hostDetails: {
...state.endpointDetails.hostDetails,
detailsError: undefined,
diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts
index 82057af233e43..dd0bc79f1ba52 100644
--- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts
+++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts
@@ -5,6 +5,7 @@
* 2.0.
*/
+import { EuiSuperDatePickerRecentRange } from '@elastic/eui';
import {
ActivityLog,
HostInfo,
@@ -41,9 +42,14 @@ export interface EndpointState {
disabled?: boolean;
page: number;
pageSize: number;
- startDate?: string;
- endDate?: string;
+ startDate: string;
+ endDate: string;
isInvalidDateRange: boolean;
+ autoRefreshOptions: {
+ enabled: boolean;
+ duration: number;
+ };
+ recentlyUsedDateRanges: EuiSuperDatePickerRecentRange[];
};
logData: AsyncResourceState;
};
diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/utils.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/utils.test.ts
index fa2aaaa16ae37..ee723bd0bf0f5 100644
--- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/utils.test.ts
+++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/utils.test.ts
@@ -10,12 +10,14 @@ import { getIsInvalidDateRange } from './utils';
describe('utils', () => {
describe('getIsInvalidDateRange', () => {
- it('should return FALSE when either dates are undefined', () => {
- expect(getIsInvalidDateRange({})).toBe(false);
- expect(getIsInvalidDateRange({ startDate: moment().subtract(1, 'd').toISOString() })).toBe(
- false
- );
- expect(getIsInvalidDateRange({ endDate: moment().toISOString() })).toBe(false);
+ it('should return FALSE when startDate is before endDate', () => {
+ expect(getIsInvalidDateRange({ startDate: 'now-1d', endDate: 'now' })).toBe(false);
+ expect(
+ getIsInvalidDateRange({
+ startDate: moment().subtract(1, 'd').toISOString(),
+ endDate: moment().toISOString(),
+ })
+ ).toBe(false);
});
it('should return TRUE when startDate is after endDate', () => {
diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/utils.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/utils.ts
index e2d619743c83b..1bfb99c68ef66 100644
--- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/utils.ts
+++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/utils.ts
@@ -5,6 +5,7 @@
* 2.0.
*/
+import dateMath from '@elastic/datemath';
import moment from 'moment';
import { HostInfo, HostMetadata } from '../../../../common/endpoint/types';
@@ -29,12 +30,12 @@ export const getIsInvalidDateRange = ({
startDate,
endDate,
}: {
- startDate?: string;
- endDate?: string;
+ startDate: string;
+ endDate: string;
}) => {
- if (startDate && endDate) {
- const start = moment(startDate);
- const end = moment(endDate);
+ const start = moment(dateMath.parse(startDate));
+ const end = moment(dateMath.parse(endDate));
+ if (start.isValid() && end.isValid()) {
return start.isAfter(end);
}
return false;
diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/activity_log_date_range_picker/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/activity_log_date_range_picker/index.tsx
index e921078539303..30ab082559c7b 100644
--- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/activity_log_date_range_picker/index.tsx
+++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/activity_log_date_range_picker/index.tsx
@@ -8,95 +8,140 @@
import { useDispatch } from 'react-redux';
import React, { memo, useCallback } from 'react';
import styled from 'styled-components';
-import moment from 'moment';
-import { EuiFlexGroup, EuiFlexItem, EuiDatePicker, EuiDatePickerRange } from '@elastic/eui';
+import dateMath from '@elastic/datemath';
+import {
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiSuperDatePicker,
+ EuiSuperDatePickerRecentRange,
+} from '@elastic/eui';
-import * as i18 from '../../../translations';
import { useEndpointSelector } from '../../../hooks';
-import { getActivityLogDataPaging } from '../../../../store/selectors';
+import {
+ getActivityLogDataPaging,
+ getActivityLogRequestLoading,
+} from '../../../../store/selectors';
+import { DEFAULT_TIMEPICKER_QUICK_RANGES } from '../../../../../../../../common/constants';
+import { useUiSetting$ } from '../../../../../../../common/lib/kibana';
+
+interface Range {
+ from: string;
+ to: string;
+ display: string;
+}
const DatePickerWrapper = styled.div`
width: ${(props) => props.theme.eui.fractions.single.percentage};
- background: white;
+ max-width: 350px;
`;
const StickyFlexItem = styled(EuiFlexItem)`
- max-width: 350px;
+ background: ${(props) => `${props.theme.eui.euiHeaderBackgroundColor}`};
position: sticky;
- top: ${(props) => props.theme.eui.euiSizeM};
+ top: 0;
z-index: 1;
- padding: ${(props) => `0 ${props.theme.eui.paddingSizes.m}`};
+ padding: ${(props) => `${props.theme.eui.paddingSizes.m}`};
`;
export const DateRangePicker = memo(() => {
const dispatch = useDispatch();
- const { page, pageSize, startDate, endDate, isInvalidDateRange } = useEndpointSelector(
- getActivityLogDataPaging
- );
+ const {
+ page,
+ pageSize,
+ startDate,
+ endDate,
+ autoRefreshOptions,
+ recentlyUsedDateRanges,
+ } = useEndpointSelector(getActivityLogDataPaging);
+
+ const activityLogLoading = useEndpointSelector(getActivityLogRequestLoading);
- const onChangeStartDate = useCallback(
- (date) => {
+ const dispatchActionUpdateActivityLogPaging = useCallback(
+ async ({ start, end }) => {
dispatch({
type: 'endpointDetailsActivityLogUpdatePaging',
payload: {
disabled: false,
page,
pageSize,
- startDate: date ? date?.toISOString() : undefined,
- endDate: endDate ? endDate : undefined,
+ startDate: dateMath.parse(start)?.toISOString(),
+ endDate: dateMath.parse(end)?.toISOString(),
},
});
},
- [dispatch, endDate, page, pageSize]
+ [dispatch, page, pageSize]
);
- const onChangeEndDate = useCallback(
- (date) => {
+ const onRefreshChange = useCallback(
+ (evt) => {
dispatch({
- type: 'endpointDetailsActivityLogUpdatePaging',
+ type: 'userUpdatedActivityLogRefreshOptions',
payload: {
- disabled: false,
- page,
- pageSize,
- startDate: startDate ? startDate : undefined,
- endDate: date ? date.toISOString() : undefined,
+ autoRefreshOptions: { enabled: !evt.isPaused, duration: evt.refreshInterval },
},
});
},
- [dispatch, startDate, page, pageSize]
+ [dispatch]
);
+ const onRefresh = useCallback(() => {
+ dispatch({
+ type: 'endpointDetailsActivityLogUpdatePaging',
+ payload: {
+ disabled: false,
+ page,
+ pageSize,
+ startDate,
+ endDate,
+ },
+ });
+ }, [dispatch, page, pageSize, startDate, endDate]);
+
+ const onTimeChange = useCallback(
+ ({ start: newStart, end: newEnd }) => {
+ const newRecentlyUsedDateRanges = [
+ { start: newStart, end: newEnd },
+ ...recentlyUsedDateRanges
+ .filter(
+ (recentlyUsedRange) =>
+ !(recentlyUsedRange.start === newStart && recentlyUsedRange.end === newEnd)
+ )
+ .slice(0, 9),
+ ];
+ dispatch({
+ type: 'userUpdatedActivityLogRecentlyUsedDateRanges',
+ payload: newRecentlyUsedDateRanges,
+ });
+
+ dispatchActionUpdateActivityLogPaging({ start: newStart, end: newEnd });
+ },
+ [dispatch, recentlyUsedDateRanges, dispatchActionUpdateActivityLogPaging]
+ );
+
+ const [quickRanges] = useUiSetting$(DEFAULT_TIMEPICKER_QUICK_RANGES);
+ const commonlyUsedRanges = !quickRanges.length
+ ? []
+ : quickRanges.map(({ from, to, display }) => ({
+ start: from,
+ end: to,
+ label: display,
+ }));
+
return (
-
-
+
+
-
- }
- endDateControl={
-
- }
+
diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_activity_log.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_activity_log.tsx
index 5172b59450e03..f0b6b5fbc8962 100644
--- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_activity_log.tsx
+++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_activity_log.tsx
@@ -9,11 +9,13 @@ import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import styled from 'styled-components';
import {
+ EuiCallOut,
EuiText,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingContent,
EuiEmptyPrompt,
+ EuiSpacer,
} from '@elastic/eui';
import { useDispatch } from 'react-redux';
import { LogEntry } from './components/log_entry';
@@ -114,6 +116,17 @@ export const EndpointActivityLog = memo(
<>
+ {!isPagingDisabled && activityLogLoaded && !activityLogData.length && (
+ <>
+
+
+ >
+ )}
{activityLogLoaded &&
activityLogData.map((logEntry) => (
diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoints.stories.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoints.stories.tsx
index 372bd4491d7d4..123a51e5a52bd 100644
--- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoints.stories.tsx
+++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoints.stories.tsx
@@ -22,6 +22,8 @@ export const dummyEndpointActivityLog = (
data: {
page: 1,
pageSize: 50,
+ startDate: moment().subtract(5, 'day').fromNow().toString(),
+ endDate: moment().toString(),
data: [
{
type: 'action',
diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx
index 996198568ad27..ea999334ee771 100644
--- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx
@@ -10,6 +10,8 @@ import * as reactTestingLibrary from '@testing-library/react';
import { EndpointList } from './index';
import '../../../../common/mock/match_media';
+import { createUseUiSetting$Mock } from '../../../../../public/common/lib/kibana/kibana_react.mock';
+
import {
mockEndpointDetailsApiResult,
mockEndpointResultList,
@@ -28,7 +30,7 @@ import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_da
import { POLICY_STATUS_TO_HEALTH_COLOR, POLICY_STATUS_TO_TEXT } from './host_constants';
import { mockPolicyResultList } from '../../policy/store/test_mock_utils';
import { getEndpointDetailsPath } from '../../../common/routing';
-import { KibanaServices, useKibana, useToasts } from '../../../../common/lib/kibana';
+import { KibanaServices, useKibana, useToasts, useUiSetting$ } from '../../../../common/lib/kibana';
import { hostIsolationHttpMocks } from '../../../../common/lib/endpoint_isolation/mocks';
import {
createFailedResourceState,
@@ -40,7 +42,11 @@ import {
import { getCurrentIsolationRequestState } from '../store/selectors';
import { licenseService } from '../../../../common/hooks/use_license';
import { FleetActionGenerator } from '../../../../../common/endpoint/data_generators/fleet_action_generator';
-import { APP_PATH, MANAGEMENT_PATH } from '../../../../../common/constants';
+import {
+ APP_PATH,
+ MANAGEMENT_PATH,
+ DEFAULT_TIMEPICKER_QUICK_RANGES,
+} from '../../../../../common/constants';
import { TransformStats, TRANSFORM_STATE } from '../types';
import { metadataTransformPrefix } from '../../../../../common/endpoint/constants';
@@ -63,6 +69,59 @@ jest.mock('../../policy/store/services/ingest', () => {
sendGetEndpointSecurityPackage: () => Promise.resolve({}),
};
});
+const mockUseUiSetting$ = useUiSetting$ as jest.Mock;
+const timepickerRanges = [
+ {
+ from: 'now/d',
+ to: 'now/d',
+ display: 'Today',
+ },
+ {
+ from: 'now/w',
+ to: 'now/w',
+ display: 'This week',
+ },
+ {
+ from: 'now-15m',
+ to: 'now',
+ display: 'Last 15 minutes',
+ },
+ {
+ from: 'now-30m',
+ to: 'now',
+ display: 'Last 30 minutes',
+ },
+ {
+ from: 'now-1h',
+ to: 'now',
+ display: 'Last 1 hour',
+ },
+ {
+ from: 'now-24h',
+ to: 'now',
+ display: 'Last 24 hours',
+ },
+ {
+ from: 'now-7d',
+ to: 'now',
+ display: 'Last 7 days',
+ },
+ {
+ from: 'now-30d',
+ to: 'now',
+ display: 'Last 30 days',
+ },
+ {
+ from: 'now-90d',
+ to: 'now',
+ display: 'Last 90 days',
+ },
+ {
+ from: 'now-1y',
+ to: 'now',
+ display: 'Last 1 year',
+ },
+];
jest.mock('../../../../common/lib/kibana');
jest.mock('../../../../common/hooks/use_license');
@@ -759,6 +818,14 @@ describe('when on the endpoint list page', () => {
disconnect: jest.fn(),
}));
+ mockUseUiSetting$.mockImplementation((key, defaultValue) => {
+ const useUiSetting$Mock = createUseUiSetting$Mock();
+
+ return key === DEFAULT_TIMEPICKER_QUICK_RANGES
+ ? [timepickerRanges, jest.fn()]
+ : useUiSetting$Mock(key, defaultValue);
+ });
+
const fleetActionGenerator = new FleetActionGenerator('seed');
const responseData = fleetActionGenerator.generateResponse({
agent_id: agentId,
@@ -766,9 +833,12 @@ describe('when on the endpoint list page', () => {
const actionData = fleetActionGenerator.generate({
agents: [agentId],
});
+
getMockData = () => ({
page: 1,
pageSize: 50,
+ startDate: 'now-1d',
+ endDate: 'now',
data: [
{
type: 'response',
@@ -838,7 +908,7 @@ describe('when on the endpoint list page', () => {
expect(emptyState).not.toBe(null);
});
- it('should display empty state when no log data', async () => {
+ it('should not display empty state when no log data', async () => {
const activityLogTab = await renderResult.findByTestId('activity_log');
reactTestingLibrary.act(() => {
reactTestingLibrary.fireEvent.click(activityLogTab);
@@ -848,36 +918,39 @@ describe('when on the endpoint list page', () => {
dispatchEndpointDetailsActivityLogChanged('success', {
page: 1,
pageSize: 50,
+ startDate: 'now-1d',
+ endDate: 'now',
data: [],
});
});
const emptyState = await renderResult.queryByTestId('activityLogEmpty');
- expect(emptyState).not.toBe(null);
+ expect(emptyState).toBe(null);
+
+ const superDatePicker = await renderResult.queryByTestId('activityLogSuperDatePicker');
+ expect(superDatePicker).not.toBe(null);
});
- it('should not display empty state with no log data while date range filter is active', async () => {
- const activityLogTab = await renderResult.findByTestId('activity_log');
+ it('should display activity log when tab is loaded using the URL', async () => {
+ const userChangedUrlChecker = middlewareSpy.waitForAction('userChangedUrl');
reactTestingLibrary.act(() => {
- reactTestingLibrary.fireEvent.click(activityLogTab);
+ history.push(
+ `${MANAGEMENT_PATH}/endpoints?page_index=0&page_size=10&selected_endpoint=1&show=activity_log`
+ );
});
+ const changedUrlAction = await userChangedUrlChecker;
+ expect(changedUrlAction.payload.search).toEqual(
+ '?page_index=0&page_size=10&selected_endpoint=1&show=activity_log'
+ );
await middlewareSpy.waitForAction('endpointDetailsActivityLogChanged');
reactTestingLibrary.act(() => {
- dispatchEndpointDetailsActivityLogChanged('success', {
- page: 1,
- pageSize: 50,
- startDate: new Date().toISOString(),
- data: [],
- });
+ dispatchEndpointDetailsActivityLogChanged('success', getMockData());
});
-
- const emptyState = await renderResult.queryByTestId('activityLogEmpty');
- const dateRangePicker = await renderResult.queryByTestId('activityLogDateRangePicker');
- expect(emptyState).toBe(null);
- expect(dateRangePicker).not.toBe(null);
+ const logEntries = await renderResult.queryAllByTestId('timelineEntry');
+ expect(logEntries.length).toEqual(2);
});
- it('should display activity log when tab is loaded using the URL', async () => {
+ it('should display a callout message if no log data', async () => {
const userChangedUrlChecker = middlewareSpy.waitForAction('userChangedUrl');
reactTestingLibrary.act(() => {
history.push(
@@ -890,10 +963,17 @@ describe('when on the endpoint list page', () => {
);
await middlewareSpy.waitForAction('endpointDetailsActivityLogChanged');
reactTestingLibrary.act(() => {
- dispatchEndpointDetailsActivityLogChanged('success', getMockData());
+ dispatchEndpointDetailsActivityLogChanged('success', {
+ page: 1,
+ pageSize: 50,
+ startDate: 'now-1d',
+ endDate: 'now',
+ data: [],
+ });
});
- const logEntries = await renderResult.queryAllByTestId('timelineEntry');
- expect(logEntries.length).toEqual(2);
+
+ const activityLogCallout = await renderResult.findByTestId('activityLogNoDataCallout');
+ expect(activityLogCallout).not.toBeNull();
});
});
diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts
index 57ad3e4808bd5..c8a29eed3fda7 100644
--- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts
+++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts
@@ -15,20 +15,6 @@ export const ACTIVITY_LOG = {
tabTitle: i18n.translate('xpack.securitySolution.endpointDetails.activityLog', {
defaultMessage: 'Activity Log',
}),
- datePicker: {
- startDate: i18n.translate(
- 'xpack.securitySolution.endpointDetails.activityLog.datePicker.startDate',
- {
- defaultMessage: 'Pick a start date',
- }
- ),
- endDate: i18n.translate(
- 'xpack.securitySolution.endpointDetails.activityLog.datePicker.endDate',
- {
- defaultMessage: 'Pick an end date',
- }
- ),
- },
LogEntry: {
endOfLog: i18n.translate(
'xpack.securitySolution.endpointDetails.activityLog.logEntry.action.endOfLog',
@@ -36,6 +22,13 @@ export const ACTIVITY_LOG = {
defaultMessage: 'Nothing more to show',
}
),
+ dateRangeMessage: i18n.translate(
+ 'xpack.securitySolution.endpointDetails.activityLog.logEntry.dateRangeMessage.title',
+ {
+ defaultMessage:
+ 'Nothing to show for selected date range, please select another and try again.',
+ }
+ ),
emptyState: {
title: i18n.translate(
'xpack.securitySolution.endpointDetails.activityLog.logEntry.emptyState.title',
diff --git a/x-pack/plugins/security_solution/server/config.test.ts b/x-pack/plugins/security_solution/server/config.test.ts
new file mode 100644
index 0000000000000..67956acd6656f
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/config.test.ts
@@ -0,0 +1,80 @@
+/*
+ * 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 { configSchema } from './config';
+
+describe('config', () => {
+ describe('alertIgnoreFields', () => {
+ test('should default to an empty array', () => {
+ expect(configSchema.validate({}).alertIgnoreFields).toEqual([]);
+ });
+
+ test('should accept an array of strings', () => {
+ expect(
+ configSchema.validate({ alertIgnoreFields: ['foo.bar', 'mars.bar'] }).alertIgnoreFields
+ ).toEqual(['foo.bar', 'mars.bar']);
+ });
+
+ test('should throw if a non string is being sent in', () => {
+ expect(
+ () =>
+ configSchema.validate({
+ alertIgnoreFields: 5,
+ }).alertIgnoreFields
+ ).toThrow('[alertIgnoreFields]: expected value of type [array] but got [number]');
+ });
+
+ test('should throw if we send in an invalid regular expression as a string', () => {
+ expect(
+ () =>
+ configSchema.validate({
+ alertIgnoreFields: ['/(/'],
+ }).alertIgnoreFields
+ ).toThrow(
+ '[alertIgnoreFields]: "Invalid regular expression: /(/: Unterminated group" at array position 0'
+ );
+ });
+
+ test('should throw with two errors if we send two invalid regular expressions', () => {
+ expect(
+ () =>
+ configSchema.validate({
+ alertIgnoreFields: ['/(/', '/(invalid/'],
+ }).alertIgnoreFields
+ ).toThrow(
+ '[alertIgnoreFields]: "Invalid regular expression: /(/: Unterminated group" at array position 0. "Invalid regular expression: /(invalid/: Unterminated group" at array position 1'
+ );
+ });
+
+ test('should throw with two errors with a valid string mixed in if we send two invalid regular expressions', () => {
+ expect(
+ () =>
+ configSchema.validate({
+ alertIgnoreFields: ['/(/', 'valid.string', '/(invalid/'],
+ }).alertIgnoreFields
+ ).toThrow(
+ '[alertIgnoreFields]: "Invalid regular expression: /(/: Unterminated group" at array position 0. "Invalid regular expression: /(invalid/: Unterminated group" at array position 2'
+ );
+ });
+
+ test('should accept a valid regular expression within the string', () => {
+ expect(
+ configSchema.validate({
+ alertIgnoreFields: ['/(.*)/'],
+ }).alertIgnoreFields
+ ).toEqual(['/(.*)/']);
+ });
+
+ test('should accept two valid regular expressions', () => {
+ expect(
+ configSchema.validate({
+ alertIgnoreFields: ['/(.*)/', '/(.valid*)/'],
+ }).alertIgnoreFields
+ ).toEqual(['/(.*)/', '/(.valid*)/']);
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/server/config.ts b/x-pack/plugins/security_solution/server/config.ts
index a1c6601520a54..0850e43b21eda 100644
--- a/x-pack/plugins/security_solution/server/config.ts
+++ b/x-pack/plugins/security_solution/server/config.ts
@@ -21,12 +21,61 @@ export const configSchema = schema.object({
maxRuleImportPayloadBytes: schema.number({ defaultValue: 10485760 }),
maxTimelineImportExportSize: schema.number({ defaultValue: 10000 }),
maxTimelineImportPayloadBytes: schema.number({ defaultValue: 10485760 }),
+
+ /**
+ * This is used within the merge strategies:
+ * server/lib/detection_engine/signals/source_fields_merging
+ *
+ * For determining which strategy for merging "fields" and "_source" together to get
+ * runtime fields, constant keywords, etc...
+ *
+ * "missingFields" (default) This will only merge fields that are missing from the _source and exist in the fields.
+ * "noFields" This will turn off all merging of runtime fields, constant keywords from fields.
+ * "allFields" This will merge and overwrite anything found within "fields" into "_source" before indexing the data.
+ */
alertMergeStrategy: schema.oneOf(
[schema.literal('allFields'), schema.literal('missingFields'), schema.literal('noFields')],
{
defaultValue: 'missingFields',
}
),
+
+ /**
+ * This is used within the merge strategies:
+ * server/lib/detection_engine/signals/source_fields_merging
+ *
+ * For determining if we need to ignore particular "fields" and not merge them with "_source" such as
+ * runtime fields, constant keywords, etc...
+ *
+ * This feature and functionality is mostly as "safety feature" meaning that we have had bugs in the past
+ * where something down the stack unexpectedly ends up in the fields API which causes documents to not
+ * be indexable. Rather than changing alertMergeStrategy to be "noFields", you can use this array to add
+ * any problematic values.
+ *
+ * You can use plain dotted notation strings such as "host.name" or a regular expression such as "/host\..+/"
+ */
+ alertIgnoreFields: schema.arrayOf(schema.string(), {
+ defaultValue: [],
+ validate(ignoreFields) {
+ const errors = ignoreFields.flatMap((ignoreField, index) => {
+ if (ignoreField.startsWith('/') && ignoreField.endsWith('/')) {
+ try {
+ new RegExp(ignoreField.slice(1, -1));
+ return [];
+ } catch (error) {
+ return [`"${error.message}" at array position ${index}`];
+ }
+ } else {
+ return [];
+ }
+ });
+ if (errors.length !== 0) {
+ return errors.join('. ');
+ } else {
+ return undefined;
+ }
+ },
+ }),
[SIGNALS_INDEX_KEY]: schema.string({ defaultValue: DEFAULT_SIGNALS_INDEX }),
/**
diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.test.ts
index 83f38bc904576..4bd63c83169e5 100644
--- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.test.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.test.ts
@@ -48,19 +48,13 @@ describe('Action Log API', () => {
}).not.toThrow();
});
- it('should work without query params', () => {
+ it('should not work when no params while requesting with query params', () => {
expect(() => {
EndpointActionLogRequestSchema.query.validate({});
- }).not.toThrow();
- });
-
- it('should work with query params', () => {
- expect(() => {
- EndpointActionLogRequestSchema.query.validate({ page: 10, page_size: 100 });
- }).not.toThrow();
+ }).toThrow();
});
- it('should work with all query params', () => {
+ it('should work with all required query params', () => {
expect(() => {
EndpointActionLogRequestSchema.query.validate({
page: 10,
@@ -71,24 +65,24 @@ describe('Action Log API', () => {
}).not.toThrow();
});
- it('should work with just startDate', () => {
+ it('should not work without endDate', () => {
expect(() => {
EndpointActionLogRequestSchema.query.validate({
page: 1,
page_size: 100,
start_date: new Date(new Date().setDate(new Date().getDate() - 1)).toISOString(), // yesterday
});
- }).not.toThrow();
+ }).toThrow();
});
- it('should work with just endDate', () => {
+ it('should not work without startDate', () => {
expect(() => {
EndpointActionLogRequestSchema.query.validate({
page: 1,
page_size: 100,
end_date: new Date().toISOString(), // today
});
- }).not.toThrow();
+ }).toThrow();
});
it('should not work without allowed page and page_size params', () => {
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions.ts
index 80fb1c5d9c7b0..a04a6eea5ab65 100644
--- a/x-pack/plugins/security_solution/server/endpoint/services/actions.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions.ts
@@ -31,8 +31,8 @@ export const getAuditLogResponse = async ({
elasticAgentId: string;
page: number;
pageSize: number;
- startDate?: string;
- endDate?: string;
+ startDate: string;
+ endDate: string;
context: SecuritySolutionRequestHandlerContext;
logger: Logger;
}): Promise => {
@@ -71,8 +71,8 @@ const getActivityLog = async ({
elasticAgentId: string;
size: number;
from: number;
- startDate?: string;
- endDate?: string;
+ startDate: string;
+ endDate: string;
logger: Logger;
}) => {
const options = {
@@ -84,13 +84,10 @@ const getActivityLog = async ({
let actionsResult;
let responsesResult;
- const dateFilters = [];
- if (startDate) {
- dateFilters.push({ range: { '@timestamp': { gte: startDate } } });
- }
- if (endDate) {
- dateFilters.push({ range: { '@timestamp': { lte: endDate } } });
- }
+ const dateFilters = [
+ { range: { '@timestamp': { gte: startDate } } },
+ { range: { '@timestamp': { lte: endDate } } },
+ ];
try {
// fetch actions with matching agent_id
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts
index a768273c9d147..1ac85f9a27969 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts
@@ -26,6 +26,7 @@ export const createMockConfig = (): ConfigType => ({
endpointResultListDefaultPageSize: 10,
packagerTaskInterval: '60s',
alertMergeStrategy: 'missingFields',
+ alertIgnoreFields: [],
prebuiltRulesFromFileSystem: true,
prebuiltRulesFromSavedObjects: false,
});
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/rule_registry_log_client.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/rule_registry_log_client.ts
index f0da8dad16ab0..a5515f8db8552 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/rule_registry_log_client.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/rule_registry_log_client.ts
@@ -142,76 +142,78 @@ export class RuleRegistryLogClient implements IRuleRegistryLogClient {
invariant(result.aggregations, 'Search response should contain aggregations');
return Object.fromEntries(
- result.aggregations.rules.buckets.map((bucket) => [
- bucket.key,
- bucket.most_recent_logs.hits.hits.map((event) => {
- const logEntry = parseRuleExecutionLog(event._source);
- invariant(
- logEntry[ALERT_RULE_UUID] ?? '',
- 'Malformed execution log entry: rule.id field not found'
- );
+ result.aggregations.rules.buckets.map<[ruleId: string, logs: IRuleStatusSOAttributes[]]>(
+ (bucket) => [
+ bucket.key as string,
+ bucket.most_recent_logs.hits.hits.map((event) => {
+ const logEntry = parseRuleExecutionLog(event._source);
+ invariant(
+ logEntry[ALERT_RULE_UUID] ?? '',
+ 'Malformed execution log entry: rule.id field not found'
+ );
- const lastFailure = bucket.last_failure.event.hits.hits[0]
- ? parseRuleExecutionLog(bucket.last_failure.event.hits.hits[0]._source)
- : undefined;
+ const lastFailure = bucket.last_failure.event.hits.hits[0]
+ ? parseRuleExecutionLog(bucket.last_failure.event.hits.hits[0]._source)
+ : undefined;
- const lastSuccess = bucket.last_success.event.hits.hits[0]
- ? parseRuleExecutionLog(bucket.last_success.event.hits.hits[0]._source)
- : undefined;
+ const lastSuccess = bucket.last_success.event.hits.hits[0]
+ ? parseRuleExecutionLog(bucket.last_success.event.hits.hits[0]._source)
+ : undefined;
- const lookBack = bucket.indexing_lookback.event.hits.hits[0]
- ? parseRuleExecutionLog(bucket.indexing_lookback.event.hits.hits[0]._source)
- : undefined;
+ const lookBack = bucket.indexing_lookback.event.hits.hits[0]
+ ? parseRuleExecutionLog(bucket.indexing_lookback.event.hits.hits[0]._source)
+ : undefined;
- const executionGap = bucket.execution_gap.event.hits.hits[0]
- ? parseRuleExecutionLog(bucket.execution_gap.event.hits.hits[0]._source)[
- getMetricField(ExecutionMetric.executionGap)
- ]
- : undefined;
+ const executionGap = bucket.execution_gap.event.hits.hits[0]
+ ? parseRuleExecutionLog(bucket.execution_gap.event.hits.hits[0]._source)[
+ getMetricField(ExecutionMetric.executionGap)
+ ]
+ : undefined;
- const searchDuration = bucket.search_duration_max.event.hits.hits[0]
- ? parseRuleExecutionLog(bucket.search_duration_max.event.hits.hits[0]._source)[
- getMetricField(ExecutionMetric.searchDurationMax)
- ]
- : undefined;
+ const searchDuration = bucket.search_duration_max.event.hits.hits[0]
+ ? parseRuleExecutionLog(bucket.search_duration_max.event.hits.hits[0]._source)[
+ getMetricField(ExecutionMetric.searchDurationMax)
+ ]
+ : undefined;
- const indexingDuration = bucket.indexing_duration_max.event.hits.hits[0]
- ? parseRuleExecutionLog(bucket.indexing_duration_max.event.hits.hits[0]._source)[
- getMetricField(ExecutionMetric.indexingDurationMax)
- ]
- : undefined;
+ const indexingDuration = bucket.indexing_duration_max.event.hits.hits[0]
+ ? parseRuleExecutionLog(bucket.indexing_duration_max.event.hits.hits[0]._source)[
+ getMetricField(ExecutionMetric.indexingDurationMax)
+ ]
+ : undefined;
- const alertId = logEntry[ALERT_RULE_UUID] ?? '';
- const statusDate = logEntry[TIMESTAMP];
- const lastFailureAt = lastFailure?.[TIMESTAMP];
- const lastFailureMessage = lastFailure?.[MESSAGE];
- const lastSuccessAt = lastSuccess?.[TIMESTAMP];
- const lastSuccessMessage = lastSuccess?.[MESSAGE];
- const status = (logEntry[RULE_STATUS] as RuleExecutionStatus) || null;
- const lastLookBackDate = lookBack?.[getMetricField(ExecutionMetric.indexingLookback)];
- const gap = executionGap ? moment.duration(executionGap).humanize() : null;
- const bulkCreateTimeDurations = indexingDuration
- ? [makeFloatString(indexingDuration)]
- : null;
- const searchAfterTimeDurations = searchDuration
- ? [makeFloatString(searchDuration)]
- : null;
+ const alertId = logEntry[ALERT_RULE_UUID] ?? '';
+ const statusDate = logEntry[TIMESTAMP];
+ const lastFailureAt = lastFailure?.[TIMESTAMP];
+ const lastFailureMessage = lastFailure?.[MESSAGE];
+ const lastSuccessAt = lastSuccess?.[TIMESTAMP];
+ const lastSuccessMessage = lastSuccess?.[MESSAGE];
+ const status = (logEntry[RULE_STATUS] as RuleExecutionStatus) || null;
+ const lastLookBackDate = lookBack?.[getMetricField(ExecutionMetric.indexingLookback)];
+ const gap = executionGap ? moment.duration(executionGap).humanize() : null;
+ const bulkCreateTimeDurations = indexingDuration
+ ? [makeFloatString(indexingDuration)]
+ : null;
+ const searchAfterTimeDurations = searchDuration
+ ? [makeFloatString(searchDuration)]
+ : null;
- return {
- alertId,
- statusDate,
- lastFailureAt,
- lastFailureMessage,
- lastSuccessAt,
- lastSuccessMessage,
- status,
- lastLookBackDate,
- gap,
- bulkCreateTimeDurations,
- searchAfterTimeDurations,
- };
- }),
- ])
+ return {
+ alertId,
+ statusDate,
+ lastFailureAt,
+ lastFailureMessage,
+ lastSuccessAt,
+ lastSuccessMessage,
+ status,
+ lastLookBackDate,
+ gap,
+ bulkCreateTimeDurations,
+ searchAfterTimeDurations,
+ };
+ }),
+ ]
+ )
);
}
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_factory.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_factory.ts
index 879d776f83df2..3992c3afaa302 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_factory.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_factory.ts
@@ -40,6 +40,7 @@ export const createSecurityRuleTypeFactory: CreateSecurityRuleTypeFactory = ({
lists,
logger,
mergeStrategy,
+ ignoreFields,
ruleDataClient,
ruleDataService,
}) => (type) => {
@@ -208,6 +209,7 @@ export const createSecurityRuleTypeFactory: CreateSecurityRuleTypeFactory = ({
const wrapHits = wrapHitsFactory({
logger,
+ ignoreFields,
mergeStrategy,
ruleSO,
spaceId,
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_bulk_body.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_bulk_body.ts
index ae2ebc787451b..c09d707fe484e 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_bulk_body.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_bulk_body.ts
@@ -36,10 +36,11 @@ export const buildBulkBody = (
ruleSO: SavedObject,
doc: SignalSourceHit,
mergeStrategy: ConfigType['alertMergeStrategy'],
+ ignoreFields: ConfigType['alertIgnoreFields'],
applyOverrides: boolean,
buildReasonMessage: BuildReasonMessage
): RACAlert => {
- const mergedDoc = getMergeStrategy(mergeStrategy)({ doc });
+ const mergedDoc = getMergeStrategy(mergeStrategy)({ doc, ignoreFields });
const rule = applyOverrides
? buildRuleWithOverrides(ruleSO, mergedDoc._source ?? {})
: buildRuleWithoutOverrides(ruleSO);
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/wrap_hits_factory.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/wrap_hits_factory.ts
index 62946c52b7f40..95c7b4e90b29c 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/wrap_hits_factory.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/wrap_hits_factory.ts
@@ -7,7 +7,7 @@
import { Logger } from 'kibana/server';
-import { SearchAfterAndBulkCreateParams, SignalSourceHit, WrapHits } from '../../signals/types';
+import { SearchAfterAndBulkCreateParams, WrapHits } from '../../signals/types';
import { buildBulkBody } from './utils/build_bulk_body';
import { generateId } from '../../signals/utils';
import { filterDuplicateSignals } from '../../signals/filter_duplicate_signals';
@@ -16,6 +16,7 @@ import { WrappedRACAlert } from '../types';
export const wrapHitsFactory = ({
logger,
+ ignoreFields,
mergeStrategy,
ruleSO,
spaceId,
@@ -23,6 +24,7 @@ export const wrapHitsFactory = ({
logger: Logger;
ruleSO: SearchAfterAndBulkCreateParams['ruleSO'];
mergeStrategy: ConfigType['alertMergeStrategy'];
+ ignoreFields: ConfigType['alertIgnoreFields'];
spaceId: string | null | undefined;
}): WrapHits => (events, buildReasonMessage) => {
try {
@@ -38,8 +40,9 @@ export const wrapHitsFactory = ({
_source: buildBulkBody(
spaceId,
ruleSO,
- doc as SignalSourceHit,
+ doc,
mergeStrategy,
+ ignoreFields,
true,
buildReasonMessage
),
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.test.ts
index f13a5a5e0e715..fe836c872dcad 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.test.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.test.ts
@@ -56,6 +56,7 @@ describe('Indicator Match Alerts', () => {
experimentalFeatures: allowedExperimentalValues,
lists: dependencies.lists,
logger: dependencies.logger,
+ ignoreFields: [],
mergeStrategy: 'allFields',
ruleDataClient: dependencies.ruleDataClient,
ruleDataService: dependencies.ruleDataService,
@@ -97,6 +98,7 @@ describe('Indicator Match Alerts', () => {
lists: dependencies.lists,
logger: dependencies.logger,
mergeStrategy: 'allFields',
+ ignoreFields: [],
ruleDataClient: dependencies.ruleDataClient,
ruleDataService: dependencies.ruleDataService,
version: '1.0.0',
@@ -135,6 +137,7 @@ describe('Indicator Match Alerts', () => {
lists: dependencies.lists,
logger: dependencies.logger,
mergeStrategy: 'allFields',
+ ignoreFields: [],
ruleDataClient: dependencies.ruleDataClient,
ruleDataService: dependencies.ruleDataService,
version: '1.0.0',
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.ts
index 71acc2e1cee85..f2dfe69debed0 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.ts
@@ -19,6 +19,7 @@ export const createIndicatorMatchAlertType = (createOptions: CreateRuleOptions)
lists,
logger,
mergeStrategy,
+ ignoreFields,
ruleDataClient,
version,
ruleDataService,
@@ -27,6 +28,7 @@ export const createIndicatorMatchAlertType = (createOptions: CreateRuleOptions)
lists,
logger,
mergeStrategy,
+ ignoreFields,
ruleDataClient,
ruleDataService,
});
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.test.ts
index 40566ffa04e6a..23cd2e94aedf8 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.test.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.test.ts
@@ -98,6 +98,7 @@ describe('Machine Learning Alerts', () => {
lists: dependencies.lists,
logger: dependencies.logger,
mergeStrategy: 'allFields',
+ ignoreFields: [],
ml: mlMock,
ruleDataClient: dependencies.ruleDataClient,
ruleDataService: dependencies.ruleDataService,
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.ts
index 1d872df35de3a..cdaeb4be76d02 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.ts
@@ -14,11 +14,20 @@ import { createSecurityRuleTypeFactory } from '../create_security_rule_type_fact
import { CreateRuleOptions } from '../types';
export const createMlAlertType = (createOptions: CreateRuleOptions) => {
- const { lists, logger, mergeStrategy, ml, ruleDataClient, ruleDataService } = createOptions;
+ const {
+ lists,
+ logger,
+ mergeStrategy,
+ ignoreFields,
+ ml,
+ ruleDataClient,
+ ruleDataService,
+ } = createOptions;
const createSecurityRuleType = createSecurityRuleTypeFactory({
lists,
logger,
mergeStrategy,
+ ignoreFields,
ruleDataClient,
ruleDataService,
});
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts
index 903cf6adadd43..ed791af08890c 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts
@@ -32,6 +32,7 @@ describe('Custom query alerts', () => {
lists: dependencies.lists,
logger: dependencies.logger,
mergeStrategy: 'allFields',
+ ignoreFields: [],
ruleDataClient: dependencies.ruleDataClient,
ruleDataService: dependencies.ruleDataService,
version: '1.0.0',
@@ -79,6 +80,7 @@ describe('Custom query alerts', () => {
lists: dependencies.lists,
logger: dependencies.logger,
mergeStrategy: 'allFields',
+ ignoreFields: [],
ruleDataClient: dependencies.ruleDataClient,
ruleDataService: dependencies.ruleDataService,
version: '1.0.0',
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.ts
index e59037f38ce56..2f185853754b3 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.ts
@@ -19,6 +19,7 @@ export const createQueryAlertType = (createOptions: CreateRuleOptions) => {
lists,
logger,
mergeStrategy,
+ ignoreFields,
ruleDataClient,
version,
ruleDataService,
@@ -27,6 +28,7 @@ export const createQueryAlertType = (createOptions: CreateRuleOptions) => {
lists,
logger,
mergeStrategy,
+ ignoreFields,
ruleDataClient,
ruleDataService,
});
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts
index f061240c4a6e5..d50ab566c75cb 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts
@@ -96,6 +96,7 @@ export type CreateSecurityRuleTypeFactory = (options: {
lists: SetupPlugins['lists'];
logger: Logger;
mergeStrategy: ConfigType['alertMergeStrategy'];
+ ignoreFields: ConfigType['alertIgnoreFields'];
ruleDataClient: IRuleDataClient;
ruleDataService: IRuleDataPluginService;
}) => <
@@ -124,6 +125,7 @@ export interface CreateRuleOptions {
lists: SetupPlugins['lists'];
logger: Logger;
mergeStrategy: ConfigType['alertMergeStrategy'];
+ ignoreFields: ConfigType['alertIgnoreFields'];
ml?: SetupPlugins['ml'];
ruleDataClient: IRuleDataClient;
version: string;
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts
index 206f3ae59d246..5f392bed75f76 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts
@@ -43,6 +43,7 @@ describe('buildBulkBody', () => {
ruleSO,
doc,
'missingFields',
+ [],
buildReasonMessage
);
// Timestamp will potentially always be different so remove it for the test
@@ -114,6 +115,7 @@ describe('buildBulkBody', () => {
ruleSO,
doc,
'missingFields',
+ [],
buildReasonMessage
);
// Timestamp will potentially always be different so remove it for the test
@@ -199,6 +201,7 @@ describe('buildBulkBody', () => {
ruleSO,
doc,
'missingFields',
+ [],
buildReasonMessage
);
// Timestamp will potentially always be different so remove it for the test
@@ -270,6 +273,7 @@ describe('buildBulkBody', () => {
ruleSO,
doc,
'missingFields',
+ [],
buildReasonMessage
);
// Timestamp will potentially always be different so remove it for the test
@@ -338,6 +342,7 @@ describe('buildBulkBody', () => {
ruleSO,
doc,
'missingFields',
+ [],
buildReasonMessage
);
// Timestamp will potentially always be different so remove it for the test
@@ -405,6 +410,7 @@ describe('buildBulkBody', () => {
ruleSO,
doc,
'missingFields',
+ [],
buildReasonMessage
);
const expected: Omit & { someKey: string } = {
@@ -468,6 +474,7 @@ describe('buildBulkBody', () => {
ruleSO,
doc,
'missingFields',
+ [],
buildReasonMessage
);
const expected: Omit & { someKey: string } = {
@@ -712,6 +719,7 @@ describe('buildSignalFromEvent', () => {
ruleSO,
true,
'missingFields',
+ [],
buildReasonMessage
);
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts
index a4e812e8f111a..f8e39964523d0 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts
@@ -37,9 +37,10 @@ export const buildBulkBody = (
ruleSO: SavedObject,
doc: SignalSourceHit,
mergeStrategy: ConfigType['alertMergeStrategy'],
+ ignoreFields: ConfigType['alertIgnoreFields'],
buildReasonMessage: BuildReasonMessage
): SignalHit => {
- const mergedDoc = getMergeStrategy(mergeStrategy)({ doc });
+ const mergedDoc = getMergeStrategy(mergeStrategy)({ doc, ignoreFields });
const rule = buildRuleWithOverrides(ruleSO, mergedDoc._source ?? {});
const timestamp = new Date().toISOString();
const reason = buildReasonMessage({ mergedDoc, rule });
@@ -76,11 +77,19 @@ export const buildSignalGroupFromSequence = (
ruleSO: SavedObject,
outputIndex: string,
mergeStrategy: ConfigType['alertMergeStrategy'],
+ ignoreFields: ConfigType['alertIgnoreFields'],
buildReasonMessage: BuildReasonMessage
): WrappedSignalHit[] => {
const wrappedBuildingBlocks = wrapBuildingBlocks(
sequence.events.map((event) => {
- const signal = buildSignalFromEvent(event, ruleSO, false, mergeStrategy, buildReasonMessage);
+ const signal = buildSignalFromEvent(
+ event,
+ ruleSO,
+ false,
+ mergeStrategy,
+ ignoreFields,
+ buildReasonMessage
+ );
signal.signal.rule.building_block_type = 'default';
return signal;
}),
@@ -146,9 +155,10 @@ export const buildSignalFromEvent = (
ruleSO: SavedObject,
applyOverrides: boolean,
mergeStrategy: ConfigType['alertMergeStrategy'],
+ ignoreFields: ConfigType['alertIgnoreFields'],
buildReasonMessage: BuildReasonMessage
): SignalHit => {
- const mergedEvent = getMergeStrategy(mergeStrategy)({ doc: event });
+ const mergedEvent = getMergeStrategy(mergeStrategy)({ doc: event, ignoreFields });
const rule = applyOverrides
? buildRuleWithOverrides(ruleSO, mergedEvent._source ?? {})
: buildRuleWithoutOverrides(ruleSO);
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts
index 8bf0c986b9c25..55a184a1c0bcc 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts
@@ -74,6 +74,7 @@ describe('searchAfterAndBulkCreate', () => {
ruleSO,
signalsIndex: DEFAULT_SIGNALS_INDEX,
mergeStrategy: 'missingFields',
+ ignoreFields: [],
});
});
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts
index 39728235db39c..9af8680ec726a 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts
@@ -195,6 +195,7 @@ describe('signal_rule_alert_type', () => {
ml: mlMock,
lists: listMock.createSetup(),
mergeStrategy: 'missingFields',
+ ignoreFields: [],
ruleDataService,
});
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts
index 1c4efea0a1d59..68d60f7757e4a 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts
@@ -82,6 +82,7 @@ export const signalRulesAlertType = ({
ml,
lists,
mergeStrategy,
+ ignoreFields,
ruleDataService,
}: {
logger: Logger;
@@ -91,6 +92,7 @@ export const signalRulesAlertType = ({
ml: SetupPlugins['ml'];
lists: SetupPlugins['lists'] | undefined;
mergeStrategy: ConfigType['alertMergeStrategy'];
+ ignoreFields: ConfigType['alertIgnoreFields'];
ruleDataService: IRuleDataPluginService;
}): SignalRuleAlertTypeDefinition => {
return {
@@ -275,12 +277,14 @@ export const signalRulesAlertType = ({
ruleSO: savedObject,
signalsIndex: params.outputIndex,
mergeStrategy,
+ ignoreFields,
});
const wrapSequences = wrapSequencesFactory({
ruleSO: savedObject,
signalsIndex: params.outputIndex,
mergeStrategy,
+ ignoreFields,
});
if (isMlRule(type)) {
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_all_fields_with_source.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_all_fields_with_source.test.ts
index b900ea268fd6e..6af82d3a71028 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_all_fields_with_source.test.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_all_fields_with_source.test.ts
@@ -44,7 +44,7 @@ describe('merge_all_fields_with_source', () => {
test('when source is "undefined", merged doc is "undefined"', () => {
const _source: SignalSourceHit['_source'] = {};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
@@ -53,7 +53,7 @@ describe('merge_all_fields_with_source', () => {
foo: [],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
@@ -62,7 +62,7 @@ describe('merge_all_fields_with_source', () => {
foo: 'value',
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
@@ -71,7 +71,7 @@ describe('merge_all_fields_with_source', () => {
foo: ['value'],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
@@ -80,7 +80,7 @@ describe('merge_all_fields_with_source', () => {
foo: ['value_1', 'value_2'],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
@@ -89,7 +89,7 @@ describe('merge_all_fields_with_source', () => {
foo: { bar: 'some value' },
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
@@ -98,7 +98,7 @@ describe('merge_all_fields_with_source', () => {
foo: [{ bar: 'some value' }],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
@@ -107,7 +107,7 @@ describe('merge_all_fields_with_source', () => {
foo: [{ bar: 'some value' }, { foo: 'some other value' }],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
});
@@ -133,7 +133,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': [],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
@@ -142,7 +142,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': 'value',
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
@@ -151,7 +151,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': ['value'],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
@@ -160,7 +160,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': ['value_1', 'value_2'],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
@@ -169,7 +169,7 @@ describe('merge_all_fields_with_source', () => {
foo: { bar: 'some value' },
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
@@ -178,7 +178,7 @@ describe('merge_all_fields_with_source', () => {
foo: [{ bar: 'some value' }],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
@@ -187,7 +187,7 @@ describe('merge_all_fields_with_source', () => {
foo: [{ bar: 'some value' }, { foo: 'some other value' }],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
});
@@ -217,7 +217,7 @@ describe('merge_all_fields_with_source', () => {
foo: { bar: [] },
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
@@ -226,7 +226,7 @@ describe('merge_all_fields_with_source', () => {
foo: { bar: 'value' },
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
@@ -235,7 +235,7 @@ describe('merge_all_fields_with_source', () => {
foo: { bar: ['value'] },
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
@@ -244,7 +244,7 @@ describe('merge_all_fields_with_source', () => {
foo: { bar: ['value_1', 'value_2'] },
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
@@ -253,7 +253,7 @@ describe('merge_all_fields_with_source', () => {
foo: { bar: { mars: 'some value' } },
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
@@ -262,7 +262,7 @@ describe('merge_all_fields_with_source', () => {
foo: { bar: [{ mars: 'some value' }] },
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
@@ -271,7 +271,7 @@ describe('merge_all_fields_with_source', () => {
foo: { bar: [{ mars: 'some value' }, { mars: 'some other value' }] },
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
});
@@ -299,7 +299,7 @@ describe('merge_all_fields_with_source', () => {
'bar.foo': [],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
@@ -308,7 +308,7 @@ describe('merge_all_fields_with_source', () => {
'bar.foo': 'value',
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
@@ -317,7 +317,7 @@ describe('merge_all_fields_with_source', () => {
'bar.foo': ['value'],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
@@ -326,7 +326,7 @@ describe('merge_all_fields_with_source', () => {
'bar.foo': ['value_1', 'value_2'],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
@@ -335,7 +335,7 @@ describe('merge_all_fields_with_source', () => {
foo: { bar: 'some value' },
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
@@ -344,7 +344,7 @@ describe('merge_all_fields_with_source', () => {
foo: [{ bar: 'some value' }],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
@@ -353,7 +353,7 @@ describe('merge_all_fields_with_source', () => {
foo: [{ bar: 'some value' }, { foo: 'some other value' }],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(_source);
});
});
@@ -376,7 +376,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': ['other_value_1'],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual({
foo: {
bar: 'other_value_1',
@@ -389,7 +389,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': ['other_value_1', 'other_value_2'],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual({
foo: {
bar: ['other_value_1', 'other_value_2'],
@@ -402,7 +402,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': [{ zed: 'other_value_1' }],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual({
foo: { bar: { zed: 'other_value_1' } },
});
@@ -413,7 +413,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual({
foo: { bar: [{ zed: 'other_value_1' }, { zed: 'other_value_2' }] },
});
@@ -440,7 +440,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': ['other_value_1'],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual({
foo: {
bar: 'other_value_1',
@@ -453,7 +453,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': ['other_value_1', 'other_value_2'],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual({
foo: { bar: ['other_value_1', 'other_value_2'] },
});
@@ -464,7 +464,7 @@ describe('merge_all_fields_with_source', () => {
foo: [{ bar: 'other_value_1' }],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual({
foo: {
bar: 'other_value_1',
@@ -477,7 +477,7 @@ describe('merge_all_fields_with_source', () => {
foo: [{ bar: 'other_value_1' }, { bar: 'other_value_2' }],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual({
foo: [{ bar: 'other_value_1' }, { bar: 'other_value_2' }],
});
@@ -503,7 +503,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': ['other_value_1'],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual({
'foo.bar': 'other_value_1',
});
@@ -514,7 +514,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': ['other_value_1', 'other_value_2'],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(fields);
});
@@ -523,7 +523,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': [{ zed: 'other_value_1' }],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual({
'foo.bar': { zed: 'other_value_1' },
});
@@ -534,7 +534,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(fields);
});
});
@@ -560,7 +560,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': ['other_value_1'],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual({
foo: { bar: ['other_value_1'] },
});
@@ -571,7 +571,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': ['other_value_1', 'other_value_2'],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual({
foo: { bar: ['other_value_1', 'other_value_2'] },
});
@@ -582,7 +582,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': [{ zed: 'other_value_1' }],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual({
foo: { bar: [{ zed: 'other_value_1' }] },
});
@@ -593,7 +593,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual({
foo: { bar: [{ zed: 'other_value_1' }, { zed: 'other_value_2' }] },
});
@@ -619,7 +619,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': ['other_value_1'],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(fields);
});
@@ -628,7 +628,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': ['other_value_1', 'other_value_2'],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(fields);
});
@@ -637,7 +637,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': [{ zed: 'other_value_1' }],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(fields);
});
@@ -646,7 +646,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(fields);
});
});
@@ -670,7 +670,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': ['other_value_1'],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual({
foo: { bar: ['other_value_1'] },
});
@@ -681,7 +681,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': ['other_value_1', 'other_value_2'],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual({
foo: { bar: ['other_value_1', 'other_value_2'] },
});
@@ -692,7 +692,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': [{ zed: 'other_value_1' }],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual({
foo: { bar: [{ zed: 'other_value_1' }] },
});
@@ -703,7 +703,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual({
foo: { bar: [{ zed: 'other_value_1' }, { zed: 'other_value_2' }] },
});
@@ -729,7 +729,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': ['other_value_1'],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(fields);
});
@@ -738,7 +738,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': ['other_value_1', 'other_value_2'],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(fields);
});
@@ -747,7 +747,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': [{ zed: 'other_value_1' }],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(fields);
});
@@ -756,7 +756,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual(fields);
});
});
@@ -782,7 +782,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': ['other_value_1'],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual({
foo: {
bar: ['other_value_1'],
@@ -795,7 +795,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': ['other_value_1', 'other_value_2'],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual({
foo: {
bar: ['other_value_1', 'other_value_2'],
@@ -808,7 +808,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': [{ zed: 'other_value_1' }],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual({
foo: {
bar: [{ zed: 'other_value_1' }],
@@ -821,7 +821,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual({
foo: {
bar: [{ zed: 'other_value_1' }, { zed: 'other_value_2' }],
@@ -849,7 +849,7 @@ describe('merge_all_fields_with_source', () => {
'foo.bar': ['other_value_1'],
};
const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields };
- const merged = mergeAllFieldsWithSource({ doc })._source;
+ const merged = mergeAllFieldsWithSource({ doc, ignoreFields: [] })._source;
expect(merged).toEqual