{
const visibleErrorMessage = getOriginalRequestErrorMessage(error) || errorMessage;
+
return (
diff --git a/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx b/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx
index 7e85ce5ecef714..5ab410a1c0af25 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx
@@ -53,6 +53,7 @@ export function createMockVisualization(): jest.Mocked {
setDimension: jest.fn(),
removeDimension: jest.fn(),
+ getErrorMessages: jest.fn((_state, _frame) => undefined),
};
}
@@ -92,6 +93,7 @@ export function createMockDatasource(id: string): DatasourceMock {
// this is an additional property which doesn't exist on real datasources
// but can be used to validate whether specific API mock functions are called
publicAPIMock,
+ getErrorMessages: jest.fn((_state) => undefined),
};
}
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts
index 1609ff1dbc80eb..a3f48b162475a3 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts
@@ -614,4 +614,178 @@ describe('IndexPattern Data Source', () => {
});
});
});
+
+ describe('#getErrorMessages', () => {
+ it('should detect a missing reference in a layer', () => {
+ const state = {
+ indexPatternRefs: [],
+ existingFields: {},
+ isFirstExistenceFetch: false,
+ indexPatterns: expectedIndexPatterns,
+ layers: {
+ first: {
+ indexPatternId: '1',
+ columnOrder: ['col1'],
+ columns: {
+ col1: {
+ dataType: 'number',
+ isBucketed: false,
+ label: 'Foo',
+ operationType: 'count', // <= invalid
+ sourceField: 'bytes',
+ },
+ },
+ },
+ },
+ currentIndexPatternId: '1',
+ };
+ const messages = indexPatternDatasource.getErrorMessages(state as IndexPatternPrivateState);
+ expect(messages).toHaveLength(1);
+ expect(messages![0]).toEqual({
+ shortMessage: 'Invalid reference.',
+ longMessage: 'Field "bytes" has an invalid reference.',
+ });
+ });
+
+ it('should detect and batch missing references in a layer', () => {
+ const state = {
+ indexPatternRefs: [],
+ existingFields: {},
+ isFirstExistenceFetch: false,
+ indexPatterns: expectedIndexPatterns,
+ layers: {
+ first: {
+ indexPatternId: '1',
+ columnOrder: ['col1', 'col2'],
+ columns: {
+ col1: {
+ dataType: 'number',
+ isBucketed: false,
+ label: 'Foo',
+ operationType: 'count', // <= invalid
+ sourceField: 'bytes',
+ },
+ col2: {
+ dataType: 'number',
+ isBucketed: false,
+ label: 'Foo',
+ operationType: 'count', // <= invalid
+ sourceField: 'memory',
+ },
+ },
+ },
+ },
+ currentIndexPatternId: '1',
+ };
+ const messages = indexPatternDatasource.getErrorMessages(state as IndexPatternPrivateState);
+ expect(messages).toHaveLength(1);
+ expect(messages![0]).toEqual({
+ shortMessage: 'Invalid references.',
+ longMessage: 'Fields "bytes", "memory" have invalid reference.',
+ });
+ });
+
+ it('should detect and batch missing references in multiple layers', () => {
+ const state = {
+ indexPatternRefs: [],
+ existingFields: {},
+ isFirstExistenceFetch: false,
+ indexPatterns: expectedIndexPatterns,
+ layers: {
+ first: {
+ indexPatternId: '1',
+ columnOrder: ['col1', 'col2'],
+ columns: {
+ col1: {
+ dataType: 'number',
+ isBucketed: false,
+ label: 'Foo',
+ operationType: 'count', // <= invalid
+ sourceField: 'bytes',
+ },
+ col2: {
+ dataType: 'number',
+ isBucketed: false,
+ label: 'Foo',
+ operationType: 'count', // <= invalid
+ sourceField: 'memory',
+ },
+ },
+ },
+ second: {
+ indexPatternId: '1',
+ columnOrder: ['col1'],
+ columns: {
+ col1: {
+ dataType: 'string',
+ isBucketed: false,
+ label: 'Foo',
+ operationType: 'count', // <= invalid
+ sourceField: 'source',
+ },
+ },
+ },
+ },
+ currentIndexPatternId: '1',
+ };
+ const messages = indexPatternDatasource.getErrorMessages(state as IndexPatternPrivateState);
+ expect(messages).toHaveLength(2);
+ expect(messages).toEqual([
+ {
+ shortMessage: 'Invalid references on Layer 1.',
+ longMessage: 'Layer 1 has invalid references in fields "bytes", "memory".',
+ },
+ {
+ shortMessage: 'Invalid reference on Layer 2.',
+ longMessage: 'Layer 2 has an invalid reference in field "source".',
+ },
+ ]);
+ });
+
+ it('should return no errors if all references are satified', () => {
+ const state = {
+ indexPatternRefs: [],
+ existingFields: {},
+ isFirstExistenceFetch: false,
+ indexPatterns: expectedIndexPatterns,
+ layers: {
+ first: {
+ indexPatternId: '1',
+ columnOrder: ['col1'],
+ columns: {
+ col1: {
+ dataType: 'number',
+ isBucketed: false,
+ label: 'Foo',
+ operationType: 'document',
+ sourceField: 'bytes',
+ },
+ },
+ },
+ },
+ currentIndexPatternId: '1',
+ };
+ expect(
+ indexPatternDatasource.getErrorMessages(state as IndexPatternPrivateState)
+ ).not.toBeDefined();
+ });
+
+ it('should return no errors with layers with no columns', () => {
+ const state: IndexPatternPrivateState = {
+ indexPatternRefs: [],
+ existingFields: {},
+ isFirstExistenceFetch: false,
+ indexPatterns: expectedIndexPatterns,
+ layers: {
+ first: {
+ indexPatternId: '1',
+ columnOrder: [],
+ columns: {},
+ },
+ },
+ currentIndexPatternId: '1',
+ };
+ expect(indexPatternDatasource.getErrorMessages(state)).not.toBeDefined();
+ });
+ });
});
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx
index edc984f5e8016a..0d822927808084 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx
@@ -39,7 +39,12 @@ import {
getDatasourceSuggestionsForVisualizeField,
} from './indexpattern_suggestions';
-import { isDraggedField, normalizeOperationDataType } from './utils';
+import {
+ getInvalidFieldReferencesForLayer,
+ getInvalidReferences,
+ isDraggedField,
+ normalizeOperationDataType,
+} from './utils';
import { LayerPanel } from './layerpanel';
import { IndexPatternColumn } from './operations';
import { IndexPatternField, IndexPatternPrivateState, IndexPatternPersistedState } from './types';
@@ -49,6 +54,7 @@ import { VisualizeFieldContext } from '../../../../../src/plugins/ui_actions/pub
import { deleteColumn } from './state_helpers';
import { Datasource, StateSetter } from '../index';
import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public';
+import { FieldBasedIndexPatternColumn } from './operations/definitions/column_types';
import { Dragging } from '../drag_drop/providers';
export { OperationType, IndexPatternColumn } from './operations';
@@ -335,6 +341,84 @@ export function getIndexPatternDatasource({
},
getDatasourceSuggestionsFromCurrentState,
getDatasourceSuggestionsForVisualizeField,
+
+ getErrorMessages(state) {
+ if (!state) {
+ return;
+ }
+ const invalidLayers = getInvalidReferences(state);
+
+ if (invalidLayers.length === 0) {
+ return;
+ }
+
+ const realIndex = Object.values(state.layers)
+ .map((layer, i) => {
+ const filteredIndex = invalidLayers.indexOf(layer);
+ if (filteredIndex > -1) {
+ return [filteredIndex, i + 1];
+ }
+ })
+ .filter(Boolean) as Array<[number, number]>;
+ const invalidFieldsPerLayer: string[][] = getInvalidFieldReferencesForLayer(
+ invalidLayers,
+ state.indexPatterns
+ );
+ const originalLayersList = Object.keys(state.layers);
+
+ return realIndex.map(([filteredIndex, layerIndex]) => {
+ const fieldsWithBrokenReferences: string[] = invalidFieldsPerLayer[filteredIndex].map(
+ (columnId) => {
+ const column = invalidLayers[filteredIndex].columns[
+ columnId
+ ] as FieldBasedIndexPatternColumn;
+ return column.sourceField;
+ }
+ );
+
+ if (originalLayersList.length === 1) {
+ return {
+ shortMessage: i18n.translate(
+ 'xpack.lens.indexPattern.dataReferenceFailureShortSingleLayer',
+ {
+ defaultMessage: 'Invalid {fields, plural, one {reference} other {references}}.',
+ values: {
+ fields: fieldsWithBrokenReferences.length,
+ },
+ }
+ ),
+ longMessage: i18n.translate(
+ 'xpack.lens.indexPattern.dataReferenceFailureLongSingleLayer',
+ {
+ defaultMessage: `{fieldsLength, plural, one {Field} other {Fields}} "{fields}" {fieldsLength, plural, one {has an} other {have}} invalid reference.`,
+ values: {
+ fields: fieldsWithBrokenReferences.join('", "'),
+ fieldsLength: fieldsWithBrokenReferences.length,
+ },
+ }
+ ),
+ };
+ }
+ return {
+ shortMessage: i18n.translate('xpack.lens.indexPattern.dataReferenceFailureShort', {
+ defaultMessage:
+ 'Invalid {fieldsLength, plural, one {reference} other {references}} on Layer {layer}.',
+ values: {
+ layer: layerIndex,
+ fieldsLength: fieldsWithBrokenReferences.length,
+ },
+ }),
+ longMessage: i18n.translate('xpack.lens.indexPattern.dataReferenceFailureLong', {
+ defaultMessage: `Layer {layer} has {fieldsLength, plural, one {an invalid} other {invalid}} {fieldsLength, plural, one {reference} other {references}} in {fieldsLength, plural, one {field} other {fields}} "{fields}".`,
+ values: {
+ layer: layerIndex,
+ fields: fieldsWithBrokenReferences.join('", "'),
+ fieldsLength: fieldsWithBrokenReferences.length,
+ },
+ }),
+ };
+ });
+ },
};
return indexPatternDatasource;
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts b/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts
index df6bde0ba1a359..d3d65617f2253f 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts
@@ -5,7 +5,7 @@
*/
import { DataType } from '../types';
-import { IndexPatternPrivateState, IndexPattern } from './types';
+import { IndexPatternPrivateState, IndexPattern, IndexPatternLayer } from './types';
import { DraggedField } from './indexpattern';
import {
BaseIndexPatternColumn,
@@ -43,7 +43,11 @@ export function isDraggedField(fieldCandidate: unknown): fieldCandidate is Dragg
}
export function hasInvalidReference(state: IndexPatternPrivateState) {
- return Object.values(state.layers).some((layer) => {
+ return getInvalidReferences(state).length > 0;
+}
+
+export function getInvalidReferences(state: IndexPatternPrivateState) {
+ return Object.values(state.layers).filter((layer) => {
return layer.columnOrder.some((columnId) => {
const column = layer.columns[columnId];
return (
@@ -58,19 +62,39 @@ export function hasInvalidReference(state: IndexPatternPrivateState) {
});
}
+export function getInvalidFieldReferencesForLayer(
+ layers: IndexPatternLayer[],
+ indexPatternMap: Record
+) {
+ return layers.map((layer) => {
+ return layer.columnOrder.filter((columnId) => {
+ const column = layer.columns[columnId];
+ return (
+ hasField(column) &&
+ fieldIsInvalid(
+ column.sourceField,
+ column.operationType,
+ indexPatternMap[layer.indexPatternId]
+ )
+ );
+ });
+ });
+}
+
export function fieldIsInvalid(
sourceField: string | undefined,
operationType: OperationType | undefined,
indexPattern: IndexPattern
) {
const operationDefinition = operationType && operationDefinitionMap[operationType];
+
return Boolean(
sourceField &&
operationDefinition &&
!indexPattern.fields.some(
(field) =>
field.name === sourceField &&
- operationDefinition.input === 'field' &&
+ operationDefinition?.input === 'field' &&
operationDefinition.getPossibleOperationForField(field) !== undefined
)
);
diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts b/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts
index 80c7a174b32646..5ee33f9b4b3dd1 100644
--- a/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts
+++ b/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts
@@ -193,4 +193,28 @@ describe('metric_visualization', () => {
`);
});
});
+
+ describe('#getErrorMessages', () => {
+ it('returns undefined if no error is raised', () => {
+ const datasource: DatasourcePublicAPI = {
+ ...createMockDatasource('l1').publicAPIMock,
+ getOperationForColumnId(_: string) {
+ return {
+ id: 'a',
+ dataType: 'number',
+ isBucketed: false,
+ label: 'shazm',
+ };
+ },
+ };
+ const frame = {
+ ...mockFrame(),
+ datasourceLayers: { l1: datasource },
+ };
+
+ const error = metricVisualization.getErrorMessages(exampleState(), frame);
+
+ expect(error).not.toBeDefined();
+ });
+ });
});
diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx
index 77d189ce53d012..b75ac89d7e4d86 100644
--- a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx
+++ b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx
@@ -115,4 +115,9 @@ export const metricVisualization: Visualization = {
removeDimension({ prevState }) {
return { ...prevState, accessor: undefined };
},
+
+ getErrorMessages(state, frame) {
+ // Is it possible to break it?
+ return undefined;
+ },
};
diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts b/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts
new file mode 100644
index 00000000000000..628d42d3de6670
--- /dev/null
+++ b/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts
@@ -0,0 +1,73 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { getPieVisualization } from './visualization';
+import { PieVisualizationState } from './types';
+import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_service/mocks';
+import { DatasourcePublicAPI, FramePublicAPI } from '../types';
+import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks';
+
+jest.mock('../id_generator');
+
+const LAYER_ID = 'l1';
+
+const pieVisualization = getPieVisualization({
+ paletteService: chartPluginMock.createPaletteRegistry(),
+});
+
+function exampleState(): PieVisualizationState {
+ return {
+ shape: 'pie',
+ layers: [
+ {
+ layerId: LAYER_ID,
+ groups: [],
+ metric: undefined,
+ numberDisplay: 'percent',
+ categoryDisplay: 'default',
+ legendDisplay: 'default',
+ nestedLegend: false,
+ },
+ ],
+ };
+}
+
+function mockFrame(): FramePublicAPI {
+ return {
+ ...createMockFramePublicAPI(),
+ addNewLayer: () => LAYER_ID,
+ datasourceLayers: {
+ [LAYER_ID]: createMockDatasource(LAYER_ID).publicAPIMock,
+ },
+ };
+}
+
+// Just a basic bootstrap here to kickstart the tests
+describe('pie_visualization', () => {
+ describe('#getErrorMessages', () => {
+ it('returns undefined if no error is raised', () => {
+ const datasource: DatasourcePublicAPI = {
+ ...createMockDatasource('l1').publicAPIMock,
+ getOperationForColumnId(_: string) {
+ return {
+ id: 'a',
+ dataType: 'number',
+ isBucketed: false,
+ label: 'shazm',
+ };
+ },
+ };
+ const frame = {
+ ...mockFrame(),
+ datasourceLayers: { l1: datasource },
+ };
+
+ const error = pieVisualization.getErrorMessages(exampleState(), frame);
+
+ expect(error).not.toBeDefined();
+ });
+ });
+});
diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx
index 791480162b7fac..62e99396edbc72 100644
--- a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx
+++ b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx
@@ -233,4 +233,9 @@ export const getPieVisualization = ({
domElement
);
},
+
+ getErrorMessages(state, frame) {
+ // not possible to break it?
+ return undefined;
+ },
});
diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts
index 6696a9328c8371..27ab8f258bba85 100644
--- a/x-pack/plugins/lens/public/types.ts
+++ b/x-pack/plugins/lens/public/types.ts
@@ -181,6 +181,7 @@ export interface Datasource {
getDatasourceSuggestionsFromCurrentState: (state: T) => Array>;
getPublicAPI: (props: PublicAPIProps) => DatasourcePublicAPI;
+ getErrorMessages: (state: T) => Array<{ shortMessage: string; longMessage: string }> | undefined;
/**
* uniqueLabels of dimensions exposed for aria-labels of dragged dimensions
*/
@@ -571,6 +572,14 @@ export interface Visualization {
state: T,
datasourceLayers: Record
) => Ast | string | null;
+ /**
+ * The frame will call this function on all visualizations at few stages (pre-build/build error) in order
+ * to provide more context to the error and show it to the user
+ */
+ getErrorMessages: (
+ state: T,
+ frame: FramePublicAPI
+ ) => Array<{ shortMessage: string; longMessage: string }> | undefined;
}
export interface LensFilterEvent {
diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts
index 4dde646ab64a5d..7c49afa53af3ea 100644
--- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts
+++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts
@@ -407,4 +407,219 @@ describe('xy_visualization', () => {
expect(ops.filter(filterOperations).map((x) => x.dataType)).toEqual(['number']);
});
});
+
+ describe('#getErrorMessages', () => {
+ it("should not return an error when there's only one dimension (X or Y)", () => {
+ expect(
+ xyVisualization.getErrorMessages(
+ {
+ ...exampleState(),
+ layers: [
+ {
+ layerId: 'first',
+ seriesType: 'area',
+ xAccessor: 'a',
+ accessors: [],
+ },
+ ],
+ },
+ createMockFramePublicAPI()
+ )
+ ).not.toBeDefined();
+ });
+ it("should not return an error when there's only one dimension on multiple layers (same axis everywhere)", () => {
+ expect(
+ xyVisualization.getErrorMessages(
+ {
+ ...exampleState(),
+ layers: [
+ {
+ layerId: 'first',
+ seriesType: 'area',
+ xAccessor: 'a',
+ accessors: [],
+ },
+ {
+ layerId: 'second',
+ seriesType: 'area',
+ xAccessor: 'a',
+ accessors: [],
+ },
+ ],
+ },
+ createMockFramePublicAPI()
+ )
+ ).not.toBeDefined();
+ });
+ it('should not return an error when mixing different valid configurations in multiple layers', () => {
+ expect(
+ xyVisualization.getErrorMessages(
+ {
+ ...exampleState(),
+ layers: [
+ {
+ layerId: 'first',
+ seriesType: 'area',
+ xAccessor: 'a',
+ accessors: ['a'],
+ },
+ {
+ layerId: 'second',
+ seriesType: 'area',
+ xAccessor: undefined,
+ accessors: ['a'],
+ splitAccessor: 'a',
+ },
+ ],
+ },
+ createMockFramePublicAPI()
+ )
+ ).not.toBeDefined();
+ });
+ it("should not return an error when there's only one splitAccessor dimension configured", () => {
+ expect(
+ xyVisualization.getErrorMessages(
+ {
+ ...exampleState(),
+ layers: [
+ {
+ layerId: 'first',
+ seriesType: 'area',
+ xAccessor: undefined,
+ accessors: [],
+ splitAccessor: 'a',
+ },
+ ],
+ },
+ createMockFramePublicAPI()
+ )
+ ).not.toBeDefined();
+
+ expect(
+ xyVisualization.getErrorMessages(
+ {
+ ...exampleState(),
+ layers: [
+ {
+ layerId: 'first',
+ seriesType: 'area',
+ xAccessor: undefined,
+ accessors: [],
+ splitAccessor: 'a',
+ },
+ {
+ layerId: 'second',
+ seriesType: 'area',
+ xAccessor: undefined,
+ accessors: [],
+ splitAccessor: 'a',
+ },
+ ],
+ },
+ createMockFramePublicAPI()
+ )
+ ).not.toBeDefined();
+ });
+ it('should return an error when there are multiple layers, one axis configured for each layer (but different axis from each other)', () => {
+ expect(
+ xyVisualization.getErrorMessages(
+ {
+ ...exampleState(),
+ layers: [
+ {
+ layerId: 'first',
+ seriesType: 'area',
+ xAccessor: 'a',
+ accessors: [],
+ },
+ {
+ layerId: 'second',
+ seriesType: 'area',
+ xAccessor: undefined,
+ accessors: ['a'],
+ },
+ ],
+ },
+ createMockFramePublicAPI()
+ )
+ ).toEqual([
+ {
+ shortMessage: 'Missing Vertical axis.',
+ longMessage: 'Layer 1 requires a field for the Vertical axis.',
+ },
+ ]);
+ });
+ it('should return an error with batched messages for the same error with multiple layers', () => {
+ expect(
+ xyVisualization.getErrorMessages(
+ {
+ ...exampleState(),
+ layers: [
+ {
+ layerId: 'first',
+ seriesType: 'area',
+ xAccessor: 'a',
+ accessors: ['a'],
+ },
+ {
+ layerId: 'second',
+ seriesType: 'area',
+ xAccessor: undefined,
+ accessors: [],
+ splitAccessor: 'a',
+ },
+ {
+ layerId: 'third',
+ seriesType: 'area',
+ xAccessor: undefined,
+ accessors: [],
+ splitAccessor: 'a',
+ },
+ ],
+ },
+ createMockFramePublicAPI()
+ )
+ ).toEqual([
+ {
+ shortMessage: 'Missing Vertical axis.',
+ longMessage: 'Layers 2, 3 require a field for the Vertical axis.',
+ },
+ ]);
+ });
+ it("should return an error when some layers are complete but other layers aren't", () => {
+ expect(
+ xyVisualization.getErrorMessages(
+ {
+ ...exampleState(),
+ layers: [
+ {
+ layerId: 'first',
+ seriesType: 'area',
+ xAccessor: 'a',
+ accessors: [],
+ },
+ {
+ layerId: 'second',
+ seriesType: 'area',
+ xAccessor: 'a',
+ accessors: ['a'],
+ },
+ {
+ layerId: 'third',
+ seriesType: 'area',
+ xAccessor: 'a',
+ accessors: ['a'],
+ },
+ ],
+ },
+ createMockFramePublicAPI()
+ )
+ ).toEqual([
+ {
+ shortMessage: 'Missing Vertical axis.',
+ longMessage: 'Layer 1 requires a field for the Vertical axis.',
+ },
+ ]);
+ });
+ });
});
diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx
index c41d8e977297b2..c7f775586ca0da 100644
--- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx
+++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx
@@ -174,29 +174,16 @@ export const getXyVisualization = ({
groups: [
{
groupId: 'x',
- groupLabel: isHorizontal
- ? i18n.translate('xpack.lens.xyChart.verticalAxisLabel', {
- defaultMessage: 'Vertical axis',
- })
- : i18n.translate('xpack.lens.xyChart.horizontalAxisLabel', {
- defaultMessage: 'Horizontal axis',
- }),
+ groupLabel: getAxisName('x', { isHorizontal }),
accessors: layer.xAccessor ? [layer.xAccessor] : [],
filterOperations: isBucketed,
suggestedPriority: 1,
supportsMoreColumns: !layer.xAccessor,
- required: !layer.seriesType.includes('percentage'),
dataTestSubj: 'lnsXY_xDimensionPanel',
},
{
groupId: 'y',
- groupLabel: isHorizontal
- ? i18n.translate('xpack.lens.xyChart.horizontalAxisLabel', {
- defaultMessage: 'Horizontal axis',
- })
- : i18n.translate('xpack.lens.xyChart.verticalAxisLabel', {
- defaultMessage: 'Vertical axis',
- }),
+ groupLabel: getAxisName('y', { isHorizontal }),
accessors: sortedAccessors,
filterOperations: isNumericMetric,
supportsMoreColumns: true,
@@ -309,8 +296,117 @@ export const getXyVisualization = ({
toExpression: (state, layers, attributes) =>
toExpression(state, layers, paletteService, attributes),
toPreviewExpression: (state, layers) => toPreviewExpression(state, layers, paletteService),
+
+ getErrorMessages(state, frame) {
+ // Data error handling below here
+ const hasNoAccessors = ({ accessors }: LayerConfig) =>
+ accessors == null || accessors.length === 0;
+ const hasNoSplitAccessor = ({ splitAccessor, seriesType }: LayerConfig) =>
+ seriesType.includes('percentage') && splitAccessor == null;
+
+ const errors: Array<{
+ shortMessage: string;
+ longMessage: string;
+ }> = [];
+
+ // check if the layers in the state are compatible with this type of chart
+ if (state && state.layers.length > 1) {
+ // Order is important here: Y Axis is fundamental to exist to make it valid
+ const checks: Array<[string, (layer: LayerConfig) => boolean]> = [
+ ['Y', hasNoAccessors],
+ ['Break down', hasNoSplitAccessor],
+ ];
+
+ // filter out those layers with no accessors at all
+ const filteredLayers = state.layers.filter(
+ ({ accessors, xAccessor, splitAccessor }: LayerConfig) =>
+ accessors.length > 0 || xAccessor != null || splitAccessor != null
+ );
+ for (const [dimension, criteria] of checks) {
+ const result = validateLayersForDimension(dimension, filteredLayers, criteria);
+ if (!result.valid) {
+ errors.push(result.payload);
+ }
+ }
+ }
+
+ return errors.length ? errors : undefined;
+ },
});
+function validateLayersForDimension(
+ dimension: string,
+ layers: LayerConfig[],
+ missingCriteria: (layer: LayerConfig) => boolean
+):
+ | { valid: true }
+ | {
+ valid: false;
+ payload: { shortMessage: string; longMessage: string };
+ } {
+ // Multiple layers must be consistent:
+ // * either a dimension is missing in ALL of them
+ // * or should not miss on any
+ if (layers.every(missingCriteria) || !layers.some(missingCriteria)) {
+ return { valid: true };
+ }
+ // otherwise it's an error and it has to be reported
+ const layerMissingAccessors = layers.reduce((missing: number[], layer, i) => {
+ if (missingCriteria(layer)) {
+ missing.push(i);
+ }
+ return missing;
+ }, []);
+
+ return {
+ valid: false,
+ payload: getMessageIdsForDimension(dimension, layerMissingAccessors, isHorizontalChart(layers)),
+ };
+}
+
+function getAxisName(axis: 'x' | 'y', { isHorizontal }: { isHorizontal: boolean }) {
+ const vertical = i18n.translate('xpack.lens.xyChart.verticalAxisLabel', {
+ defaultMessage: 'Vertical axis',
+ });
+ const horizontal = i18n.translate('xpack.lens.xyChart.horizontalAxisLabel', {
+ defaultMessage: 'Horizontal axis',
+ });
+ if (axis === 'x') {
+ return isHorizontal ? vertical : horizontal;
+ }
+ return isHorizontal ? horizontal : vertical;
+}
+
+// i18n ids cannot be dynamically generated, hence the function below
+function getMessageIdsForDimension(dimension: string, layers: number[], isHorizontal: boolean) {
+ const layersList = layers.map((i: number) => i + 1).join(', ');
+ switch (dimension) {
+ case 'Break down':
+ return {
+ shortMessage: i18n.translate('xpack.lens.xyVisualization.dataFailureSplitShort', {
+ defaultMessage: `Missing {axis}.`,
+ values: { axis: 'Break down by axis' },
+ }),
+ longMessage: i18n.translate('xpack.lens.xyVisualization.dataFailureSplitLong', {
+ defaultMessage: `{layers, plural, one {Layer} other {Layers}} {layersList} {layers, plural, one {requires} other {require}} a field for the {axis}.`,
+ values: { layers: layers.length, layersList, axis: 'Break down by axis' },
+ }),
+ };
+ case 'Y':
+ return {
+ shortMessage: i18n.translate('xpack.lens.xyVisualization.dataFailureYShort', {
+ defaultMessage: `Missing {axis}.`,
+ values: { axis: getAxisName('y', { isHorizontal }) },
+ }),
+ longMessage: i18n.translate('xpack.lens.xyVisualization.dataFailureYLong', {
+ defaultMessage: `{layers, plural, one {Layer} other {Layers}} {layersList} {layers, plural, one {requires} other {require}} a field for the {axis}.`,
+ values: { layers: layers.length, layersList, axis: getAxisName('y', { isHorizontal }) },
+ }),
+ };
+ }
+ return { shortMessage: '', longMessage: '' };
+}
+
function newLayerState(seriesType: SeriesType, layerId: string): LayerConfig {
return {
layerId,
diff --git a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js
index 100b2afcc97cea..73163cb70ada9a 100644
--- a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js
+++ b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js
@@ -98,7 +98,7 @@ class AnnotationsTableUI extends Component {
if (dataCounts.processed_record_count > 0) {
// Load annotations for the selected job.
ml.annotations
- .getAnnotations({
+ .getAnnotations$({
jobIds: [job.job_id],
earliestMs: null,
latestMs: null,
diff --git a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.test.js b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.test.js
index 11e196b1c8e3f6..b19328f89fbe47 100644
--- a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.test.js
+++ b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.test.js
@@ -24,7 +24,7 @@ jest.mock('../../../services/ml_api_service', () => {
return {
ml: {
annotations: {
- getAnnotations: jest.fn().mockReturnValue(mockAnnotations$),
+ getAnnotations$: jest.fn().mockReturnValue(mockAnnotations$),
},
},
};
diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js
index c3bdacde5abd87..f6889c9a6f24c2 100644
--- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js
+++ b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js
@@ -392,7 +392,7 @@ export function loadAnnotationsTableData(selectedCells, selectedJobs, interval,
return new Promise((resolve) => {
ml.annotations
- .getAnnotations({
+ .getAnnotations$({
jobIds,
earliestMs: timeRange.earliestMs,
latestMs: timeRange.latestMs,
diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/annotations.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/annotations.ts
index f9e19ba6f757ea..d028bacb49a778 100644
--- a/x-pack/plugins/ml/public/application/services/ml_api_service/annotations.ts
+++ b/x-pack/plugins/ml/public/application/services/ml_api_service/annotations.ts
@@ -13,7 +13,7 @@ import { http, http$ } from '../http_service';
import { basePath } from './index';
export const annotations = {
- getAnnotations(obj: {
+ getAnnotations$(obj: {
jobIds: string[];
earliestMs: number;
latestMs: number;
@@ -30,6 +30,23 @@ export const annotations = {
});
},
+ getAnnotations(obj: {
+ jobIds: string[];
+ earliestMs: number | null;
+ latestMs: number | null;
+ maxAnnotations: number;
+ fields?: FieldToBucket[];
+ detectorIndex?: number;
+ entities?: any[];
+ }) {
+ const body = JSON.stringify(obj);
+ return http({
+ path: `${basePath()}/annotations`,
+ method: 'POST',
+ body,
+ });
+ },
+
indexAnnotation(obj: Annotation) {
const body = JSON.stringify(obj);
return http({
diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/_timeseriesexplorer_annotations.scss b/x-pack/plugins/ml/public/application/timeseriesexplorer/_timeseriesexplorer_annotations.scss
index 4399327c55dca7..0c38d8e7ca1718 100644
--- a/x-pack/plugins/ml/public/application/timeseriesexplorer/_timeseriesexplorer_annotations.scss
+++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/_timeseriesexplorer_annotations.scss
@@ -76,3 +76,25 @@ $mlAnnotationRectDefaultFillOpacity: 0.05;
.mlAnnotationHidden {
display: none;
}
+
+// context annotation marker
+.mlContextAnnotationRect {
+ stroke: $euiColorFullShade;
+ stroke-width: $mlAnnotationBorderWidth;
+ stroke-opacity: $mlAnnotationRectDefaultStrokeOpacity;
+ transition: stroke-opacity $euiAnimSpeedFast;
+
+ fill: $euiColorFullShade;
+ fill-opacity: $mlAnnotationRectDefaultFillOpacity;
+ transition: fill-opacity $euiAnimSpeedFast;
+
+ shape-rendering: geometricPrecision;
+}
+
+.mlContextAnnotationRect-isBlur {
+ stroke-opacity: $mlAnnotationRectDefaultStrokeOpacity / 2;
+ transition: stroke-opacity $euiAnimSpeedFast;
+
+ fill-opacity: $mlAnnotationRectDefaultFillOpacity / 2;
+ transition: fill-opacity $euiAnimSpeedFast;
+}
diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.d.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.d.ts
index 9a06f6d6b8e035..04b666b4fc6848 100644
--- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.d.ts
+++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.d.ts
@@ -6,6 +6,7 @@
import d3 from 'd3';
+import React from 'react';
import { Annotation } from '../../../../../common/types/annotations';
import { CombinedJob } from '../../../../../common/types/anomaly_detection_jobs';
import { ChartTooltipService } from '../../../components/chart_tooltip';
@@ -19,6 +20,33 @@ interface State {
annotation: Annotation | null;
}
-export interface TimeseriesChart extends React.Component {
+interface TimeseriesChartProps {
+ annotation: object;
+ autoZoomDuration: number;
+ bounds: object;
+ contextAggregationInterval: object;
+ contextChartData: any[];
+ contextForecastData: any[];
+ contextChartSelected: any;
+ detectorIndex: number;
+ focusAggregationInterval: object;
+ focusAnnotationData: Annotation[];
+ focusChartData: any[];
+ focusForecastData: any[];
+ modelPlotEnabled: boolean;
+ renderFocusChartOnly: boolean;
+ selectedJob: CombinedJob;
+ showForecast: boolean;
+ showModelBounds: boolean;
+ svgWidth: number;
+ swimlaneData: any[];
+ zoomFrom: object;
+ zoomTo: object;
+ zoomFromFocusLoaded: object;
+ zoomToFocusLoaded: object;
+ tooltipService: object;
+}
+
+declare class TimeseriesChart extends React.Component {
focusXScale: d3.scale.Ordinal<{}, number>;
}
diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js
index 448d39db3e4441..3169ecfd1bbc76 100644
--- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js
+++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js
@@ -25,6 +25,7 @@ import { annotation$ } from '../../../services/annotations_service';
import { formatValue } from '../../../formatters/format_value';
import {
LINE_CHART_ANOMALY_RADIUS,
+ ANNOTATION_SYMBOL_HEIGHT,
MULTI_BUCKET_SYMBOL_SIZE,
SCHEDULED_EVENT_SYMBOL_HEIGHT,
drawLineChartDots,
@@ -48,6 +49,7 @@ import {
renderAnnotations,
highlightFocusChartAnnotation,
unhighlightFocusChartAnnotation,
+ ANNOTATION_MIN_WIDTH,
} from './timeseries_chart_annotations';
const focusZoomPanelHeight = 25;
@@ -57,6 +59,8 @@ const contextChartHeight = 60;
const contextChartLineTopMargin = 3;
const chartSpacing = 25;
const swimlaneHeight = 30;
+const ctxAnnotationMargin = 2;
+const annotationHeight = ANNOTATION_SYMBOL_HEIGHT + ctxAnnotationMargin * 2;
const margin = { top: 10, right: 10, bottom: 15, left: 40 };
const ZOOM_INTERVAL_OPTIONS = [
@@ -80,9 +84,16 @@ const anomalyGrayScale = d3.scale
.domain([3, 25, 50, 75, 100])
.range(['#dce7ed', '#b0c5d6', '#b1a34e', '#b17f4e', '#c88686']);
-function getSvgHeight() {
+function getSvgHeight(showAnnotations) {
+ const adjustedAnnotationHeight = showAnnotations ? annotationHeight : 0;
return (
- focusHeight + contextChartHeight + swimlaneHeight + chartSpacing + margin.top + margin.bottom
+ focusHeight +
+ contextChartHeight +
+ swimlaneHeight +
+ adjustedAnnotationHeight +
+ chartSpacing +
+ margin.top +
+ margin.bottom
);
}
@@ -225,7 +236,12 @@ class TimeseriesChartIntl extends Component {
}
componentDidUpdate(prevProps) {
- if (this.props.renderFocusChartOnly === false || prevProps.svgWidth !== this.props.svgWidth) {
+ if (
+ this.props.renderFocusChartOnly === false ||
+ prevProps.svgWidth !== this.props.svgWidth ||
+ prevProps.showAnnotations !== this.props.showAnnotations ||
+ prevProps.annotationData !== this.props.annotationData
+ ) {
this.renderChart();
this.drawContextChartSelection();
}
@@ -246,6 +262,7 @@ class TimeseriesChartIntl extends Component {
modelPlotEnabled,
selectedJob,
svgWidth,
+ showAnnotations,
} = this.props;
const createFocusChart = this.createFocusChart.bind(this);
@@ -254,7 +271,7 @@ class TimeseriesChartIntl extends Component {
const focusYAxis = this.focusYAxis;
const focusYScale = this.focusYScale;
- const svgHeight = getSvgHeight();
+ const svgHeight = getSvgHeight(showAnnotations);
// Clear any existing elements from the visualization,
// then build the svg elements for the bubble chart.
@@ -367,7 +384,13 @@ class TimeseriesChartIntl extends Component {
// Draw each of the component elements.
createFocusChart(focus, this.vizWidth, focusHeight);
- drawContextElements(context, this.vizWidth, contextChartHeight, swimlaneHeight);
+ drawContextElements(
+ context,
+ this.vizWidth,
+ contextChartHeight,
+ swimlaneHeight,
+ annotationHeight
+ );
}
contextChartInitialized = false;
@@ -947,10 +970,19 @@ class TimeseriesChartIntl extends Component {
}
drawContextElements(cxtGroup, cxtWidth, cxtChartHeight, swlHeight) {
- const { bounds, contextChartData, contextForecastData, modelPlotEnabled } = this.props;
-
+ const {
+ bounds,
+ contextChartData,
+ contextForecastData,
+ modelPlotEnabled,
+ annotationData,
+ showAnnotations,
+ } = this.props;
const data = contextChartData;
+ const showFocusChartTooltip = this.showFocusChartTooltip.bind(this);
+ const hideFocusChartTooltip = this.props.tooltipService.hide.bind(this.props.tooltipService);
+
this.contextXScale = d3.time
.scale()
.range([0, cxtWidth])
@@ -997,20 +1029,26 @@ class TimeseriesChartIntl extends Component {
.domain([chartLimits.min, chartLimits.max]);
const borders = cxtGroup.append('g').attr('class', 'axis');
+ const brushChartHeight = showAnnotations
+ ? cxtChartHeight + swlHeight + annotationHeight
+ : cxtChartHeight + swlHeight;
// Add borders left and right.
+ borders.append('line').attr('x1', 0).attr('y1', 0).attr('x2', 0).attr('y2', brushChartHeight);
borders
.append('line')
- .attr('x1', 0)
+ .attr('x1', cxtWidth)
.attr('y1', 0)
- .attr('x2', 0)
- .attr('y2', cxtChartHeight + swlHeight);
+ .attr('x2', cxtWidth)
+ .attr('y2', brushChartHeight);
+
+ // Add bottom borders
borders
.append('line')
- .attr('x1', cxtWidth)
- .attr('y1', 0)
+ .attr('x1', 0)
+ .attr('y1', brushChartHeight)
.attr('x2', cxtWidth)
- .attr('y2', cxtChartHeight + swlHeight);
+ .attr('y2', brushChartHeight);
// Add x axis.
const timeBuckets = getTimeBucketsFromCache();
@@ -1065,6 +1103,61 @@ class TimeseriesChartIntl extends Component {
cxtGroup.append('path').datum(data).attr('class', 'values-line').attr('d', contextValuesLine);
drawLineChartDots(data, cxtGroup, contextValuesLine, 1);
+ // Add annotation markers to the context area
+ cxtGroup.append('g').classed('mlContextAnnotations', true);
+
+ const [contextXRangeStart, contextXRangeEnd] = this.contextXScale.range();
+ const ctxAnnotations = cxtGroup
+ .select('.mlContextAnnotations')
+ .selectAll('g.mlContextAnnotation')
+ .data(showAnnotations && annotationData ? annotationData : [], (d) => d._id || '');
+
+ ctxAnnotations.enter().append('g').classed('mlContextAnnotation', true);
+
+ const ctxAnnotationRects = ctxAnnotations
+ .selectAll('.mlContextAnnotationRect')
+ .data((d) => [d]);
+
+ ctxAnnotationRects
+ .enter()
+ .append('rect')
+ .attr('rx', ctxAnnotationMargin)
+ .attr('ry', ctxAnnotationMargin)
+ .on('mouseover', function (d) {
+ showFocusChartTooltip(d, this);
+ })
+ .on('mouseout', () => hideFocusChartTooltip())
+ .classed('mlContextAnnotationRect', true);
+
+ ctxAnnotationRects
+ .attr('x', (d) => {
+ const date = moment(d.timestamp);
+ let xPos = this.contextXScale(date);
+
+ if (xPos - ANNOTATION_SYMBOL_HEIGHT <= contextXRangeStart) {
+ xPos = 0;
+ }
+ if (xPos + ANNOTATION_SYMBOL_HEIGHT >= contextXRangeEnd) {
+ xPos = contextXRangeEnd - ANNOTATION_SYMBOL_HEIGHT;
+ }
+
+ return xPos;
+ })
+ .attr('y', cxtChartHeight + swlHeight + 2)
+ .attr('height', ANNOTATION_SYMBOL_HEIGHT)
+ .attr('width', (d) => {
+ const start = this.contextXScale(moment(d.timestamp)) + 1;
+ const end =
+ typeof d.end_timestamp !== 'undefined'
+ ? this.contextXScale(moment(d.end_timestamp)) - 1
+ : start + ANNOTATION_MIN_WIDTH;
+ const width = Math.max(ANNOTATION_MIN_WIDTH, end - start);
+ return width;
+ });
+
+ ctxAnnotations.classed('mlAnnotationHidden', !showAnnotations);
+ ctxAnnotationRects.exit().remove();
+
// Create the path elements for the forecast value line and bounds area.
if (contextForecastData !== undefined) {
cxtGroup
diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_annotations.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_annotations.ts
index 0b541d54ee7b3e..bd86d07dcd8b7d 100644
--- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_annotations.ts
+++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_annotations.ts
@@ -90,7 +90,7 @@ const ANNOTATION_DEFAULT_LEVEL = 1;
const ANNOTATION_LEVEL_HEIGHT = 28;
const ANNOTATION_UPPER_RECT_MARGIN = 0;
const ANNOTATION_UPPER_TEXT_MARGIN = -7;
-const ANNOTATION_MIN_WIDTH = 2;
+export const ANNOTATION_MIN_WIDTH = 2;
const ANNOTATION_RECT_BORDER_RADIUS = 2;
const ANNOTATION_TEXT_VERTICAL_OFFSET = 26;
const ANNOTATION_TEXT_RECT_VERTICAL_OFFSET = 12;
diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_with_tooltip.tsx b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_with_tooltip.tsx
new file mode 100644
index 00000000000000..89e7d292dbdf2b
--- /dev/null
+++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_with_tooltip.tsx
@@ -0,0 +1,140 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { FC, useEffect, useState, useCallback } from 'react';
+import { i18n } from '@kbn/i18n';
+import { MlTooltipComponent } from '../../../components/chart_tooltip';
+import { TimeseriesChart } from './timeseries_chart';
+import { CombinedJob } from '../../../../../common/types/anomaly_detection_jobs';
+import { ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE } from '../../../../../common/constants/search';
+import { extractErrorMessage } from '../../../../../common/util/errors';
+import { Annotation } from '../../../../../common/types/annotations';
+import { useMlKibana, useNotifications } from '../../../contexts/kibana';
+import { getBoundsRoundedToInterval } from '../../../util/time_buckets';
+import { ANNOTATION_EVENT_USER } from '../../../../../common/constants/annotations';
+import { getControlsForDetector } from '../../get_controls_for_detector';
+
+interface TimeSeriesChartWithTooltipsProps {
+ bounds: any;
+ detectorIndex: number;
+ renderFocusChartOnly: boolean;
+ selectedJob: CombinedJob;
+ selectedEntities: Record;
+ showAnnotations: boolean;
+ showForecast: boolean;
+ showModelBounds: boolean;
+ chartProps: any;
+ lastRefresh: number;
+ contextAggregationInterval: any;
+}
+export const TimeSeriesChartWithTooltips: FC = ({
+ bounds,
+ detectorIndex,
+ renderFocusChartOnly,
+ selectedJob,
+ selectedEntities,
+ showAnnotations,
+ showForecast,
+ showModelBounds,
+ chartProps,
+ lastRefresh,
+ contextAggregationInterval,
+}) => {
+ const { toasts: toastNotifications } = useNotifications();
+ const {
+ services: {
+ mlServices: { mlApiServices },
+ },
+ } = useMlKibana();
+
+ const [annotationData, setAnnotationData] = useState([]);
+
+ const showAnnotationErrorToastNotification = useCallback((error?: string) => {
+ toastNotifications.addDanger({
+ title: i18n.translate(
+ 'xpack.ml.timeSeriesExplorer.mlSingleMetricViewerChart.annotationsErrorTitle',
+ {
+ defaultMessage: 'An error occurred fetching annotations',
+ }
+ ),
+ ...(error ? { text: extractErrorMessage(error) } : {}),
+ });
+ }, []);
+
+ useEffect(() => {
+ let unmounted = false;
+ const entities = getControlsForDetector(detectorIndex, selectedEntities, selectedJob.job_id);
+ const nonBlankEntities = Array.isArray(entities)
+ ? entities.filter((entity) => entity.fieldValue !== null)
+ : undefined;
+ const searchBounds = getBoundsRoundedToInterval(bounds, contextAggregationInterval, false);
+
+ /**
+ * Loads the full list of annotations for job without any aggs or time boundaries
+ * used to indicate existence of annotations that are beyond the selected time
+ * in the time series brush area
+ */
+ const loadAnnotations = async (jobId: string) => {
+ try {
+ const resp = await mlApiServices.annotations.getAnnotations({
+ jobIds: [jobId],
+ earliestMs: searchBounds.min.valueOf(),
+ latestMs: searchBounds.max.valueOf(),
+ maxAnnotations: ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE,
+ fields: [
+ {
+ field: 'event',
+ missing: ANNOTATION_EVENT_USER,
+ },
+ ],
+ detectorIndex,
+ entities: nonBlankEntities,
+ });
+ if (!unmounted) {
+ if (Array.isArray(resp.annotations[jobId])) {
+ setAnnotationData(resp.annotations[jobId]);
+ }
+ }
+ } catch (error) {
+ showAnnotationErrorToastNotification(error);
+ }
+ };
+
+ loadAnnotations(selectedJob.job_id);
+
+ return () => {
+ unmounted = true;
+ };
+ }, [
+ selectedJob.job_id,
+ detectorIndex,
+ lastRefresh,
+ selectedEntities,
+ bounds,
+ contextAggregationInterval,
+ ]);
+
+ return (
+
+
+ {(tooltipService) => (
+
+ )}
+
+
+ );
+};
diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts
index cb66b8d53e6609..530ba567ed9f78 100644
--- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts
+++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts
@@ -6,7 +6,7 @@
import { FC } from 'react';
-import { getDateFormatTz, TimeRangeBounds } from '../explorer/explorer_utils';
+import { TimeRangeBounds } from '../explorer/explorer_utils';
declare const TimeSeriesExplorer: FC<{
appStateHandler: (action: string, payload: any) => void;
diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js
index 5e452dab2f883a..720c1377d4035d 100644
--- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js
+++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js
@@ -47,12 +47,10 @@ import {
import { AnnotationFlyout } from '../components/annotations/annotation_flyout';
import { AnnotationsTable } from '../components/annotations/annotations_table';
import { AnomaliesTable } from '../components/anomalies_table/anomalies_table';
-import { MlTooltipComponent } from '../components/chart_tooltip';
import { ForecastingModal } from './components/forecasting_modal/forecasting_modal';
import { LoadingIndicator } from '../components/loading_indicator/loading_indicator';
import { SelectInterval } from '../components/controls/select_interval/select_interval';
import { SelectSeverity } from '../components/controls/select_severity/select_severity';
-import { TimeseriesChart } from './components/timeseries_chart/timeseries_chart';
import { TimeseriesexplorerNoChartData } from './components/timeseriesexplorer_no_chart_data';
import { TimeSeriesExplorerPage } from './timeseriesexplorer_page';
@@ -83,6 +81,7 @@ import {
import { ANOMALY_DETECTION_DEFAULT_TIME_RANGE } from '../../../common/constants/settings';
import { getControlsForDetector } from './get_controls_for_detector';
import { SeriesControls } from './components/series_controls';
+import { TimeSeriesChartWithTooltips } from './components/timeseries_chart/timeseries_chart_with_tooltip';
// Used to indicate the chart is being plotted across
// all partition field values, where the cardinality of the field cannot be
@@ -175,6 +174,7 @@ export class TimeSeriesExplorer extends React.Component {
this.resizeRef.current !== null ? this.resizeRef.current.offsetWidth - containerPadding : 0,
});
};
+ unmounted = false;
/**
* Subject for listening brush time range selection.
@@ -877,6 +877,7 @@ export class TimeSeriesExplorer extends React.Component {
componentWillUnmount() {
this.subscriptions.unsubscribe();
this.resizeChecker.destroy();
+ this.unmounted = true;
}
render() {
@@ -957,7 +958,6 @@ export class TimeSeriesExplorer extends React.Component {
isEqual(this.previousChartProps.focusForecastData, chartProps.focusForecastData) &&
isEqual(this.previousChartProps.focusChartData, chartProps.focusChartData) &&
isEqual(this.previousChartProps.focusAnnotationData, chartProps.focusAnnotationData) &&
- this.previousShowAnnotations === showAnnotations &&
this.previousShowForecast === showForecast &&
this.previousShowModelBounds === showModelBounds &&
this.props.previousRefresh === lastRefresh
@@ -966,7 +966,6 @@ export class TimeSeriesExplorer extends React.Component {
}
this.previousChartProps = chartProps;
- this.previousShowAnnotations = showAnnotations;
this.previousShowForecast = showForecast;
this.previousShowModelBounds = showModelBounds;
@@ -1134,23 +1133,19 @@ export class TimeSeriesExplorer extends React.Component {
)}
-
-
- {(tooltipService) => (
-
- )}
-
-
+
{focusAnnotationError !== undefined && (
<>
{
return {
type: this.type,
- rollupIndex: this.rollupIndex,
+ params: {
+ rollup_index: this.rollupIndex,
+ },
};
};
}
diff --git a/x-pack/plugins/rollup/server/lib/__tests__/fixtures/index.js b/x-pack/plugins/rollup/server/lib/__tests__/fixtures/index.js
new file mode 100644
index 00000000000000..e97606c1fadfba
--- /dev/null
+++ b/x-pack/plugins/rollup/server/lib/__tests__/fixtures/index.js
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { jobs } from './jobs';
diff --git a/src/plugins/data/server/index_patterns/fetcher/lib/__tests__/fixtures/jobs.js b/x-pack/plugins/rollup/server/lib/__tests__/fixtures/jobs.js
similarity index 65%
rename from src/plugins/data/server/index_patterns/fetcher/lib/__tests__/fixtures/jobs.js
rename to x-pack/plugins/rollup/server/lib/__tests__/fixtures/jobs.js
index 39ebd9595eeafc..c03b7c33abe0a1 100644
--- a/src/plugins/data/server/index_patterns/fetcher/lib/__tests__/fixtures/jobs.js
+++ b/x-pack/plugins/rollup/server/lib/__tests__/fixtures/jobs.js
@@ -1,20 +1,7 @@
/*
- * Licensed to Elasticsearch B.V. under one or more contributor
- * license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright
- * ownership. Elasticsearch B.V. licenses this file to you under
- * the Apache License, Version 2.0 (the "License"); you may
- * not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
*/
export const jobs = [
diff --git a/src/plugins/data/server/index_patterns/fetcher/lib/__tests__/jobs_compatibility.js b/x-pack/plugins/rollup/server/lib/__tests__/jobs_compatibility.js
similarity index 81%
rename from src/plugins/data/server/index_patterns/fetcher/lib/__tests__/jobs_compatibility.js
rename to x-pack/plugins/rollup/server/lib/__tests__/jobs_compatibility.js
index e3c93ac1f86160..a67f67de859f5b 100644
--- a/src/plugins/data/server/index_patterns/fetcher/lib/__tests__/jobs_compatibility.js
+++ b/x-pack/plugins/rollup/server/lib/__tests__/jobs_compatibility.js
@@ -1,22 +1,8 @@
/*
- * Licensed to Elasticsearch B.V. under one or more contributor
- * license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright
- * ownership. Elasticsearch B.V. licenses this file to you under
- * the Apache License, Version 2.0 (the "License"); you may
- * not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
*/
-
import expect from '@kbn/expect';
import { areJobsCompatible, mergeJobConfigurations } from '../jobs_compatibility';
import { jobs } from './fixtures';
diff --git a/src/plugins/data/server/index_patterns/fetcher/lib/jobs_compatibility.ts b/x-pack/plugins/rollup/server/lib/jobs_compatibility.ts
similarity index 79%
rename from src/plugins/data/server/index_patterns/fetcher/lib/jobs_compatibility.ts
rename to x-pack/plugins/rollup/server/lib/jobs_compatibility.ts
index f21de8907ee245..f5f54cf9a54e86 100644
--- a/src/plugins/data/server/index_patterns/fetcher/lib/jobs_compatibility.ts
+++ b/x-pack/plugins/rollup/server/lib/jobs_compatibility.ts
@@ -1,20 +1,7 @@
/*
- * Licensed to Elasticsearch B.V. under one or more contributor
- * license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright
- * ownership. Elasticsearch B.V. licenses this file to you under
- * the Apache License, Version 2.0 (the "License"); you may
- * not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
*/
import { isEqual } from 'lodash';
diff --git a/x-pack/plugins/rollup/server/lib/map_capabilities.ts b/x-pack/plugins/rollup/server/lib/map_capabilities.ts
new file mode 100644
index 00000000000000..233c6d1dd4b4b6
--- /dev/null
+++ b/x-pack/plugins/rollup/server/lib/map_capabilities.ts
@@ -0,0 +1,24 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { mergeJobConfigurations } from './jobs_compatibility';
+
+export function getCapabilitiesForRollupIndices(indices: { [key: string]: any }) {
+ const indexNames = Object.keys(indices);
+ const capabilities = {} as { [key: string]: any };
+
+ indexNames.forEach((index) => {
+ try {
+ capabilities[index] = mergeJobConfigurations(indices[index].rollup_jobs);
+ } catch (e) {
+ capabilities[index] = {
+ error: e.message,
+ };
+ }
+ });
+
+ return capabilities;
+}
diff --git a/src/plugins/data/server/index_patterns/fetcher/lib/merge_capabilities_with_fields.ts b/x-pack/plugins/rollup/server/lib/merge_capabilities_with_fields.ts
similarity index 70%
rename from src/plugins/data/server/index_patterns/fetcher/lib/merge_capabilities_with_fields.ts
rename to x-pack/plugins/rollup/server/lib/merge_capabilities_with_fields.ts
index dd69f4b7ff0078..51111e9e45d0ab 100644
--- a/src/plugins/data/server/index_patterns/fetcher/lib/merge_capabilities_with_fields.ts
+++ b/x-pack/plugins/rollup/server/lib/merge_capabilities_with_fields.ts
@@ -1,30 +1,20 @@
/*
- * Licensed to Elasticsearch B.V. under one or more contributor
- * license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright
- * ownership. Elasticsearch B.V. licenses this file to you under
- * the Apache License, Version 2.0 (the "License"); you may
- * not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
*/
// Merge rollup capabilities information with field information
-import { FieldDescriptor } from '../index_patterns_fetcher';
+export interface Field {
+ name?: string;
+ [key: string]: any;
+}
export const mergeCapabilitiesWithFields = (
rollupIndexCapabilities: { [key: string]: any },
fieldsFromFieldCapsApi: { [key: string]: any },
- previousFields: FieldDescriptor[] = []
+ previousFields: Field[] = []
) => {
const rollupFields = [...previousFields];
const rollupFieldNames: string[] = [];
diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.ts b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.ts
index dcf6629d353974..f439ac555aed93 100644
--- a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.ts
+++ b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.ts
@@ -6,11 +6,8 @@
import { keyBy, isString } from 'lodash';
import { ILegacyScopedClusterClient } from 'src/core/server';
import { ReqFacade } from '../../../../../../src/plugins/vis_type_timeseries/server';
-
-import {
- mergeCapabilitiesWithFields,
- getCapabilitiesForRollupIndices,
-} from '../../../../../../src/plugins/data/server';
+import { mergeCapabilitiesWithFields } from '../merge_capabilities_with_fields';
+import { getCapabilitiesForRollupIndices } from '../map_capabilities';
const getRollupIndices = (rollupData: { [key: string]: any }) => Object.keys(rollupData);
diff --git a/x-pack/plugins/rollup/server/plugin.ts b/x-pack/plugins/rollup/server/plugin.ts
index 51920af7c8cbc5..fe193150fc1cae 100644
--- a/x-pack/plugins/rollup/server/plugin.ts
+++ b/x-pack/plugins/rollup/server/plugin.ts
@@ -36,7 +36,8 @@ import { registerRollupSearchStrategy } from './lib/search_strategies';
import { elasticsearchJsPlugin } from './client/elasticsearch_rollup';
import { isEsError } from './shared_imports';
import { formatEsError } from './lib/format_es_error';
-import { getCapabilitiesForRollupIndices } from '../../../../src/plugins/data/server';
+import { getCapabilitiesForRollupIndices } from './lib/map_capabilities';
+import { mergeCapabilitiesWithFields } from './lib/merge_capabilities_with_fields';
interface RollupContext {
client: ILegacyScopedClusterClient;
@@ -106,6 +107,7 @@ export class RollupPlugin implements Plugin {
isEsError,
formatEsError,
getCapabilitiesForRollupIndices,
+ mergeCapabilitiesWithFields,
},
sharedImports: {
IndexPatternsFetcher,
diff --git a/x-pack/plugins/rollup/server/routes/api/index_patterns/index.ts b/x-pack/plugins/rollup/server/routes/api/index_patterns/index.ts
new file mode 100644
index 00000000000000..7bf525ca4aa984
--- /dev/null
+++ b/x-pack/plugins/rollup/server/routes/api/index_patterns/index.ts
@@ -0,0 +1,12 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { RouteDependencies } from '../../../types';
+import { registerFieldsForWildcardRoute } from './register_fields_for_wildcard_route';
+
+export function registerIndexPatternsRoutes(dependencies: RouteDependencies) {
+ registerFieldsForWildcardRoute(dependencies);
+}
diff --git a/x-pack/plugins/rollup/server/routes/api/index_patterns/register_fields_for_wildcard_route.ts b/x-pack/plugins/rollup/server/routes/api/index_patterns/register_fields_for_wildcard_route.ts
new file mode 100644
index 00000000000000..df9907fbf731a1
--- /dev/null
+++ b/x-pack/plugins/rollup/server/routes/api/index_patterns/register_fields_for_wildcard_route.ts
@@ -0,0 +1,142 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { keyBy } from 'lodash';
+import { schema } from '@kbn/config-schema';
+import { Field } from '../../../lib/merge_capabilities_with_fields';
+import { RouteDependencies } from '../../../types';
+import type { IndexPatternsFetcher as IndexPatternsFetcherType } from '../../../../../../../src/plugins/data/server';
+
+const parseMetaFields = (metaFields: string | string[]) => {
+ let parsedFields: string[] = [];
+ if (typeof metaFields === 'string') {
+ parsedFields = JSON.parse(metaFields);
+ } else {
+ parsedFields = metaFields;
+ }
+ return parsedFields;
+};
+
+const getFieldsForWildcardRequest = async (
+ context: any,
+ request: any,
+ response: any,
+ IndexPatternsFetcher: typeof IndexPatternsFetcherType
+) => {
+ const { asCurrentUser } = context.core.elasticsearch.client;
+ const indexPatterns = new IndexPatternsFetcher(asCurrentUser);
+ const { pattern, meta_fields: metaFields } = request.query;
+
+ let parsedFields: string[] = [];
+ try {
+ parsedFields = parseMetaFields(metaFields);
+ } catch (error) {
+ return response.badRequest({
+ body: error,
+ });
+ }
+
+ try {
+ const fields = await indexPatterns.getFieldsForWildcard({
+ pattern,
+ metaFields: parsedFields,
+ });
+
+ return response.ok({
+ body: { fields },
+ headers: {
+ 'content-type': 'application/json',
+ },
+ });
+ } catch (error) {
+ return response.notFound();
+ }
+};
+
+/**
+ * Get list of fields for rollup index pattern, in the format of regular index pattern fields
+ */
+export const registerFieldsForWildcardRoute = ({
+ router,
+ license,
+ lib: { isEsError, formatEsError, getCapabilitiesForRollupIndices, mergeCapabilitiesWithFields },
+ sharedImports: { IndexPatternsFetcher },
+}: RouteDependencies) => {
+ const querySchema = schema.object({
+ pattern: schema.string(),
+ meta_fields: schema.arrayOf(schema.string(), {
+ defaultValue: [],
+ }),
+ params: schema.string({
+ validate(value) {
+ try {
+ const params = JSON.parse(value);
+ const keys = Object.keys(params);
+ const { rollup_index: rollupIndex } = params;
+
+ if (!rollupIndex) {
+ return '[request query.params]: "rollup_index" is required';
+ } else if (keys.length > 1) {
+ const invalidParams = keys.filter((key) => key !== 'rollup_index');
+ return `[request query.params]: ${invalidParams.join(', ')} is not allowed`;
+ }
+ } catch (err) {
+ return '[request query.params]: expected JSON string';
+ }
+ },
+ }),
+ });
+
+ router.get(
+ {
+ path: '/api/index_patterns/rollup/_fields_for_wildcard',
+ validate: {
+ query: querySchema,
+ },
+ },
+ license.guardApiRoute(async (context, request, response) => {
+ const { params, meta_fields: metaFields } = request.query;
+
+ try {
+ // Make call and use field information from response
+ const { payload } = await getFieldsForWildcardRequest(
+ context,
+ request,
+ response,
+ IndexPatternsFetcher
+ );
+ const fields = payload.fields;
+ const parsedParams = JSON.parse(params);
+ const rollupIndex = parsedParams.rollup_index;
+ const rollupFields: Field[] = [];
+ const fieldsFromFieldCapsApi: { [key: string]: any } = keyBy(fields, 'name');
+ const rollupIndexCapabilities = getCapabilitiesForRollupIndices(
+ await context.rollup!.client.callAsCurrentUser('rollup.rollupIndexCapabilities', {
+ indexPattern: rollupIndex,
+ })
+ )[rollupIndex].aggs;
+
+ // Keep meta fields
+ metaFields.forEach(
+ (field: string) =>
+ fieldsFromFieldCapsApi[field] && rollupFields.push(fieldsFromFieldCapsApi[field])
+ );
+
+ const mergedRollupFields = mergeCapabilitiesWithFields(
+ rollupIndexCapabilities,
+ fieldsFromFieldCapsApi,
+ rollupFields
+ );
+ return response.ok({ body: { fields: mergedRollupFields } });
+ } catch (err) {
+ if (isEsError(err)) {
+ return response.customError({ statusCode: err.statusCode, body: err });
+ }
+ return response.internalError({ body: err });
+ }
+ })
+ );
+};
diff --git a/x-pack/plugins/rollup/server/routes/index.ts b/x-pack/plugins/rollup/server/routes/index.ts
index 322003c0ee325c..b25480855b4a21 100644
--- a/x-pack/plugins/rollup/server/routes/index.ts
+++ b/x-pack/plugins/rollup/server/routes/index.ts
@@ -6,11 +6,13 @@
import { RouteDependencies } from '../types';
+import { registerIndexPatternsRoutes } from './api/index_patterns';
import { registerIndicesRoutes } from './api/indices';
import { registerJobsRoutes } from './api/jobs';
import { registerSearchRoutes } from './api/search';
export function registerApiRoutes(dependencies: RouteDependencies) {
+ registerIndexPatternsRoutes(dependencies);
registerIndicesRoutes(dependencies);
registerJobsRoutes(dependencies);
registerSearchRoutes(dependencies);
diff --git a/x-pack/plugins/rollup/server/types.ts b/x-pack/plugins/rollup/server/types.ts
index 89e13e69c4da29..b167806cf8d5df 100644
--- a/x-pack/plugins/rollup/server/types.ts
+++ b/x-pack/plugins/rollup/server/types.ts
@@ -8,7 +8,6 @@ import { IRouter } from 'src/core/server';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
import { VisTypeTimeseriesSetup } from 'src/plugins/vis_type_timeseries/server';
-import { getCapabilitiesForRollupIndices } from 'src/plugins/data/server';
import { IndexManagementPluginSetup } from '../../index_management/server';
import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server';
import { LicensingPluginSetup } from '../../licensing/server';
@@ -16,6 +15,8 @@ import { License } from './services';
import { IndexPatternsFetcher } from './shared_imports';
import { isEsError } from './shared_imports';
import { formatEsError } from './lib/format_es_error';
+import { getCapabilitiesForRollupIndices } from './lib/map_capabilities';
+import { mergeCapabilitiesWithFields } from './lib/merge_capabilities_with_fields';
export interface Dependencies {
indexManagement?: IndexManagementPluginSetup;
@@ -32,6 +33,7 @@ export interface RouteDependencies {
isEsError: typeof isEsError;
formatEsError: typeof formatEsError;
getCapabilitiesForRollupIndices: typeof getCapabilitiesForRollupIndices;
+ mergeCapabilitiesWithFields: typeof mergeCapabilitiesWithFields;
};
sharedImports: {
IndexPatternsFetcher: typeof IndexPatternsFetcher;
diff --git a/x-pack/test/api_integration/apis/management/rollup/constants.js b/x-pack/test/api_integration/apis/management/rollup/constants.js
index 0313434cf716c4..fe899c4c10c880 100644
--- a/x-pack/test/api_integration/apis/management/rollup/constants.js
+++ b/x-pack/test/api_integration/apis/management/rollup/constants.js
@@ -5,7 +5,7 @@
*/
export const API_BASE_PATH = '/api/rollup';
-export const INDEX_PATTERNS_EXTENSION_BASE_PATH = '/api/index_patterns';
+export const INDEX_PATTERNS_EXTENSION_BASE_PATH = '/api/index_patterns/rollup';
export const ROLLUP_INDEX_NAME = 'rollup_index';
export const INDEX_TO_ROLLUP_MAPPINGS = {
properties: {
diff --git a/x-pack/test/api_integration/apis/management/rollup/index_patterns_extensions.js b/x-pack/test/api_integration/apis/management/rollup/index_patterns_extensions.js
index 0a93e8b8bd1e37..357b952e7e66d4 100644
--- a/x-pack/test/api_integration/apis/management/rollup/index_patterns_extensions.js
+++ b/x-pack/test/api_integration/apis/management/rollup/index_patterns_extensions.js
@@ -26,6 +26,7 @@ export default function ({ getService }) {
describe('query params validation', () => {
let uri;
let body;
+ let params;
it('"pattern" is required', async () => {
uri = `${BASE_URI}`;
@@ -35,17 +36,62 @@ export default function ({ getService }) {
);
});
+ it('"params" is required', async () => {
+ params = { pattern: 'foo' };
+ uri = `${BASE_URI}?${stringify(params, { sort: false })}`;
+ ({ body } = await supertest.get(uri).expect(400));
+ expect(body.message).to.contain(
+ '[request query.params]: expected value of type [string]'
+ );
+ });
+
+ it('"params" must be a valid JSON string', async () => {
+ params = { pattern: 'foo', params: 'foobarbaz' };
+ uri = `${BASE_URI}?${stringify(params, { sort: false })}`;
+ ({ body } = await supertest.get(uri).expect(400));
+ expect(body.message).to.contain('[request query.params]: expected JSON string');
+ });
+
+ it('"params" requires a "rollup_index" property', async () => {
+ params = { pattern: 'foo', params: JSON.stringify({}) };
+ uri = `${BASE_URI}?${stringify(params, { sort: false })}`;
+ ({ body } = await supertest.get(uri).expect(400));
+ expect(body.message).to.contain('[request query.params]: "rollup_index" is required');
+ });
+
+ it('"params" only accepts a "rollup_index" property', async () => {
+ params = {
+ pattern: 'foo',
+ params: JSON.stringify({ rollup_index: 'my_index', someProp: 'bar' }),
+ };
+ uri = `${BASE_URI}?${stringify(params, { sort: false })}`;
+ ({ body } = await supertest.get(uri).expect(400));
+ expect(body.message).to.contain('[request query.params]: someProp is not allowed');
+ });
+
+ it('"meta_fields" must be an Array', async () => {
+ params = {
+ pattern: 'foo',
+ params: JSON.stringify({ rollup_index: 'bar' }),
+ meta_fields: 'stringValue',
+ };
+ uri = `${BASE_URI}?${stringify(params, { sort: false })}`;
+ ({ body } = await supertest.get(uri).expect(400));
+ expect(body.message).to.contain(
+ '[request query.meta_fields]: could not parse array value from json input'
+ );
+ });
+
it('should return 404 the rollup index to query does not exist', async () => {
uri = `${BASE_URI}?${stringify(
{
pattern: 'foo',
- type: 'rollup',
- rollup_index: 'bar',
+ params: JSON.stringify({ rollup_index: 'bar' }),
},
{ sort: false }
)}`;
({ body } = await supertest.get(uri).expect(404));
- expect(body.message).to.contain('No indices match pattern "foo"');
+ expect(body.message).to.contain('[index_not_found_exception] no such index [bar]');
});
});
@@ -59,8 +105,7 @@ export default function ({ getService }) {
// Query for wildcard
const params = {
pattern: indexName,
- type: 'rollup',
- rollup_index: rollupIndex,
+ params: JSON.stringify({ rollup_index: rollupIndex }),
};
const uri = `${BASE_URI}?${stringify(params, { sort: false })}`;
const { body } = await supertest.get(uri).expect(200);