diff --git a/docs/getting-started/quick-start-guide.asciidoc b/docs/getting-started/quick-start-guide.asciidoc index d614aece5b42..2bddd9bf6145 100644 --- a/docs/getting-started/quick-start-guide.asciidoc +++ b/docs/getting-started/quick-start-guide.asciidoc @@ -138,7 +138,7 @@ image::images/dashboard_sampleDataAddFilter_7.15.0.png[The [eCommerce] Revenue D [[quick-start-whats-next]] == What's next? -*Add your own data.* Ready to add your own data? Go to {fleet-guide}/fleet-quick-start.html[Quick start: Get logs and metrics into the Elastic Stack] to learn how to ingest your data, or go to <> and learn about all the other ways you can add data. +*Add your own data.* Ready to add your own data? Go to {observability-guide}/ingest-logs-metrics-uptime.html[Ingest logs, metrics, and uptime data with {agent}], or go to <> and learn about all the other ways you can add data. *Explore your own data in Discover.* Ready to learn more about exploring your data in *Discover*? Go to <>. diff --git a/docs/osquery/osquery.asciidoc b/docs/osquery/osquery.asciidoc index 1e4e6604a7c7..a4f3c8046314 100644 --- a/docs/osquery/osquery.asciidoc +++ b/docs/osquery/osquery.asciidoc @@ -365,7 +365,7 @@ The following is an example of an **error response** for an undefined action que == System requirements * {fleet-guide}/fleet-overview.html[Fleet] is enabled on your cluster, and -one or more {fleet-guide}/elastic-agent-installation-configuration.html[Elastic Agents] is enrolled. +one or more {fleet-guide}/elastic-agent-installation.html[Elastic Agents] is enrolled. * The https://docs.elastic.co/en/integrations/osquery_manager[*Osquery Manager*] integration has been added and configured for an agent policy through Fleet. diff --git a/docs/setup/connect-to-elasticsearch.asciidoc b/docs/setup/connect-to-elasticsearch.asciidoc index c7f745648cbe..ad38ac1710fd 100644 --- a/docs/setup/connect-to-elasticsearch.asciidoc +++ b/docs/setup/connect-to-elasticsearch.asciidoc @@ -47,7 +47,7 @@ so you can quickly get insights into your data, and {fleet} mode offers several image::images/addData_fleet_7.15.0.png[Add data using Fleet] To get started, refer to -{fleet-guide}/fleet-quick-start.html[Quick start: Get logs and metrics into the Elastic Stack]. +{observability-guide}/ingest-logs-metrics-uptime.html[Ingest logs, metrics, and uptime data with {agent}]. [discrete] [[upload-data-kibana]] diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 16fa8eb73420..4802a4da8182 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -275,7 +275,7 @@ that the {kib} server uses to perform maintenance on the {kib} index at startup. is an alternative to `elasticsearch.username` and `elasticsearch.password`. | `enterpriseSearch.host` - | The URL of your Enterprise Search instance + | The http(s) URL of your Enterprise Search instance. For example, in a local self-managed setup, set this to `http://localhost:3002`. Authentication between Kibana and the Enterprise Search host URL, such as via OAuth, is not supported. You can also {enterprise-search-ref}/configure-ssl-tls.html#configure-ssl-tls-in-kibana[configure Kibana to trust your Enterprise Search TLS certificate authority]. | `interpreter.enableInVisualize` | Enables use of interpreter in Visualize. *Default: `true`* diff --git a/src/plugins/custom_integrations/common/index.ts b/src/plugins/custom_integrations/common/index.ts index 944ac6ba3e6e..98148bb22c81 100755 --- a/src/plugins/custom_integrations/common/index.ts +++ b/src/plugins/custom_integrations/common/index.ts @@ -49,6 +49,7 @@ export const INTEGRATION_CATEGORY_DISPLAY = { project_management: 'Project Management', software_development: 'Software Development', upload_file: 'Upload a file', + website_search: 'Website Search', }; /** diff --git a/src/plugins/vis_types/table/common/types.ts b/src/plugins/vis_types/table/common/types.ts index 015af80adf0d..9f607a964977 100644 --- a/src/plugins/vis_types/table/common/types.ts +++ b/src/plugins/vis_types/table/common/types.ts @@ -24,5 +24,6 @@ export interface TableVisParams { showTotal: boolean; totalFunc: AggTypes; percentageCol: string; + autoFitRowToContent?: boolean; row?: boolean; } diff --git a/src/plugins/vis_types/table/public/__snapshots__/table_vis_fn.test.ts.snap b/src/plugins/vis_types/table/public/__snapshots__/table_vis_fn.test.ts.snap index be7e2822f128..1a2badbd2663 100644 --- a/src/plugins/vis_types/table/public/__snapshots__/table_vis_fn.test.ts.snap +++ b/src/plugins/vis_types/table/public/__snapshots__/table_vis_fn.test.ts.snap @@ -26,6 +26,7 @@ Object { "type": "render", "value": Object { "visConfig": Object { + "autoFitRowToContent": false, "buckets": Array [], "metrics": Array [ Object { diff --git a/src/plugins/vis_types/table/public/components/__snapshots__/table_vis_basic.test.tsx.snap b/src/plugins/vis_types/table/public/components/__snapshots__/table_vis_basic.test.tsx.snap index 85cf9422630d..38e3dcbb7097 100644 --- a/src/plugins/vis_types/table/public/components/__snapshots__/table_vis_basic.test.tsx.snap +++ b/src/plugins/vis_types/table/public/components/__snapshots__/table_vis_basic.test.tsx.snap @@ -17,6 +17,7 @@ exports[`TableVisBasic should init data grid 1`] = ` "header": "underline", } } + key="0" minSizeForControls={1} onColumnResize={[Function]} renderCellValue={[Function]} @@ -55,6 +56,7 @@ exports[`TableVisBasic should init data grid with title provided - for split mod "header": "underline", } } + key="0" minSizeForControls={1} onColumnResize={[Function]} renderCellValue={[Function]} @@ -86,6 +88,7 @@ exports[`TableVisBasic should render the toolbar 1`] = ` "header": "underline", } } + key="0" minSizeForControls={1} onColumnResize={[Function]} renderCellValue={[Function]} diff --git a/src/plugins/vis_types/table/public/components/table_vis_basic.test.tsx b/src/plugins/vis_types/table/public/components/table_vis_basic.test.tsx index 0fb74a41b5df..0296f2ec1327 100644 --- a/src/plugins/vis_types/table/public/components/table_vis_basic.test.tsx +++ b/src/plugins/vis_types/table/public/components/table_vis_basic.test.tsx @@ -67,6 +67,7 @@ describe('TableVisBasic', () => { }); it('should sort rows by column and pass the sorted rows for consumers', () => { + (createTableVisCell as jest.Mock).mockClear(); const uiStateProps = { ...props.uiStateProps, sort: { @@ -96,7 +97,7 @@ describe('TableVisBasic', () => { visConfig={{ ...props.visConfig, showToolbar: true }} /> ); - expect(createTableVisCell).toHaveBeenCalledWith(sortedRows, table.formattedColumns); + expect(createTableVisCell).toHaveBeenCalledWith(sortedRows, table.formattedColumns, undefined); expect(createGridColumns).toHaveBeenCalledWith( table.columns, sortedRows, diff --git a/src/plugins/vis_types/table/public/components/table_vis_basic.tsx b/src/plugins/vis_types/table/public/components/table_vis_basic.tsx index e627b9e7f92f..cfe1ce5d40a1 100644 --- a/src/plugins/vis_types/table/public/components/table_vis_basic.tsx +++ b/src/plugins/vis_types/table/public/components/table_vis_basic.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { memo, useCallback, useMemo } from 'react'; +import React, { memo, useCallback, useMemo, useEffect, useState, useRef } from 'react'; import { EuiDataGrid, EuiDataGridProps, EuiDataGridSorting, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { orderBy } from 'lodash'; @@ -47,8 +47,16 @@ export const TableVisBasic = memo( // renderCellValue is a component which renders a cell based on column and row indexes const renderCellValue = useMemo( - () => createTableVisCell(sortedRows, formattedColumns), - [formattedColumns, sortedRows] + () => createTableVisCell(sortedRows, formattedColumns, visConfig.autoFitRowToContent), + [formattedColumns, sortedRows, visConfig.autoFitRowToContent] + ); + + const rowHeightsOptions = useMemo( + () => + visConfig.autoFitRowToContent + ? ({ defaultHeight: 'auto' } as unknown as EuiDataGridProps['rowHeightsOptions']) + : undefined, + [visConfig.autoFitRowToContent] ); // Columns config @@ -103,6 +111,26 @@ export const TableVisBasic = memo( [columns, setColumnsWidth] ); + const firstRender = useRef(true); + const [dataGridUpdateCounter, setDataGridUpdateCounter] = useState(0); + + // key was added as temporary solution to force re-render if we change autoFitRowToContent or we get new data + // cause we have problem with correct updating height cache in EUI datagrid when we use auto-height + // will be removed as soon as fix problem on EUI side + useEffect(() => { + // skip first render + if (firstRender.current) { + firstRender.current = false; + return; + } + // skip if auto height was turned off + if (!visConfig.autoFitRowToContent) { + return; + } + // update counter to remount grid from scratch + setDataGridUpdateCounter((counter) => counter + 1); + }, [visConfig.autoFitRowToContent, table, sort, pagination, columnsWidth]); + return ( <> {title && ( @@ -111,12 +139,14 @@ export const TableVisBasic = memo( )} id), diff --git a/src/plugins/vis_types/table/public/components/table_vis_cell.tsx b/src/plugins/vis_types/table/public/components/table_vis_cell.tsx index 9749cdcb5740..7d7af447db48 100644 --- a/src/plugins/vis_types/table/public/components/table_vis_cell.tsx +++ b/src/plugins/vis_types/table/public/components/table_vis_cell.tsx @@ -13,7 +13,7 @@ import { DatatableRow } from 'src/plugins/expressions'; import { FormattedColumns } from '../types'; export const createTableVisCell = - (rows: DatatableRow[], formattedColumns: FormattedColumns) => + (rows: DatatableRow[], formattedColumns: FormattedColumns, autoFitRowToContent?: boolean) => ({ rowIndex, columnId }: EuiDataGridCellValueElementProps) => { const rowValue = rows[rowIndex][columnId]; const column = formattedColumns[columnId]; @@ -28,7 +28,7 @@ export const createTableVisCell = */ dangerouslySetInnerHTML={{ __html: content }} // eslint-disable-line react/no-danger data-test-subj="tbvChartCellContent" - className="tbvChartCellContent" + className={autoFitRowToContent ? '' : 'tbvChartCellContent'} /> ); diff --git a/src/plugins/vis_types/table/public/components/table_vis_options.tsx b/src/plugins/vis_types/table/public/components/table_vis_options.tsx index 8a6b8586fce7..698aca6034a6 100644 --- a/src/plugins/vis_types/table/public/components/table_vis_options.tsx +++ b/src/plugins/vis_types/table/public/components/table_vis_options.tsx @@ -93,6 +93,16 @@ function TableOptions({ data-test-subj="showMetricsAtAllLevels" /> + + { splitColumn: undefined, splitRow: undefined, showMetricsAtAllLevels: false, + autoFitRowToContent: false, sort: { columnIndex: null, direction: null, diff --git a/src/plugins/vis_types/table/public/table_vis_fn.ts b/src/plugins/vis_types/table/public/table_vis_fn.ts index ebddb0b4b7fe..861923ef5086 100644 --- a/src/plugins/vis_types/table/public/table_vis_fn.ts +++ b/src/plugins/vis_types/table/public/table_vis_fn.ts @@ -118,6 +118,11 @@ export const createTableVisFn = (): TableExpressionFunctionDefinition => ({ defaultMessage: 'Specifies calculating function for the total row. Possible options are: ', }), }, + autoFitRowToContent: { + types: ['boolean'], + help: '', + default: false, + }, }, fn(input, args, handlers) { const convertedData = tableVisResponseHandler(input, args); diff --git a/src/plugins/vis_types/table/public/table_vis_type.ts b/src/plugins/vis_types/table/public/table_vis_type.ts index 4664e87cea79..a641224e23f5 100644 --- a/src/plugins/vis_types/table/public/table_vis_type.ts +++ b/src/plugins/vis_types/table/public/table_vis_type.ts @@ -35,6 +35,7 @@ export const tableVisTypeDefinition: VisTypeDefinition = { showToolbar: false, totalFunc: 'sum', percentageCol: '', + autoFitRowToContent: false, }, }, editorConfig: { diff --git a/src/plugins/vis_types/table/public/to_ast.ts b/src/plugins/vis_types/table/public/to_ast.ts index 8e1c92c8dde4..0268708f22df 100644 --- a/src/plugins/vis_types/table/public/to_ast.ts +++ b/src/plugins/vis_types/table/public/to_ast.ts @@ -64,6 +64,7 @@ export const toExpressionAst: VisToExpressionAst = (vis, params) showMetricsAtAllLevels: vis.params.showMetricsAtAllLevels, showToolbar: vis.params.showToolbar, showTotal: vis.params.showTotal, + autoFitRowToContent: vis.params.autoFitRowToContent, totalFunc: vis.params.totalFunc, title: vis.title, metrics: metrics.map(prepareDimension), diff --git a/x-pack/plugins/apm/common/search_strategies/failed_transactions_correlations/types.ts b/x-pack/plugins/apm/common/search_strategies/failed_transactions_correlations/types.ts index 266d7246c35d..28ce2ff24b96 100644 --- a/x-pack/plugins/apm/common/search_strategies/failed_transactions_correlations/types.ts +++ b/x-pack/plugins/apm/common/search_strategies/failed_transactions_correlations/types.ts @@ -5,12 +5,7 @@ * 2.0. */ -import { - FieldValuePair, - HistogramItem, - RawResponseBase, - SearchStrategyClientParams, -} from '../types'; +import { FieldValuePair, HistogramItem } from '../types'; import { FAILED_TRANSACTIONS_IMPACT_THRESHOLD } from './constants'; import { FieldStats } from '../field_stats_types'; @@ -33,11 +28,7 @@ export interface FailedTransactionsCorrelationsParams { percentileThreshold: number; } -export type FailedTransactionsCorrelationsRequestParams = - FailedTransactionsCorrelationsParams & SearchStrategyClientParams; - -export interface FailedTransactionsCorrelationsRawResponse - extends RawResponseBase { +export interface FailedTransactionsCorrelationsRawResponse { log: string[]; failedTransactionsCorrelations?: FailedTransactionsCorrelation[]; percentileThresholdValue?: number; diff --git a/x-pack/plugins/apm/common/search_strategies/latency_correlations/types.ts b/x-pack/plugins/apm/common/search_strategies/latency_correlations/types.ts index 2eb2b3715945..ea74175a3dac 100644 --- a/x-pack/plugins/apm/common/search_strategies/latency_correlations/types.ts +++ b/x-pack/plugins/apm/common/search_strategies/latency_correlations/types.ts @@ -5,12 +5,7 @@ * 2.0. */ -import { - FieldValuePair, - HistogramItem, - RawResponseBase, - SearchStrategyClientParams, -} from '../types'; +import { FieldValuePair, HistogramItem } from '../types'; import { FieldStats } from '../field_stats_types'; export interface LatencyCorrelation extends FieldValuePair { @@ -33,10 +28,7 @@ export interface LatencyCorrelationsParams { analyzeCorrelations: boolean; } -export type LatencyCorrelationsRequestParams = LatencyCorrelationsParams & - SearchStrategyClientParams; - -export interface LatencyCorrelationsRawResponse extends RawResponseBase { +export interface LatencyCorrelationsRawResponse { log: string[]; overallHistogram?: HistogramItem[]; percentileThresholdValue?: number; diff --git a/x-pack/plugins/apm/common/search_strategies/types.ts b/x-pack/plugins/apm/common/search_strategies/types.ts index d7c6eab1f07c..ff925f70fc9b 100644 --- a/x-pack/plugins/apm/common/search_strategies/types.ts +++ b/x-pack/plugins/apm/common/search_strategies/types.ts @@ -31,16 +31,26 @@ export interface RawResponseBase { took: number; } -export interface SearchStrategyClientParams { +export interface SearchStrategyClientParamsBase { environment: string; kuery: string; serviceName?: string; transactionName?: string; transactionType?: string; +} + +export interface RawSearchStrategyClientParams + extends SearchStrategyClientParamsBase { start?: string; end?: string; } +export interface SearchStrategyClientParams + extends SearchStrategyClientParamsBase { + start: number; + end: number; +} + export interface SearchStrategyServerParams { index: string; includeFrozen?: boolean; diff --git a/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.test.tsx b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.test.tsx index 9956452c565b..918f94e64ef0 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.test.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.test.tsx @@ -19,6 +19,7 @@ import type { IKibanaSearchResponse } from 'src/plugins/data/public'; import { EuiThemeProvider } from 'src/plugins/kibana_react/common'; import { createKibanaReactContext } from 'src/plugins/kibana_react/public'; import type { LatencyCorrelationsRawResponse } from '../../../../common/search_strategies/latency_correlations/types'; +import type { RawResponseBase } from '../../../../common/search_strategies/types'; import { MockUrlParamsContextProvider } from '../../../context/url_params_context/mock_url_params_context_provider'; import { ApmPluginContextValue } from '../../../context/apm_plugin/apm_plugin_context'; import { @@ -34,7 +35,9 @@ function Wrapper({ dataSearchResponse, }: { children?: ReactNode; - dataSearchResponse: IKibanaSearchResponse; + dataSearchResponse: IKibanaSearchResponse< + LatencyCorrelationsRawResponse & RawResponseBase + >; }) { const mockDataSearch = jest.fn(() => of(dataSearchResponse)); diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.test.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.test.tsx index bd0ff4c87c3b..0e9639de4aa7 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.test.tsx @@ -8,43 +8,24 @@ import { render, screen, waitFor } from '@testing-library/react'; import { createMemoryHistory } from 'history'; import React, { ReactNode } from 'react'; -import { of } from 'rxjs'; import { CoreStart } from 'kibana/public'; import { merge } from 'lodash'; -import { dataPluginMock } from 'src/plugins/data/public/mocks'; -import type { IKibanaSearchResponse } from 'src/plugins/data/public'; import { EuiThemeProvider } from 'src/plugins/kibana_react/common'; import { createKibanaReactContext } from 'src/plugins/kibana_react/public'; -import type { LatencyCorrelationsRawResponse } from '../../../../../common/search_strategies/latency_correlations/types'; import { MockUrlParamsContextProvider } from '../../../../context/url_params_context/mock_url_params_context_provider'; import { ApmPluginContextValue } from '../../../../context/apm_plugin/apm_plugin_context'; import { mockApmPluginContextValue, MockApmPluginContextWrapper, } from '../../../../context/apm_plugin/mock_apm_plugin_context'; +import * as useFetcherModule from '../../../../hooks/use_fetcher'; import { fromQuery } from '../../../shared/Links/url_helpers'; import { getFormattedSelection, TransactionDistribution } from './index'; -function Wrapper({ - children, - dataSearchResponse, -}: { - children?: ReactNode; - dataSearchResponse: IKibanaSearchResponse; -}) { - const mockDataSearch = jest.fn(() => of(dataSearchResponse)); - - const dataPluginMockStart = dataPluginMock.createStartContract(); +function Wrapper({ children }: { children?: ReactNode }) { const KibanaReactContext = createKibanaReactContext({ - data: { - ...dataPluginMockStart, - search: { - ...dataPluginMockStart.search, - search: mockDataSearch, - }, - }, usageCollection: { reportUiCounter: () => {} }, } as Partial); @@ -105,18 +86,14 @@ describe('transaction_details/distribution', () => { describe('TransactionDistribution', () => { it('shows loading indicator when the service is running and returned no results yet', async () => { + jest.spyOn(useFetcherModule, 'useFetcher').mockImplementation(() => ({ + data: {}, + refetch: () => {}, + status: useFetcherModule.FETCH_STATUS.LOADING, + })); + render( - + { }); it("doesn't show loading indicator when the service isn't running", async () => { + jest.spyOn(useFetcherModule, 'useFetcher').mockImplementation(() => ({ + data: { percentileThresholdValue: 1234, overallHistogram: [] }, + refetch: () => {}, + status: useFetcherModule.FETCH_STATUS.SUCCESS, + })); + render( - + { + if (serviceName && environment && start && end) { + return callApmApi({ + endpoint: 'GET /internal/apm/latency/overall_distribution', + params: { + query: { + serviceName, + transactionName, + transactionType, + kuery, + environment, + start, + end, + percentileThreshold: DEFAULT_PERCENTILE_THRESHOLD, + }, + }, + }); + } + }, + [ + serviceName, + transactionName, + transactionType, + kuery, + environment, + start, + end, + ] ); + const overallHistogram = + data.overallHistogram === undefined && status !== FETCH_STATUS.LOADING + ? [] + : data.overallHistogram; + const hasData = + Array.isArray(overallHistogram) && overallHistogram.length > 0; + useEffect(() => { - if (isErrorMessage(progress.error)) { + if (isErrorMessage(error)) { notifications.toasts.addDanger({ title: i18n.translate( 'xpack.apm.transactionDetails.distribution.errorTitle', @@ -119,10 +156,10 @@ export function TransactionDistribution({ defaultMessage: 'An error occurred fetching the distribution', } ), - text: progress.error.toString(), + text: error.toString(), }); } - }, [progress.error, notifications.toasts]); + }, [error, notifications.toasts]); const trackApmEvent = useUiTracker({ app: 'apm' }); @@ -213,7 +250,7 @@ export function TransactionDistribution({ data={transactionDistributionChartData} markerCurrentTransaction={markerCurrentTransaction} markerPercentile={DEFAULT_PERCENTILE_THRESHOLD} - markerValue={response.percentileThresholdValue ?? 0} + markerValue={data.percentileThresholdValue ?? 0} onChartSelection={onTrackedChartSelection as BrushEndListener} hasData={hasData} selection={selection} diff --git a/x-pack/plugins/apm/public/hooks/use_search_strategy.ts b/x-pack/plugins/apm/public/hooks/use_search_strategy.ts index ca8d28b106f8..275eddb68ae0 100644 --- a/x-pack/plugins/apm/public/hooks/use_search_strategy.ts +++ b/x-pack/plugins/apm/public/hooks/use_search_strategy.ts @@ -16,7 +16,7 @@ import { } from '../../../../../src/plugins/data/public'; import { useKibana } from '../../../../../src/plugins/kibana_react/public'; -import type { SearchStrategyClientParams } from '../../common/search_strategies/types'; +import type { RawSearchStrategyClientParams } from '../../common/search_strategies/types'; import type { RawResponseBase } from '../../common/search_strategies/types'; import type { LatencyCorrelationsParams, @@ -77,13 +77,15 @@ interface SearchStrategyReturnBase { export function useSearchStrategy( searchStrategyName: typeof APM_SEARCH_STRATEGIES.APM_LATENCY_CORRELATIONS, searchStrategyParams: LatencyCorrelationsParams -): SearchStrategyReturnBase; +): SearchStrategyReturnBase; // Function overload for Failed Transactions Correlations export function useSearchStrategy( searchStrategyName: typeof APM_SEARCH_STRATEGIES.APM_FAILED_TRANSACTIONS_CORRELATIONS, searchStrategyParams: FailedTransactionsCorrelationsParams -): SearchStrategyReturnBase; +): SearchStrategyReturnBase< + FailedTransactionsCorrelationsRawResponse & RawResponseBase +>; export function useSearchStrategy< TRawResponse extends RawResponseBase, @@ -145,7 +147,7 @@ export function useSearchStrategy< // Submit the search request using the `data.search` service. searchSubscription$.current = data.search .search< - IKibanaSearchRequest, + IKibanaSearchRequest, IKibanaSearchResponse >(request, { strategy: searchStrategyName, diff --git a/x-pack/plugins/apm/server/lib/latency/get_overall_latency_distribution.ts b/x-pack/plugins/apm/server/lib/latency/get_overall_latency_distribution.ts new file mode 100644 index 000000000000..39470869488c --- /dev/null +++ b/x-pack/plugins/apm/server/lib/latency/get_overall_latency_distribution.ts @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; + +import { ProcessorEvent } from '../../../common/processor_event'; + +import { withApmSpan } from '../../utils/with_apm_span'; + +import { + getHistogramIntervalRequest, + getHistogramRangeSteps, +} from '../search_strategies/queries/query_histogram_range_steps'; +import { getTransactionDurationRangesRequest } from '../search_strategies/queries/query_ranges'; + +import { getPercentileThresholdValue } from './get_percentile_threshold_value'; +import type { + OverallLatencyDistributionOptions, + OverallLatencyDistributionResponse, +} from './types'; + +export async function getOverallLatencyDistribution( + options: OverallLatencyDistributionOptions +) { + return withApmSpan('get_overall_latency_distribution', async () => { + const overallLatencyDistribution: OverallLatencyDistributionResponse = { + log: [], + }; + + const { setup, ...rawParams } = options; + const { apmEventClient } = setup; + const params = { + // pass on an empty index because we're using only the body attribute + // of the request body getters we're reusing from search strategies. + index: '', + ...rawParams, + }; + + // #1: get 95th percentile to be displayed as a marker in the log log chart + overallLatencyDistribution.percentileThresholdValue = + await getPercentileThresholdValue(options); + + // finish early if we weren't able to identify the percentileThresholdValue. + if (!overallLatencyDistribution.percentileThresholdValue) { + return overallLatencyDistribution; + } + + // #2: get histogram range steps + const steps = 100; + + const { body: histogramIntervalRequestBody } = + getHistogramIntervalRequest(params); + + const histogramIntervalResponse = (await apmEventClient.search( + 'get_histogram_interval', + { + // TODO: add support for metrics + apm: { events: [ProcessorEvent.transaction] }, + body: histogramIntervalRequestBody, + } + )) as { + aggregations?: { + transaction_duration_min: estypes.AggregationsValueAggregate; + transaction_duration_max: estypes.AggregationsValueAggregate; + }; + hits: { total: estypes.SearchTotalHits }; + }; + + if ( + !histogramIntervalResponse.aggregations || + histogramIntervalResponse.hits.total.value === 0 + ) { + return overallLatencyDistribution; + } + + const min = + histogramIntervalResponse.aggregations.transaction_duration_min.value; + const max = + histogramIntervalResponse.aggregations.transaction_duration_max.value * 2; + + const histogramRangeSteps = getHistogramRangeSteps(min, max, steps); + + // #3: get histogram chart data + const { body: transactionDurationRangesRequestBody } = + getTransactionDurationRangesRequest(params, histogramRangeSteps); + + const transactionDurationRangesResponse = (await apmEventClient.search( + 'get_transaction_duration_ranges', + { + // TODO: add support for metrics + apm: { events: [ProcessorEvent.transaction] }, + body: transactionDurationRangesRequestBody, + } + )) as { + aggregations?: { + logspace_ranges: estypes.AggregationsMultiBucketAggregate<{ + from: number; + doc_count: number; + }>; + }; + }; + + if (!transactionDurationRangesResponse.aggregations) { + return overallLatencyDistribution; + } + + overallLatencyDistribution.overallHistogram = + transactionDurationRangesResponse.aggregations.logspace_ranges.buckets + .map((d) => ({ + key: d.from, + doc_count: d.doc_count, + })) + .filter((d) => d.key !== undefined); + + return overallLatencyDistribution; + }); +} diff --git a/x-pack/plugins/apm/server/lib/latency/get_percentile_threshold_value.ts b/x-pack/plugins/apm/server/lib/latency/get_percentile_threshold_value.ts new file mode 100644 index 000000000000..0d417a370e0b --- /dev/null +++ b/x-pack/plugins/apm/server/lib/latency/get_percentile_threshold_value.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; + +import { ProcessorEvent } from '../../../common/processor_event'; + +import { getTransactionDurationPercentilesRequest } from '../search_strategies/queries/query_percentiles'; + +import type { OverallLatencyDistributionOptions } from './types'; + +export async function getPercentileThresholdValue( + options: OverallLatencyDistributionOptions +) { + const { setup, percentileThreshold, ...rawParams } = options; + const { apmEventClient } = setup; + const params = { + // pass on an empty index because we're using only the body attribute + // of the request body getters we're reusing from search strategies. + index: '', + ...rawParams, + }; + + const { body: transactionDurationPercentilesRequestBody } = + getTransactionDurationPercentilesRequest(params, [percentileThreshold]); + + const transactionDurationPercentilesResponse = (await apmEventClient.search( + 'get_transaction_duration_percentiles', + { + // TODO: add support for metrics + apm: { events: [ProcessorEvent.transaction] }, + body: transactionDurationPercentilesRequestBody, + } + )) as { + aggregations?: { + transaction_duration_percentiles: estypes.AggregationsTDigestPercentilesAggregate; + }; + }; + + if (!transactionDurationPercentilesResponse.aggregations) { + return; + } + + const percentilesResponseThresholds = + transactionDurationPercentilesResponse.aggregations + .transaction_duration_percentiles?.values ?? {}; + + return percentilesResponseThresholds[`${percentileThreshold}.0`]; +} diff --git a/x-pack/plugins/apm/server/lib/latency/types.ts b/x-pack/plugins/apm/server/lib/latency/types.ts new file mode 100644 index 000000000000..8dad1a39bd15 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/latency/types.ts @@ -0,0 +1,23 @@ +/* + * 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 { Setup } from '../helpers/setup_request'; +import { CorrelationsOptions } from '../search_strategies/queries/get_filters'; + +export interface OverallLatencyDistributionOptions extends CorrelationsOptions { + percentileThreshold: number; + setup: Setup; +} + +export interface OverallLatencyDistributionResponse { + log: string[]; + percentileThresholdValue?: number; + overallHistogram?: Array<{ + key: number; + doc_count: number; + }>; +} diff --git a/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/failed_transactions_correlations_search_service.ts b/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/failed_transactions_correlations_search_service.ts index af5e535abdc3..efc28ce98e5e 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/failed_transactions_correlations_search_service.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/failed_transactions_correlations_search_service.ts @@ -9,17 +9,15 @@ import { chunk } from 'lodash'; import type { ElasticsearchClient } from 'src/core/server'; -import type { ISearchStrategy } from '../../../../../../../src/plugins/data/server'; -import { - IKibanaSearchRequest, - IKibanaSearchResponse, -} from '../../../../../../../src/plugins/data/common'; - import { EVENT_OUTCOME } from '../../../../common/elasticsearch_fieldnames'; import { EventOutcome } from '../../../../common/event_outcome'; -import type { SearchStrategyServerParams } from '../../../../common/search_strategies/types'; import type { - FailedTransactionsCorrelationsRequestParams, + SearchStrategyClientParams, + SearchStrategyServerParams, + RawResponseBase, +} from '../../../../common/search_strategies/types'; +import type { + FailedTransactionsCorrelationsParams, FailedTransactionsCorrelationsRawResponse, } from '../../../../common/search_strategies/failed_transactions_correlations/types'; import type { ApmIndicesConfig } from '../../settings/apm_indices/get_apm_indices'; @@ -38,22 +36,18 @@ import { failedTransactionsCorrelationsSearchServiceStateProvider } from './fail import { ERROR_CORRELATION_THRESHOLD } from '../constants'; import { fetchFieldsStats } from '../queries/field_stats/get_fields_stats'; -export type FailedTransactionsCorrelationsSearchServiceProvider = +type FailedTransactionsCorrelationsSearchServiceProvider = SearchServiceProvider< - FailedTransactionsCorrelationsRequestParams, - FailedTransactionsCorrelationsRawResponse + FailedTransactionsCorrelationsParams & SearchStrategyClientParams, + FailedTransactionsCorrelationsRawResponse & RawResponseBase >; -export type FailedTransactionsCorrelationsSearchStrategy = ISearchStrategy< - IKibanaSearchRequest, - IKibanaSearchResponse ->; - export const failedTransactionsCorrelationsSearchServiceProvider: FailedTransactionsCorrelationsSearchServiceProvider = ( esClient: ElasticsearchClient, getApmIndices: () => Promise, - searchServiceParams: FailedTransactionsCorrelationsRequestParams, + searchServiceParams: FailedTransactionsCorrelationsParams & + SearchStrategyClientParams, includeFrozen: boolean ) => { const { addLogMessage, getLogMessages } = searchServiceLogProvider(); @@ -63,7 +57,8 @@ export const failedTransactionsCorrelationsSearchServiceProvider: FailedTransact async function fetchErrorCorrelations() { try { const indices = await getApmIndices(); - const params: FailedTransactionsCorrelationsRequestParams & + const params: FailedTransactionsCorrelationsParams & + SearchStrategyClientParams & SearchStrategyServerParams = { ...searchServiceParams, index: indices.transaction, diff --git a/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/index.ts b/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/index.ts index ec91165cb481..4763cd994d30 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/index.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/index.ts @@ -5,8 +5,4 @@ * 2.0. */ -export { - failedTransactionsCorrelationsSearchServiceProvider, - FailedTransactionsCorrelationsSearchServiceProvider, - FailedTransactionsCorrelationsSearchStrategy, -} from './failed_transactions_correlations_search_service'; +export { failedTransactionsCorrelationsSearchServiceProvider } from './failed_transactions_correlations_search_service'; diff --git a/x-pack/plugins/apm/server/lib/search_strategies/latency_correlations/index.ts b/x-pack/plugins/apm/server/lib/search_strategies/latency_correlations/index.ts index 073bb122896f..040aa5a7e424 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/latency_correlations/index.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/latency_correlations/index.ts @@ -5,8 +5,4 @@ * 2.0. */ -export { - latencyCorrelationsSearchServiceProvider, - LatencyCorrelationsSearchServiceProvider, - LatencyCorrelationsSearchStrategy, -} from './latency_correlations_search_service'; +export { latencyCorrelationsSearchServiceProvider } from './latency_correlations_search_service'; diff --git a/x-pack/plugins/apm/server/lib/search_strategies/latency_correlations/latency_correlations_search_service.ts b/x-pack/plugins/apm/server/lib/search_strategies/latency_correlations/latency_correlations_search_service.ts index 4862f7dd1de1..f170818d018d 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/latency_correlations/latency_correlations_search_service.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/latency_correlations/latency_correlations_search_service.ts @@ -8,15 +8,13 @@ import { range } from 'lodash'; import type { ElasticsearchClient } from 'src/core/server'; -import type { ISearchStrategy } from '../../../../../../../src/plugins/data/server'; -import { - IKibanaSearchRequest, - IKibanaSearchResponse, -} from '../../../../../../../src/plugins/data/common'; - -import type { SearchStrategyServerParams } from '../../../../common/search_strategies/types'; import type { - LatencyCorrelationsRequestParams, + RawResponseBase, + SearchStrategyClientParams, + SearchStrategyServerParams, +} from '../../../../common/search_strategies/types'; +import type { + LatencyCorrelationsParams, LatencyCorrelationsRawResponse, } from '../../../../common/search_strategies/latency_correlations/types'; @@ -38,21 +36,16 @@ import type { SearchServiceProvider } from '../search_strategy_provider'; import { latencyCorrelationsSearchServiceStateProvider } from './latency_correlations_search_service_state'; import { fetchFieldsStats } from '../queries/field_stats/get_fields_stats'; -export type LatencyCorrelationsSearchServiceProvider = SearchServiceProvider< - LatencyCorrelationsRequestParams, - LatencyCorrelationsRawResponse ->; - -export type LatencyCorrelationsSearchStrategy = ISearchStrategy< - IKibanaSearchRequest, - IKibanaSearchResponse +type LatencyCorrelationsSearchServiceProvider = SearchServiceProvider< + LatencyCorrelationsParams & SearchStrategyClientParams, + LatencyCorrelationsRawResponse & RawResponseBase >; export const latencyCorrelationsSearchServiceProvider: LatencyCorrelationsSearchServiceProvider = ( esClient: ElasticsearchClient, getApmIndices: () => Promise, - searchServiceParams: LatencyCorrelationsRequestParams, + searchServiceParams: LatencyCorrelationsParams & SearchStrategyClientParams, includeFrozen: boolean ) => { const { addLogMessage, getLogMessages } = searchServiceLogProvider(); @@ -61,7 +54,9 @@ export const latencyCorrelationsSearchServiceProvider: LatencyCorrelationsSearch async function fetchCorrelations() { let params: - | (LatencyCorrelationsRequestParams & SearchStrategyServerParams) + | (LatencyCorrelationsParams & + SearchStrategyClientParams & + SearchStrategyServerParams) | undefined; try { diff --git a/x-pack/plugins/apm/server/lib/search_strategies/queries/field_stats/get_field_stats.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/queries/field_stats/get_field_stats.test.ts index deb89ace47c5..d3cee1c4ca59 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/queries/field_stats/get_field_stats.test.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/queries/field_stats/get_field_stats.test.ts @@ -15,8 +15,8 @@ import { fetchFieldsStats } from './get_fields_stats'; const params = { index: 'apm-*', - start: '2020', - end: '2021', + start: 1577836800000, + end: 1609459200000, includeFrozen: false, environment: ENVIRONMENT_ALL.value, kuery: '', diff --git a/x-pack/plugins/apm/server/lib/search_strategies/queries/get_query_with_params.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/queries/get_query_with_params.test.ts index c77b4df78f86..9d0441e51319 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/queries/get_query_with_params.test.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/queries/get_query_with_params.test.ts @@ -14,8 +14,8 @@ describe('correlations', () => { const query = getQueryWithParams({ params: { index: 'apm-*', - start: '2020', - end: '2021', + start: 1577836800000, + end: 1609459200000, includeFrozen: false, environment: ENVIRONMENT_ALL.value, kuery: '', @@ -45,8 +45,8 @@ describe('correlations', () => { index: 'apm-*', serviceName: 'actualServiceName', transactionName: 'actualTransactionName', - start: '2020', - end: '2021', + start: 1577836800000, + end: 1609459200000, environment: 'dev', kuery: '', includeFrozen: false, @@ -93,8 +93,8 @@ describe('correlations', () => { const query = getQueryWithParams({ params: { index: 'apm-*', - start: '2020', - end: '2021', + start: 1577836800000, + end: 1609459200000, includeFrozen: false, environment: ENVIRONMENT_ALL.value, kuery: '', diff --git a/x-pack/plugins/apm/server/lib/search_strategies/queries/get_query_with_params.ts b/x-pack/plugins/apm/server/lib/search_strategies/queries/get_query_with_params.ts index f00c89503f10..31a98b0a6bb1 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/queries/get_query_with_params.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/queries/get_query_with_params.ts @@ -6,15 +6,10 @@ */ import type { estypes } from '@elastic/elasticsearch'; -import { getOrElse } from 'fp-ts/lib/Either'; -import { pipe } from 'fp-ts/lib/pipeable'; -import * as t from 'io-ts'; -import { failure } from 'io-ts/lib/PathReporter'; import type { FieldValuePair, SearchStrategyParams, } from '../../../../common/search_strategies/types'; -import { rangeRt } from '../../../routes/default_api_types'; import { getCorrelationsFilters } from './get_filters'; export const getTermsQuery = ({ fieldName, fieldValue }: FieldValuePair) => { @@ -36,22 +31,14 @@ export const getQueryWithParams = ({ params, termFilters }: QueryParams) => { transactionName, } = params; - // converts string based start/end to epochmillis - const decodedRange = pipe( - rangeRt.decode({ start, end }), - getOrElse((errors) => { - throw new Error(failure(errors).join('\n')); - }) - ); - const correlationFilters = getCorrelationsFilters({ environment, kuery, serviceName, transactionType, transactionName, - start: decodedRange.start, - end: decodedRange.end, + start, + end, }); return { diff --git a/x-pack/plugins/apm/server/lib/search_strategies/queries/get_request_base.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/queries/get_request_base.test.ts index fd5f52207d4c..eb771e1e1aaf 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/queries/get_request_base.test.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/queries/get_request_base.test.ts @@ -16,6 +16,8 @@ describe('correlations', () => { includeFrozen: true, environment: ENVIRONMENT_ALL.value, kuery: '', + start: 1577836800000, + end: 1609459200000, }); expect(requestBase).toEqual({ index: 'apm-*', @@ -29,6 +31,8 @@ describe('correlations', () => { index: 'apm-*', environment: ENVIRONMENT_ALL.value, kuery: '', + start: 1577836800000, + end: 1609459200000, }); expect(requestBase).toEqual({ index: 'apm-*', diff --git a/x-pack/plugins/apm/server/lib/search_strategies/queries/query_correlation.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/queries/query_correlation.test.ts index fc2dacce61a7..40fcc1744449 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/queries/query_correlation.test.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/queries/query_correlation.test.ts @@ -18,8 +18,8 @@ import { const params = { index: 'apm-*', - start: '2020', - end: '2021', + start: 1577836800000, + end: 1609459200000, includeFrozen: false, environment: ENVIRONMENT_ALL.value, kuery: '', diff --git a/x-pack/plugins/apm/server/lib/search_strategies/queries/query_field_candidates.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/queries/query_field_candidates.test.ts index 6e0521ac1a00..bae42666e6db 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/queries/query_field_candidates.test.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/queries/query_field_candidates.test.ts @@ -20,8 +20,8 @@ import { const params = { index: 'apm-*', - start: '2020', - end: '2021', + start: 1577836800000, + end: 1609459200000, includeFrozen: false, environment: ENVIRONMENT_ALL.value, kuery: '', diff --git a/x-pack/plugins/apm/server/lib/search_strategies/queries/query_field_value_pairs.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/queries/query_field_value_pairs.test.ts index 9ffbf6b2ce18..ab7a0b4e0207 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/queries/query_field_value_pairs.test.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/queries/query_field_value_pairs.test.ts @@ -20,8 +20,8 @@ import { const params = { index: 'apm-*', - start: '2020', - end: '2021', + start: 1577836800000, + end: 1609459200000, includeFrozen: false, environment: ENVIRONMENT_ALL.value, kuery: '', diff --git a/x-pack/plugins/apm/server/lib/search_strategies/queries/query_fractions.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/queries/query_fractions.test.ts index daf6b368c78b..9c704ef7b489 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/queries/query_fractions.test.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/queries/query_fractions.test.ts @@ -17,8 +17,8 @@ import { const params = { index: 'apm-*', - start: '2020', - end: '2021', + start: 1577836800000, + end: 1609459200000, includeFrozen: false, environment: ENVIRONMENT_ALL.value, kuery: '', diff --git a/x-pack/plugins/apm/server/lib/search_strategies/queries/query_histogram.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/queries/query_histogram.test.ts index 7ecb1d2d8a33..7cc6106f671a 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/queries/query_histogram.test.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/queries/query_histogram.test.ts @@ -17,8 +17,8 @@ import { const params = { index: 'apm-*', - start: '2020', - end: '2021', + start: 1577836800000, + end: 1609459200000, includeFrozen: false, environment: ENVIRONMENT_ALL.value, kuery: '', diff --git a/x-pack/plugins/apm/server/lib/search_strategies/queries/query_histogram_range_steps.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/queries/query_histogram_range_steps.test.ts index ffc86c7ef6c3..41a2fa9a5039 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/queries/query_histogram_range_steps.test.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/queries/query_histogram_range_steps.test.ts @@ -17,8 +17,8 @@ import { const params = { index: 'apm-*', - start: '2020', - end: '2021', + start: 1577836800000, + end: 1609459200000, includeFrozen: false, environment: ENVIRONMENT_ALL.value, kuery: '', diff --git a/x-pack/plugins/apm/server/lib/search_strategies/queries/query_histogram_range_steps.ts b/x-pack/plugins/apm/server/lib/search_strategies/queries/query_histogram_range_steps.ts index 790919d19302..439bb9e4b9cd 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/queries/query_histogram_range_steps.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/queries/query_histogram_range_steps.ts @@ -17,7 +17,11 @@ import type { SearchStrategyParams } from '../../../../common/search_strategies/ import { getQueryWithParams } from './get_query_with_params'; import { getRequestBase } from './get_request_base'; -const getHistogramRangeSteps = (min: number, max: number, steps: number) => { +export const getHistogramRangeSteps = ( + min: number, + max: number, + steps: number +) => { // A d3 based scale function as a helper to get equally distributed bins on a log scale. // We round the final values because the ES range agg we use won't accept numbers with decimals for `transaction.duration.us`. const logFn = scaleLog().domain([min, max]).range([1, steps]); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/queries/query_histograms_generator.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/queries/query_histograms_generator.test.ts index 375e32b1472c..00e8c26497eb 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/queries/query_histograms_generator.test.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/queries/query_histograms_generator.test.ts @@ -17,8 +17,8 @@ import { fetchTransactionDurationHistograms } from './query_histograms_generator const params = { index: 'apm-*', - start: '2020', - end: '2021', + start: 1577836800000, + end: 1609459200000, includeFrozen: false, environment: ENVIRONMENT_ALL.value, kuery: '', diff --git a/x-pack/plugins/apm/server/lib/search_strategies/queries/query_percentiles.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/queries/query_percentiles.test.ts index ce86ffd9654e..57e3e6cadb9b 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/queries/query_percentiles.test.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/queries/query_percentiles.test.ts @@ -17,8 +17,8 @@ import { const params = { index: 'apm-*', - start: '2020', - end: '2021', + start: 1577836800000, + end: 1609459200000, includeFrozen: false, environment: ENVIRONMENT_ALL.value, kuery: '', diff --git a/x-pack/plugins/apm/server/lib/search_strategies/queries/query_ranges.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/queries/query_ranges.test.ts index e210eb7d41e7..7d67e80ae339 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/queries/query_ranges.test.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/queries/query_ranges.test.ts @@ -17,8 +17,8 @@ import { const params = { index: 'apm-*', - start: '2020', - end: '2021', + start: 1577836800000, + end: 1609459200000, includeFrozen: false, environment: ENVIRONMENT_ALL.value, kuery: '', diff --git a/x-pack/plugins/apm/server/lib/search_strategies/search_strategy_provider.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/search_strategy_provider.test.ts index 8a9d04df3203..034bd2a60ad1 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/search_strategy_provider.test.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/search_strategy_provider.test.ts @@ -13,7 +13,7 @@ import { IKibanaSearchRequest } from '../../../../../../src/plugins/data/common' import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values'; import type { LatencyCorrelationsParams } from '../../../common/search_strategies/latency_correlations/types'; -import type { SearchStrategyClientParams } from '../../../common/search_strategies/types'; +import type { RawSearchStrategyClientParams } from '../../../common/search_strategies/types'; import type { ApmIndicesConfig } from '../settings/apm_indices/get_apm_indices'; @@ -112,7 +112,7 @@ describe('APM Correlations search strategy', () => { let mockDeps: SearchStrategyDependencies; let params: Required< IKibanaSearchRequest< - LatencyCorrelationsParams & SearchStrategyClientParams + LatencyCorrelationsParams & RawSearchStrategyClientParams > >['params']; diff --git a/x-pack/plugins/apm/server/lib/search_strategies/search_strategy_provider.ts b/x-pack/plugins/apm/server/lib/search_strategies/search_strategy_provider.ts index cec10294460b..8035e9e4d97c 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/search_strategy_provider.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/search_strategy_provider.ts @@ -7,6 +7,10 @@ import uuid from 'uuid'; import { of } from 'rxjs'; +import { getOrElse } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import * as t from 'io-ts'; +import { failure } from 'io-ts/lib/PathReporter'; import type { ElasticsearchClient } from 'src/core/server'; @@ -16,18 +20,21 @@ import { IKibanaSearchResponse, } from '../../../../../../src/plugins/data/common'; -import type { SearchStrategyClientParams } from '../../../common/search_strategies/types'; -import type { RawResponseBase } from '../../../common/search_strategies/types'; -import type { ApmIndicesConfig } from '../settings/apm_indices/get_apm_indices'; - import type { - LatencyCorrelationsSearchServiceProvider, - LatencyCorrelationsSearchStrategy, -} from './latency_correlations'; + RawResponseBase, + RawSearchStrategyClientParams, + SearchStrategyClientParams, +} from '../../../common/search_strategies/types'; +import type { + LatencyCorrelationsParams, + LatencyCorrelationsRawResponse, +} from '../../../common/search_strategies/latency_correlations/types'; import type { - FailedTransactionsCorrelationsSearchServiceProvider, - FailedTransactionsCorrelationsSearchStrategy, -} from './failed_transactions_correlations'; + FailedTransactionsCorrelationsParams, + FailedTransactionsCorrelationsRawResponse, +} from '../../../common/search_strategies/failed_transactions_correlations/types'; +import { rangeRt } from '../../routes/default_api_types'; +import type { ApmIndicesConfig } from '../settings/apm_indices/get_apm_indices'; interface SearchServiceState { cancel: () => void; @@ -56,35 +63,50 @@ export type SearchServiceProvider< // Failed Transactions Correlations function overload export function searchStrategyProvider( - searchServiceProvider: FailedTransactionsCorrelationsSearchServiceProvider, + searchServiceProvider: SearchServiceProvider< + FailedTransactionsCorrelationsParams & SearchStrategyClientParams, + FailedTransactionsCorrelationsRawResponse & RawResponseBase + >, getApmIndices: () => Promise, includeFrozen: boolean -): FailedTransactionsCorrelationsSearchStrategy; +): ISearchStrategy< + IKibanaSearchRequest< + FailedTransactionsCorrelationsParams & RawSearchStrategyClientParams + >, + IKibanaSearchResponse< + FailedTransactionsCorrelationsRawResponse & RawResponseBase + > +>; // Latency Correlations function overload export function searchStrategyProvider( - searchServiceProvider: LatencyCorrelationsSearchServiceProvider, + searchServiceProvider: SearchServiceProvider< + LatencyCorrelationsParams & SearchStrategyClientParams, + LatencyCorrelationsRawResponse & RawResponseBase + >, getApmIndices: () => Promise, includeFrozen: boolean -): LatencyCorrelationsSearchStrategy; +): ISearchStrategy< + IKibanaSearchRequest< + LatencyCorrelationsParams & RawSearchStrategyClientParams + >, + IKibanaSearchResponse +>; -export function searchStrategyProvider< - TSearchStrategyClientParams extends SearchStrategyClientParams, - TRawResponse extends RawResponseBase ->( +export function searchStrategyProvider( searchServiceProvider: SearchServiceProvider< - TSearchStrategyClientParams, - TRawResponse + TRequestParams & SearchStrategyClientParams, + TResponseParams & RawResponseBase >, getApmIndices: () => Promise, includeFrozen: boolean ): ISearchStrategy< - IKibanaSearchRequest, - IKibanaSearchResponse + IKibanaSearchRequest, + IKibanaSearchResponse > { const searchServiceMap = new Map< string, - GetSearchServiceState + GetSearchServiceState >(); return { @@ -93,9 +115,21 @@ export function searchStrategyProvider< throw new Error('Invalid request parameters.'); } + const { start: startString, end: endString } = request.params; + + // converts string based start/end to epochmillis + const decodedRange = pipe( + rangeRt.decode({ start: startString, end: endString }), + getOrElse((errors) => { + throw new Error(failure(errors).join('\n')); + }) + ); + // The function to fetch the current state of the search service. // This will be either an existing service for a follow up fetch or a new one for new requests. - let getSearchServiceState: GetSearchServiceState; + let getSearchServiceState: GetSearchServiceState< + TResponseParams & RawResponseBase + >; // If the request includes an ID, we require that the search service already exists // otherwise we throw an error. The client should never poll a service that's been cancelled or finished. @@ -111,10 +145,30 @@ export function searchStrategyProvider< getSearchServiceState = existingGetSearchServiceState; } else { + const { + start, + end, + environment, + kuery, + serviceName, + transactionName, + transactionType, + ...requestParams + } = request.params; + getSearchServiceState = searchServiceProvider( deps.esClient.asCurrentUser, getApmIndices, - request.params as TSearchStrategyClientParams, + { + environment, + kuery, + serviceName, + transactionName, + transactionType, + start: decodedRange.start, + end: decodedRange.end, + ...(requestParams as unknown as TRequestParams), + }, includeFrozen ); } diff --git a/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts b/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts index 472e46fecfa1..3fa6152d953f 100644 --- a/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts +++ b/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts @@ -17,6 +17,7 @@ import { environmentsRouteRepository } from './environments'; import { errorsRouteRepository } from './errors'; import { apmFleetRouteRepository } from './fleet'; import { indexPatternRouteRepository } from './index_pattern'; +import { latencyDistributionRouteRepository } from './latency_distribution'; import { metricsRouteRepository } from './metrics'; import { observabilityOverviewRouteRepository } from './observability_overview'; import { rumRouteRepository } from './rum_client'; @@ -41,6 +42,7 @@ const getTypedGlobalApmServerRouteRepository = () => { .merge(indexPatternRouteRepository) .merge(environmentsRouteRepository) .merge(errorsRouteRepository) + .merge(latencyDistributionRouteRepository) .merge(metricsRouteRepository) .merge(observabilityOverviewRouteRepository) .merge(rumRouteRepository) diff --git a/x-pack/plugins/apm/server/routes/latency_distribution.ts b/x-pack/plugins/apm/server/routes/latency_distribution.ts new file mode 100644 index 000000000000..ea921a7f4838 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/latency_distribution.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 * as t from 'io-ts'; +import { toNumberRt } from '@kbn/io-ts-utils'; +import { getOverallLatencyDistribution } from '../lib/latency/get_overall_latency_distribution'; +import { setupRequest } from '../lib/helpers/setup_request'; +import { createApmServerRoute } from './create_apm_server_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; +import { environmentRt, kueryRt, rangeRt } from './default_api_types'; + +const latencyOverallDistributionRoute = createApmServerRoute({ + endpoint: 'GET /internal/apm/latency/overall_distribution', + params: t.type({ + query: t.intersection([ + t.partial({ + serviceName: t.string, + transactionName: t.string, + transactionType: t.string, + }), + environmentRt, + kueryRt, + rangeRt, + t.type({ + percentileThreshold: toNumberRt, + }), + ]), + }), + options: { tags: ['access:apm'] }, + handler: async (resources) => { + const setup = await setupRequest(resources); + + const { + environment, + kuery, + serviceName, + transactionType, + transactionName, + start, + end, + percentileThreshold, + } = resources.params.query; + + return getOverallLatencyDistribution({ + environment, + kuery, + serviceName, + transactionType, + transactionName, + start, + end, + percentileThreshold, + setup, + }); + }, +}); + +export const latencyDistributionRouteRepository = + createApmServerRouteRepository().add(latencyOverallDistributionRoute); diff --git a/x-pack/plugins/enterprise_search/server/integrations.ts b/x-pack/plugins/enterprise_search/server/integrations.ts index 48909261243e..eee5cdc3aaec 100644 --- a/x-pack/plugins/enterprise_search/server/integrations.ts +++ b/x-pack/plugins/enterprise_search/server/integrations.ts @@ -301,4 +301,67 @@ export const registerEnterpriseSearchIntegrations = ( ...integration, }); }); + + customIntegrations.registerCustomIntegration({ + id: 'app_search_web_crawler', + title: i18n.translate('xpack.enterpriseSearch.appSearch.integrations.webCrawlerName', { + defaultMessage: 'Web Crawler', + }), + description: i18n.translate( + 'xpack.enterpriseSearch.appSearch.integrations.webCrawlerDescription', + { + defaultMessage: "Add search to your website with App Search's web crawler.", + } + ), + categories: ['website_search'], + uiInternalPath: '/app/enterprise_search/app_search/engines/new?method=crawler', + icons: [ + { + type: 'eui', + src: 'globe', + }, + ], + shipper: 'enterprise_search', + isBeta: false, + }); + + customIntegrations.registerCustomIntegration({ + id: 'app_search_json', + title: i18n.translate('xpack.enterpriseSearch.appSearch.integrations.jsonName', { + defaultMessage: 'JSON', + }), + description: i18n.translate('xpack.enterpriseSearch.appSearch.integrations.jsonDescription', { + defaultMessage: 'Search over your JSON data with App Search.', + }), + categories: ['upload_file'], + uiInternalPath: '/app/enterprise_search/app_search/engines/new?method=json', + icons: [ + { + type: 'eui', + src: 'exportAction', + }, + ], + shipper: 'enterprise_search', + isBeta: false, + }); + + customIntegrations.registerCustomIntegration({ + id: 'app_search_api', + title: i18n.translate('xpack.enterpriseSearch.appSearch.integrations.apiName', { + defaultMessage: 'API', + }), + description: i18n.translate('xpack.enterpriseSearch.appSearch.integrations.apiDescription', { + defaultMessage: "Add search to your application with App Search's robust APIs.", + }), + categories: ['custom'], + uiInternalPath: '/app/enterprise_search/app_search/engines/new?method=api', + icons: [ + { + type: 'eui', + src: 'editorCodeBlock', + }, + ], + shipper: 'enterprise_search', + isBeta: false, + }); }; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx index 881fc566c932..6e3eba19c52e 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx @@ -452,7 +452,7 @@ export function Detail() { name: ( ), isSelected: panel === 'policies', diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx index d6dc7e8440da..69487454dcb9 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx @@ -211,7 +211,7 @@ export const PackagePoliciesPage = ({ name, version }: PackagePoliciesPanelProps { field: 'packagePolicy.name', name: i18n.translate('xpack.fleet.epm.packageDetails.integrationList.name', { - defaultMessage: 'Integration', + defaultMessage: 'Integration Policy', }), render(_, { packagePolicy }) { return ; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/update_button.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/update_button.tsx index 2acd5634b1e5..b5a8394fa2cb 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/update_button.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/update_button.tsx @@ -154,6 +154,7 @@ export const UpdateButton: React.FunctionComponent = ({ return; } + setIsUpdateModalVisible(false); setIsUpgradingPackagePolicies(true); await installPackage({ name, version, title }); @@ -166,7 +167,6 @@ export const UpdateButton: React.FunctionComponent = ({ ); setIsUpgradingPackagePolicies(false); - setIsUpdateModalVisible(false); notifications.toasts.addSuccess({ title: toMountPoint( @@ -285,15 +285,14 @@ export const UpdateButton: React.FunctionComponent = ({ setIsUpdateModalVisible(true) : handleClickUpdate } > diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts index 58edb1aae80e..b9f1191da8af 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts @@ -28,6 +28,14 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { ]); }; + const setReloadIndicesResponse = (response: HttpResponse = []) => { + server.respondWith('POST', `${API_BASE_PATH}/indices/reload`, [ + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify(response), + ]); + }; + const setLoadDataStreamsResponse = (response: HttpResponse = []) => { server.respondWith('GET', `${API_BASE_PATH}/data_streams`, [ 200, @@ -118,6 +126,7 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { return { setLoadTemplatesResponse, setLoadIndicesResponse, + setReloadIndicesResponse, setLoadDataStreamsResponse, setLoadDataStreamResponse, setDeleteDataStreamResponse, diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts index 900f7ddbf084..2576b5f92b7b 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts @@ -47,9 +47,12 @@ export const setup = async (overridingDependencies: any = {}): Promise { - const { find } = testBed; - const contextMenu = find('indexContextMenu'); - contextMenu.find(`button[data-test-subj="${optionDataTestSubject}"]`).simulate('click'); + const { find, component } = testBed; + + await act(async () => { + find(`indexContextMenu.${optionDataTestSubject}`).simulate('click'); + }); + component.update(); }; const clickIncludeHiddenIndicesToggle = () => { @@ -57,9 +60,13 @@ export const setup = async (overridingDependencies: any = {}): Promise { - const { find } = testBed; - find('indexActionsContextMenuButton').simulate('click'); + const clickManageContextMenuButton = async () => { + const { find, component } = testBed; + + await act(async () => { + find('indexActionsContextMenuButton').simulate('click'); + }); + component.update(); }; const getIncludeHiddenIndicesToggleStatus = () => { diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts index e23c1a59eb13..f95ea373d58b 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts @@ -10,7 +10,7 @@ import { act } from 'react-dom/test-utils'; import { API_BASE_PATH } from '../../../common/constants'; import { setupEnvironment, nextTick } from '../helpers'; import { IndicesTestBed, setup } from './indices_tab.helpers'; -import { createDataStreamPayload } from './data_streams_tab.helpers'; +import { createDataStreamPayload, createNonDataStreamIndex } from './data_streams_tab.helpers'; /** * The below import is required to avoid a console error warn from the "brace" package @@ -23,9 +23,14 @@ import { createMemoryHistory } from 'history'; stubWebWorker(); // unhandled promise rejection https://github.com/elastic/kibana/issues/112699 -describe.skip('', () => { - const { server, httpRequestsMockHelpers } = setupEnvironment(); +describe('', () => { let testBed: IndicesTestBed; + let server: ReturnType['server']; + let httpRequestsMockHelpers: ReturnType['httpRequestsMockHelpers']; + + beforeEach(() => { + ({ server, httpRequestsMockHelpers } = setupEnvironment()); + }); afterAll(() => { server.restore(); @@ -108,19 +113,9 @@ describe.skip('', () => { describe('index detail panel with % character in index name', () => { const indexName = 'test%'; + beforeEach(async () => { - const index = { - health: 'green', - status: 'open', - primary: 1, - replica: 1, - documents: 10000, - documents_deleted: 100, - size: '156kb', - primary_size: '156kb', - name: indexName, - }; - httpRequestsMockHelpers.setLoadIndicesResponse([index]); + httpRequestsMockHelpers.setLoadIndicesResponse([createNonDataStreamIndex(indexName)]); testBed = await setup(); const { component, find } = testBed; @@ -165,20 +160,11 @@ describe.skip('', () => { describe('index actions', () => { const indexName = 'testIndex'; + beforeEach(async () => { - const index = { - health: 'green', - status: 'open', - primary: 1, - replica: 1, - documents: 10000, - documents_deleted: 100, - size: '156kb', - primary_size: '156kb', - name: indexName, - }; - - httpRequestsMockHelpers.setLoadIndicesResponse([index]); + httpRequestsMockHelpers.setLoadIndicesResponse([createNonDataStreamIndex(indexName)]); + httpRequestsMockHelpers.setReloadIndicesResponse({ indexNames: [indexName] }); + testBed = await setup(); const { find, component } = testBed; component.update(); @@ -188,11 +174,15 @@ describe.skip('', () => { test('should be able to flush index', async () => { const { actions } = testBed; + await actions.clickManageContextMenuButton(); await actions.clickContextMenuOption('flushIndexMenuButton'); - const latestRequest = server.requests[server.requests.length - 1]; - expect(latestRequest.url).toBe(`${API_BASE_PATH}/indices/flush`); + const requestsCount = server.requests.length; + expect(server.requests[requestsCount - 2].url).toBe(`${API_BASE_PATH}/indices/flush`); + // After the indices are flushed, we imediately reload them. So we need to expect to see + // a reload server call also. + expect(server.requests[requestsCount - 1].url).toBe(`${API_BASE_PATH}/indices/reload`); }); }); }); diff --git a/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx b/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx index 8752a4118fb6..1a44beab260c 100644 --- a/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx +++ b/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx @@ -50,17 +50,13 @@ const initLegacyShims = () => { ruleTypeRegistry: ruleTypeRegistryMock.create(), }; const data = { query: { timefilter: { timefilter: {} } } } as any; - const ngInjector = {} as angular.auto.IInjectorService; - Legacy.init( - { - core: coreMock.createStart(), - data, - isCloud: false, - triggersActionsUi, - usageCollection: {}, - } as any, - ngInjector - ); + Legacy.init({ + core: coreMock.createStart(), + data, + isCloud: false, + triggersActionsUi, + usageCollection: {}, + } as any); }; const ALERTS_FEATURE_ID = 'alerts'; diff --git a/x-pack/plugins/monitoring/public/angular/angular_i18n/directive.ts b/x-pack/plugins/monitoring/public/angular/angular_i18n/directive.ts deleted file mode 100644 index 1aaff99a6a5c..000000000000 --- a/x-pack/plugins/monitoring/public/angular/angular_i18n/directive.ts +++ /dev/null @@ -1,103 +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 { IDirective, IRootElementService, IScope } from 'angular'; - -import { I18nServiceType } from './provider'; - -interface I18nScope extends IScope { - values?: Record; - defaultMessage: string; - id: string; -} - -const HTML_KEY_PREFIX = 'html_'; -const PLACEHOLDER_SEPARATOR = '@I18N@'; - -export const i18nDirective: [string, string, typeof i18nDirectiveFn] = [ - 'i18n', - '$sanitize', - i18nDirectiveFn, -]; - -function i18nDirectiveFn( - i18n: I18nServiceType, - $sanitize: (html: string) => string -): IDirective { - return { - restrict: 'A', - scope: { - id: '@i18nId', - defaultMessage: '@i18nDefaultMessage', - values: ' { - setContent($element, $scope, $sanitize, i18n); - }); - } else { - setContent($element, $scope, $sanitize, i18n); - } - }, - }; -} - -function setContent( - $element: IRootElementService, - $scope: I18nScope, - $sanitize: (html: string) => string, - i18n: I18nServiceType -) { - const originalValues = $scope.values; - const valuesWithPlaceholders = {} as Record; - let hasValuesWithPlaceholders = false; - - // If we have values with the keys that start with HTML_KEY_PREFIX we should replace - // them with special placeholders that later on will be inserted as HTML - // into the DOM, the rest of the content will be treated as text. We don't - // sanitize values at this stage as some of the values can be excluded from - // the translated string (e.g. not used by ICU conditional statements). - if (originalValues) { - for (const [key, value] of Object.entries(originalValues)) { - if (key.startsWith(HTML_KEY_PREFIX)) { - valuesWithPlaceholders[ - key.slice(HTML_KEY_PREFIX.length) - ] = `${PLACEHOLDER_SEPARATOR}${key}${PLACEHOLDER_SEPARATOR}`; - - hasValuesWithPlaceholders = true; - } else { - valuesWithPlaceholders[key] = value; - } - } - } - - const label = i18n($scope.id, { - values: valuesWithPlaceholders, - defaultMessage: $scope.defaultMessage, - }); - - // If there are no placeholders to replace treat everything as text, otherwise - // insert label piece by piece replacing every placeholder with corresponding - // sanitized HTML content. - if (!hasValuesWithPlaceholders) { - $element.text(label); - } else { - $element.empty(); - for (const contentOrPlaceholder of label.split(PLACEHOLDER_SEPARATOR)) { - if (!contentOrPlaceholder) { - continue; - } - - $element.append( - originalValues!.hasOwnProperty(contentOrPlaceholder) - ? $sanitize(originalValues![contentOrPlaceholder]) - : document.createTextNode(contentOrPlaceholder) - ); - } - } -} diff --git a/x-pack/plugins/monitoring/public/angular/angular_i18n/filter.ts b/x-pack/plugins/monitoring/public/angular/angular_i18n/filter.ts deleted file mode 100644 index e4e553fa47b6..000000000000 --- a/x-pack/plugins/monitoring/public/angular/angular_i18n/filter.ts +++ /dev/null @@ -1,19 +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 { I18nServiceType } from './provider'; - -export const i18nFilter: [string, typeof i18nFilterFn] = ['i18n', i18nFilterFn]; - -function i18nFilterFn(i18n: I18nServiceType) { - return (id: string, { defaultMessage = '', values = {} } = {}) => { - return i18n(id, { - values, - defaultMessage, - }); - }; -} diff --git a/x-pack/plugins/monitoring/public/angular/angular_i18n/index.ts b/x-pack/plugins/monitoring/public/angular/angular_i18n/index.ts deleted file mode 100644 index 8915c96e59be..000000000000 --- a/x-pack/plugins/monitoring/public/angular/angular_i18n/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export { I18nProvider } from './provider'; - -export { i18nFilter } from './filter'; -export { i18nDirective } from './directive'; - -// re-export types: https://github.com/babel/babel-loader/issues/603 -import { I18nServiceType as _I18nServiceType } from './provider'; -export type I18nServiceType = _I18nServiceType; diff --git a/x-pack/plugins/monitoring/public/angular/angular_i18n/provider.ts b/x-pack/plugins/monitoring/public/angular/angular_i18n/provider.ts deleted file mode 100644 index b1da1bad6e39..000000000000 --- a/x-pack/plugins/monitoring/public/angular/angular_i18n/provider.ts +++ /dev/null @@ -1,25 +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 { i18n } from '@kbn/i18n'; - -export type I18nServiceType = ReturnType; - -export class I18nProvider implements angular.IServiceProvider { - public addTranslation = i18n.addTranslation; - public getTranslation = i18n.getTranslation; - public setLocale = i18n.setLocale; - public getLocale = i18n.getLocale; - public setDefaultLocale = i18n.setDefaultLocale; - public getDefaultLocale = i18n.getDefaultLocale; - public setFormats = i18n.setFormats; - public getFormats = i18n.getFormats; - public getRegisteredLocales = i18n.getRegisteredLocales; - public init = i18n.init; - public load = i18n.load; - public $get = () => i18n.translate; -} diff --git a/x-pack/plugins/monitoring/public/angular/helpers/format_angular_http_error.ts b/x-pack/plugins/monitoring/public/angular/helpers/format_angular_http_error.ts deleted file mode 100644 index abdcf157a3c8..000000000000 --- a/x-pack/plugins/monitoring/public/angular/helpers/format_angular_http_error.ts +++ /dev/null @@ -1,43 +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 { i18n } from '@kbn/i18n'; -import type { IHttpResponse } from 'angular'; - -type AngularHttpError = IHttpResponse<{ message: string }>; - -export function isAngularHttpError(error: any): error is AngularHttpError { - return ( - error && - typeof error.status === 'number' && - typeof error.statusText === 'string' && - error.data && - typeof error.data.message === 'string' - ); -} - -export function formatAngularHttpError(error: AngularHttpError) { - // is an Angular $http "error object" - if (error.status === -1) { - // status = -1 indicates that the request was failed to reach the server - return i18n.translate('xpack.monitoring.notify.fatalError.unavailableServerErrorMessage', { - defaultMessage: - 'An HTTP request has failed to connect. ' + - 'Please check if the Kibana server is running and that your browser has a working connection, ' + - 'or contact your system administrator.', - }); - } - - return i18n.translate('xpack.monitoring.notify.fatalError.errorStatusMessage', { - defaultMessage: 'Error {errStatus} {errStatusText}: {errMessage}', - values: { - errStatus: error.status, - errStatusText: error.statusText, - errMessage: error.data.message, - }, - }); -} diff --git a/x-pack/plugins/monitoring/public/angular/top_nav/angular_config.tsx b/x-pack/plugins/monitoring/public/angular/top_nav/angular_config.tsx deleted file mode 100644 index 9c2e931d24a9..000000000000 --- a/x-pack/plugins/monitoring/public/angular/top_nav/angular_config.tsx +++ /dev/null @@ -1,349 +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 { - ICompileProvider, - IHttpProvider, - IHttpService, - ILocationProvider, - IModule, - IRootScopeService, - IRequestConfig, -} from 'angular'; -import $ from 'jquery'; -import { set } from '@elastic/safer-lodash-set'; -import { get } from 'lodash'; -import * as Rx from 'rxjs'; -import { ChromeBreadcrumb, EnvironmentMode, PackageInfo } from 'kibana/public'; -import { History } from 'history'; - -import { CoreStart } from 'kibana/public'; -import { formatAngularHttpError, isAngularHttpError } from '../helpers/format_angular_http_error'; - -export interface RouteConfiguration { - controller?: string | ((...args: any[]) => void); - redirectTo?: string; - resolveRedirectTo?: (...args: any[]) => void; - reloadOnSearch?: boolean; - reloadOnUrl?: boolean; - outerAngularWrapperRoute?: boolean; - resolve?: object; - template?: string; - k7Breadcrumbs?: (...args: any[]) => ChromeBreadcrumb[]; - requireUICapability?: string; -} - -function isSystemApiRequest(request: IRequestConfig) { - const { headers } = request; - return headers && !!headers['kbn-system-request']; -} - -/** - * Detects whether a given angular route is a dummy route that doesn't - * require any action. There are two ways this can happen: - * If `outerAngularWrapperRoute` is set on the route config object, - * it means the local application service set up this route on the outer angular - * and the internal routes will handle the hooks. - * - * If angular did not detect a route and it is the local angular, we are currently - * navigating away from a URL controlled by a local angular router and the - * application will get unmounted. In this case the outer router will handle - * the hooks. - * @param $route Injected $route dependency - * @param isLocalAngular Flag whether this is the local angular router - */ -function isDummyRoute($route: any, isLocalAngular: boolean) { - return ( - ($route.current && $route.current.$$route && $route.current.$$route.outerAngularWrapperRoute) || - (!$route.current && isLocalAngular) - ); -} - -export const configureAppAngularModule = ( - angularModule: IModule, - newPlatform: { - core: CoreStart; - readonly env: { - mode: Readonly; - packageInfo: Readonly; - }; - }, - isLocalAngular: boolean, - getHistory?: () => History -) => { - const core = 'core' in newPlatform ? newPlatform.core : newPlatform; - const packageInfo = newPlatform.env.packageInfo; - - angularModule - .value('kbnVersion', packageInfo.version) - .value('buildNum', packageInfo.buildNum) - .value('buildSha', packageInfo.buildSha) - .value('esUrl', getEsUrl(core)) - .value('uiCapabilities', core.application.capabilities) - .config(setupCompileProvider(newPlatform.env.mode.dev)) - .config(setupLocationProvider()) - .config($setupXsrfRequestInterceptor(packageInfo.version)) - .run(capture$httpLoadingCount(core)) - .run(digestOnHashChange(getHistory)) - .run($setupBreadcrumbsAutoClear(core, isLocalAngular)) - .run($setupBadgeAutoClear(core, isLocalAngular)) - .run($setupHelpExtensionAutoClear(core, isLocalAngular)) - .run($setupUICapabilityRedirect(core)); -}; - -const getEsUrl = (newPlatform: CoreStart) => { - const a = document.createElement('a'); - a.href = newPlatform.http.basePath.prepend('/elasticsearch'); - const protocolPort = /https/.test(a.protocol) ? 443 : 80; - const port = a.port || protocolPort; - return { - host: a.hostname, - port, - protocol: a.protocol, - pathname: a.pathname, - }; -}; - -const digestOnHashChange = (getHistory?: () => History) => ($rootScope: IRootScopeService) => { - if (!getHistory) return; - const unlisten = getHistory().listen(() => { - // dispatch synthetic hash change event to update hash history objects and angular routing - // this is necessary because hash updates triggered by using popState won't trigger this event naturally. - // this has to happen in the next tick to not change the existing timing of angular digest cycles. - setTimeout(() => { - window.dispatchEvent(new HashChangeEvent('hashchange')); - }, 0); - }); - $rootScope.$on('$destroy', unlisten); -}; - -const setupCompileProvider = (devMode: boolean) => ($compileProvider: ICompileProvider) => { - if (!devMode) { - $compileProvider.debugInfoEnabled(false); - } -}; - -const setupLocationProvider = () => ($locationProvider: ILocationProvider) => { - $locationProvider.html5Mode({ - enabled: false, - requireBase: false, - rewriteLinks: false, - }); - - $locationProvider.hashPrefix(''); -}; - -export const $setupXsrfRequestInterceptor = (version: string) => { - // Configure jQuery prefilter - $.ajaxPrefilter(({ kbnXsrfToken = true }: any, originalOptions, jqXHR) => { - if (kbnXsrfToken) { - jqXHR.setRequestHeader('kbn-version', version); - } - }); - - return ($httpProvider: IHttpProvider) => { - // Configure $httpProvider interceptor - $httpProvider.interceptors.push(() => { - return { - request(opts) { - const { kbnXsrfToken = true } = opts as any; - if (kbnXsrfToken) { - set(opts, ['headers', 'kbn-version'], version); - } - return opts; - }, - }; - }); - }; -}; - -/** - * Injected into angular module by ui/chrome angular integration - * and adds a root-level watcher that will capture the count of - * active $http requests on each digest loop and expose the count to - * the core.loadingCount api - */ -const capture$httpLoadingCount = - (newPlatform: CoreStart) => ($rootScope: IRootScopeService, $http: IHttpService) => { - newPlatform.http.addLoadingCountSource( - new Rx.Observable((observer) => { - const unwatch = $rootScope.$watch(() => { - const reqs = $http.pendingRequests || []; - observer.next(reqs.filter((req) => !isSystemApiRequest(req)).length); - }); - - return unwatch; - }) - ); - }; - -/** - * integrates with angular to automatically redirect to home if required - * capability is not met - */ -const $setupUICapabilityRedirect = - (newPlatform: CoreStart) => ($rootScope: IRootScopeService, $injector: any) => { - const isKibanaAppRoute = window.location.pathname.endsWith('/app/kibana'); - // this feature only works within kibana app for now after everything is - // switched to the application service, this can be changed to handle all - // apps. - if (!isKibanaAppRoute) { - return; - } - $rootScope.$on( - '$routeChangeStart', - (event, { $$route: route }: { $$route?: RouteConfiguration } = {}) => { - if (!route || !route.requireUICapability) { - return; - } - - if (!get(newPlatform.application.capabilities, route.requireUICapability)) { - $injector.get('$location').url('/home'); - event.preventDefault(); - } - } - ); - }; - -/** - * internal angular run function that will be called when angular bootstraps and - * lets us integrate with the angular router so that we can automatically clear - * the breadcrumbs if we switch to a Kibana app that does not use breadcrumbs correctly - */ -const $setupBreadcrumbsAutoClear = - (newPlatform: CoreStart, isLocalAngular: boolean) => - ($rootScope: IRootScopeService, $injector: any) => { - // A flag used to determine if we should automatically - // clear the breadcrumbs between angular route changes. - let breadcrumbSetSinceRouteChange = false; - const $route = $injector.has('$route') ? $injector.get('$route') : {}; - - // reset breadcrumbSetSinceRouteChange any time the breadcrumbs change, even - // if it was done directly through the new platform - newPlatform.chrome.getBreadcrumbs$().subscribe({ - next() { - breadcrumbSetSinceRouteChange = true; - }, - }); - - $rootScope.$on('$routeChangeStart', () => { - breadcrumbSetSinceRouteChange = false; - }); - - $rootScope.$on('$routeChangeSuccess', () => { - if (isDummyRoute($route, isLocalAngular)) { - return; - } - const current = $route.current || {}; - - if (breadcrumbSetSinceRouteChange || (current.$$route && current.$$route.redirectTo)) { - return; - } - - const k7BreadcrumbsProvider = current.k7Breadcrumbs; - if (!k7BreadcrumbsProvider) { - newPlatform.chrome.setBreadcrumbs([]); - return; - } - - try { - newPlatform.chrome.setBreadcrumbs($injector.invoke(k7BreadcrumbsProvider)); - } catch (error) { - if (isAngularHttpError(error)) { - error = formatAngularHttpError(error); - } - newPlatform.fatalErrors.add(error, 'location'); - } - }); - }; - -/** - * internal angular run function that will be called when angular bootstraps and - * lets us integrate with the angular router so that we can automatically clear - * the badge if we switch to a Kibana app that does not use the badge correctly - */ -const $setupBadgeAutoClear = - (newPlatform: CoreStart, isLocalAngular: boolean) => - ($rootScope: IRootScopeService, $injector: any) => { - // A flag used to determine if we should automatically - // clear the badge between angular route changes. - let badgeSetSinceRouteChange = false; - const $route = $injector.has('$route') ? $injector.get('$route') : {}; - - $rootScope.$on('$routeChangeStart', () => { - badgeSetSinceRouteChange = false; - }); - - $rootScope.$on('$routeChangeSuccess', () => { - if (isDummyRoute($route, isLocalAngular)) { - return; - } - const current = $route.current || {}; - - if (badgeSetSinceRouteChange || (current.$$route && current.$$route.redirectTo)) { - return; - } - - const badgeProvider = current.badge; - if (!badgeProvider) { - newPlatform.chrome.setBadge(undefined); - return; - } - - try { - newPlatform.chrome.setBadge($injector.invoke(badgeProvider)); - } catch (error) { - if (isAngularHttpError(error)) { - error = formatAngularHttpError(error); - } - newPlatform.fatalErrors.add(error, 'location'); - } - }); - }; - -/** - * internal angular run function that will be called when angular bootstraps and - * lets us integrate with the angular router so that we can automatically clear - * the helpExtension if we switch to a Kibana app that does not set its own - * helpExtension - */ -const $setupHelpExtensionAutoClear = - (newPlatform: CoreStart, isLocalAngular: boolean) => - ($rootScope: IRootScopeService, $injector: any) => { - /** - * reset helpExtensionSetSinceRouteChange any time the helpExtension changes, even - * if it was done directly through the new platform - */ - let helpExtensionSetSinceRouteChange = false; - newPlatform.chrome.getHelpExtension$().subscribe({ - next() { - helpExtensionSetSinceRouteChange = true; - }, - }); - - const $route = $injector.has('$route') ? $injector.get('$route') : {}; - - $rootScope.$on('$routeChangeStart', () => { - if (isDummyRoute($route, isLocalAngular)) { - return; - } - helpExtensionSetSinceRouteChange = false; - }); - - $rootScope.$on('$routeChangeSuccess', () => { - if (isDummyRoute($route, isLocalAngular)) { - return; - } - const current = $route.current || {}; - - if (helpExtensionSetSinceRouteChange || (current.$$route && current.$$route.redirectTo)) { - return; - } - - newPlatform.chrome.setHelpExtension(current.helpExtension); - }); - }; diff --git a/x-pack/plugins/monitoring/public/angular/top_nav/index.ts b/x-pack/plugins/monitoring/public/angular/top_nav/index.ts deleted file mode 100644 index b3501e4cbad1..000000000000 --- a/x-pack/plugins/monitoring/public/angular/top_nav/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export * from './angular_config'; -// @ts-ignore -export { createTopNavDirective, createTopNavHelper, loadKbnTopNavDirectives } from './kbn_top_nav'; diff --git a/x-pack/plugins/monitoring/public/angular/top_nav/kbn_top_nav.d.ts b/x-pack/plugins/monitoring/public/angular/top_nav/kbn_top_nav.d.ts deleted file mode 100644 index 0cff77241bb9..000000000000 --- a/x-pack/plugins/monitoring/public/angular/top_nav/kbn_top_nav.d.ts +++ /dev/null @@ -1,16 +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 { Injectable, IDirectiveFactory, IScope, IAttributes, IController } from 'angular'; - -export const createTopNavDirective: Injectable< - IDirectiveFactory ->; -export const createTopNavHelper: ( - options: unknown -) => Injectable>; -export function loadKbnTopNavDirectives(navUi: unknown): void; diff --git a/x-pack/plugins/monitoring/public/angular/top_nav/kbn_top_nav.js b/x-pack/plugins/monitoring/public/angular/top_nav/kbn_top_nav.js deleted file mode 100644 index 6edcca6aa714..000000000000 --- a/x-pack/plugins/monitoring/public/angular/top_nav/kbn_top_nav.js +++ /dev/null @@ -1,119 +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 angular from 'angular'; -import 'ngreact'; - -export function createTopNavDirective() { - return { - restrict: 'E', - template: '', - compile: (elem) => { - const child = document.createElement('kbn-top-nav-helper'); - - // Copy attributes to the child directive - for (const attr of elem[0].attributes) { - child.setAttribute(attr.name, attr.value); - } - - // Add a special attribute that will change every time that one - // of the config array's disableButton function return value changes. - child.setAttribute('disabled-buttons', 'disabledButtons'); - - // Append helper directive - elem.append(child); - - const linkFn = ($scope, _, $attr) => { - // Watch config changes - $scope.$watch( - () => { - const config = $scope.$eval($attr.config) || []; - return config.map((item) => { - // Copy key into id, as it's a reserved react propery. - // This is done for Angular directive backward compatibility. - // In React only id is recognized. - if (item.key && !item.id) { - item.id = item.key; - } - - // Watch the disableButton functions - if (typeof item.disableButton === 'function') { - return item.disableButton(); - } - return item.disableButton; - }); - }, - (newVal) => { - $scope.disabledButtons = newVal; - }, - true - ); - }; - - return linkFn; - }, - }; -} - -export const createTopNavHelper = - ({ TopNavMenu }) => - (reactDirective) => { - return reactDirective(TopNavMenu, [ - ['config', { watchDepth: 'value' }], - ['setMenuMountPoint', { watchDepth: 'reference' }], - ['disabledButtons', { watchDepth: 'reference' }], - - ['query', { watchDepth: 'reference' }], - ['savedQuery', { watchDepth: 'reference' }], - ['intl', { watchDepth: 'reference' }], - - ['onQuerySubmit', { watchDepth: 'reference' }], - ['onFiltersUpdated', { watchDepth: 'reference' }], - ['onRefreshChange', { watchDepth: 'reference' }], - ['onClearSavedQuery', { watchDepth: 'reference' }], - ['onSaved', { watchDepth: 'reference' }], - ['onSavedQueryUpdated', { watchDepth: 'reference' }], - ['onSavedQueryIdChange', { watchDepth: 'reference' }], - - ['indexPatterns', { watchDepth: 'collection' }], - ['filters', { watchDepth: 'collection' }], - - // All modifiers default to true. - // Set to false to hide subcomponents. - 'showSearchBar', - 'showQueryBar', - 'showQueryInput', - 'showSaveQuery', - 'showDatePicker', - 'showFilterBar', - - 'appName', - 'screenTitle', - 'dateRangeFrom', - 'dateRangeTo', - 'savedQueryId', - 'isRefreshPaused', - 'refreshInterval', - 'disableAutoFocus', - 'showAutoRefreshOnly', - - // temporary flag to use the stateful components - 'useDefaultBehaviors', - ]); - }; - -let isLoaded = false; - -export function loadKbnTopNavDirectives(navUi) { - if (!isLoaded) { - isLoaded = true; - angular - .module('kibana') - .directive('kbnTopNav', createTopNavDirective) - .directive('kbnTopNavHelper', createTopNavHelper(navUi)); - } -} diff --git a/x-pack/plugins/monitoring/public/application/contexts/global_state_context.tsx b/x-pack/plugins/monitoring/public/application/contexts/global_state_context.tsx index e6638b4c4fed..cc8619dbc7ad 100644 --- a/x-pack/plugins/monitoring/public/application/contexts/global_state_context.tsx +++ b/x-pack/plugins/monitoring/public/application/contexts/global_state_context.tsx @@ -32,31 +32,8 @@ export const GlobalStateProvider: React.FC = ({ toasts, children, }) => { - // TODO: remove fakeAngularRootScope and fakeAngularLocation when angular is removed - const fakeAngularRootScope: Partial = { - $on: - (name: string, listener: (event: ng.IAngularEvent, ...args: any[]) => any): (() => void) => - () => {}, - $applyAsync: () => {}, - }; - - const fakeAngularLocation: Partial = { - search: () => { - return {} as any; - }, - replace: () => { - return {} as any; - }, - }; - const localState: State = {}; - const state = new GlobalState( - query, - toasts, - fakeAngularRootScope, - fakeAngularLocation, - localState as { [key: string]: unknown } - ); + const state = new GlobalState(query, toasts, localState as { [key: string]: unknown }); const initialState: any = state.getState(); for (const key in initialState) { diff --git a/x-pack/plugins/monitoring/public/legacy_shims.ts b/x-pack/plugins/monitoring/public/legacy_shims.ts index 72d50aac1dbb..48484421839b 100644 --- a/x-pack/plugins/monitoring/public/legacy_shims.ts +++ b/x-pack/plugins/monitoring/public/legacy_shims.ts @@ -44,7 +44,7 @@ const angularNoop = () => { export interface IShims { toastNotifications: CoreStart['notifications']['toasts']; capabilities: CoreStart['application']['capabilities']; - getAngularInjector: typeof angularNoop | (() => angular.auto.IInjectorService); + getAngularInjector: typeof angularNoop; getBasePath: () => string; getInjected: (name: string, defaultValue?: unknown) => unknown; breadcrumbs: { @@ -73,23 +73,18 @@ export interface IShims { export class Legacy { private static _shims: IShims; - public static init( - { - core, - data, - isCloud, - triggersActionsUi, - usageCollection, - appMountParameters, - }: MonitoringStartPluginDependencies, - ngInjector?: angular.auto.IInjectorService - ) { + public static init({ + core, + data, + isCloud, + triggersActionsUi, + usageCollection, + appMountParameters, + }: MonitoringStartPluginDependencies) { this._shims = { toastNotifications: core.notifications.toasts, capabilities: core.application.capabilities, - getAngularInjector: ngInjector - ? (): angular.auto.IInjectorService => ngInjector - : angularNoop, + getAngularInjector: angularNoop, getBasePath: (): string => core.http.basePath.get(), getInjected: (name: string, defaultValue?: unknown): string | unknown => core.injectedMetadata.getInjectedVar(name, defaultValue), diff --git a/x-pack/plugins/monitoring/public/url_state.ts b/x-pack/plugins/monitoring/public/url_state.ts index 25086411c65a..8f89df732b80 100644 --- a/x-pack/plugins/monitoring/public/url_state.ts +++ b/x-pack/plugins/monitoring/public/url_state.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import { Subscription } from 'rxjs'; import { History, createHashHistory } from 'history'; import { MonitoringStartPluginDependencies } from './types'; @@ -27,10 +26,6 @@ import { withNotifyOnErrors, } from '../../../../src/plugins/kibana_utils/public'; -interface Route { - params: { _g: unknown }; -} - interface RawObject { [key: string]: unknown; } @@ -57,7 +52,6 @@ export interface MonitoringAppStateTransitions { const GLOBAL_STATE_KEY = '_g'; const objectEquals = (objA: any, objB: any) => JSON.stringify(objA) === JSON.stringify(objB); -// TODO: clean all angular references after angular is removed export class GlobalState { private readonly stateSyncRef: ISyncStateRef; private readonly stateContainer: StateContainer< @@ -70,13 +64,10 @@ export class GlobalState { private readonly timefilterRef: MonitoringStartPluginDependencies['data']['query']['timefilter']['timefilter']; private lastAssignedState: MonitoringAppState = {}; - private lastKnownGlobalState?: string; constructor( queryService: MonitoringStartPluginDependencies['data']['query'], toasts: MonitoringStartPluginDependencies['core']['notifications']['toasts'], - rootScope: Partial, - ngLocation: Partial, externalState: RawObject ) { this.timefilterRef = queryService.timefilter.timefilter; @@ -102,9 +93,6 @@ export class GlobalState { this.stateContainerChangeSub = this.stateContainer.state$.subscribe(() => { this.lastAssignedState = this.getState(); - if (!this.stateContainer.get() && this.lastKnownGlobalState) { - ngLocation.search?.(`${GLOBAL_STATE_KEY}=${this.lastKnownGlobalState}`).replace(); - } // TODO: check if this is not needed after https://github.com/elastic/kibana/pull/109132 is merged if (Legacy.isInitializated()) { @@ -112,15 +100,11 @@ export class GlobalState { } this.syncExternalState(externalState); - rootScope.$applyAsync?.(); }); this.syncQueryStateWithUrlManager = syncQueryStateWithUrl(queryService, this.stateStorage); this.stateSyncRef.start(); - this.startHashSync(rootScope, ngLocation); this.lastAssignedState = this.getState(); - - rootScope.$on?.('$destroy', () => this.destroy()); } private syncExternalState(externalState: { [key: string]: unknown }) { @@ -137,24 +121,6 @@ export class GlobalState { } } - private startHashSync( - rootScope: Partial, - ngLocation: Partial - ) { - rootScope.$on?.( - '$routeChangeStart', - (_: { preventDefault: () => void }, newState: Route, oldState: Route) => { - const currentGlobalState = oldState?.params?._g; - const nextGlobalState = newState?.params?._g; - if (!nextGlobalState && currentGlobalState && typeof currentGlobalState === 'string') { - newState.params._g = currentGlobalState; - ngLocation.search?.(`${GLOBAL_STATE_KEY}=${currentGlobalState}`).replace(); - } - this.lastKnownGlobalState = (nextGlobalState || currentGlobalState) as string; - } - ); - } - public setState(state?: { [key: string]: unknown }) { const currentAppState = this.getState(); const newAppState = { ...currentAppState, ...state }; diff --git a/x-pack/plugins/observability/public/components/shared/add_data_buttons/synthetics_add_data.tsx b/x-pack/plugins/observability/public/components/shared/add_data_buttons/synthetics_add_data.tsx index af91624769e6..d852d6fdb9a3 100644 --- a/x-pack/plugins/observability/public/components/shared/add_data_buttons/synthetics_add_data.tsx +++ b/x-pack/plugins/observability/public/components/shared/add_data_buttons/synthetics_add_data.tsx @@ -16,9 +16,9 @@ export function SyntheticsAddData() { return ( diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts index e4473b183d72..c12e67bc9b1a 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts @@ -49,7 +49,9 @@ import { MONITORS_DURATION_LABEL, PAGE_LOAD_TIME_LABEL, LABELS_FIELD, + STEP_NAME_LABEL, } from './labels'; +import { SYNTHETICS_STEP_NAME } from './field_names/synthetics'; export const DEFAULT_TIME = { from: 'now-1h', to: 'now' }; @@ -77,6 +79,7 @@ export const FieldLabels: Record = { 'monitor.id': MONITOR_ID_LABEL, 'monitor.status': MONITOR_STATUS_LABEL, 'monitor.duration.us': MONITORS_DURATION_LABEL, + [SYNTHETICS_STEP_NAME]: STEP_NAME_LABEL, 'agent.hostname': AGENT_HOST_LABEL, 'host.hostname': HOST_NAME_LABEL, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/field_names/synthetics.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/field_names/synthetics.ts index eff73d242de7..0f2864855272 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/field_names/synthetics.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/field_names/synthetics.ts @@ -11,3 +11,5 @@ export const SYNTHETICS_LCP = 'browser.experience.lcp.us'; export const SYNTHETICS_FCP = 'browser.experience.fcp.us'; export const SYNTHETICS_DOCUMENT_ONLOAD = 'browser.experience.load.us'; export const SYNTHETICS_DCL = 'browser.experience.dcl.us'; +export const SYNTHETICS_STEP_NAME = 'synthetics.step.name.keyword'; +export const SYNTHETICS_STEP_DURATION = 'synthetics.step.duration.us'; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/labels.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/labels.ts index cdaa89fc7138..599f846af2ff 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/labels.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/labels.ts @@ -231,6 +231,20 @@ export const MONITORS_DURATION_LABEL = i18n.translate( } ); +export const STEP_DURATION_LABEL = i18n.translate( + 'xpack.observability.expView.fieldLabels.stepDurationLabel', + { + defaultMessage: 'Step duration', + } +); + +export const STEP_NAME_LABEL = i18n.translate( + 'xpack.observability.expView.fieldLabels.stepNameLabel', + { + defaultMessage: 'Step name', + } +); + export const WEB_APPLICATION_LABEL = i18n.translate( 'xpack.observability.expView.fieldLabels.webApplication', { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts index 38c9ecc06491..a31bef7c9c21 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts @@ -194,10 +194,12 @@ export class LensAttributes { label, sourceField, columnType, + columnFilter, operationType, }: { sourceField: string; columnType?: string; + columnFilter?: ColumnFilter; operationType?: string; label?: string; seriesConfig: SeriesConfig; @@ -214,6 +216,7 @@ export class LensAttributes { operationType, label, seriesConfig, + columnFilter, }); } if (operationType?.includes('th')) { @@ -228,11 +231,13 @@ export class LensAttributes { label, seriesConfig, operationType, + columnFilter, }: { sourceField: string; operationType: 'average' | 'median' | 'sum' | 'unique_count'; label?: string; seriesConfig: SeriesConfig; + columnFilter?: ColumnFilter; }): | AvgIndexPatternColumn | MedianIndexPatternColumn @@ -247,6 +252,7 @@ export class LensAttributes { operationType: capitalize(operationType), }, }), + filter: columnFilter, operationType, }; } @@ -391,6 +397,7 @@ export class LensAttributes { return this.getNumberColumn({ sourceField: fieldName, columnType, + columnFilter: columnFilters?.[0], operationType, label: columnLabel || label, seriesConfig: layerConfig.seriesConfig, @@ -447,10 +454,10 @@ export class LensAttributes { return this.getColumnBasedOnType({ sourceField, - operationType: breakdown === PERCENTILE ? PERCENTILE_RANKS[0] : operationType, label, layerConfig, colIndex: 0, + operationType: breakdown === PERCENTILE ? PERCENTILE_RANKS[0] : operationType, }); } @@ -629,7 +636,12 @@ export class LensAttributes { [`y-axis-column-${layerId}`]: { ...mainYAxis, label, - filter: { query: columnFilter, language: 'kuery' }, + filter: { + query: mainYAxis.filter + ? `${columnFilter} and ${mainYAxis.filter.query}` + : columnFilter, + language: 'kuery', + }, ...(timeShift ? { timeShift } : {}), }, ...(breakdown && sourceField !== USE_BREAK_DOWN_COLUMN && breakdown !== PERCENTILE diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/data_distribution_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/data_distribution_config.ts index da90f45d1520..fb44da8e4327 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/data_distribution_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/data_distribution_config.ts @@ -29,6 +29,7 @@ import { SYNTHETICS_FCP, SYNTHETICS_LCP, } from '../constants/field_names/synthetics'; +import { buildExistsFilter } from '../utils'; export function getSyntheticsDistributionConfig({ series, @@ -58,7 +59,10 @@ export function getSyntheticsDistributionConfig({ 'url.port', ], baseFilters: [], - definitionFields: ['monitor.name', 'url.full'], + definitionFields: [ + { field: 'monitor.name', nested: 'synthetics.step.name.keyword', singleSelection: true }, + { field: 'url.full', filters: buildExistsFilter('summary.up', indexPattern) }, + ], metricOptions: [ { label: MONITORS_DURATION_LABEL, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/field_formats.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/field_formats.ts index 5f8a6a28ca81..f3e3fe081784 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/field_formats.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/field_formats.ts @@ -11,6 +11,7 @@ import { SYNTHETICS_DOCUMENT_ONLOAD, SYNTHETICS_FCP, SYNTHETICS_LCP, + SYNTHETICS_STEP_DURATION, } from '../constants/field_names/synthetics'; export const syntheticsFieldFormats: FieldFormat[] = [ @@ -27,6 +28,19 @@ export const syntheticsFieldFormats: FieldFormat[] = [ }, }, }, + { + field: SYNTHETICS_STEP_DURATION, + format: { + id: 'duration', + params: { + inputFormat: 'microseconds', + outputFormat: 'humanizePrecise', + outputPrecision: 1, + showSuffix: true, + useShortSuffix: true, + }, + }, + }, { field: SYNTHETICS_LCP, format: { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts index 6df9cdcd0503..8951ffcda63d 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ConfigProps, SeriesConfig } from '../../types'; +import { ColumnFilter, ConfigProps, SeriesConfig } from '../../types'; import { FieldLabels, OPERATION_COLUMN, @@ -21,6 +21,7 @@ import { FCP_LABEL, LCP_LABEL, MONITORS_DURATION_LABEL, + STEP_DURATION_LABEL, UP_LABEL, } from '../constants/labels'; import { @@ -30,10 +31,26 @@ import { SYNTHETICS_DOCUMENT_ONLOAD, SYNTHETICS_FCP, SYNTHETICS_LCP, + SYNTHETICS_STEP_DURATION, + SYNTHETICS_STEP_NAME, } from '../constants/field_names/synthetics'; +import { buildExistsFilter } from '../utils'; const SUMMARY_UP = 'summary.up'; const SUMMARY_DOWN = 'summary.down'; +export const isStepLevelMetric = (metric?: string) => { + if (!metric) { + return false; + } + return [ + SYNTHETICS_LCP, + SYNTHETICS_FCP, + SYNTHETICS_CLS, + SYNTHETICS_DCL, + SYNTHETICS_STEP_DURATION, + SYNTHETICS_DOCUMENT_ONLOAD, + ].includes(metric); +}; export function getSyntheticsKPIConfig({ indexPattern }: ConfigProps): SeriesConfig { return { reportType: ReportTypes.KPI, @@ -50,10 +67,19 @@ export function getSyntheticsKPIConfig({ indexPattern }: ConfigProps): SeriesCon ], hasOperationType: false, filterFields: ['observer.geo.name', 'monitor.type', 'tags'], - breakdownFields: ['observer.geo.name', 'monitor.type', 'monitor.name', PERCENTILE], + breakdownFields: [ + 'observer.geo.name', + 'monitor.type', + 'monitor.name', + SYNTHETICS_STEP_NAME, + PERCENTILE, + ], baseFilters: [], palette: { type: 'palette', name: 'status' }, - definitionFields: ['monitor.name', 'url.full'], + definitionFields: [ + { field: 'monitor.name', nested: SYNTHETICS_STEP_NAME, singleSelection: true }, + { field: 'url.full', filters: buildExistsFilter('summary.up', indexPattern) }, + ], metricOptions: [ { label: MONITORS_DURATION_LABEL, @@ -73,37 +99,59 @@ export function getSyntheticsKPIConfig({ indexPattern }: ConfigProps): SeriesCon label: DOWN_LABEL, columnType: OPERATION_COLUMN, }, + { + label: STEP_DURATION_LABEL, + field: SYNTHETICS_STEP_DURATION, + id: SYNTHETICS_STEP_DURATION, + columnType: OPERATION_COLUMN, + columnFilters: [STEP_END_FILTER], + }, { label: LCP_LABEL, field: SYNTHETICS_LCP, id: SYNTHETICS_LCP, columnType: OPERATION_COLUMN, + columnFilters: [STEP_METRIC_FILTER], }, { label: FCP_LABEL, field: SYNTHETICS_FCP, id: SYNTHETICS_FCP, columnType: OPERATION_COLUMN, + columnFilters: [STEP_METRIC_FILTER], }, { label: DCL_LABEL, field: SYNTHETICS_DCL, id: SYNTHETICS_DCL, columnType: OPERATION_COLUMN, + columnFilters: [STEP_METRIC_FILTER], }, { label: DOCUMENT_ONLOAD_LABEL, field: SYNTHETICS_DOCUMENT_ONLOAD, id: SYNTHETICS_DOCUMENT_ONLOAD, columnType: OPERATION_COLUMN, + columnFilters: [STEP_METRIC_FILTER], }, { label: CLS_LABEL, field: SYNTHETICS_CLS, id: SYNTHETICS_CLS, columnType: OPERATION_COLUMN, + columnFilters: [STEP_METRIC_FILTER], }, ], labels: { ...FieldLabels, [SUMMARY_UP]: UP_LABEL, [SUMMARY_DOWN]: DOWN_LABEL }, }; } + +const STEP_METRIC_FILTER: ColumnFilter = { + language: 'kuery', + query: `synthetics.type: step/metrics`, +}; + +const STEP_END_FILTER: ColumnFilter = { + language: 'kuery', + query: `synthetics.type: step/end`, +}; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts index adc6d4bb1446..4563509eeb19 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts @@ -77,7 +77,8 @@ export const sampleAttributeCoreWebVital = { dataType: 'number', filter: { language: 'kuery', - query: 'transaction.type: page-load and processor.event: transaction', + query: + 'transaction.type: page-load and processor.event: transaction and transaction.marks.agent.largestContentfulPaint < 2500', }, isBucketed: false, label: 'Good', diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/breakdown/breakdowns.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/breakdown/breakdowns.tsx index a235cbd8852a..f30a80f87ebb 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/breakdown/breakdowns.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/breakdown/breakdowns.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect } from 'react'; import styled from 'styled-components'; import { EuiSuperSelect, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -17,6 +17,8 @@ import { PERCENTILE, } from '../../configurations/constants'; import { SeriesConfig, SeriesUrl } from '../../types'; +import { SYNTHETICS_STEP_NAME } from '../../configurations/constants/field_names/synthetics'; +import { isStepLevelMetric } from '../../configurations/synthetics/kpi_over_time_config'; interface Props { seriesId: number; @@ -51,6 +53,18 @@ export function Breakdowns({ seriesConfig, seriesId, series }: Props) { } }; + useEffect(() => { + if ( + !isStepLevelMetric(series.selectedMetricField) && + selectedBreakdown === SYNTHETICS_STEP_NAME + ) { + setSeries(seriesId, { + ...series, + breakdown: undefined, + }); + } + }); + if (!seriesConfig) { return null; } @@ -71,11 +85,26 @@ export function Breakdowns({ seriesConfig, seriesId, series }: Props) { } const options = items - .map(({ id, label }) => ({ - inputDisplay: label, - value: id, - dropdownDisplay: label, - })) + .map(({ id, label }) => { + if (id === SYNTHETICS_STEP_NAME && !isStepLevelMetric(series.selectedMetricField)) { + return { + inputDisplay: label, + value: id, + dropdownDisplay: ( + + <>{label} + + ), + disabled: true, + }; + } else { + return { + inputDisplay: label, + value: id, + dropdownDisplay: label, + }; + } + }) .filter(({ value }) => !(value === PERCENTILE && isRecordsMetric)); let valueOfSelected = @@ -121,6 +150,14 @@ export const BREAKDOWN_WARNING = i18n.translate('xpack.observability.exp.breakDo defaultMessage: 'Breakdowns can be applied to only one series at a time.', }); +export const BREAKDOWN_UNAVAILABLE = i18n.translate( + 'xpack.observability.exp.breakDownFilter.unavailable', + { + defaultMessage: + 'Step name breakdown is not available for monitor duration metric. Use step duration metric to breakdown by step name.', + } +); + const Wrapper = styled.span` .euiToolTipAnchor { width: 100%; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/incomplete_badge.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/incomplete_badge.tsx index 4e1c38592190..8e64f4bcea68 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/incomplete_badge.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/incomplete_badge.tsx @@ -31,7 +31,14 @@ export function IncompleteBadge({ seriesConfig, series }: Props) { const incompleteDefinition = isEmpty(reportDefinitions) ? i18n.translate('xpack.observability.overview.exploratoryView.missingReportDefinition', { defaultMessage: 'Missing {reportDefinition}', - values: { reportDefinition: labels?.[definitionFields[0]] }, + values: { + reportDefinition: + labels?.[ + typeof definitionFields[0] === 'string' + ? definitionFields[0] + : definitionFields[0].field + ], + }, }) : ''; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_col.tsx index fbd7c34303d9..a665ec199913 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_col.tsx @@ -6,10 +6,13 @@ */ import React from 'react'; +import { isEmpty } from 'lodash'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { useSeriesStorage } from '../../hooks/use_series_storage'; import { SeriesConfig, SeriesUrl } from '../../types'; import { ReportDefinitionField } from './report_definition_field'; +import { isStepLevelMetric } from '../../configurations/synthetics/kpi_over_time_config'; +import { SYNTHETICS_STEP_NAME } from '../../configurations/constants/field_names/synthetics'; export function ReportDefinitionCol({ seriesId, @@ -41,19 +44,64 @@ export function ReportDefinitionCol({ } }; + const hasFieldDataSelected = (field: string) => { + return !isEmpty(series.reportDefinitions?.[field]); + }; + return ( - {definitionFields.map((field) => ( - - - - ))} + {definitionFields.map((field) => { + const fieldStr = typeof field === 'string' ? field : field.field; + const singleSelection = typeof field !== 'string' && field.singleSelection; + const nestedField = typeof field !== 'string' && field.nested; + const filters = typeof field !== 'string' ? field.filters : undefined; + + const isNonStepMetric = !isStepLevelMetric(series.selectedMetricField); + + const hideNestedStep = nestedField === SYNTHETICS_STEP_NAME && isNonStepMetric; + + if (hideNestedStep && nestedField && selectedReportDefinitions[nestedField]?.length > 0) { + setSeries(seriesId, { + ...series, + reportDefinitions: { ...selectedReportDefinitions, [nestedField]: [] }, + }); + } + + let nestedFieldElement; + + if (nestedField && hasFieldDataSelected(fieldStr) && !hideNestedStep) { + nestedFieldElement = ( + + + + ); + } + + return ( + <> + + + + {nestedFieldElement} + + ); + })} ); } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_field.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_field.tsx index 01f36e85c03a..f3e0eb767d33 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_field.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_field.tsx @@ -7,7 +7,7 @@ import React, { useMemo } from 'react'; import { isEmpty } from 'lodash'; -import { ExistsFilter } from '@kbn/es-query'; +import { ExistsFilter, PhraseFilter } from '@kbn/es-query'; import FieldValueSuggestions from '../../../field_value_suggestions'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; import { ESFilter } from '../../../../../../../../../src/core/types/elasticsearch'; @@ -19,32 +19,49 @@ import { ALL_VALUES_SELECTED } from '../../../field_value_suggestions/field_valu interface Props { seriesId: number; series: SeriesUrl; - field: string; + singleSelection?: boolean; + keepHistory?: boolean; + field: string | { field: string; nested: string }; seriesConfig: SeriesConfig; onChange: (field: string, value?: string[]) => void; + filters?: Array; } -export function ReportDefinitionField({ series, field, seriesConfig, onChange }: Props) { +export function ReportDefinitionField({ + singleSelection, + keepHistory, + series, + field: fieldProp, + seriesConfig, + onChange, + filters, +}: Props) { const { indexPattern } = useAppIndexPatternContext(series.dataType); + const field = typeof fieldProp === 'string' ? fieldProp : fieldProp.field; + const { reportDefinitions: selectedReportDefinitions = {} } = series; const { labels, baseFilters, definitionFields } = seriesConfig; const queryFilters = useMemo(() => { const filtersN: ESFilter[] = []; - (baseFilters ?? []).forEach((qFilter: PersistableFilter | ExistsFilter) => { - if (qFilter.query) { - filtersN.push(qFilter.query); - } - const existFilter = qFilter as ExistsFilter; - if (existFilter.query.exists) { - filtersN.push({ exists: existFilter.query.exists }); - } - }); + (baseFilters ?? []) + .concat(filters ?? []) + .forEach((qFilter: PersistableFilter | ExistsFilter) => { + if (qFilter.query) { + filtersN.push(qFilter.query); + } + const existFilter = qFilter as ExistsFilter; + if (existFilter.query.exists) { + filtersN.push({ exists: existFilter.query.exists }); + } + }); if (!isEmpty(selectedReportDefinitions)) { - definitionFields.forEach((fieldT) => { + definitionFields.forEach((fieldObj) => { + const fieldT = typeof fieldObj === 'string' ? fieldObj : fieldObj.field; + if (indexPattern && selectedReportDefinitions?.[fieldT] && fieldT !== field) { const values = selectedReportDefinitions?.[fieldT]; if (!values.includes(ALL_VALUES_SELECTED)) { @@ -65,7 +82,7 @@ export function ReportDefinitionField({ series, field, seriesConfig, onChange }: return ( ); } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/expanded_series_row.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/expanded_series_row.tsx index 180be1ac0414..12e0ceca2064 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/expanded_series_row.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/expanded_series_row.tsx @@ -48,13 +48,13 @@ export function ExpandedSeriesRow(seriesProps: Props) { return (
- - + + - + diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts index 001664cf1278..acd49fc25588 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts @@ -56,7 +56,15 @@ export interface SeriesConfig { filterFields: Array; seriesTypes: SeriesType[]; baseFilters?: Array; - definitionFields: string[]; + definitionFields: Array< + | string + | { + field: string; + nested?: string; + singleSelection?: boolean; + filters?: Array; + } + >; metricOptions?: MetricOption[]; labels: Record; hasOperationType: boolean; diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_combobox.tsx b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_combobox.tsx index 0735df53888a..e04d5463d549 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_combobox.tsx +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_combobox.tsx @@ -42,6 +42,7 @@ export function FieldValueCombobox({ usePrependLabel = true, compressed = true, required = true, + singleSelection = false, allowAllValuesSelection, onChange: onSelectionChange, }: FieldValueSelectionProps) { @@ -68,6 +69,7 @@ export function FieldValueCombobox({ const comboBox = ( ); diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/types.ts b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/types.ts index 6f6d520a8315..95b24aa69b1e 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/types.ts +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/types.ts @@ -29,6 +29,7 @@ interface CommonProps { allowAllValuesSelection?: boolean; cardinalityField?: string; required?: boolean; + keepHistory?: boolean; } export type FieldValueSuggestionsProps = CommonProps & { diff --git a/x-pack/plugins/observability/public/pages/overview/empty_section.ts b/x-pack/plugins/observability/public/pages/overview/empty_section.ts index 2747b2ecdebc..f249a820a60a 100644 --- a/x-pack/plugins/observability/public/pages/overview/empty_section.ts +++ b/x-pack/plugins/observability/public/pages/overview/empty_section.ts @@ -69,7 +69,7 @@ export const getEmptySections = ({ core }: { core: CoreStart }): ISection[] => { linkTitle: i18n.translate('xpack.observability.emptySection.apps.uptime.link', { defaultMessage: 'Install Heartbeat', }), - href: core.http.basePath.prepend('/app/home#/tutorial/uptimeMonitors'), + href: core.http.basePath.prepend('/app/integrations/detail/synthetics/overview'), }, { id: 'ux', diff --git a/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts index 287d86c6fba9..69b623de0b43 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts @@ -20,7 +20,8 @@ import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; import { CASES_URL } from '../../urls/navigation'; -describe('Cases connectors', () => { +// Skipping flakey test: https://github.com/elastic/kibana/issues/115438 +describe.skip('Cases connectors', () => { const configureResult = { connector: { id: 'e271c3b8-f702-4fbc-98e0-db942b573bbd', diff --git a/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts index 45b8b23cae47..605265f7311b 100644 --- a/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts @@ -6,7 +6,7 @@ */ import sinon, { SinonFakeServer } from 'sinon'; -import { API_BASE_PATH } from '../../../common/constants'; +import { API_BASE_PATH } from '../../../common'; type HttpResponse = Record | any[]; @@ -54,7 +54,7 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { }; const setLoadSnapshotsResponse = (response: HttpResponse = {}) => { - const defaultResponse = { errors: {}, snapshots: [], repositories: [] }; + const defaultResponse = { errors: {}, snapshots: [], repositories: [], total: 0 }; server.respondWith('GET', `${API_BASE_PATH}snapshots`, mockResponse(defaultResponse, response)); }; diff --git a/x-pack/plugins/snapshot_restore/__jest__/client_integration/home.test.ts b/x-pack/plugins/snapshot_restore/__jest__/client_integration/home.test.ts index 52303e1134f9..071868e23f7f 100644 --- a/x-pack/plugins/snapshot_restore/__jest__/client_integration/home.test.ts +++ b/x-pack/plugins/snapshot_restore/__jest__/client_integration/home.test.ts @@ -8,7 +8,7 @@ import { act } from 'react-dom/test-utils'; import * as fixtures from '../../test/fixtures'; import { SNAPSHOT_STATE } from '../../public/application/constants'; -import { API_BASE_PATH } from '../../common/constants'; +import { API_BASE_PATH } from '../../common'; import { setupEnvironment, pageHelpers, @@ -431,6 +431,7 @@ describe('', () => { httpRequestsMockHelpers.setLoadSnapshotsResponse({ snapshots: [], repositories: ['my-repo'], + total: 0, }); testBed = await setup(); @@ -469,6 +470,7 @@ describe('', () => { httpRequestsMockHelpers.setLoadSnapshotsResponse({ snapshots, repositories: [REPOSITORY_NAME], + total: 2, }); testBed = await setup(); @@ -501,18 +503,10 @@ describe('', () => { }); }); - test('should show a warning if the number of snapshots exceeded the limit', () => { - // We have mocked the SNAPSHOT_LIST_MAX_SIZE to 2, so the warning should display - const { find, exists } = testBed; - expect(exists('maxSnapshotsWarning')).toBe(true); - expect(find('maxSnapshotsWarning').text()).toContain( - 'Cannot show the full list of snapshots' - ); - }); - test('should show a warning if one repository contains errors', async () => { httpRequestsMockHelpers.setLoadSnapshotsResponse({ snapshots, + total: 2, repositories: [REPOSITORY_NAME], errors: { repository_with_errors: { diff --git a/x-pack/plugins/snapshot_restore/common/constants.ts b/x-pack/plugins/snapshot_restore/common/constants.ts index a7c83ecf702e..b18e118dc5ff 100644 --- a/x-pack/plugins/snapshot_restore/common/constants.ts +++ b/x-pack/plugins/snapshot_restore/common/constants.ts @@ -65,9 +65,3 @@ export const TIME_UNITS: { [key: string]: 'd' | 'h' | 'm' | 's' } = { MINUTE: 'm', SECOND: 's', }; - -/** - * [Temporary workaround] In order to prevent client-side performance issues for users with a large number of snapshots, - * we set a hard-coded limit on the number of snapshots we return from the ES snapshots API - */ -export const SNAPSHOT_LIST_MAX_SIZE = 1000; diff --git a/x-pack/plugins/snapshot_restore/public/application/lib/index.ts b/x-pack/plugins/snapshot_restore/public/application/lib/index.ts index 1ec4d5b2907f..19a42bef4cea 100644 --- a/x-pack/plugins/snapshot_restore/public/application/lib/index.ts +++ b/x-pack/plugins/snapshot_restore/public/application/lib/index.ts @@ -6,3 +6,12 @@ */ export { useDecodedParams } from './use_decoded_params'; + +export { + SortField, + SortDirection, + SnapshotListParams, + getListParams, + getQueryFromListParams, + DEFAULT_SNAPSHOT_LIST_PARAMS, +} from './snapshot_list_params'; diff --git a/x-pack/plugins/snapshot_restore/public/application/lib/snapshot_list_params.ts b/x-pack/plugins/snapshot_restore/public/application/lib/snapshot_list_params.ts new file mode 100644 index 000000000000..b75a3e01fb61 --- /dev/null +++ b/x-pack/plugins/snapshot_restore/public/application/lib/snapshot_list_params.ts @@ -0,0 +1,88 @@ +/* + * 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 { Direction, Query } from '@elastic/eui'; + +export type SortField = + | 'snapshot' + | 'repository' + | 'indices' + | 'startTimeInMillis' + | 'durationInMillis' + | 'shards.total' + | 'shards.failed'; + +export type SortDirection = Direction; + +interface SnapshotTableParams { + sortField: SortField; + sortDirection: SortDirection; + pageIndex: number; + pageSize: number; +} +interface SnapshotSearchParams { + searchField?: string; + searchValue?: string; + searchMatch?: string; + searchOperator?: string; +} +export type SnapshotListParams = SnapshotTableParams & SnapshotSearchParams; + +// By default, we'll display the most recent snapshots at the top of the table (no query). +export const DEFAULT_SNAPSHOT_LIST_PARAMS: SnapshotListParams = { + sortField: 'startTimeInMillis', + sortDirection: 'desc', + pageIndex: 0, + pageSize: 20, +}; + +const resetSearchOptions = (listParams: SnapshotListParams): SnapshotListParams => ({ + ...listParams, + searchField: undefined, + searchValue: undefined, + searchMatch: undefined, + searchOperator: undefined, +}); + +// to init the query for repository and policyName search passed via url +export const getQueryFromListParams = (listParams: SnapshotListParams): Query => { + const { searchField, searchValue } = listParams; + if (!searchField || !searchValue) { + return Query.MATCH_ALL; + } + return Query.parse(`${searchField}=${searchValue}`); +}; + +export const getListParams = (listParams: SnapshotListParams, query: Query): SnapshotListParams => { + if (!query) { + return resetSearchOptions(listParams); + } + const clause = query.ast.clauses[0]; + if (!clause) { + return resetSearchOptions(listParams); + } + // term queries (free word search) converts to snapshot name search + if (clause.type === 'term') { + return { + ...listParams, + searchField: 'snapshot', + searchValue: String(clause.value), + searchMatch: clause.match, + searchOperator: 'eq', + }; + } + if (clause.type === 'field') { + return { + ...listParams, + searchField: clause.field, + searchValue: String(clause.value), + searchMatch: clause.match, + searchOperator: clause.operator, + }; + } + return resetSearchOptions(listParams); +}; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_table/index.ts b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/index.ts similarity index 55% rename from x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_table/index.ts rename to x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/index.ts index 7ea85f415090..8c69ca0297e3 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_table/index.ts +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/index.ts @@ -6,3 +6,7 @@ */ export { SnapshotTable } from './snapshot_table'; +export { RepositoryError } from './repository_error'; +export { RepositoryEmptyPrompt } from './repository_empty_prompt'; +export { SnapshotEmptyPrompt } from './snapshot_empty_prompt'; +export { SnapshotSearchBar } from './snapshot_search_bar'; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/repository_empty_prompt.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/repository_empty_prompt.tsx new file mode 100644 index 000000000000..4c5e050ea489 --- /dev/null +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/repository_empty_prompt.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { useHistory } from 'react-router-dom'; +import { EuiButton, EuiEmptyPrompt, EuiPageContent } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { reactRouterNavigate } from '../../../../../shared_imports'; +import { linkToAddRepository } from '../../../../services/navigation'; + +export const RepositoryEmptyPrompt: React.FunctionComponent = () => { + const history = useHistory(); + return ( + + + + + } + body={ + <> +

+ +

+

+ + + +

+ + } + data-test-subj="emptyPrompt" + /> +
+ ); +}; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/repository_error.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/repository_error.tsx new file mode 100644 index 000000000000..d3902770333c --- /dev/null +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/repository_error.tsx @@ -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 React from 'react'; +import { useHistory } from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiEmptyPrompt, EuiLink, EuiPageContent } from '@elastic/eui'; +import { reactRouterNavigate } from '../../../../../shared_imports'; +import { linkToRepositories } from '../../../../services/navigation'; + +export const RepositoryError: React.FunctionComponent = () => { + const history = useHistory(); + return ( + + + + + } + body={ +

+ + + + ), + }} + /> +

+ } + /> +
+ ); +}; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_empty_prompt.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_empty_prompt.tsx new file mode 100644 index 000000000000..2cfc1d5ebefc --- /dev/null +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_empty_prompt.tsx @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Fragment } from 'react'; +import { useHistory } from 'react-router-dom'; +import { EuiButton, EuiEmptyPrompt, EuiIcon, EuiLink, EuiPageContent } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { APP_SLM_CLUSTER_PRIVILEGES } from '../../../../../../common'; +import { reactRouterNavigate, WithPrivileges } from '../../../../../shared_imports'; +import { linkToAddPolicy, linkToPolicies } from '../../../../services/navigation'; +import { useCore } from '../../../../app_context'; + +export const SnapshotEmptyPrompt: React.FunctionComponent<{ policiesCount: number }> = ({ + policiesCount, +}) => { + const { docLinks } = useCore(); + const history = useHistory(); + return ( + + + + + } + body={ + `cluster.${name}`)}> + {({ hasPrivileges }) => + hasPrivileges ? ( + +

+ + + + ), + }} + /> +

+

+ {policiesCount === 0 ? ( + + + + ) : ( + + + + )} +

+
+ ) : ( + +

+ +

+

+ + {' '} + + +

+
+ ) + } +
+ } + data-test-subj="emptyPrompt" + /> +
+ ); +}; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_search_bar.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_search_bar.tsx new file mode 100644 index 000000000000..b3e2c24e396f --- /dev/null +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_search_bar.tsx @@ -0,0 +1,178 @@ +/* + * 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 useDebounce from 'react-use/lib/useDebounce'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { SearchFilterConfig } from '@elastic/eui/src/components/search_bar/search_filters'; +import { SchemaType } from '@elastic/eui/src/components/search_bar/search_box'; +import { EuiSearchBarOnChangeArgs } from '@elastic/eui/src/components/search_bar/search_bar'; +import { EuiButton, EuiCallOut, EuiSearchBar, EuiSpacer, Query } from '@elastic/eui'; +import { SnapshotDeleteProvider } from '../../../../components'; +import { SnapshotDetails } from '../../../../../../common/types'; +import { getQueryFromListParams, SnapshotListParams, getListParams } from '../../../../lib'; + +const SEARCH_DEBOUNCE_VALUE_MS = 200; + +const onlyOneClauseMessage = i18n.translate( + 'xpack.snapshotRestore.snapshotList.searchBar.onlyOneClauseMessage', + { + defaultMessage: 'You can only use one clause in the search bar', + } +); +// for now limit the search bar to snapshot, repository and policyName queries +const searchSchema: SchemaType = { + strict: true, + fields: { + snapshot: { + type: 'string', + }, + repository: { + type: 'string', + }, + policyName: { + type: 'string', + }, + }, +}; + +interface Props { + listParams: SnapshotListParams; + setListParams: (listParams: SnapshotListParams) => void; + reload: () => void; + selectedItems: SnapshotDetails[]; + onSnapshotDeleted: (snapshotsDeleted: Array<{ snapshot: string; repository: string }>) => void; + repositories: string[]; +} + +export const SnapshotSearchBar: React.FunctionComponent = ({ + listParams, + setListParams, + reload, + selectedItems, + onSnapshotDeleted, + repositories, +}) => { + const [cachedListParams, setCachedListParams] = useState(listParams); + // send the request after the user has stopped typing + useDebounce( + () => { + setListParams(cachedListParams); + }, + SEARCH_DEBOUNCE_VALUE_MS, + [cachedListParams] + ); + + const deleteButton = selectedItems.length ? ( + + {( + deleteSnapshotPrompt: ( + ids: Array<{ snapshot: string; repository: string }>, + onSuccess?: (snapshotsDeleted: Array<{ snapshot: string; repository: string }>) => void + ) => void + ) => { + return ( + + deleteSnapshotPrompt( + selectedItems.map(({ snapshot, repository }) => ({ snapshot, repository })), + onSnapshotDeleted + ) + } + color="danger" + data-test-subj="srSnapshotListBulkDeleteActionButton" + > + + + ); + }} + + ) : ( + [] + ); + const searchFilters: SearchFilterConfig[] = [ + { + type: 'field_value_selection' as const, + field: 'repository', + name: i18n.translate('xpack.snapshotRestore.snapshotList.table.repositoryFilterLabel', { + defaultMessage: 'Repository', + }), + operator: 'exact', + multiSelect: false, + options: repositories.map((repository) => ({ + value: repository, + view: repository, + })), + }, + ]; + const reloadButton = ( + + + + ); + + const [query, setQuery] = useState(getQueryFromListParams(listParams)); + const [error, setError] = useState(null); + + const onSearchBarChange = (args: EuiSearchBarOnChangeArgs) => { + const { query: changedQuery, error: queryError } = args; + if (queryError) { + setError(queryError); + } else if (changedQuery) { + setError(null); + setQuery(changedQuery); + if (changedQuery.ast.clauses.length > 1) { + setError({ name: onlyOneClauseMessage, message: onlyOneClauseMessage }); + } else { + setCachedListParams(getListParams(listParams, changedQuery)); + } + } + }; + + return ( + <> + + + {error ? ( + <> + + } + /> + + + ) : null} + + ); +}; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_table/snapshot_table.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_table.tsx similarity index 71% rename from x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_table/snapshot_table.tsx rename to x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_table.tsx index 47f8d9b833e4..5db702fcbd96 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_table/snapshot_table.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_table.tsx @@ -7,34 +7,28 @@ import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiTableSortingType } from '@elastic/eui/src/components/basic_table/table_types'; + import { - EuiButton, - EuiInMemoryTable, EuiLink, - Query, EuiLoadingSpinner, EuiToolTip, EuiButtonIcon, + Criteria, + EuiBasicTable, } from '@elastic/eui'; - import { SnapshotDetails } from '../../../../../../common/types'; -import { UseRequestResponse } from '../../../../../shared_imports'; +import { UseRequestResponse, reactRouterNavigate } from '../../../../../shared_imports'; import { SNAPSHOT_STATE, UIM_SNAPSHOT_SHOW_DETAILS_CLICK } from '../../../../constants'; import { useServices } from '../../../../app_context'; -import { linkToRepository, linkToRestoreSnapshot } from '../../../../services/navigation'; +import { + linkToRepository, + linkToRestoreSnapshot, + linkToSnapshot as openSnapshotDetailsUrl, +} from '../../../../services/navigation'; +import { SnapshotListParams, SortDirection, SortField } from '../../../../lib'; import { DataPlaceholder, FormattedDateTime, SnapshotDeleteProvider } from '../../../../components'; - -import { reactRouterNavigate } from '../../../../../../../../../src/plugins/kibana_react/public'; - -interface Props { - snapshots: SnapshotDetails[]; - repositories: string[]; - reload: UseRequestResponse['resendRequest']; - openSnapshotDetailsUrl: (repositoryName: string, snapshotId: string) => string; - repositoryFilter?: string; - policyFilter?: string; - onSnapshotDeleted: (snapshotsDeleted: Array<{ snapshot: string; repository: string }>) => void; -} +import { SnapshotSearchBar } from './snapshot_search_bar'; const getLastSuccessfulManagedSnapshot = ( snapshots: SnapshotDetails[] @@ -51,15 +45,28 @@ const getLastSuccessfulManagedSnapshot = ( return successfulSnapshots[0]; }; -export const SnapshotTable: React.FunctionComponent = ({ - snapshots, - repositories, - reload, - openSnapshotDetailsUrl, - onSnapshotDeleted, - repositoryFilter, - policyFilter, -}) => { +interface Props { + snapshots: SnapshotDetails[]; + repositories: string[]; + reload: UseRequestResponse['resendRequest']; + onSnapshotDeleted: (snapshotsDeleted: Array<{ snapshot: string; repository: string }>) => void; + listParams: SnapshotListParams; + setListParams: (listParams: SnapshotListParams) => void; + totalItemCount: number; + isLoading: boolean; +} + +export const SnapshotTable: React.FunctionComponent = (props: Props) => { + const { + snapshots, + repositories, + reload, + onSnapshotDeleted, + listParams, + setListParams, + totalItemCount, + isLoading, + } = props; const { i18n, uiMetricService, history } = useServices(); const [selectedItems, setSelectedItems] = useState([]); @@ -71,7 +78,7 @@ export const SnapshotTable: React.FunctionComponent = ({ name: i18n.translate('xpack.snapshotRestore.snapshotList.table.snapshotColumnTitle', { defaultMessage: 'Snapshot', }), - truncateText: true, + truncateText: false, sortable: true, render: (snapshotId: string, snapshot: SnapshotDetails) => ( = ({ name: i18n.translate('xpack.snapshotRestore.snapshotList.table.repositoryColumnTitle', { defaultMessage: 'Repository', }), - truncateText: true, + truncateText: false, sortable: true, render: (repositoryName: string) => ( = ({ name: i18n.translate('xpack.snapshotRestore.snapshotList.table.startTimeColumnTitle', { defaultMessage: 'Date created', }), - truncateText: true, + truncateText: false, sortable: true, render: (startTimeInMillis: number) => ( @@ -263,30 +270,20 @@ export const SnapshotTable: React.FunctionComponent = ({ }, ]; - // By default, we'll display the most recent snapshots at the top of the table. - const sorting = { + const sorting: EuiTableSortingType = { sort: { - field: 'startTimeInMillis', - direction: 'desc' as const, + field: listParams.sortField as keyof SnapshotDetails, + direction: listParams.sortDirection, }, }; const pagination = { - initialPageSize: 20, + pageIndex: listParams.pageIndex, + pageSize: listParams.pageSize, + totalItemCount, pageSizeOptions: [10, 20, 50], }; - const searchSchema = { - fields: { - repository: { - type: 'string', - }, - policyName: { - type: 'string', - }, - }, - }; - const selection = { onSelectionChange: (newSelectedItems: SnapshotDetails[]) => setSelectedItems(newSelectedItems), selectable: ({ snapshot }: SnapshotDetails) => @@ -306,103 +303,44 @@ export const SnapshotTable: React.FunctionComponent = ({ }, }; - const search = { - toolsLeft: selectedItems.length ? ( - - {( - deleteSnapshotPrompt: ( - ids: Array<{ snapshot: string; repository: string }>, - onSuccess?: (snapshotsDeleted: Array<{ snapshot: string; repository: string }>) => void - ) => void - ) => { - return ( - - deleteSnapshotPrompt( - selectedItems.map(({ snapshot, repository }) => ({ snapshot, repository })), - onSnapshotDeleted - ) - } - color="danger" - data-test-subj="srSnapshotListBulkDeleteActionButton" - > - - - ); - }} - - ) : undefined, - toolsRight: ( - - - - ), - box: { - incremental: true, - schema: searchSchema, - }, - filters: [ - { - type: 'field_value_selection' as const, - field: 'repository', - name: i18n.translate('xpack.snapshotRestore.snapshotList.table.repositoryFilterLabel', { - defaultMessage: 'Repository', - }), - multiSelect: false, - options: repositories.map((repository) => ({ - value: repository, - view: repository, - })), - }, - ], - defaultQuery: policyFilter - ? Query.parse(`policyName="${policyFilter}"`, { - schema: { - ...searchSchema, - strict: true, - }, - }) - : repositoryFilter - ? Query.parse(`repository="${repositoryFilter}"`, { - schema: { - ...searchSchema, - strict: true, - }, - }) - : '', - }; - return ( - ({ - 'data-test-subj': 'row', - })} - cellProps={() => ({ - 'data-test-subj': 'cell', - })} - data-test-subj="snapshotTable" - /> + <> + + ) => { + const { page: { index, size } = {}, sort: { field, direction } = {} } = criteria; + + setListParams({ + ...listParams, + sortField: (field as SortField) ?? listParams.sortField, + sortDirection: (direction as SortDirection) ?? listParams.sortDirection, + pageIndex: index ?? listParams.pageIndex, + pageSize: size ?? listParams.pageSize, + }); + }} + loading={isLoading} + isSelectable={true} + selection={selection} + pagination={pagination} + rowProps={() => ({ + 'data-test-subj': 'row', + })} + cellProps={() => ({ + 'data-test-subj': 'cell', + })} + data-test-subj="snapshotTable" + /> + ); }; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_list.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_list.tsx index 92c03d1be936..da7ec42f746a 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_list.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_list.tsx @@ -5,37 +5,26 @@ * 2.0. */ -import React, { Fragment, useState, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import { parse } from 'query-string'; import { FormattedMessage } from '@kbn/i18n/react'; import { RouteComponentProps } from 'react-router-dom'; -import { - EuiPageContent, - EuiButton, - EuiCallOut, - EuiLink, - EuiEmptyPrompt, - EuiSpacer, - EuiIcon, -} from '@elastic/eui'; +import { EuiCallOut, EuiLink, EuiSpacer } from '@elastic/eui'; -import { APP_SLM_CLUSTER_PRIVILEGES, SNAPSHOT_LIST_MAX_SIZE } from '../../../../../common'; -import { WithPrivileges, PageLoading, PageError, Error } from '../../../../shared_imports'; +import { PageLoading, PageError, Error, reactRouterNavigate } from '../../../../shared_imports'; import { BASE_PATH, UIM_SNAPSHOT_LIST_LOAD } from '../../../constants'; import { useLoadSnapshots } from '../../../services/http'; -import { - linkToRepositories, - linkToAddRepository, - linkToPolicies, - linkToAddPolicy, - linkToSnapshot, -} from '../../../services/navigation'; -import { useCore, useServices } from '../../../app_context'; -import { useDecodedParams } from '../../../lib'; -import { SnapshotDetails } from './snapshot_details'; -import { SnapshotTable } from './snapshot_table'; +import { linkToRepositories } from '../../../services/navigation'; +import { useServices } from '../../../app_context'; +import { useDecodedParams, SnapshotListParams, DEFAULT_SNAPSHOT_LIST_PARAMS } from '../../../lib'; -import { reactRouterNavigate } from '../../../../../../../../src/plugins/kibana_react/public'; +import { SnapshotDetails } from './snapshot_details'; +import { + SnapshotTable, + RepositoryEmptyPrompt, + SnapshotEmptyPrompt, + RepositoryError, +} from './components'; interface MatchParams { repositoryName?: string; @@ -47,22 +36,22 @@ export const SnapshotList: React.FunctionComponent { const { repositoryName, snapshotId } = useDecodedParams(); + const [listParams, setListParams] = useState(DEFAULT_SNAPSHOT_LIST_PARAMS); const { error, + isInitialRequest, isLoading, - data: { snapshots = [], repositories = [], policies = [], errors = {} }, + data: { + snapshots = [], + repositories = [], + policies = [], + errors = {}, + total: totalSnapshotsCount, + }, resendRequest: reload, - } = useLoadSnapshots(); + } = useLoadSnapshots(listParams); - const { uiMetricService, i18n } = useServices(); - const { docLinks } = useCore(); - - const openSnapshotDetailsUrl = ( - repositoryNameToOpen: string, - snapshotIdToOpen: string - ): string => { - return linkToSnapshot(repositoryNameToOpen, snapshotIdToOpen); - }; + const { uiMetricService } = useServices(); const closeSnapshotDetails = () => { history.push(`${BASE_PATH}/snapshots`); @@ -86,22 +75,32 @@ export const SnapshotList: React.FunctionComponent(undefined); - const [filteredPolicy, setFilteredPolicy] = useState(undefined); useEffect(() => { if (search) { const parsedParams = parse(search.replace(/^\?/, ''), { sort: false }); const { repository, policy } = parsedParams; - if (policy && policy !== filteredPolicy) { - setFilteredPolicy(String(policy)); + if (policy) { + setListParams((prev: SnapshotListParams) => ({ + ...prev, + searchField: 'policyName', + searchValue: String(policy), + searchMatch: 'must', + searchOperator: 'exact', + })); history.replace(`${BASE_PATH}/snapshots`); - } else if (repository && repository !== filteredRepository) { - setFilteredRepository(String(repository)); + } else if (repository) { + setListParams((prev: SnapshotListParams) => ({ + ...prev, + searchField: 'repository', + searchValue: String(repository), + searchMatch: 'must', + searchOperator: 'exact', + })); history.replace(`${BASE_PATH}/snapshots`); } } - }, [filteredPolicy, filteredRepository, history, search]); + }, [listParams, history, search]); // Track component loaded useEffect(() => { @@ -110,7 +109,8 @@ export const SnapshotList: React.FunctionComponent @@ -134,190 +134,11 @@ export const SnapshotList: React.FunctionComponent ); } else if (Object.keys(errors).length && repositories.length === 0) { - content = ( - - - - - } - body={ -

- - - - ), - }} - /> -

- } - /> -
- ); + content = ; } else if (repositories.length === 0) { - content = ( - - - - - } - body={ - <> -

- -

-

- - - -

- - } - data-test-subj="emptyPrompt" - /> -
- ); - } else if (snapshots.length === 0) { - content = ( - - - - - } - body={ - `cluster.${name}`)} - > - {({ hasPrivileges }) => - hasPrivileges ? ( - -

- - - - ), - }} - /> -

-

- {policies.length === 0 ? ( - - - - ) : ( - - - - )} -

-
- ) : ( - -

- -

-

- - {' '} - - -

-
- ) - } -
- } - data-test-subj="emptyPrompt" - /> -
- ); + content = ; + } else if (totalSnapshotsCount === 0 && !listParams.searchField && !isLoading) { + content = ; } else { const repositoryErrorsWarning = Object.keys(errors).length ? ( <> @@ -351,53 +172,19 @@ export const SnapshotList: React.FunctionComponent ) : null; - const maxSnapshotsWarning = snapshots.length === SNAPSHOT_LIST_MAX_SIZE && ( - <> - - - -
- ), - }} - /> -
- - - ); - content = (
{repositoryErrorsWarning} - {maxSnapshotsWarning} -
); diff --git a/x-pack/plugins/snapshot_restore/public/application/services/http/snapshot_requests.ts b/x-pack/plugins/snapshot_restore/public/application/services/http/snapshot_requests.ts index 3d64dc96958d..c02d0f053f78 100644 --- a/x-pack/plugins/snapshot_restore/public/application/services/http/snapshot_requests.ts +++ b/x-pack/plugins/snapshot_restore/public/application/services/http/snapshot_requests.ts @@ -5,8 +5,10 @@ * 2.0. */ -import { API_BASE_PATH } from '../../../../common/constants'; +import { HttpFetchQuery } from 'kibana/public'; +import { API_BASE_PATH } from '../../../../common'; import { UIM_SNAPSHOT_DELETE, UIM_SNAPSHOT_DELETE_MANY } from '../../constants'; +import { SnapshotListParams } from '../../lib'; import { UiMetricService } from '../ui_metric'; import { sendRequest, useRequest } from './use_request'; @@ -18,11 +20,12 @@ export const setUiMetricServiceSnapshot = (_uiMetricService: UiMetricService) => }; // End hack -export const useLoadSnapshots = () => +export const useLoadSnapshots = (query: SnapshotListParams) => useRequest({ path: `${API_BASE_PATH}snapshots`, method: 'get', initialData: [], + query: query as unknown as HttpFetchQuery, }); export const useLoadSnapshot = (repositoryName: string, snapshotId: string) => diff --git a/x-pack/plugins/snapshot_restore/public/shared_imports.ts b/x-pack/plugins/snapshot_restore/public/shared_imports.ts index d1b9f37703c0..a3cda90d26f2 100644 --- a/x-pack/plugins/snapshot_restore/public/shared_imports.ts +++ b/x-pack/plugins/snapshot_restore/public/shared_imports.ts @@ -26,3 +26,5 @@ export { } from '../../../../src/plugins/es_ui_shared/public'; export { APP_WRAPPER_CLASS } from '../../../../src/core/public'; + +export { reactRouterNavigate } from '../../../../src/plugins/kibana_react/public'; diff --git a/x-pack/plugins/snapshot_restore/server/lib/get_snapshot_search_wildcard.test.ts b/x-pack/plugins/snapshot_restore/server/lib/get_snapshot_search_wildcard.test.ts new file mode 100644 index 000000000000..d3e5c604d22a --- /dev/null +++ b/x-pack/plugins/snapshot_restore/server/lib/get_snapshot_search_wildcard.test.ts @@ -0,0 +1,60 @@ +/* + * 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 { getSnapshotSearchWildcard } from './get_snapshot_search_wildcard'; + +describe('getSnapshotSearchWildcard', () => { + it('exact match search converts to a wildcard without *', () => { + const searchParams = { + field: 'snapshot', + value: 'testSearch', + operator: 'exact', + match: 'must', + }; + const wildcard = getSnapshotSearchWildcard(searchParams); + expect(wildcard).toEqual('testSearch'); + }); + + it('partial match search converts to a wildcard with *', () => { + const searchParams = { field: 'snapshot', value: 'testSearch', operator: 'eq', match: 'must' }; + const wildcard = getSnapshotSearchWildcard(searchParams); + expect(wildcard).toEqual('*testSearch*'); + }); + + it('excluding search converts to "all, except" wildcard (exact match)', () => { + const searchParams = { + field: 'snapshot', + value: 'testSearch', + operator: 'exact', + match: 'must_not', + }; + const wildcard = getSnapshotSearchWildcard(searchParams); + expect(wildcard).toEqual('*,-testSearch'); + }); + + it('excluding search converts to "all, except" wildcard (partial match)', () => { + const searchParams = { + field: 'snapshot', + value: 'testSearch', + operator: 'eq', + match: 'must_not', + }; + const wildcard = getSnapshotSearchWildcard(searchParams); + expect(wildcard).toEqual('*,-*testSearch*'); + }); + + it('excluding search for policy name converts to "all,_none, except" wildcard', () => { + const searchParams = { + field: 'policyName', + value: 'testSearch', + operator: 'exact', + match: 'must_not', + }; + const wildcard = getSnapshotSearchWildcard(searchParams); + expect(wildcard).toEqual('*,_none,-testSearch'); + }); +}); diff --git a/x-pack/plugins/snapshot_restore/server/lib/get_snapshot_search_wildcard.ts b/x-pack/plugins/snapshot_restore/server/lib/get_snapshot_search_wildcard.ts new file mode 100644 index 000000000000..df8926d78571 --- /dev/null +++ b/x-pack/plugins/snapshot_restore/server/lib/get_snapshot_search_wildcard.ts @@ -0,0 +1,30 @@ +/* + * 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. + */ + +interface SearchParams { + field: string; + value: string; + match?: string; + operator?: string; +} + +export const getSnapshotSearchWildcard = ({ + field, + value, + match, + operator, +}: SearchParams): string => { + // if the operator is NOT for exact match, convert to *value* wildcard that matches any substring + value = operator === 'exact' ? value : `*${value}*`; + + // ES API new "-"("except") wildcard removes matching items from a list of already selected items + // To find all items not containing the search value, use "*,-{searchValue}" + // When searching for policy name, also add "_none" to find snapshots without a policy as well + const excludingWildcard = field === 'policyName' ? `*,_none,-${value}` : `*,-${value}`; + + return match === 'must_not' ? excludingWildcard : value; +}; diff --git a/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.test.ts b/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.test.ts index f71c5ec9ffc0..4ecd34a43adb 100644 --- a/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.test.ts +++ b/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.test.ts @@ -51,6 +51,10 @@ describe('[Snapshot and Restore API Routes] Snapshots', () => { const mockRequest: RequestMock = { method: 'get', path: addBasePath('snapshots'), + query: { + sortField: 'startTimeInMillis', + sortDirection: 'desc', + }, }; const mockSnapshotGetManagedRepositoryEsResponse = { diff --git a/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts b/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts index 6838ae2700f3..4de0c3011fed 100644 --- a/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts +++ b/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts @@ -7,11 +7,36 @@ import { schema, TypeOf } from '@kbn/config-schema'; import type { SnapshotDetailsEs } from '../../../common/types'; -import { SNAPSHOT_LIST_MAX_SIZE } from '../../../common/constants'; import { deserializeSnapshotDetails } from '../../../common/lib'; import type { RouteDependencies } from '../../types'; import { getManagedRepositoryName } from '../../lib'; import { addBasePath } from '../helpers'; +import { snapshotListSchema } from './validate_schemas'; +import { getSnapshotSearchWildcard } from '../../lib/get_snapshot_search_wildcard'; + +const sortFieldToESParams = { + snapshot: 'name', + repository: 'repository', + indices: 'index_count', + startTimeInMillis: 'start_time', + durationInMillis: 'duration', + 'shards.total': 'shard_count', + 'shards.failed': 'failed_shard_count', +}; + +const isSearchingForNonExistentRepository = ( + repositories: string[], + value: string, + match?: string, + operator?: string +): boolean => { + // only check if searching for an exact match (repository=test) + if (match === 'must' && operator === 'exact') { + return !(repositories || []).includes(value); + } + // otherwise we will use a wildcard, so allow the request + return false; +}; export function registerSnapshotsRoutes({ router, @@ -20,9 +45,18 @@ export function registerSnapshotsRoutes({ }: RouteDependencies) { // GET all snapshots router.get( - { path: addBasePath('snapshots'), validate: false }, + { path: addBasePath('snapshots'), validate: { query: snapshotListSchema } }, license.guardApiRoute(async (ctx, req, res) => { const { client: clusterClient } = ctx.core.elasticsearch; + const sortField = + sortFieldToESParams[(req.query as TypeOf).sortField]; + const sortDirection = (req.query as TypeOf).sortDirection; + const pageIndex = (req.query as TypeOf).pageIndex; + const pageSize = (req.query as TypeOf).pageSize; + const searchField = (req.query as TypeOf).searchField; + const searchValue = (req.query as TypeOf).searchValue; + const searchMatch = (req.query as TypeOf).searchMatch; + const searchOperator = (req.query as TypeOf).searchOperator; const managedRepository = await getManagedRepositoryName(clusterClient.asCurrentUser); @@ -55,18 +89,60 @@ export function registerSnapshotsRoutes({ return handleEsError({ error: e, response: res }); } + // if the search is for a repository name with exact match (repository=test) + // and that repository doesn't exist, ES request throws an error + // that is why we return an empty snapshots array instead of sending an ES request + if ( + searchField === 'repository' && + isSearchingForNonExistentRepository(repositories, searchValue!, searchMatch, searchOperator) + ) { + return res.ok({ + body: { + snapshots: [], + policies, + repositories, + errors: [], + total: 0, + }, + }); + } try { // If any of these repositories 504 they will cost the request significant time. const { body: fetchedSnapshots } = await clusterClient.asCurrentUser.snapshot.get({ - repository: '_all', - snapshot: '_all', + repository: + searchField === 'repository' + ? getSnapshotSearchWildcard({ + field: searchField, + value: searchValue!, + match: searchMatch, + operator: searchOperator, + }) + : '_all', ignore_unavailable: true, // Allow request to succeed even if some snapshots are unavailable. - // @ts-expect-error @elastic/elasticsearch "desc" is a new param - order: 'desc', - // TODO We are temporarily hard-coding the maximum number of snapshots returned - // in order to prevent an unusable UI for users with large number of snapshots - // In the near future, this will be resolved with server-side pagination - size: SNAPSHOT_LIST_MAX_SIZE, + snapshot: + searchField === 'snapshot' + ? getSnapshotSearchWildcard({ + field: searchField, + value: searchValue!, + match: searchMatch, + operator: searchOperator, + }) + : '_all', + // @ts-expect-error @elastic/elasticsearch new API params + // https://github.com/elastic/elasticsearch-specification/issues/845 + slm_policy_filter: + searchField === 'policyName' + ? getSnapshotSearchWildcard({ + field: searchField, + value: searchValue!, + match: searchMatch, + operator: searchOperator, + }) + : '*,_none', + order: sortDirection, + sort: sortField, + size: pageSize, + offset: pageIndex * pageSize, }); // Decorate each snapshot with the repository with which it's associated. @@ -79,8 +155,10 @@ export function registerSnapshotsRoutes({ snapshots: snapshots || [], policies, repositories, - // @ts-expect-error @elastic/elasticsearch "failures" is a new field in the response + // @ts-expect-error @elastic/elasticsearch https://github.com/elastic/elasticsearch-specification/issues/845 errors: fetchedSnapshots?.failures, + // @ts-expect-error @elastic/elasticsearch "total" is a new field in the response + total: fetchedSnapshots?.total, }, }); } catch (e) { @@ -170,7 +248,7 @@ export function registerSnapshotsRoutes({ const snapshots = req.body; try { - // We intentially perform deletion requests sequentially (blocking) instead of in parallel (non-blocking) + // We intentionally perform deletion requests sequentially (blocking) instead of in parallel (non-blocking) // because there can only be one snapshot deletion task performed at a time (ES restriction). for (let i = 0; i < snapshots.length; i++) { const { snapshot, repository } = snapshots[i]; diff --git a/x-pack/plugins/snapshot_restore/server/routes/api/validate_schemas.ts b/x-pack/plugins/snapshot_restore/server/routes/api/validate_schemas.ts index af31466c2cef..e93ee2b3d78c 100644 --- a/x-pack/plugins/snapshot_restore/server/routes/api/validate_schemas.ts +++ b/x-pack/plugins/snapshot_restore/server/routes/api/validate_schemas.ts @@ -26,6 +26,31 @@ const snapshotRetentionSchema = schema.object({ minCount: schema.maybe(schema.oneOf([schema.number(), schema.literal('')])), }); +export const snapshotListSchema = schema.object({ + sortField: schema.oneOf([ + schema.literal('snapshot'), + schema.literal('repository'), + schema.literal('indices'), + schema.literal('durationInMillis'), + schema.literal('startTimeInMillis'), + schema.literal('shards.total'), + schema.literal('shards.failed'), + ]), + sortDirection: schema.oneOf([schema.literal('desc'), schema.literal('asc')]), + pageIndex: schema.number(), + pageSize: schema.number(), + searchField: schema.maybe( + schema.oneOf([ + schema.literal('snapshot'), + schema.literal('repository'), + schema.literal('policyName'), + ]) + ), + searchValue: schema.maybe(schema.string()), + searchMatch: schema.maybe(schema.oneOf([schema.literal('must'), schema.literal('must_not')])), + searchOperator: schema.maybe(schema.oneOf([schema.literal('eq'), schema.literal('exact')])), +}); + export const policySchema = schema.object({ name: schema.string(), snapshotName: schema.string(), diff --git a/x-pack/plugins/snapshot_restore/server/routes/helpers.ts b/x-pack/plugins/snapshot_restore/server/routes/helpers.ts index 1f49d2f3cabf..e73db4d992ff 100644 --- a/x-pack/plugins/snapshot_restore/server/routes/helpers.ts +++ b/x-pack/plugins/snapshot_restore/server/routes/helpers.ts @@ -5,6 +5,6 @@ * 2.0. */ -import { API_BASE_PATH } from '../../common/constants'; +import { API_BASE_PATH } from '../../common'; export const addBasePath = (uri: string): string => API_BASE_PATH + uri; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index c941cbd9ddf8..3df5e4ee6c48 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -23900,9 +23900,6 @@ "xpack.snapshotRestore.snapshotList.table.snapshotColumnTitle": "スナップショット", "xpack.snapshotRestore.snapshotList.table.startTimeColumnTitle": "日付が作成されました", "xpack.snapshotRestore.snapshots.breadcrumbTitle": "スナップショット", - "xpack.snapshotRestore.snapshotsList.maxSnapshotsDisplayedDescription": "表示可能なスナップショットの最大数に達しました。スナップショットをすべて表示するには、{docLink}を使用してください。", - "xpack.snapshotRestore.snapshotsList.maxSnapshotsDisplayedDocLinkText": "Elasticsearch API", - "xpack.snapshotRestore.snapshotsList.maxSnapshotsDisplayedTitle": "スナップショットの一覧を表示できません。", "xpack.snapshotRestore.snapshotState.completeLabel": "スナップショット完了", "xpack.snapshotRestore.snapshotState.failedLabel": "スナップショット失敗", "xpack.snapshotRestore.snapshotState.incompatibleLabel": "互換性のないバージョン", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e9e9f02c8fe9..d9af3edb8101 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -24302,9 +24302,6 @@ "xpack.snapshotRestore.snapshotList.table.snapshotColumnTitle": "快照", "xpack.snapshotRestore.snapshotList.table.startTimeColumnTitle": "创建日期", "xpack.snapshotRestore.snapshots.breadcrumbTitle": "快照", - "xpack.snapshotRestore.snapshotsList.maxSnapshotsDisplayedDescription": "已达到最大可查看快照数目。要查看您的所有快照,请使用{docLink}。", - "xpack.snapshotRestore.snapshotsList.maxSnapshotsDisplayedDocLinkText": "Elasticsearch API", - "xpack.snapshotRestore.snapshotsList.maxSnapshotsDisplayedTitle": "无法显示快照的完整列表", "xpack.snapshotRestore.snapshotState.completeLabel": "快照完成", "xpack.snapshotRestore.snapshotState.failedLabel": "快照失败", "xpack.snapshotRestore.snapshotState.incompatibleLabel": "不兼容版本", diff --git a/x-pack/plugins/uptime/public/apps/use_no_data_config.ts b/x-pack/plugins/uptime/public/apps/use_no_data_config.ts index dc00a25e3a11..6e73a6d5e826 100644 --- a/x-pack/plugins/uptime/public/apps/use_no_data_config.ts +++ b/x-pack/plugins/uptime/public/apps/use_no_data_config.ts @@ -31,13 +31,13 @@ export function useNoDataConfig(): KibanaPageTemplateProps['noDataConfig'] { actions: { beats: { title: i18n.translate('xpack.uptime.noDataConfig.beatsCard.title', { - defaultMessage: 'Add monitors with Heartbeat', + defaultMessage: 'Add monitors with the Elastic Synthetics integration', }), description: i18n.translate('xpack.uptime.noDataConfig.beatsCard.description', { defaultMessage: 'Proactively monitor the availability of your sites and services. Receive alerts and resolve issues faster to optimize your users experience.', }), - href: basePath + `/app/home#/tutorial/uptimeMonitors`, + href: basePath + `/app/integrations/detail/synthetics/overview`, }, }, docsLink: docLinks!.links.observability.guide, diff --git a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx index 0265588c3fde..76b9378ca4ff 100644 --- a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx +++ b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx @@ -45,11 +45,13 @@ describe('ActionMenuContent', () => { it('renders Add Data link', () => { const { getByLabelText, getByText } = render(); - const addDataAnchor = getByLabelText('Navigate to a tutorial about adding Uptime data'); + const addDataAnchor = getByLabelText( + 'Navigate to the Elastic Synthetics integration to add Uptime data' + ); // this href value is mocked, so it doesn't correspond to the real link // that Kibana core services will provide - expect(addDataAnchor.getAttribute('href')).toBe('/home#/tutorial/uptimeMonitors'); + expect(addDataAnchor.getAttribute('href')).toBe('/integrations/detail/synthetics/overview'); expect(getByText('Add data')); }); }); diff --git a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx index 21ef3428696e..789953258750 100644 --- a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx +++ b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx @@ -56,9 +56,8 @@ export function ActionMenuContent(): React.ReactElement { time: { from: dateRangeStart, to: dateRangeEnd }, breakdown: monitorId ? 'observer.geo.name' : 'monitor.type', reportDefinitions: { - 'monitor.name': selectedMonitor?.monitor?.name - ? [selectedMonitor?.monitor?.name] - : ['ALL_VALUES'], + 'monitor.name': selectedMonitor?.monitor?.name ? [selectedMonitor?.monitor?.name] : [], + 'url.full': ['ALL_VALUES'], }, name: monitorId ? `${monitorId}-response-duration` : 'All monitors response duration', }, @@ -100,9 +99,11 @@ export function ActionMenuContent(): React.ReactElement { diff --git a/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx b/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx index ac129bdb327d..60ccec84c3bb 100644 --- a/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx +++ b/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx @@ -83,7 +83,7 @@ const createMockStore = () => { const mockAppUrls: Record = { uptime: '/app/uptime', observability: '/app/observability', - '/home#/tutorial/uptimeMonitors': '/home#/tutorial/uptimeMonitors', + '/integrations/detail/synthetics/overview': '/integrations/detail/synthetics/overview', }; /* default mock core */ diff --git a/x-pack/test/api_integration/apis/management/snapshot_restore/index.ts b/x-pack/test/api_integration/apis/management/snapshot_restore/index.ts index 4d39ff1494f8..db5dbc9735e6 100644 --- a/x-pack/test/api_integration/apis/management/snapshot_restore/index.ts +++ b/x-pack/test/api_integration/apis/management/snapshot_restore/index.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('Snapshot and Restore', () => { - loadTestFile(require.resolve('./snapshot_restore')); + loadTestFile(require.resolve('./policies')); + loadTestFile(require.resolve('./snapshots')); }); } diff --git a/x-pack/test/api_integration/apis/management/snapshot_restore/lib/elasticsearch.ts b/x-pack/test/api_integration/apis/management/snapshot_restore/lib/elasticsearch.ts index 9b4d39a3b10b..a59c90fe2913 100644 --- a/x-pack/test/api_integration/apis/management/snapshot_restore/lib/elasticsearch.ts +++ b/x-pack/test/api_integration/apis/management/snapshot_restore/lib/elasticsearch.ts @@ -7,9 +7,10 @@ import { FtrProviderContext } from '../../../../ftr_provider_context'; -interface SlmPolicy { +export interface SlmPolicy { + policyName: string; + // snapshot name name: string; - snapshotName: string; schedule: string; repository: string; isManagedPolicy: boolean; @@ -29,23 +30,22 @@ interface SlmPolicy { } /** - * Helpers to create and delete SLM policies on the Elasticsearch instance + * Helpers to create and delete SLM policies, repositories and snapshots on the Elasticsearch instance * during our tests. - * @param {ElasticsearchClient} es The Elasticsearch client instance */ export const registerEsHelpers = (getService: FtrProviderContext['getService']) => { let policiesCreated: string[] = []; const es = getService('es'); - const createRepository = (repoName: string) => { + const createRepository = (repoName: string, repoPath?: string) => { return es.snapshot .createRepository({ repository: repoName, body: { type: 'fs', settings: { - location: '/tmp/', + location: repoPath ?? '/tmp/repo', }, }, verify: false, @@ -55,12 +55,12 @@ export const registerEsHelpers = (getService: FtrProviderContext['getService']) const createPolicy = (policy: SlmPolicy, cachePolicy?: boolean) => { if (cachePolicy) { - policiesCreated.push(policy.name); + policiesCreated.push(policy.policyName); } return es.slm .putLifecycle({ - policy_id: policy.name, + policy_id: policy.policyName, // TODO: bring {@link SlmPolicy} in line with {@link PutSnapshotLifecycleRequest['body']} // @ts-expect-error body: policy, @@ -90,11 +90,34 @@ export const registerEsHelpers = (getService: FtrProviderContext['getService']) console.log(`[Cleanup error] Error deleting ES resources: ${err.message}`); }); + const executePolicy = (policyName: string) => { + return es.slm.executeLifecycle({ policy_id: policyName }).then(({ body }) => body); + }; + + const createSnapshot = (snapshotName: string, repositoryName: string) => { + return es.snapshot + .create({ snapshot: snapshotName, repository: repositoryName }) + .then(({ body }) => body); + }; + + const deleteSnapshots = (repositoryName: string) => { + es.snapshot + .delete({ repository: repositoryName, snapshot: '*' }) + .then(() => {}) + .catch((err) => { + // eslint-disable-next-line no-console + console.log(`[Cleanup error] Error deleting snapshots: ${err.message}`); + }); + }; + return { createRepository, createPolicy, deletePolicy, cleanupPolicies, getPolicy, + executePolicy, + createSnapshot, + deleteSnapshots, }; }; diff --git a/x-pack/test/api_integration/apis/management/snapshot_restore/lib/index.ts b/x-pack/test/api_integration/apis/management/snapshot_restore/lib/index.ts index 27a4d9c59cff..a9721c585659 100644 --- a/x-pack/test/api_integration/apis/management/snapshot_restore/lib/index.ts +++ b/x-pack/test/api_integration/apis/management/snapshot_restore/lib/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { registerEsHelpers } from './elasticsearch'; +export { registerEsHelpers, SlmPolicy } from './elasticsearch'; diff --git a/x-pack/test/api_integration/apis/management/snapshot_restore/snapshot_restore.ts b/x-pack/test/api_integration/apis/management/snapshot_restore/policies.ts similarity index 95% rename from x-pack/test/api_integration/apis/management/snapshot_restore/snapshot_restore.ts rename to x-pack/test/api_integration/apis/management/snapshot_restore/policies.ts index a6ac2d057c84..e0734680887d 100644 --- a/x-pack/test/api_integration/apis/management/snapshot_restore/snapshot_restore.ts +++ b/x-pack/test/api_integration/apis/management/snapshot_restore/policies.ts @@ -19,7 +19,7 @@ export default function ({ getService }: FtrProviderContext) { const { createRepository, createPolicy, deletePolicy, cleanupPolicies, getPolicy } = registerEsHelpers(getService); - describe('Snapshot Lifecycle Management', function () { + describe('SLM policies', function () { this.tags(['skipCloud']); // file system repositories are not supported in cloud before(async () => { @@ -134,9 +134,8 @@ export default function ({ getService }: FtrProviderContext) { describe('Update', () => { const POLICY_NAME = 'test_update_policy'; + const SNAPSHOT_NAME = 'my_snapshot'; const POLICY = { - name: POLICY_NAME, - snapshotName: 'my_snapshot', schedule: '0 30 1 * * ?', repository: REPO_NAME, config: { @@ -159,7 +158,7 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { // Create SLM policy that can be used to test PUT request try { - await createPolicy(POLICY, true); + await createPolicy({ ...POLICY, policyName: POLICY_NAME, name: SNAPSHOT_NAME }, true); } catch (err) { // eslint-disable-next-line no-console console.log('[Setup error] Error creating policy'); @@ -175,6 +174,8 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ ...POLICY, + name: POLICY_NAME, + snapshotName: SNAPSHOT_NAME, schedule: '0 0 0 ? * 7', }) .expect(200); @@ -212,7 +213,7 @@ export default function ({ getService }: FtrProviderContext) { const { body } = await supertest .put(uri) .set('kbn-xsrf', 'xxx') - .send(requiredFields) + .send({ ...requiredFields, name: POLICY_NAME, snapshotName: SNAPSHOT_NAME }) .expect(200); expect(body).to.eql({ diff --git a/x-pack/test/api_integration/apis/management/snapshot_restore/snapshots.ts b/x-pack/test/api_integration/apis/management/snapshot_restore/snapshots.ts new file mode 100644 index 000000000000..1677013dd5e7 --- /dev/null +++ b/x-pack/test/api_integration/apis/management/snapshot_restore/snapshots.ts @@ -0,0 +1,729 @@ +/* + * 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 '../../../ftr_provider_context'; +import { registerEsHelpers, SlmPolicy } from './lib'; +import { SnapshotDetails } from '../../../../../plugins/snapshot_restore/common/types'; + +const REPO_NAME_1 = 'test_repo_1'; +const REPO_NAME_2 = 'test_another_repo_2'; +const REPO_PATH_1 = '/tmp/repo_1'; +const REPO_PATH_2 = '/tmp/repo_2'; +// SLM policies to test policyName filter +const POLICY_NAME_1 = 'test_policy_1'; +const POLICY_NAME_2 = 'test_another_policy_2'; +const POLICY_SNAPSHOT_NAME_1 = 'backup_snapshot'; +const POLICY_SNAPSHOT_NAME_2 = 'a_snapshot'; +// snapshots created without SLM policies +const BATCH_SIZE_1 = 3; +const BATCH_SIZE_2 = 5; +const BATCH_SNAPSHOT_NAME_1 = 'another_snapshot'; +const BATCH_SNAPSHOT_NAME_2 = 'xyz_another_snapshot'; +// total count consists of both batches' sizes + 2 snapshots created by 2 SLM policies (one each) +const SNAPSHOT_COUNT = BATCH_SIZE_1 + BATCH_SIZE_2 + 2; +// API defaults used in the UI +const PAGE_INDEX = 0; +const PAGE_SIZE = 20; +const SORT_FIELD = 'startTimeInMillis'; +const SORT_DIRECTION = 'desc'; + +interface ApiParams { + pageIndex?: number; + pageSize?: number; + + sortField?: string; + sortDirection?: string; + + searchField?: string; + searchValue?: string; + searchMatch?: string; + searchOperator?: string; +} +const getApiPath = ({ + pageIndex, + pageSize, + sortField, + sortDirection, + searchField, + searchValue, + searchMatch, + searchOperator, +}: ApiParams): string => { + let path = `/api/snapshot_restore/snapshots?sortField=${sortField ?? SORT_FIELD}&sortDirection=${ + sortDirection ?? SORT_DIRECTION + }&pageIndex=${pageIndex ?? PAGE_INDEX}&pageSize=${pageSize ?? PAGE_SIZE}`; + // all 4 parameters should be used at the same time to configure the correct search request + if (searchField && searchValue && searchMatch && searchOperator) { + path = `${path}&searchField=${searchField}&searchValue=${searchValue}&searchMatch=${searchMatch}&searchOperator=${searchOperator}`; + } + return path; +}; +const getPolicyBody = (policy: Partial): SlmPolicy => { + return { + policyName: 'default_policy', + name: 'default_snapshot', + schedule: '0 30 1 * * ?', + repository: 'default_repo', + isManagedPolicy: false, + config: { + indices: ['default_index'], + ignoreUnavailable: true, + }, + ...policy, + }; +}; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + const { + createSnapshot, + createRepository, + createPolicy, + executePolicy, + cleanupPolicies, + deleteSnapshots, + } = registerEsHelpers(getService); + + describe('Snapshots', function () { + this.tags(['skipCloud']); // file system repositories are not supported in cloud + + // names of snapshots created by SLM policies have random suffixes, save full names for tests + let snapshotName1: string; + let snapshotName2: string; + + before(async () => { + /* + * This setup creates following repos, SLM policies and snapshots: + * Repo 1 "test_repo_1" with 5 snapshots + * "backup_snapshot..." (created by SLM policy "test_policy_1") + * "a_snapshot..." (created by SLM policy "test_another_policy_2") + * "another_snapshot_0" to "another_snapshot_2" (no SLM policy) + * + * Repo 2 "test_another_repo_2" with 5 snapshots + * "xyz_another_snapshot_0" to "xyz_another_snapshot_4" (no SLM policy) + */ + try { + await createRepository(REPO_NAME_1, REPO_PATH_1); + await createRepository(REPO_NAME_2, REPO_PATH_2); + await createPolicy( + getPolicyBody({ + policyName: POLICY_NAME_1, + repository: REPO_NAME_1, + name: POLICY_SNAPSHOT_NAME_1, + }), + true + ); + await createPolicy( + getPolicyBody({ + policyName: POLICY_NAME_2, + repository: REPO_NAME_1, + name: POLICY_SNAPSHOT_NAME_2, + }), + true + ); + ({ snapshot_name: snapshotName1 } = await executePolicy(POLICY_NAME_1)); + // a short timeout to let the 1st snapshot start, otherwise the sorting by start time might be flaky + await new Promise((resolve) => setTimeout(resolve, 2000)); + ({ snapshot_name: snapshotName2 } = await executePolicy(POLICY_NAME_2)); + for (let i = 0; i < BATCH_SIZE_1; i++) { + await createSnapshot(`${BATCH_SNAPSHOT_NAME_1}_${i}`, REPO_NAME_1); + } + for (let i = 0; i < BATCH_SIZE_2; i++) { + await createSnapshot(`${BATCH_SNAPSHOT_NAME_2}_${i}`, REPO_NAME_2); + } + } catch (err) { + // eslint-disable-next-line no-console + console.log('[Setup error] Error creating snapshots'); + throw err; + } + }); + + after(async () => { + await cleanupPolicies(); + await deleteSnapshots(REPO_NAME_1); + await deleteSnapshots(REPO_NAME_2); + }); + + describe('pagination', () => { + it('returns pageSize number of snapshots', async () => { + const pageSize = 7; + const { + body: { total, snapshots }, + } = await supertest + .get( + getApiPath({ + pageSize, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + expect(total).to.eql(SNAPSHOT_COUNT); + expect(snapshots.length).to.eql(pageSize); + }); + + it('returns next page of snapshots', async () => { + const pageSize = 3; + let pageIndex = 0; + const { + body: { snapshots: firstPageSnapshots }, + } = await supertest + .get( + getApiPath({ + pageIndex, + pageSize, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + const firstPageSnapshotName = firstPageSnapshots[0].snapshot; + expect(firstPageSnapshots.length).to.eql(pageSize); + + pageIndex = 1; + const { + body: { snapshots: secondPageSnapshots }, + } = await supertest + .get( + getApiPath({ + pageIndex, + pageSize, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + const secondPageSnapshotName = secondPageSnapshots[0].snapshot; + expect(secondPageSnapshots.length).to.eql(pageSize); + expect(secondPageSnapshotName).to.not.eql(firstPageSnapshotName); + }); + }); + + describe('sorting', () => { + it('sorts by snapshot name (asc)', async () => { + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + sortField: 'snapshot', + sortDirection: 'asc', + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + /* + * snapshots name in asc order: + * "a_snapshot...", "another_snapshot...", "backup_snapshot...", "xyz_another_snapshot..." + */ + const snapshotName = snapshots[0].snapshot; + // snapshotName2 is "a_snapshot..." + expect(snapshotName).to.eql(snapshotName2); + }); + + it('sorts by snapshot name (desc)', async () => { + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + sortField: 'snapshot', + sortDirection: 'desc', + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + /* + * snapshots name in desc order: + * "xyz_another_snapshot...", "backup_snapshot...", "another_snapshot...", "a_snapshot..." + */ + const snapshotName = snapshots[0].snapshot; + expect(snapshotName).to.eql('xyz_another_snapshot_4'); + }); + + it('sorts by repository name (asc)', async () => { + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + sortField: 'repository', + sortDirection: 'asc', + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + // repositories in asc order: "test_another_repo_2", "test_repo_1" + const repositoryName = snapshots[0].repository; + expect(repositoryName).to.eql(REPO_NAME_2); // "test_another_repo_2" + }); + + it('sorts by repository name (desc)', async () => { + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + sortField: 'repository', + sortDirection: 'desc', + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + // repositories in desc order: "test_repo_1", "test_another_repo_2" + const repositoryName = snapshots[0].repository; + expect(repositoryName).to.eql(REPO_NAME_1); // "test_repo_1" + }); + + it('sorts by startTimeInMillis (asc)', async () => { + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + sortField: 'startTimeInMillis', + sortDirection: 'asc', + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + const snapshotName = snapshots[0].snapshot; + // the 1st snapshot that was created during this test setup + expect(snapshotName).to.eql(snapshotName1); + }); + + it('sorts by startTimeInMillis (desc)', async () => { + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + sortField: 'startTimeInMillis', + sortDirection: 'desc', + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + const snapshotName = snapshots[0].snapshot; + // the last snapshot that was created during this test setup + expect(snapshotName).to.eql('xyz_another_snapshot_4'); + }); + + // these properties are only tested as being accepted by the API + const sortFields = ['indices', 'durationInMillis', 'shards.total', 'shards.failed']; + sortFields.forEach((sortField: string) => { + it(`allows sorting by ${sortField} (asc)`, async () => { + await supertest + .get( + getApiPath({ + sortField, + sortDirection: 'asc', + }) + ) + .set('kbn-xsrf', 'xxx') + .send() + .expect(200); + }); + + it(`allows sorting by ${sortField} (desc)`, async () => { + await supertest + .get( + getApiPath({ + sortField, + sortDirection: 'desc', + }) + ) + .set('kbn-xsrf', 'xxx') + .send() + .expect(200); + }); + }); + }); + + describe('search', () => { + describe('snapshot name', () => { + it('exact match', async () => { + // list snapshots with the name "another_snapshot_2" + const searchField = 'snapshot'; + const searchValue = 'another_snapshot_2'; + const searchMatch = 'must'; + const searchOperator = 'exact'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + expect(snapshots.length).to.eql(1); + expect(snapshots[0].snapshot).to.eql('another_snapshot_2'); + }); + + it('partial match', async () => { + // list snapshots with the name containing with "another" + const searchField = 'snapshot'; + const searchValue = 'another'; + const searchMatch = 'must'; + const searchOperator = 'eq'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + // both batches created snapshots containing "another" in the name + expect(snapshots.length).to.eql(BATCH_SIZE_1 + BATCH_SIZE_2); + const snapshotNamesContainSearch = snapshots.every((snapshot: SnapshotDetails) => + snapshot.snapshot.includes('another') + ); + expect(snapshotNamesContainSearch).to.eql(true); + }); + + it('excluding search with exact match', async () => { + // list snapshots with the name not "another_snapshot_2" + const searchField = 'snapshot'; + const searchValue = 'another_snapshot_2'; + const searchMatch = 'must_not'; + const searchOperator = 'exact'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + expect(snapshots.length).to.eql(SNAPSHOT_COUNT - 1); + const snapshotIsExcluded = snapshots.every( + (snapshot: SnapshotDetails) => snapshot.snapshot !== 'another_snapshot_2' + ); + expect(snapshotIsExcluded).to.eql(true); + }); + + it('excluding search with partial match', async () => { + // list snapshots with the name not starting with "another" + const searchField = 'snapshot'; + const searchValue = 'another'; + const searchMatch = 'must_not'; + const searchOperator = 'eq'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + // both batches created snapshots with names containing "another" + expect(snapshots.length).to.eql(SNAPSHOT_COUNT - BATCH_SIZE_1 - BATCH_SIZE_2); + const snapshotsAreExcluded = snapshots.every( + (snapshot: SnapshotDetails) => !snapshot.snapshot.includes('another') + ); + expect(snapshotsAreExcluded).to.eql(true); + }); + }); + + describe('repository name', () => { + it('search for non-existent repository returns an empty snapshot array', async () => { + // search for non-existent repository + const searchField = 'repository'; + const searchValue = 'non-existent'; + const searchMatch = 'must'; + const searchOperator = 'exact'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + expect(snapshots.length).to.eql(0); + }); + + it('exact match', async () => { + // list snapshots from repository "test_repo_1" + const searchField = 'repository'; + const searchValue = REPO_NAME_1; + const searchMatch = 'must'; + const searchOperator = 'exact'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + // repo 1 contains snapshots from batch 1 and 2 snapshots created by 2 SLM policies + expect(snapshots.length).to.eql(BATCH_SIZE_1 + 2); + const repositoryNameMatches = snapshots.every( + (snapshot: SnapshotDetails) => snapshot.repository === REPO_NAME_1 + ); + expect(repositoryNameMatches).to.eql(true); + }); + + it('partial match', async () => { + // list snapshots from repository with the name containing "another" + // i.e. snapshots from repo 2 + const searchField = 'repository'; + const searchValue = 'another'; + const searchMatch = 'must'; + const searchOperator = 'eq'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + // repo 2 only contains snapshots created by batch 2 + expect(snapshots.length).to.eql(BATCH_SIZE_2); + const repositoryNameMatches = snapshots.every((snapshot: SnapshotDetails) => + snapshot.repository.includes('another') + ); + expect(repositoryNameMatches).to.eql(true); + }); + + it('excluding search with exact match', async () => { + // list snapshots from repositories with the name not "test_repo_1" + const searchField = 'repository'; + const searchValue = REPO_NAME_1; + const searchMatch = 'must_not'; + const searchOperator = 'exact'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + // snapshots not in repo 1 are only snapshots created in batch 2 + expect(snapshots.length).to.eql(BATCH_SIZE_2); + const repositoryNameMatches = snapshots.every( + (snapshot: SnapshotDetails) => snapshot.repository !== REPO_NAME_1 + ); + expect(repositoryNameMatches).to.eql(true); + }); + + it('excluding search with partial match', async () => { + // list snapshots from repository with the name not containing "test" + const searchField = 'repository'; + const searchValue = 'test'; + const searchMatch = 'must_not'; + const searchOperator = 'eq'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + expect(snapshots.length).to.eql(0); + }); + }); + + describe('policy name', () => { + it('search for non-existent policy returns an empty snapshot array', async () => { + // search for non-existent policy + const searchField = 'policyName'; + const searchValue = 'non-existent'; + const searchMatch = 'must'; + const searchOperator = 'exact'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + expect(snapshots.length).to.eql(0); + }); + + it('exact match', async () => { + // list snapshots created by the policy "test_policy_1" + const searchField = 'policyName'; + const searchValue = POLICY_NAME_1; + const searchMatch = 'must'; + const searchOperator = 'exact'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + expect(snapshots.length).to.eql(1); + expect(snapshots[0].policyName).to.eql(POLICY_NAME_1); + }); + + it('partial match', async () => { + // list snapshots created by the policy with the name containing "another" + const searchField = 'policyName'; + const searchValue = 'another'; + const searchMatch = 'must'; + const searchOperator = 'eq'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + // 1 snapshot was created by the policy "test_another_policy_2" + expect(snapshots.length).to.eql(1); + const policyNameMatches = snapshots.every((snapshot: SnapshotDetails) => + snapshot.policyName!.includes('another') + ); + expect(policyNameMatches).to.eql(true); + }); + + it('excluding search with exact match', async () => { + // list snapshots created by the policy with the name not "test_policy_1" + const searchField = 'policyName'; + const searchValue = POLICY_NAME_1; + const searchMatch = 'must_not'; + const searchOperator = 'exact'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + // only 1 snapshot was created by policy 1 + // search results should also contain snapshots without SLM policy + expect(snapshots.length).to.eql(SNAPSHOT_COUNT - 1); + const snapshotsExcluded = snapshots.every( + (snapshot: SnapshotDetails) => (snapshot.policyName ?? '') !== POLICY_NAME_1 + ); + expect(snapshotsExcluded).to.eql(true); + }); + + it('excluding search with partial match', async () => { + // list snapshots created by the policy with the name not containing "another" + const searchField = 'policyName'; + const searchValue = 'another'; + const searchMatch = 'must_not'; + const searchOperator = 'eq'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + // only 1 snapshot was created by SLM policy containing "another" in the name + // search results should also contain snapshots without SLM policy + expect(snapshots.length).to.eql(SNAPSHOT_COUNT - 1); + const snapshotsExcluded = snapshots.every( + (snapshot: SnapshotDetails) => !(snapshot.policyName ?? '').includes('another') + ); + expect(snapshotsExcluded).to.eql(true); + }); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/config.ts b/x-pack/test/api_integration/config.ts index 3690f661c621..678f7a0d3d92 100644 --- a/x-pack/test/api_integration/config.ts +++ b/x-pack/test/api_integration/config.ts @@ -41,6 +41,7 @@ export async function getApiIntegrationConfig({ readConfigFile }: FtrConfigProvi serverArgs: [ ...xPackFunctionalTestsConfig.get('esTestCluster.serverArgs'), 'node.attr.name=apiIntegrationTestNode', + 'path.repo=/tmp/repo,/tmp/repo_1,/tmp/repo_2', ], }, }; diff --git a/x-pack/test/apm_api_integration/tests/correlations/failed_transactions.ts b/x-pack/test/apm_api_integration/tests/correlations/failed_transactions.ts index 6e2025a7fa2c..3388d5b4aa37 100644 --- a/x-pack/test/apm_api_integration/tests/correlations/failed_transactions.ts +++ b/x-pack/test/apm_api_integration/tests/correlations/failed_transactions.ts @@ -10,7 +10,7 @@ import expect from '@kbn/expect'; import { IKibanaSearchRequest } from '../../../../../src/plugins/data/common'; import type { FailedTransactionsCorrelationsParams } from '../../../../plugins/apm/common/search_strategies/failed_transactions_correlations/types'; -import type { SearchStrategyClientParams } from '../../../../plugins/apm/common/search_strategies/types'; +import type { RawSearchStrategyClientParams } from '../../../../plugins/apm/common/search_strategies/types'; import { APM_SEARCH_STRATEGIES } from '../../../../plugins/apm/common/search_strategies/constants'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -23,7 +23,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { const getRequestBody = () => { const request: IKibanaSearchRequest< - FailedTransactionsCorrelationsParams & SearchStrategyClientParams + FailedTransactionsCorrelationsParams & RawSearchStrategyClientParams > = { params: { environment: 'ENVIRONMENT_ALL', diff --git a/x-pack/test/apm_api_integration/tests/correlations/latency.ts b/x-pack/test/apm_api_integration/tests/correlations/latency.ts index 99aee770c625..75a4edd447c7 100644 --- a/x-pack/test/apm_api_integration/tests/correlations/latency.ts +++ b/x-pack/test/apm_api_integration/tests/correlations/latency.ts @@ -10,7 +10,7 @@ import expect from '@kbn/expect'; import { IKibanaSearchRequest } from '../../../../../src/plugins/data/common'; import type { LatencyCorrelationsParams } from '../../../../plugins/apm/common/search_strategies/latency_correlations/types'; -import type { SearchStrategyClientParams } from '../../../../plugins/apm/common/search_strategies/types'; +import type { RawSearchStrategyClientParams } from '../../../../plugins/apm/common/search_strategies/types'; import { APM_SEARCH_STRATEGIES } from '../../../../plugins/apm/common/search_strategies/constants'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -22,16 +22,17 @@ export default function ApiTest({ getService }: FtrProviderContext) { const supertest = getService('legacySupertestAsApmReadUser'); const getRequestBody = () => { - const request: IKibanaSearchRequest = { - params: { - environment: 'ENVIRONMENT_ALL', - start: '2020', - end: '2021', - kuery: '', - percentileThreshold: 95, - analyzeCorrelations: true, - }, - }; + const request: IKibanaSearchRequest = + { + params: { + environment: 'ENVIRONMENT_ALL', + start: '2020', + end: '2021', + kuery: '', + percentileThreshold: 95, + analyzeCorrelations: true, + }, + }; return { batch: [ diff --git a/x-pack/test/apm_api_integration/tests/error_rate/service_apis.ts b/x-pack/test/apm_api_integration/tests/error_rate/service_apis.ts new file mode 100644 index 000000000000..75ea10ed4d9d --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/error_rate/service_apis.ts @@ -0,0 +1,213 @@ +/* + * 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 { service, timerange } from '@elastic/apm-generator'; +import expect from '@kbn/expect'; +import { mean, meanBy, sumBy } from 'lodash'; +import { LatencyAggregationType } from '../../../../plugins/apm/common/latency_aggregation_types'; +import { isFiniteNumber } from '../../../../plugins/apm/common/utils/is_finite_number'; +import { PromiseReturnType } from '../../../../plugins/observability/typings/common'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { registry } from '../../common/registry'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const apmApiClient = getService('apmApiClient'); + const traceData = getService('traceData'); + + const serviceName = 'synth-go'; + 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 getErrorRateValues({ + processorEvent, + }: { + processorEvent: 'transaction' | 'metric'; + }) { + const commonQuery = { + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + environment: 'ENVIRONMENT_ALL', + }; + const [ + serviceInventoryAPIResponse, + transactionsErrorRateChartAPIResponse, + transactionsGroupDetailsAPIResponse, + serviceInstancesAPIResponse, + ] = await Promise.all([ + apmApiClient.readUser({ + endpoint: 'GET /internal/apm/services', + params: { + query: { + ...commonQuery, + kuery: `service.name : "${serviceName}" and processor.event : "${processorEvent}"`, + }, + }, + }), + apmApiClient.readUser({ + endpoint: 'GET /internal/apm/services/{serviceName}/transactions/charts/error_rate', + params: { + path: { serviceName }, + query: { + ...commonQuery, + kuery: `processor.event : "${processorEvent}"`, + transactionType: 'request', + }, + }, + }), + apmApiClient.readUser({ + endpoint: `GET /internal/apm/services/{serviceName}/transactions/groups/main_statistics`, + params: { + path: { serviceName }, + query: { + ...commonQuery, + kuery: `processor.event : "${processorEvent}"`, + transactionType: 'request', + latencyAggregationType: 'avg' as LatencyAggregationType, + }, + }, + }), + apmApiClient.readUser({ + endpoint: `GET /internal/apm/services/{serviceName}/service_overview_instances/main_statistics`, + params: { + path: { serviceName }, + query: { + ...commonQuery, + kuery: `processor.event : "${processorEvent}"`, + transactionType: 'request', + latencyAggregationType: 'avg' as LatencyAggregationType, + }, + }, + }), + ]); + + const serviceInventoryErrorRate = + serviceInventoryAPIResponse.body.items[0].transactionErrorRate; + + const errorRateChartApiMean = meanBy( + transactionsErrorRateChartAPIResponse.body.currentPeriod.transactionErrorRate.filter( + (item) => isFiniteNumber(item.y) && item.y > 0 + ), + 'y' + ); + + const transactionsGroupErrorRateSum = sumBy( + transactionsGroupDetailsAPIResponse.body.transactionGroups, + 'errorRate' + ); + + const serviceInstancesErrorRateSum = sumBy( + serviceInstancesAPIResponse.body.currentPeriod, + 'errorRate' + ); + + return { + serviceInventoryErrorRate, + errorRateChartApiMean, + transactionsGroupErrorRateSum, + serviceInstancesErrorRateSum, + }; + } + + let errorRateMetricValues: PromiseReturnType; + let errorTransactionValues: PromiseReturnType; + + registry.when('Services APIs', { config: 'basic', archives: ['apm_8.0.0_empty'] }, () => { + describe('when data is loaded ', () => { + const GO_PROD_LIST_RATE = 75; + const GO_PROD_LIST_ERROR_RATE = 25; + const GO_PROD_ID_RATE = 50; + const GO_PROD_ID_ERROR_RATE = 50; + before(async () => { + const serviceGoProdInstance = service(serviceName, 'production', 'go').instance( + 'instance-a' + ); + + const transactionNameProductList = 'GET /api/product/list'; + const transactionNameProductId = 'GET /api/product/:id'; + + await traceData.index([ + ...timerange(start, end) + .interval('1m') + .rate(GO_PROD_LIST_RATE) + .flatMap((timestamp) => + serviceGoProdInstance + .transaction(transactionNameProductList) + .timestamp(timestamp) + .duration(1000) + .success() + .serialize() + ), + ...timerange(start, end) + .interval('1m') + .rate(GO_PROD_LIST_ERROR_RATE) + .flatMap((timestamp) => + serviceGoProdInstance + .transaction(transactionNameProductList) + .duration(1000) + .timestamp(timestamp) + .failure() + .serialize() + ), + ...timerange(start, end) + .interval('1m') + .rate(GO_PROD_ID_RATE) + .flatMap((timestamp) => + serviceGoProdInstance + .transaction(transactionNameProductId) + .timestamp(timestamp) + .duration(1000) + .success() + .serialize() + ), + ...timerange(start, end) + .interval('1m') + .rate(GO_PROD_ID_ERROR_RATE) + .flatMap((timestamp) => + serviceGoProdInstance + .transaction(transactionNameProductId) + .duration(1000) + .timestamp(timestamp) + .failure() + .serialize() + ), + ]); + }); + + after(() => traceData.clean()); + + describe('compare error rate value between service inventory, error rate chart, service inventory and transactions apis', () => { + before(async () => { + [errorTransactionValues, errorRateMetricValues] = await Promise.all([ + getErrorRateValues({ processorEvent: 'transaction' }), + getErrorRateValues({ processorEvent: 'metric' }), + ]); + }); + + it('returns same avg error rate value for Transaction-based and Metric-based data', () => { + [ + errorTransactionValues.serviceInventoryErrorRate, + errorTransactionValues.errorRateChartApiMean, + errorTransactionValues.serviceInstancesErrorRateSum, + errorRateMetricValues.serviceInventoryErrorRate, + errorRateMetricValues.errorRateChartApiMean, + errorRateMetricValues.serviceInstancesErrorRateSum, + ].forEach((value) => + expect(value).to.be.equal(mean([GO_PROD_LIST_ERROR_RATE, GO_PROD_ID_ERROR_RATE]) / 100) + ); + }); + + it('returns same sum error rate value for Transaction-based and Metric-based data', () => { + [ + errorTransactionValues.transactionsGroupErrorRateSum, + errorRateMetricValues.transactionsGroupErrorRateSum, + ].forEach((value) => + expect(value).to.be.equal((GO_PROD_LIST_ERROR_RATE + GO_PROD_ID_ERROR_RATE) / 100) + ); + }); + }); + }); + }); +} diff --git a/x-pack/test/apm_api_integration/tests/index.ts b/x-pack/test/apm_api_integration/tests/index.ts index c15a7d39a6cf..f68a49658f2e 100644 --- a/x-pack/test/apm_api_integration/tests/index.ts +++ b/x-pack/test/apm_api_integration/tests/index.ts @@ -175,6 +175,10 @@ export default function apmApiIntegrationTests(providerContext: FtrProviderConte loadTestFile(require.resolve('./transactions/error_rate')); }); + describe('transactions/latency_overall_distribution', function () { + loadTestFile(require.resolve('./transactions/latency_overall_distribution')); + }); + describe('transactions/latency', function () { loadTestFile(require.resolve('./transactions/latency')); }); @@ -229,6 +233,10 @@ export default function apmApiIntegrationTests(providerContext: FtrProviderConte loadTestFile(require.resolve('./historical_data/has_data')); }); + describe('error_rate/service_apis', function () { + loadTestFile(require.resolve('./error_rate/service_apis')); + }); + describe('latency/service_apis', function () { loadTestFile(require.resolve('./latency/service_apis')); }); diff --git a/x-pack/test/apm_api_integration/tests/transactions/latency_overall_distribution.ts b/x-pack/test/apm_api_integration/tests/transactions/latency_overall_distribution.ts new file mode 100644 index 000000000000..c915ac8911e3 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/transactions/latency_overall_distribution.ts @@ -0,0 +1,65 @@ +/* + * 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 { registry } from '../../common/registry'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const apmApiClient = getService('apmApiClient'); + + const endpoint = 'GET /internal/apm/latency/overall_distribution'; + + // This matches the parameters used for the other tab's search strategy approach in `../correlations/*`. + const getOptions = () => ({ + params: { + query: { + environment: 'ENVIRONMENT_ALL', + start: '2020', + end: '2021', + kuery: '', + percentileThreshold: '95', + }, + }, + }); + + registry.when( + 'latency overall distribution without data', + { config: 'trial', archives: [] }, + () => { + it('handles the empty state', async () => { + const response = await apmApiClient.readUser({ + endpoint, + ...getOptions(), + }); + + expect(response.status).to.be(200); + expect(response.body?.percentileThresholdValue).to.be(undefined); + expect(response.body?.overallHistogram?.length).to.be(undefined); + }); + } + ); + + registry.when( + 'latency overall distribution with data and default args', + // This uses the same archive used for the other tab's search strategy approach in `../correlations/*`. + { config: 'trial', archives: ['8.0.0'] }, + () => { + it('returns percentileThresholdValue and overall histogram', async () => { + const response = await apmApiClient.readUser({ + endpoint, + ...getOptions(), + }); + + expect(response.status).to.eql(200); + // This matches the values returned for the other tab's search strategy approach in `../correlations/*`. + expect(response.body?.percentileThresholdValue).to.be(1309695.875); + expect(response.body?.overallHistogram?.length).to.be(101); + }); + } + ); +}