diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap b/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap index 0fcee477c99de0..a9fc01e3fdffb1 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap @@ -784,6 +784,9 @@ exports[`PartitionVisComponent should render correct structure for mosaic 1`] = Array [ Object { "fillLabel": Object { + "clipText": true, + "maxFontSize": 14, + "minFontSize": 14, "valueFont": Object { "fontWeight": 700, }, @@ -1865,7 +1868,7 @@ exports[`PartitionVisComponent should render correct structure for waffle 1`] = ariaUseDefaultSummary={true} baseTheme={Object {}} debugState={false} - flatLegend={false} + flatLegend={true} legendAction={[Function]} legendColorPicker={[Function]} legendMaxDepth={1} @@ -1901,7 +1904,7 @@ exports[`PartitionVisComponent should render correct structure for waffle 1`] = }, "maxFontSize": 16, "minFontSize": 10, - "outerSizeRatio": 1, + "outerSizeRatio": undefined, "sectorLineStroke": undefined, "sectorLineWidth": 1.5, }, @@ -1975,7 +1978,7 @@ exports[`PartitionVisComponent should render correct structure for waffle 1`] = }, ] } - id="mosaic" + id="waffle" layers={ Array [ Object { @@ -1994,7 +1997,7 @@ exports[`PartitionVisComponent should render correct structure for waffle 1`] = }, ] } - layout="mosaic" + layout="waffle" percentFormatter={[Function]} smallMultiples="__pie_chart_sm__" valueAccessor={[Function]} diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx index 70c120e4fd7599..8e601c0f01e2a9 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx @@ -136,7 +136,7 @@ describe('PartitionVisComponent', function () { diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_layers.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_layers.ts index 1ba15fe2a055fa..c8d7d1f30b4d54 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_layers.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_layers.ts @@ -58,7 +58,10 @@ export const getLayers = ( groupByRollup: (d: Datum) => (col.id ? d[col.id] ?? EMPTY_SLICE : col.name), showAccessor: (d: Datum) => d !== EMPTY_SLICE, nodeLabel: (d: unknown) => getNodeLabel(d, col, formatters, formatter.deserialize), - fillLabel, + fillLabel: + layerIndex === 0 && chartType === ChartTypes.MOSAIC + ? { ...fillLabel, minFontSize: 14, maxFontSize: 14, clipText: true } + : fillLabel, sortPredicate, shape: { fillColor: (d) => diff --git a/src/plugins/vis_types/heatmap/public/to_ast.ts b/src/plugins/vis_types/heatmap/public/to_ast.ts index a5a14f5412dca8..966dea1edbe3a9 100644 --- a/src/plugins/vis_types/heatmap/public/to_ast.ts +++ b/src/plugins/vis_types/heatmap/public/to_ast.ts @@ -40,6 +40,7 @@ const prepareGrid = (params: HeatmapVisParams) => { const gridConfig = buildExpressionFunction('heatmap_grid', { isCellLabelVisible: params.valueAxes?.[0].labels.show ?? false, isXAxisLabelVisible: true, + isYAxisLabelVisible: true, isYAxisTitleVisible: true, isXAxisTitleVisible: true, }); diff --git a/src/plugins/vis_types/xy/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_types/xy/public/__snapshots__/to_ast.test.ts.snap index 048b07dbf34ed9..aa2a68204108a5 100644 --- a/src/plugins/vis_types/xy/public/__snapshots__/to_ast.test.ts.snap +++ b/src/plugins/vis_types/xy/public/__snapshots__/to_ast.test.ts.snap @@ -33,7 +33,7 @@ Object { "top", ], "legendSize": Array [ - "small", + "auto", ], "maxLegendLines": Array [ 1, diff --git a/src/plugins/vis_types/xy/public/to_ast.ts b/src/plugins/vis_types/xy/public/to_ast.ts index 46ff05f4426a62..e92ca8cda82d2d 100644 --- a/src/plugins/vis_types/xy/public/to_ast.ts +++ b/src/plugins/vis_types/xy/public/to_ast.ts @@ -7,12 +7,13 @@ */ import moment from 'moment'; - +import { Position } from '@elastic/charts'; import { VisToExpressionAst, getVisSchemas, DateHistogramParams, HistogramParams, + LegendSize, } from '@kbn/visualizations-plugin/public'; import { buildExpression, buildExpressionFunction } from '@kbn/expressions-plugin/public'; import { BUCKET_TYPES } from '@kbn/data-plugin/public'; @@ -202,6 +203,11 @@ export const toExpressionAst: VisToExpressionAst = async (vis, params } } }); + let legendSize = vis.params.legendSize; + + if (vis.params.legendPosition === Position.Top || vis.params.legendPosition === Position.Bottom) { + legendSize = LegendSize.AUTO; + } const visTypeXy = buildExpressionFunction(visName, { type: vis.type.name as XyVisType, @@ -209,7 +215,7 @@ export const toExpressionAst: VisToExpressionAst = async (vis, params addTimeMarker: vis.params.addTimeMarker, truncateLegend: vis.params.truncateLegend, maxLegendLines: vis.params.maxLegendLines, - legendSize: vis.params.legendSize, + legendSize, addLegend: vis.params.addLegend, addTooltip: vis.params.addTooltip, legendPosition: vis.params.legendPosition, diff --git a/x-pack/plugins/enterprise_search/kibana.json b/x-pack/plugins/enterprise_search/kibana.json index bc12b7f447ab41..4556e5dc96106f 100644 --- a/x-pack/plugins/enterprise_search/kibana.json +++ b/x-pack/plugins/enterprise_search/kibana.json @@ -2,7 +2,7 @@ "id": "enterpriseSearch", "version": "kibana", "kibanaVersion": "kibana", - "requiredPlugins": ["features", "spaces", "security", "licensing", "data", "charts", "infra", "cloud"], + "requiredPlugins": ["features", "spaces", "security", "licensing", "data", "charts", "infra", "cloud", "esUiShared"], "configPath": ["enterpriseSearch"], "optionalPlugins": ["usageCollection", "home", "cloud", "customIntegrations"], "server": true, diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector_package/update_connector_scheduling_api_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector_package/update_connector_scheduling_api_logic.ts new file mode 100644 index 00000000000000..47679ee1caa0e1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector_package/update_connector_scheduling_api_logic.ts @@ -0,0 +1,32 @@ +/* + * 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 { createApiLogic } from '../../../shared/api_logic/create_api_logic'; +import { HttpLogic } from '../../../shared/http'; +import { ConnectorScheduling } from '../index/fetch_index_api_logic'; + +export interface UpdateConnectorSchedulingArgs { + connectorId: string; + scheduling: ConnectorScheduling; +} + +export const updateConnectorScheduling = async ({ + connectorId, + scheduling: { enabled, interval }, +}: UpdateConnectorSchedulingArgs) => { + const route = `/internal/enterprise_search/connectors/${connectorId}/scheduling`; + + await HttpLogic.values.http.post(route, { + body: JSON.stringify({ enabled, interval }), + }); + return { enabled, interval }; +}; + +export const UpdateConnectorSchedulingApiLogic = createApiLogic( + ['content', 'update_connector_scheduling_api_logic'], + updateConnectorScheduling +); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/index/fetch_index_api_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/index/fetch_index_api_logic.ts index 53ec3c1a92a82e..adfd99a8a2d240 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/index/fetch_index_api_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/index/fetch_index_api_logic.ts @@ -14,6 +14,10 @@ export interface KeyValuePair { } export type ConnectorConfiguration = Record; +export interface ConnectorScheduling { + enabled: boolean; + interval: string; +} export interface Connector { api_key_id: string | null; @@ -25,10 +29,10 @@ export interface Connector { last_synced: string | null; scheduling: { enabled: boolean; - interval: string | null; // crontab syntax + interval: string; // crontab syntax }; service_type: string | null; - status: string | null; + status: string; sync_error: string | null; sync_now: boolean; sync_status: string | null; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_configuration_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_configuration_logic.test.ts index 20bedee0a2d071..e7368ef67cebbc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_configuration_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_configuration_logic.test.ts @@ -11,10 +11,6 @@ import { ConnectorConfigurationApiLogic } from '../../../api/connector_package/u import { ConnectorConfigurationLogic } from './connector_configuration_logic'; -// jest.mock('../../api', () => ({ -// AppLogic: { values: { isOrganization: true } }, -// })); - const DEFAULT_VALUES = { configState: { foo: 'bar' }, isEditing: false, @@ -22,7 +18,7 @@ const DEFAULT_VALUES = { describe('ConnectorConfigurationLogic', () => { const { mount } = new LogicMounter(ConnectorConfigurationLogic); - const { clearFlashMessages, flashAPIErrors } = mockFlashMessageHelpers; + const { clearFlashMessages, flashAPIErrors, flashSuccessToast } = mockFlashMessageHelpers; beforeEach(() => { jest.clearAllMocks(); @@ -94,5 +90,14 @@ describe('ConnectorConfigurationLogic', () => { expect(flashAPIErrors).toHaveBeenCalledWith('error'); }); }); + describe('apiSuccess', () => { + it('should call flashAPIError', () => { + ConnectorConfigurationLogic.actions.apiSuccess({ + configuration: {}, + indexName: 'name', + }); + expect(flashSuccessToast).toHaveBeenCalledWith('Configuration successfully updated'); + }); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_configuration_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_configuration_logic.ts index 43ab912849bd9f..cb4bfe2e5621fa 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_configuration_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_configuration_logic.ts @@ -7,8 +7,14 @@ import { kea, MakeLogicType } from 'kea'; +import { i18n } from '@kbn/i18n'; + import { Actions } from '../../../../shared/api_logic/create_api_logic'; -import { clearFlashMessages, flashAPIErrors } from '../../../../shared/flash_messages'; +import { + clearFlashMessages, + flashAPIErrors, + flashSuccessToast, +} from '../../../../shared/flash_messages'; import { ConnectorConfigurationApiLogic, @@ -54,6 +60,13 @@ export const ConnectorConfigurationLogic = kea< }, listeners: { apiError: (error) => flashAPIErrors(error), + apiSuccess: () => + flashSuccessToast( + i18n.translate( + 'xpack.enterpriseSearch.content.indices.configurationConnector.configuration.successToast.title', + { defaultMessage: 'Configuration successfully updated' } + ) + ), makeRequest: () => clearFlashMessages(), }, reducers: ({ props }) => ({ diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_scheduling.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_scheduling.tsx new file mode 100644 index 00000000000000..2f0b46520b89b9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_scheduling.tsx @@ -0,0 +1,185 @@ +/* + * 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, { useState } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiSwitch, + EuiPanel, + EuiSpacer, + EuiButton, + EuiButtonEmpty, +} from '@elastic/eui'; +import { CronEditor, Frequency } from '@kbn/es-ui-shared-plugin/public'; +import { i18n } from '@kbn/i18n'; + +import { Status } from '../../../../../../common/types/api'; +import { UnsavedChangesPrompt } from '../../../../shared/unsaved_changes_prompt'; +import { UpdateConnectorSchedulingApiLogic } from '../../../api/connector_package/update_connector_scheduling_api_logic'; +import { FetchIndexApiLogic } from '../../../api/index/fetch_index_api_logic'; + +import { ConnectorSchedulingLogic } from './connector_scheduling_logic'; + +export const ConnectorSchedulingComponent: React.FC = () => { + const { data } = useValues(FetchIndexApiLogic); + const { status } = useValues(UpdateConnectorSchedulingApiLogic); + const { makeRequest } = useActions(UpdateConnectorSchedulingApiLogic); + const { hasChanges } = useValues(ConnectorSchedulingLogic); + const { setHasChanges } = useActions(ConnectorSchedulingLogic); + + const schedulingInput = data?.connector?.scheduling; + const [scheduling, setScheduling] = useState(schedulingInput); + const [fieldToPreferredValueMap, setFieldToPreferredValueMap] = useState({}); + const [simpleCron, setSimpleCron] = useState<{ + expression: string; + frequency: Frequency; + }>({ + expression: schedulingInput?.interval ?? '', + frequency: schedulingInput?.interval ? cronToFrequency(schedulingInput.interval) : 'HOUR', + }); + + const editor = scheduling && ( + { + setSimpleCron({ + expression, + frequency, + }); + setFieldToPreferredValueMap(newFieldToPreferredValueMap); + setScheduling({ ...scheduling, interval: expression }); + setHasChanges(true); + }} + /> + ); + + return scheduling ? ( + <> + + + + + + { + setScheduling({ ...scheduling, enabled: e.target.checked }); + setHasChanges(true); + }} + /> + + + + {scheduling.enabled + ? i18n.translate( + 'xpack.enterpriseSearch.content.indices.connectorScheduling.switch.enabled.description', + { + defaultMessage: + 'This source will automatically be kept in sync according to the schedule set below.', + } + ) + : i18n.translate( + 'xpack.enterpriseSearch.content.indices.connectorScheduling.switch.disabled.description', + { defaultMessage: 'Source content will not be kept in sync.' } + )} + + + {editor} + + + + { + setScheduling(schedulingInput); + setSimpleCron({ + expression: schedulingInput?.interval ?? '', + frequency: schedulingInput?.interval + ? cronToFrequency(schedulingInput.interval) + : 'HOUR', + }); + setHasChanges(false); + }} + > + {i18n.translate( + 'xpack.enterpriseSearch.content.indices.connectorScheduling.resetButton.label', + { defaultMessage: 'Reset' } + )} + + + + + makeRequest({ connectorId: data?.connector?.id ?? '', scheduling }) + } + > + {i18n.translate( + 'xpack.enterpriseSearch.content.indices.connectorScheduling.saveButton.label', + { defaultMessage: 'Save' } + )} + + + + + + + + ) : ( + <> + ); +}; + +export interface Schedule { + days: string; + hours: string; + minutes: string; +} + +function cronToFrequency(cron: string): Frequency { + const fields = cron.split(' '); + if (fields.length < 4) { + return 'YEAR'; + } + if (fields[1] === '*') { + return 'MINUTE'; + } + if (fields[2] === '*') { + return 'HOUR'; + } + if (fields[3] === '*') { + return 'DAY'; + } + if (fields[4] === '?') { + return 'WEEK'; + } + if (fields[4] === '*') { + return 'MONTH'; + } + return 'YEAR'; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_scheduling_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_scheduling_logic.test.ts new file mode 100644 index 00000000000000..2bf0b7172247c9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_scheduling_logic.test.ts @@ -0,0 +1,78 @@ +/* + * 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 { LogicMounter, mockFlashMessageHelpers } from '../../../../__mocks__/kea_logic'; + +import { UpdateConnectorSchedulingApiLogic } from '../../../api/connector_package/update_connector_scheduling_api_logic'; + +import { ConnectorSchedulingLogic } from './connector_scheduling_logic'; + +describe('ConnectorSchedulingLogic', () => { + const { mount } = new LogicMounter(ConnectorSchedulingLogic); + const { clearFlashMessages, flashAPIErrors, flashSuccessToast } = mockFlashMessageHelpers; + const DEFAULT_VALUES = { + hasChanges: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mount({}); + }); + it('has expected default values', () => { + expect(ConnectorSchedulingLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('reducers', () => { + describe('hasChanges', () => { + it('should set false on apiSuccess', () => { + ConnectorSchedulingLogic.actions.setHasChanges(true); + UpdateConnectorSchedulingApiLogic.actions.apiSuccess({ + enabled: false, + interval: '', + }); + expect(ConnectorSchedulingLogic.values).toEqual({ + ...DEFAULT_VALUES, + hasChanges: false, + }); + }); + it('should set hasChanges on setHasChanges', () => { + ConnectorSchedulingLogic.actions.setHasChanges(true); + expect(ConnectorSchedulingLogic.values).toEqual({ + ...DEFAULT_VALUES, + hasChanges: true, + }); + }); + }); + }); + + describe('actions', () => { + describe('makeRequest', () => { + it('should call clearFlashMessages', () => { + ConnectorSchedulingLogic.actions.makeRequest({ + connectorId: 'id', + scheduling: { + enabled: true, + interval: 'interval', + }, + }); + expect(clearFlashMessages).toHaveBeenCalled(); + }); + }); + describe('apiError', () => { + it('should call flashAPIError', () => { + ConnectorSchedulingLogic.actions.apiError('error' as any); + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); + describe('apiSuccess', () => { + it('should call flashAPIError', () => { + ConnectorSchedulingLogic.actions.apiSuccess('success' as any); + expect(flashSuccessToast).toHaveBeenCalledWith('Scheduling successfully updated'); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_scheduling_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_scheduling_logic.ts new file mode 100644 index 00000000000000..6ab8072d27ecf4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_scheduling_logic.ts @@ -0,0 +1,63 @@ +/* + * 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 { kea, MakeLogicType } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { Actions } from '../../../../shared/api_logic/create_api_logic'; +import { + clearFlashMessages, + flashAPIErrors, + flashSuccessToast, +} from '../../../../shared/flash_messages'; + +import { + UpdateConnectorSchedulingApiLogic, + UpdateConnectorSchedulingArgs, +} from '../../../api/connector_package/update_connector_scheduling_api_logic'; +import { ConnectorScheduling } from '../../../api/index/fetch_index_api_logic'; + +type ConnectorSchedulingActions = Pick< + Actions, + 'apiError' | 'apiSuccess' | 'makeRequest' +> & { setHasChanges: (hasChanges: boolean) => { hasChanges: boolean } }; + +interface ConnectorSchedulingValues { + hasChanges: boolean; +} + +export const ConnectorSchedulingLogic = kea< + MakeLogicType +>({ + actions: { + setHasChanges: (hasChanges) => ({ hasChanges }), + }, + connect: { + actions: [UpdateConnectorSchedulingApiLogic, ['apiError', 'apiSuccess', 'makeRequest']], + }, + listeners: { + apiError: (error) => flashAPIErrors(error), + apiSuccess: () => + flashSuccessToast( + i18n.translate( + 'xpack.enterpriseSearch.content.indices.configurationConnector.scheduling.successToast.title', + { defaultMessage: 'Scheduling successfully updated' } + ) + ), + makeRequest: () => clearFlashMessages(), + }, + reducers: { + hasChanges: [ + false, + { + apiSuccess: () => false, + setHasChanges: (_, { hasChanges }) => hasChanges, + }, + ], + }, +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/scheduling.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/scheduling.tsx deleted file mode 100644 index f9d9f1a0381d8c..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/scheduling.tsx +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -export const SearchIndexScheduling: React.FC = () => { - // TODO If index && !index.connector then do something - - return <>Scheduling; -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/search_index.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/search_index.tsx index 300792ea129082..2963aa4b40520f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/search_index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/search_index.tsx @@ -26,11 +26,11 @@ import { EnterpriseSearchContentPageTemplate } from '../layout/page_template'; import { baseBreadcrumbs } from '../search_indices'; import { ConnectorConfiguration } from './connector/connector_configuration'; +import { ConnectorSchedulingComponent } from './connector/connector_scheduling'; import { SearchIndexDocuments } from './documents'; import { SearchIndexDomainManagement } from './domain_management'; import { SearchIndexIndexMappings } from './index_mappings'; import { SearchIndexOverview } from './overview'; -import { SearchIndexScheduling } from './scheduling'; export enum SearchIndexTabId { // all indices @@ -90,7 +90,7 @@ export const SearchIndex: React.FC = () => { }), }, { - content: , + content: , id: SearchIndexTabId.SCHEDULING, name: i18n.translate('xpack.enterpriseSearch.content.searchIndex.schedulingTabLabel', { defaultMessage: 'Scheduling', diff --git a/x-pack/plugins/enterprise_search/server/lib/connectors/update_connector_scheduling.test.ts b/x-pack/plugins/enterprise_search/server/lib/connectors/update_connector_scheduling.test.ts new file mode 100644 index 00000000000000..41116505086009 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/connectors/update_connector_scheduling.test.ts @@ -0,0 +1,87 @@ +/* + * 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 { IScopedClusterClient } from '@kbn/core/server'; + +import { CONNECTORS_INDEX } from '../..'; + +import { updateConnectorScheduling } from './update_connector_scheduling'; + +describe('addConnector lib function', () => { + const mockClient = { + asCurrentUser: { + get: jest.fn(), + index: jest.fn(), + }, + asInternalUser: {}, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should update connector scheduling', async () => { + mockClient.asCurrentUser.get.mockImplementationOnce(() => { + return Promise.resolve({ + _source: { + api_key_id: null, + configuration: {}, + created_at: null, + index_name: 'index_name', + last_seen: null, + last_synced: null, + scheduling: { enabled: true, interval: '1 2 3 4 5' }, + service_type: null, + status: 'not connected', + sync_error: null, + sync_now: false, + sync_status: null, + }, + index: CONNECTORS_INDEX, + }); + }); + mockClient.asCurrentUser.index.mockImplementation(() => ({ _id: 'fakeId' })); + + await expect( + updateConnectorScheduling(mockClient as unknown as IScopedClusterClient, 'connectorId', { + enabled: true, + interval: '1 2 3 4 5', + }) + ).resolves.toEqual({ _id: 'fakeId' }); + expect(mockClient.asCurrentUser.index).toHaveBeenCalledWith({ + document: { + api_key_id: null, + configuration: {}, + created_at: null, + index_name: 'index_name', + last_seen: null, + last_synced: null, + scheduling: { enabled: true, interval: '1 2 3 4 5' }, + service_type: null, + status: 'not connected', + sync_error: null, + sync_now: false, + sync_status: null, + }, + id: 'connectorId', + index: CONNECTORS_INDEX, + }); + }); + + it('should not create index if there is no connector', async () => { + mockClient.asCurrentUser.get.mockImplementationOnce(() => { + return Promise.resolve({}); + }); + await expect( + updateConnectorScheduling(mockClient as unknown as IScopedClusterClient, 'connectorId', { + enabled: true, + interval: '1 2 3 4 5', + }) + ).rejects.toEqual(new Error('Could not find document')); + expect(mockClient.asCurrentUser.index).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/lib/connectors/update_connector_scheduling.ts b/x-pack/plugins/enterprise_search/server/lib/connectors/update_connector_scheduling.ts new file mode 100644 index 00000000000000..4fde352d4030e9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/connectors/update_connector_scheduling.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 { IScopedClusterClient } from '@kbn/core/server'; +import { i18n } from '@kbn/i18n'; + +import { CONNECTORS_INDEX } from '../..'; + +import { Connector, ConnectorScheduling } from '../../types/connector'; + +export const updateConnectorScheduling = async ( + client: IScopedClusterClient, + connectorId: string, + scheduling: ConnectorScheduling +) => { + const connectorResult = await client.asCurrentUser.get({ + id: connectorId, + index: CONNECTORS_INDEX, + }); + const connector = connectorResult._source; + if (connector) { + return await client.asCurrentUser.index({ + document: { ...connector, scheduling }, + id: connectorId, + index: CONNECTORS_INDEX, + }); + } else { + throw new Error( + i18n.translate('xpack.enterpriseSearch.server.connectors.scheduling.error', { + defaultMessage: 'Could not find document', + }) + ); + } +}; diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/connectors.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/connectors.ts index 4851fd8cad4d56..9e6337211c5e3b 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/connectors.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/connectors.ts @@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n'; import { addConnector } from '../../lib/connectors/add_connector'; import { updateConnectorConfiguration } from '../../lib/connectors/update_connector_configuration'; +import { updateConnectorScheduling } from '../../lib/connectors/update_connector_scheduling'; import { RouteDependencies } from '../../plugin'; @@ -40,21 +41,46 @@ export function registerConnectorRoutes({ router }: RouteDependencies) { ); router.post( { - path: '/internal/enterprise_search/connectors/{indexId}/configuration', + path: '/internal/enterprise_search/connectors/{connectorId}/configuration', validate: { body: schema.recordOf( schema.string(), schema.object({ label: schema.string(), value: schema.nullable(schema.string()) }) ), params: schema.object({ - indexId: schema.string(), + connectorId: schema.string(), }), }, }, async (context, request, response) => { const { client } = (await context.core).elasticsearch; try { - await updateConnectorConfiguration(client, request.params.indexId, request.body); + await updateConnectorConfiguration(client, request.params.connectorId, request.body); + return response.ok(); + } catch (error) { + return response.customError({ + body: i18n.translate('xpack.enterpriseSearch.server.routes.updateConnector.error', { + defaultMessage: 'Error fetching data from Enterprise Search', + }), + statusCode: 502, + }); + } + } + ); + router.post( + { + path: '/internal/enterprise_search/connectors/{connectorId}/scheduling', + validate: { + body: schema.object({ enabled: schema.boolean(), interval: schema.string() }), + params: schema.object({ + connectorId: schema.string(), + }), + }, + }, + async (context, request, response) => { + const { client } = (await context.core).elasticsearch; + try { + await updateConnectorScheduling(client, request.params.connectorId, request.body); return response.ok(); } catch (error) { return response.customError({ diff --git a/x-pack/plugins/enterprise_search/server/types/connector.ts b/x-pack/plugins/enterprise_search/server/types/connector.ts index fbae385b702556..38685419938e21 100644 --- a/x-pack/plugins/enterprise_search/server/types/connector.ts +++ b/x-pack/plugins/enterprise_search/server/types/connector.ts @@ -11,6 +11,10 @@ export interface KeyValuePair { } export type ConnectorConfiguration = Record; +export interface ConnectorScheduling { + enabled: boolean; + interval: string; +} export interface Connector { api_key_id: string | null; @@ -19,10 +23,7 @@ export interface Connector { index_name: string; last_seen: string | null; last_synced: string | null; - scheduling: { - enabled: boolean; - interval: string | null; // crontab syntax - }; + scheduling: ConnectorScheduling; service_type: string | null; status: string | null; sync_error: string | null; diff --git a/x-pack/plugins/observability/public/config/paths.ts b/x-pack/plugins/observability/public/config/paths.ts index 7f6599ef3c4835..5b5055afdf8796 100644 --- a/x-pack/plugins/observability/public/config/paths.ts +++ b/x-pack/plugins/observability/public/config/paths.ts @@ -12,7 +12,8 @@ export const paths = { observability: { alerts: ALERT_PAGE_LINK, rules: RULES_PAGE_LINK, - ruleDetails: (ruleId: string) => `${RULES_PAGE_LINK}/${encodeURI(ruleId)}`, + ruleDetails: (ruleId?: string | null) => + ruleId ? `${RULES_PAGE_LINK}/${encodeURI(ruleId)}` : RULES_PAGE_LINK, }, management: { rules: '/app/management/insightsAndAlerting/triggersActions/rules', diff --git a/x-pack/plugins/observability/public/pages/cases/cases.tsx b/x-pack/plugins/observability/public/pages/cases/cases.tsx index e2d853efbf45f9..ee7af69937881b 100644 --- a/x-pack/plugins/observability/public/pages/cases/cases.tsx +++ b/x-pack/plugins/observability/public/pages/cases/cases.tsx @@ -7,13 +7,15 @@ import React, { Suspense, useCallback, useState } from 'react'; -import { useKibana } from '../../utils/kibana_react'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; import { CASES_OWNER, CASES_PATH } from './constants'; import { usePluginContext } from '../../hooks/use_plugin_context'; import { LazyAlertsFlyout } from '../..'; import { useFetchAlertDetail } from './use_fetch_alert_detail'; import { useFetchAlertData } from './use_fetch_alert_data'; import { UseGetUserCasesPermissions } from '../../hooks/use_get_user_cases_permissions'; +import { paths } from '../../config'; +import { ObservabilityAppServices } from '../../application/types'; interface CasesProps { permissions: UseGetUserCasesPermissions; @@ -21,8 +23,11 @@ interface CasesProps { export const Cases = React.memo(({ permissions }) => { const { cases, - application: { getUrlForApp, navigateToApp }, - } = useKibana().services; + http: { + basePath: { prepend }, + }, + application: { navigateToUrl }, + } = useKibana().services; const { observabilityRuleTypeRegistry } = usePluginContext(); const [selectedAlertId, setSelectedAlertId] = useState(''); const casesPermissions = { all: permissions.crud, read: permissions.read }; @@ -55,17 +60,16 @@ export const Cases = React.memo(({ permissions }) => { }, ruleDetailsNavigation: { href: (ruleId) => { - return getUrlForApp('management', { - path: `/insightsAndAlerting/triggersActions/rule/${ruleId}`, - }); + return prepend(paths.observability.ruleDetails(ruleId)); }, onClick: async (ruleId, ev) => { + const ruleLink = prepend(paths.observability.ruleDetails(ruleId)); + if (ev != null) { ev.preventDefault(); } - return navigateToApp('management', { - path: `/insightsAndAlerting/triggersActions/rule/${ruleId}`, - }); + + return navigateToUrl(ruleLink); }, }, })} diff --git a/x-pack/test/accessibility/apps/tags.ts b/x-pack/test/accessibility/apps/tags.ts index da51f2f0535e2a..5369ad2d2db6b6 100644 --- a/x-pack/test/accessibility/apps/tags.ts +++ b/x-pack/test/accessibility/apps/tags.ts @@ -71,7 +71,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await a11y.testAppSnapshot(); }); - it('tag assignment panel meets a11y requirements', async () => { + // https://github.com/elastic/kibana/issues/135985 inconsistent test failure + it.skip('tag assignment panel meets a11y requirements', async () => { await testSubjects.click('euiCollapsedItemActionsButton'); const actionOnTag = 'assign'; await PageObjects.tagManagement.clickActionItem(actionOnTag); @@ -79,6 +80,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('tag management page with connections column populated meets a11y requirements', async () => { + await testSubjects.click('euiCollapsedItemActionsButton'); + const actionOnTag = 'assign'; + await PageObjects.tagManagement.clickActionItem(actionOnTag); await testSubjects.click('assignFlyout-selectAllButton'); await testSubjects.click('assignFlyoutConfirmButton'); diff --git a/x-pack/test/apm_api_integration/tests/infrastructure/generate_data.ts b/x-pack/test/apm_api_integration/tests/infrastructure/generate_data.ts new file mode 100644 index 00000000000000..b673e541e756b2 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/infrastructure/generate_data.ts @@ -0,0 +1,51 @@ +/* + * 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 { apm, timerange } from '@elastic/apm-synthtrace'; +import type { ApmSynthtraceEsClient } from '@elastic/apm-synthtrace'; + +export async function generateData({ + synthtraceEsClient, + start, + end, +}: { + synthtraceEsClient: ApmSynthtraceEsClient; + start: number; + end: number; +}) { + const serviceRunsInContainerInstance = apm + .service('synth-go', 'production', 'go') + .instance('instance-a'); + + const serviceInstance = apm.service('synth-java', 'production', 'java').instance('instance-b'); + + await synthtraceEsClient.index( + timerange(start, end) + .interval('1m') + .generator((timestamp) => { + return [ + serviceRunsInContainerInstance + .transaction('GET /apple 🍎') + .defaults({ + 'container.id': 'foo', + 'host.hostname': 'bar', + 'kubernetes.pod.name': 'baz', + }) + .timestamp(timestamp) + .duration(1000) + .success(), + serviceInstance + .transaction('GET /banana 🍌') + .defaults({ + 'host.hostname': 'bar', + }) + .timestamp(timestamp) + .duration(1000) + .success(), + ]; + }) + ); +} diff --git a/x-pack/test/apm_api_integration/tests/infrastructure/infrastructure_attributes.spec.ts b/x-pack/test/apm_api_integration/tests/infrastructure/infrastructure_attributes.spec.ts new file mode 100644 index 00000000000000..39a45a9b396c71 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/infrastructure/infrastructure_attributes.spec.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { generateData } from './generate_data'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + const apmApiClient = getService('apmApiClient'); + const synthtraceEsClient = getService('synthtraceEsClient'); + + const start = new Date('2021-01-01T00:00:00.000Z').getTime(); + const end = new Date('2021-01-01T00:15:00.000Z').getTime() - 1; + + async function callApi(serviceName: string) { + const response = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/services/{serviceName}/infrastructure_attributes', + params: { + path: { + serviceName, + }, + query: { + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + environment: 'ENVIRONMENT_ALL', + kuery: '', + }, + }, + }); + return response; + } + + registry.when( + 'Infrastructure attributes when data is not loaded', + { config: 'basic', archives: [] }, + () => { + it('handles the empty state', async () => { + const response = await callApi('synth-go'); + expect(response.status).to.be(200); + expect(response.body.containerIds.length).to.be(0); + expect(response.body.hostNames.length).to.be(0); + expect(response.body.podNames.length).to.be(0); + }); + } + ); + + registry.when( + 'Infrastructure attributes', + { config: 'basic', archives: ['apm_mappings_only_8.0.0'] }, + () => { + describe('when data is loaded', () => { + beforeEach(async () => { + await generateData({ start, end, synthtraceEsClient }); + }); + + afterEach(() => synthtraceEsClient.clean()); + + describe('when service runs in container', () => { + it('returns arrays of container ids and pod names', async () => { + const response = await callApi('synth-go'); + expect(response.status).to.be(200); + expect(response.body.containerIds.length).to.be(1); + // hostNames is always returning empty + // we can not test the infra indices api call with synthtrace + expect(response.body.hostNames.length).to.be(0); + expect(response.body.podNames.length).to.be(1); + }); + }); + + describe('when service does NOT run in container', () => { + it('returns array of host names', async () => { + const response = await callApi('synth-java'); + expect(response.status).to.be(200); + expect(response.body.containerIds.length).to.be(0); + expect(response.body.hostNames.length).to.be(1); + expect(response.body.podNames.length).to.be(0); + }); + }); + }); + } + ); +} diff --git a/x-pack/test/functional/services/cases/navigation.ts b/x-pack/test/functional/services/cases/navigation.ts index 4aca20c01aaf18..8af4d581d93cd8 100644 --- a/x-pack/test/functional/services/cases/navigation.ts +++ b/x-pack/test/functional/services/cases/navigation.ts @@ -12,9 +12,9 @@ export function CasesNavigationProvider({ getPageObject, getService }: FtrProvid const testSubjects = getService('testSubjects'); return { - async navigateToApp() { - await common.navigateToApp('cases'); - await testSubjects.existOrFail('cases-app', { timeout: 2000 }); + async navigateToApp(app: string = 'cases', appSelector: string = 'cases-app') { + await common.navigateToApp(app); + await testSubjects.existOrFail(appSelector, { timeout: 2000 }); }, async navigateToConfigurationPage() { diff --git a/x-pack/test/functional/services/observability/alerts/add_to_case.ts b/x-pack/test/functional/services/observability/alerts/add_to_case.ts index eac7541e15585c..7211325fad2a1c 100644 --- a/x-pack/test/functional/services/observability/alerts/add_to_case.ts +++ b/x-pack/test/functional/services/observability/alerts/add_to_case.ts @@ -55,7 +55,7 @@ export function ObservabilityAlertsAddToCaseProvider({ getService }: FtrProvider return await (await testSubjects.find('euiFlyoutCloseButton')).click(); }; - const getAddtoExistingCaseModalOrFail = async () => { + const getAddToExistingCaseModalOrFail = async () => { return await testSubjects.existOrFail(SELECT_CASE_MODAL); }; @@ -70,6 +70,6 @@ export function ObservabilityAlertsAddToCaseProvider({ getService }: FtrProvider closeFlyout, addToNewCaseButtonClick, addToExistingCaseButtonClick, - getAddtoExistingCaseModalOrFail, + getAddToExistingCaseModalOrFail, }; } diff --git a/x-pack/test/functional/services/observability/index.ts b/x-pack/test/functional/services/observability/index.ts index 0d167ae5d516e2..8990641cb524b1 100644 --- a/x-pack/test/functional/services/observability/index.ts +++ b/x-pack/test/functional/services/observability/index.ts @@ -10,11 +10,11 @@ import { ObservabilityUsersProvider } from './users'; import { ObservabilityAlertsProvider } from './alerts'; export function ObservabilityProvider(context: FtrProviderContext) { - const users = ObservabilityUsersProvider(context); const alerts = ObservabilityAlertsProvider(context); + const users = ObservabilityUsersProvider(context); return { - users, alerts, + users, }; } diff --git a/x-pack/test/observability_functional/apps/observability/index.ts b/x-pack/test/observability_functional/apps/observability/index.ts index c4ad070c459813..1fbe8f5f975e5f 100644 --- a/x-pack/test/observability_functional/apps/observability/index.ts +++ b/x-pack/test/observability_functional/apps/observability/index.ts @@ -10,6 +10,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('ObservabilityApp', function () { loadTestFile(require.resolve('./pages/alerts')); + loadTestFile(require.resolve('./pages/cases/case_details')); loadTestFile(require.resolve('./pages/alerts/add_to_case')); loadTestFile(require.resolve('./pages/alerts/alert_status')); loadTestFile(require.resolve('./pages/alerts/pagination')); diff --git a/x-pack/test/observability_functional/apps/observability/pages/alerts/add_to_case.ts b/x-pack/test/observability_functional/apps/observability/pages/alerts/add_to_case.ts index 918133ca53dfc8..c444638140ec2c 100644 --- a/x-pack/test/observability_functional/apps/observability/pages/alerts/add_to_case.ts +++ b/x-pack/test/observability_functional/apps/observability/pages/alerts/add_to_case.ts @@ -62,7 +62,7 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => { await retry.try(async () => { await observability.alerts.addToCase.addToExistingCaseButtonClick(); - await observability.alerts.addToCase.getAddtoExistingCaseModalOrFail(); + await observability.alerts.addToCase.getAddToExistingCaseModalOrFail(); }); }); }); diff --git a/x-pack/test/observability_functional/apps/observability/pages/cases/case_details.ts b/x-pack/test/observability_functional/apps/observability/pages/cases/case_details.ts new file mode 100644 index 00000000000000..340131c14b6a13 --- /dev/null +++ b/x-pack/test/observability_functional/apps/observability/pages/cases/case_details.ts @@ -0,0 +1,74 @@ +/* + * 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 expect from '@kbn/expect'; +import { CommentType } from '@kbn/cases-plugin/common/api'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const browser = getService('browser'); + const esArchiver = getService('esArchiver'); + const cases = getService('cases'); + const observability = getService('observability'); + const find = getService('find'); + const PageObjects = getPageObjects(['common', 'header']); + + describe('Cases', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/observability/alerts'); + await esArchiver.load('x-pack/test/functional/es_archives/infra/metrics_and_logs'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/infra/metrics_and_logs'); + await esArchiver.unload('x-pack/test/functional/es_archives/observability/alerts'); + }); + + describe('Case detail rule link', () => { + before(async () => { + await observability.users.setTestUserRole( + observability.users.defineBasicObservabilityRole({ + observabilityCases: ['all'], + logs: ['all'], + }) + ); + + const owner = 'observability'; + const caseData = await cases.api.createCase({ + title: 'Sample case', + owner, + }); + await cases.api.createAttachment({ + caseId: caseData.id, + params: { + alertId: ['alert-id'], + index: ['.internal.alerts-observability.alerts-default-000001'], + rule: { id: 'rule-id', name: 'My rule name' }, + type: CommentType.alert, + owner, + }, + }); + }); + + after(async () => { + await cases.api.deleteAllCases(); + }); + + it('should link to observability rule pages in case details', async () => { + await cases.navigation.navigateToApp('observabilityCases', 'cases-all-title'); + await cases.casesTable.waitForCasesToBeListed(); + await cases.casesTable.goToFirstListedCase(); + await PageObjects.header.waitUntilLoadingHasFinished(); + + await (await find.byCssSelector('[data-test-subj*="alert-rule-link"]')).click(); + + const url = await browser.getCurrentUrl(); + expect(url.includes('/app/observability/alerts/rules')).to.be(true); + }); + }); + }); +};